Compare commits
165
Commits
main
...
v1.0.0-beta.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
005df7db19 | ||
|
|
0a92dce8a9 | ||
|
|
cd705961ca | ||
|
|
a390296dc2 | ||
|
|
c4fd0476e4 | ||
|
|
b03b23da30 | ||
|
|
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 |
No files matched your search
@@ -6,8 +6,8 @@ contact_links:
|
|||||||
- name: Translate Clockula
|
- name: Translate Clockula
|
||||||
url: https://weblate.dev.jeanlucmakiola.de/engage/clockula/
|
url: https://weblate.dev.jeanlucmakiola.de/engage/clockula/
|
||||||
about: >-
|
about: >-
|
||||||
Translations are managed on Weblate, not here — it owns every values-*
|
Translations are managed on Weblate, which owns every values-* file, so
|
||||||
file, so a hand-edited translation gets overwritten on the next sync.
|
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.
|
No coding needed: pick or request a language and translate in the browser.
|
||||||
|
|
||||||
- name: Contributing guide
|
- name: Contributing guide
|
||||||
@@ -20,13 +20,12 @@ contact_links:
|
|||||||
url: https://codeberg.org/jlmakiola/clockula/src/branch/main/README.md
|
url: https://codeberg.org/jlmakiola/clockula/src/branch/main/README.md
|
||||||
about: >-
|
about: >-
|
||||||
Open the app's own self-check first (Settings → Why might my alarm not
|
Open the app's own self-check first (Settings → Why might my alarm not
|
||||||
ring?). It reports what your device is actually doing — battery
|
ring?). It reports what your device is doing (battery optimisation,
|
||||||
optimisation, notification and full-screen-intent permissions, the next
|
notification and full-screen-intent permissions, the next alarm as the
|
||||||
alarm as the system sees it — and that report is what makes such an issue
|
system sees it). Include that report so the issue can be acted on.
|
||||||
actionable.
|
|
||||||
|
|
||||||
- name: Scope and roadmap
|
- name: Scope and roadmap
|
||||||
url: https://codeberg.org/jlmakiola/clockula/src/branch/main/docs/ROADMAP.md
|
url: https://codeberg.org/jlmakiola/clockula/src/branch/main/docs/ROADMAP.md
|
||||||
about: >-
|
about: >-
|
||||||
What's built, what's planned, and what's deliberately out of scope —
|
What's built, what's planned, and what's deliberately out of scope.
|
||||||
check here before requesting a feature.
|
Check here before requesting a feature.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Crash report
|
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: "
|
title: "Crash: "
|
||||||
labels:
|
labels:
|
||||||
- bug
|
- bug
|
||||||
@@ -11,10 +11,10 @@ labels:
|
|||||||
<!--
|
<!--
|
||||||
Thanks for reporting a crash in Clockula!
|
Thanks for reporting a crash in Clockula!
|
||||||
|
|
||||||
If the app prefilled this for you, the crash report is already below — just add
|
If the app prefilled this for you, the crash report is already below. Add what
|
||||||
what you were doing and submit. Otherwise, paste the report from your clipboard
|
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
|
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
|
### What happened
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ labels:
|
|||||||
### What would you like Clockula to do?
|
### What would you like Clockula to do?
|
||||||
|
|
||||||
|
|
||||||
### Why — what problem does it solve?
|
### What problem would it solve?
|
||||||
|
|
||||||
|
|
||||||
### Anything else
|
### Anything else
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Please skim CONTRIBUTING.md if you haven't:
|
|||||||
https://codeberg.org/jlmakiola/clockula/src/branch/main/CONTRIBUTING.md
|
https://codeberg.org/jlmakiola/clockula/src/branch/main/CONTRIBUTING.md
|
||||||
|
|
||||||
Two things it's easy to get wrong:
|
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.
|
leak above the data layer. Everything else talks to a repository.
|
||||||
• Don't bump `versionName` / `versionCode`. That bump reaching `main` is what
|
• Don't bump `versionName` / `versionCode`. That bump reaching `main` is what
|
||||||
cuts a release, so it belongs only in a release PR.
|
cuts a release, so it belongs only in a release PR.
|
||||||
@@ -16,7 +16,7 @@ Two things it's easy to get wrong:
|
|||||||
|
|
||||||
### Why
|
### 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
|
### 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
|
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
|
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.
|
it only ever reproduces on a real device overnight.
|
||||||
|
|
||||||
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
|
./gradlew lintDebug :app:testDebugUnitTest :app:assembleDebug
|
||||||
|
|||||||
@@ -37,6 +37,47 @@ jobs:
|
|||||||
- name: Reproducible-release invariant
|
- name: Reproducible-release invariant
|
||||||
run: bash scripts/check_reproducible_release.sh
|
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
|
# Decide whether anything that affects the app build changed. Docs, store
|
||||||
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
# metadata, licence texts and forge housekeeping don't, so those PRs skip
|
||||||
# the SDK + Gradle work below but still report a green `ci`.
|
# 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
|
# about defaults to building. Only paths the Gradle build provably
|
||||||
# never reads belong here — note that the workflows themselves, the
|
# never reads belong here — note that the workflows themselves, the
|
||||||
# `.gitmodules` submodule pointer and `scripts/` are *not* in it.
|
# `.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: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
BASE="${{ github.base_ref }}"
|
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
|
||||||
+87
-254
@@ -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
|
# the release on Codeberg with the signed APK + a SHA-256 checksum as a
|
||||||
# direct-download channel — the tag is an output of the pipeline, not its
|
# direct-download channel — the tag is an output of the pipeline, not its
|
||||||
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
# trigger. Ordinary merges (no version bump) fall through `detect` and do
|
||||||
# nothing.
|
# nothing. 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
|
# 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
|
# and separate because Play can reject a good build for reasons the pipeline
|
||||||
# reasons the pipeline can't see, and that must not endanger a release which has
|
# can't see, and that must not endanger a release which already shipped to
|
||||||
# already shipped to F-Droid and Codeberg. It skips cleanly until the
|
# F-Droid and Codeberg. It skips cleanly until PLAY_SERVICE_ACCOUNT_JSON exists.
|
||||||
# PLAY_SERVICE_ACCOUNT_JSON secret exists — so it stays dormant through the
|
|
||||||
# whole pre-1.0 run, which is the correct behaviour anyway.
|
|
||||||
#
|
#
|
||||||
# This file lives in .gitea/workflows on purpose: Codeberg is canonical for git,
|
# 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
|
# issues, PRs and releases, but every secret (app key, F-Droid repo key, Hetzner
|
||||||
@@ -48,7 +48,7 @@ jobs:
|
|||||||
# secrets. repository_owner differs between the two forges regardless of
|
# secrets. repository_owner differs between the two forges regardless of
|
||||||
# URL, proxy or instance rename, so this closes it permanently.
|
# URL, proxy or instance rename, so this closes it permanently.
|
||||||
if: github.repository_owner == 'makiolaj'
|
if: github.repository_owner == 'makiolaj'
|
||||||
runs-on: docker
|
runs-on: light
|
||||||
outputs:
|
outputs:
|
||||||
is_release: ${{ steps.v.outputs.is_release }}
|
is_release: ${{ steps.v.outputs.is_release }}
|
||||||
version: ${{ steps.v.outputs.version }}
|
version: ${{ steps.v.outputs.version }}
|
||||||
@@ -75,23 +75,25 @@ jobs:
|
|||||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
|
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
# versionName -> versionCode, channel and the pre-release flag (set
|
||||||
if [ -z "$VERSION" ]; then echo "No versionName in app/build.gradle.kts" >&2; exit 1; fi
|
# while MAJOR was 0) all come from the one script beta.yaml uses too.
|
||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1); MINOR=$(echo "$VERSION" | cut -d. -f2); PATCH=$(echo "$VERSION" | cut -d. -f3)
|
INFO=$(bash scripts/version_info.sh)
|
||||||
MAJOR=${MAJOR:-0}; MINOR=${MINOR:-0}; PATCH=${PATCH:-0}
|
echo "$INFO"
|
||||||
VERSION_CODE=$(( MAJOR * 10000 + MINOR * 100 + PATCH ))
|
echo "$INFO" >> "$GITHUB_OUTPUT"
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
VERSION=$(echo "$INFO" | sed -n 's/^version=//p')
|
||||||
echo "version_code=$VERSION_CODE" >> "$GITHUB_OUTPUT"
|
CHANNEL=$(echo "$INFO" | sed -n 's/^channel=//p')
|
||||||
# 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)"
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
echo "Manual dispatch — re-sign path, not a release."
|
echo "Manual dispatch — re-sign path, not a release."
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
# A tag for this version already existing means the release shipped on
|
||||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||||
#
|
#
|
||||||
@@ -100,7 +102,11 @@ jobs:
|
|||||||
# read as absent and re-cut a release that has already shipped —
|
# read as absent and re-cut a release that has already shipped —
|
||||||
# republishing to F-Droid. Failing here is recoverable; a duplicate
|
# republishing to F-Droid. Failing here is recoverable; a duplicate
|
||||||
# release is not.
|
# 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
|
case "$STATUS" in
|
||||||
200)
|
200)
|
||||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
||||||
@@ -117,13 +123,23 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
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.
|
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||||
# re-uploads the existing index (recovery path).
|
# re-uploads the existing index (recovery path).
|
||||||
release:
|
release:
|
||||||
needs: detect
|
needs: detect
|
||||||
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
|
||||||
runs-on: docker
|
runs-on: gradle
|
||||||
env:
|
env:
|
||||||
ANDROID_HOME: /opt/android-sdk
|
ANDROID_HOME: /opt/android-sdk
|
||||||
ANDROID_SDK_ROOT: /opt/android-sdk
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
||||||
@@ -188,8 +204,8 @@ jobs:
|
|||||||
run: chmod +x ./gradlew
|
run: chmod +x ./gradlew
|
||||||
|
|
||||||
# The committed versionName is the source of truth. Pin versionCode to the
|
# The committed versionName is the source of truth. Pin versionCode to the
|
||||||
# value derived from it so the published APK's code is always
|
# value scripts/version_info.sh derives from it, so the published APK's
|
||||||
# MAJOR*10000 + MINOR*100 + PATCH even if the committed code was forgotten.
|
# code follows the scheme even if the committed code was forgotten.
|
||||||
- name: Pin versionCode to versionName
|
- name: Pin versionCode to versionName
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
run: |
|
run: |
|
||||||
@@ -324,6 +340,8 @@ jobs:
|
|||||||
# Creating it with target_commitish makes Gitea create the vX.Y.Z tag at
|
# 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
|
# 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).
|
# 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
|
- name: Create tag + Gitea release
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
env:
|
env:
|
||||||
@@ -332,217 +350,52 @@ jobs:
|
|||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
TAG="v$VERSION"
|
bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
# Notes = this version's CHANGELOG section.
|
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||||
awk -v ver="$VERSION" '
|
MAPPING=app/build/outputs/mapping/release/mapping.txt \
|
||||||
$0 ~ "^## \\[" ver "\\]" { flag = 1; next }
|
bash scripts/publish_gitea_release.sh
|
||||||
/^## \[/ { 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"
|
|
||||||
|
|
||||||
# Publish the release on Codeberg, which is canonical for tags and
|
# Publish the release on Codeberg, which is canonical for tags and
|
||||||
# releases (see docs/RELEASING.md). Codeberg push-mirrors branches + tags
|
# releases (see docs/RELEASING.md): push the tag there and attach the
|
||||||
# to Gitea, but releases aren't git objects and don't sync in either
|
# signed APK plus a SHA-256 checksum as the direct-download channel for
|
||||||
# direction — so this step pushes the tag straight to Codeberg and creates
|
# users who don't want F-Droid. The APK is identical to the F-Droid one
|
||||||
# the release there over the API, attaching the signed APK plus a SHA-256
|
# (same app key), so this adds no trust surface. Needs the
|
||||||
# checksum as the direct-download channel for users who don't want
|
# CODEBERG_RELEASE_TOKEN secret; skips cleanly if unset.
|
||||||
# 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
|
- name: Publish release to Codeberg
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
# NOT continue-on-error, and inherited that way deliberately: in
|
# NOT continue-on-error: this step reported green through 0.2.1, 0.2.2,
|
||||||
# Agendula this step reported green through five consecutive releases
|
# 0.3.0, 0.3.1 and 0.3.2 while never once publishing, which is how a
|
||||||
# (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
|
||||||
# crash-fix release reached F-Droid but not the Codeberg/Obtainium users
|
# users who needed it. A broken mirror must fail the release loudly.
|
||||||
# who needed it. A broken mirror must fail the release loudly.
|
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.CODEBERG_RELEASE_TOKEN }}
|
||||||
API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
|
API: https://codeberg.org/api/v1/repos/jlmakiola/clockula
|
||||||
SHA: ${{ github.sha }}
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
if [ -z "${TOKEN:-}" ]; then
|
[ -s release-notes.md ] || bash scripts/release_notes.sh "$VERSION" > release-notes.md
|
||||||
echo "CODEBERG_RELEASE_TOKEN not set — skipping Codeberg publish."
|
TAG="v$VERSION" NOTES_FILE=release-notes.md \
|
||||||
exit 0
|
APK=app/build/outputs/apk/release/app-release.apk \
|
||||||
fi
|
bash scripts/publish_codeberg_release.sh
|
||||||
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" )
|
|
||||||
|
|
||||||
# Release notes: reuse the section extracted for the Gitea release,
|
# Play takes an App Bundle, not the APK: a second artifact from the same
|
||||||
# fall back to the CHANGELOG entry if that step's file is gone.
|
# source and signing config. Play treats the release key only as the
|
||||||
if [ ! -s release-notes.md ]; then
|
# upload key and re-signs with its own (Play App Signing), so Play and
|
||||||
awk -v ver="$VERSION" '
|
# F-Droid installs carry different signatures and can't update each other.
|
||||||
$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.
|
|
||||||
#
|
#
|
||||||
# Built LAST and `continue-on-error`, both deliberately: everything above
|
# Built last and continue-on-error: everything above has already shipped,
|
||||||
# has already shipped by this point, and nothing Play-related may put that
|
# and nothing Play-related may take it down. The AAB never touches the
|
||||||
# at risk. Sitting mid-job without continue-on-error, this block took the
|
# F-Droid repo or the releases. AGP embeds the R8 mapping in the bundle,
|
||||||
# whole Calendula 2.17.0 release down with it — no F-Droid publish, no tag, no
|
# so Play gets deobfuscated stacktraces without a separate upload.
|
||||||
# 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.
|
|
||||||
- name: Build release AAB
|
- name: Build release AAB
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
run: ./gradlew bundleRelease
|
run: ./gradlew bundleRelease
|
||||||
|
|
||||||
# NOT actions/upload-artifact@v4: it runs @actions/artifact v2, which
|
# NOT actions/upload-artifact@v4: its client refuses any non-github.com
|
||||||
# refuses to start whenever GITHUB_SERVER_URL is not github.com — it reads
|
# server as unsupported GHES (go-gitea/gitea#36024). This fork drops that
|
||||||
# any other forge as an unsupported GHES instance and fails before it ever
|
# check. Pinned to a commit — a third-party action in the signing
|
||||||
# talks to the server (go-gitea/gitea#36024). Gitea 1.25 serves the v4
|
# pipeline must not change under us.
|
||||||
# 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.
|
|
||||||
- name: Hand the AAB to the Play job
|
- name: Hand the AAB to the Play job
|
||||||
if: env.IS_RELEASE == 'true'
|
if: env.IS_RELEASE == 'true'
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
@@ -553,46 +406,34 @@ jobs:
|
|||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 14
|
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
|
# Not a `container:` job: act_runner provides no node inside custom job
|
||||||
# forge releases have completed. Play is the one channel that can reject a
|
# containers, so JavaScript actions (checkout, download-artifact) can't run.
|
||||||
# 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.
|
|
||||||
play:
|
play:
|
||||||
needs: [detect, release]
|
needs: [detect, release]
|
||||||
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
|
# workflow_dispatch is the F-Droid re-sign recovery path; never touch Play.
|
||||||
# touch Play, so gate on a real release only.
|
|
||||||
if: needs.detect.outputs.is_release == 'true'
|
if: needs.detect.outputs.is_release == 'true'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.detect.outputs.version }}
|
VERSION: ${{ needs.detect.outputs.version }}
|
||||||
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
|
||||||
# Where the bundle lands. `production` — the release itself is already the
|
# The release itself is the gate (a bumped versionName only reaches main
|
||||||
# gate (a bumped versionName only reaches main after on-device review), so
|
# after on-device review), so it goes straight to production. Override
|
||||||
# a second manual promotion in the Play Console bought nothing but delay.
|
# with repo variables to stage instead.
|
||||||
# Override with the PLAY_TRACK repo variable to stage a release instead.
|
|
||||||
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
|
PLAY_TRACK: ${{ vars.PLAY_TRACK || 'production' }}
|
||||||
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
|
PLAY_RELEASE_STATUS: ${{ vars.PLAY_RELEASE_STATUS || 'completed' }}
|
||||||
# Set PLAY_DRY_RUN=true to validate the edit against the API and discard
|
# true validates the edit against the API and discards it.
|
||||||
# it instead of committing — used to rehearse the first upload.
|
|
||||||
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
|
PLAY_DRY_RUN: ${{ vars.PLAY_DRY_RUN || 'false' }}
|
||||||
BUNDLE_PATH: vendor/bundle
|
BUNDLE_PATH: vendor/bundle
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Skip cleanly (not fatally) when Play isn't configured yet, so the rest
|
# Skip cleanly when Play isn't configured yet, same contract as the
|
||||||
# of the release pipeline keeps working during setup — same contract as
|
# Codeberg publish.
|
||||||
# the Codeberg mirror step.
|
|
||||||
- name: Write the Play service-account key
|
- name: Write the Play service-account key
|
||||||
id: key
|
id: key
|
||||||
env:
|
env:
|
||||||
@@ -605,14 +446,11 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
printf '%s' "$PLAY_SERVICE_ACCOUNT_JSON" > play-service-account.json
|
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)" \
|
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 "PLAY_SERVICE_ACCOUNT_JSON is not a valid service-account JSON." >&2; exit 1; }
|
||||||
echo "configured=true" >> "$GITHUB_OUTPUT"
|
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
# Same GHES-detection problem as the upload side, same fix — see the
|
# Same GHES-detection fix as the upload side.
|
||||||
# handoff step in the release job.
|
|
||||||
- name: Download the AAB
|
- name: Download the AAB
|
||||||
if: steps.key.outputs.configured == 'true'
|
if: steps.key.outputs.configured == 'true'
|
||||||
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
|
uses: https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7 # v4
|
||||||
@@ -627,12 +465,10 @@ jobs:
|
|||||||
SUDO=""
|
SUDO=""
|
||||||
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
|
||||||
$SUDO apt-get update
|
$SUDO apt-get update
|
||||||
# ruby-dev + build-essential: several of fastlane's dependencies build
|
# Several fastlane dependencies build native extensions.
|
||||||
# native extensions.
|
|
||||||
$SUDO apt-get install -y ruby-full ruby-dev build-essential
|
$SUDO apt-get install -y ruby-full ruby-dev build-essential
|
||||||
ruby -v
|
ruby -v
|
||||||
|
|
||||||
# Only the first release pays the full gem build; afterwards this restores.
|
|
||||||
- name: Cache bundled gems
|
- name: Cache bundled gems
|
||||||
if: steps.key.outputs.configured == 'true'
|
if: steps.key.outputs.configured == 'true'
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -655,15 +491,12 @@ jobs:
|
|||||||
if: steps.key.outputs.configured == 'true'
|
if: steps.key.outputs.configured == 'true'
|
||||||
env:
|
env:
|
||||||
SUPPLY_JSON_KEY: play-service-account.json
|
SUPPLY_JSON_KEY: play-service-account.json
|
||||||
# supply is chatty on a TTY-less runner otherwise.
|
|
||||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||||
FASTLANE_HIDE_CHANGELOG: '1'
|
FASTLANE_HIDE_CHANGELOG: '1'
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
# Absolute: a lane body runs from fastlane/, not the workspace root.
|
||||||
AAB="$GITHUB_WORKSPACE/dist/app-release.aab"
|
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; }
|
test -f "$AAB" || { echo "No AAB at $AAB — the artifact handoff failed." >&2; ls -la dist || true; exit 1; }
|
||||||
bundle exec fastlane deploy \
|
bundle exec fastlane deploy \
|
||||||
aab:"$AAB" \
|
aab:"$AAB" \
|
||||||
@@ -672,8 +505,8 @@ jobs:
|
|||||||
dry_run:"$PLAY_DRY_RUN"
|
dry_run:"$PLAY_DRY_RUN"
|
||||||
echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track."
|
echo "Uploaded $VERSION (code $VERSION_CODE) to the '$PLAY_TRACK' track."
|
||||||
|
|
||||||
# The workspace is reused between runs on a self-hosted runner, so the
|
# The workspace is reused on a self-hosted runner; the key must not
|
||||||
# credential must not outlive the job.
|
# outlive the job.
|
||||||
- name: Shred the service-account key
|
- name: Shred the service-account key
|
||||||
if: always()
|
if: always()
|
||||||
run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json
|
run: shred -u play-service-account.json 2>/dev/null || rm -f play-service-account.json
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
name: Renovate
|
name: Renovate
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Weekly sweep. Mondays 05:00 UTC — this cron owns the cadence; the repo's
|
# Every merge to main. Mirror syncs from Codeberg fire push events here (the
|
||||||
# renovate.json5 deliberately has no internal schedule (avoids double-gating).
|
# 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:
|
schedule:
|
||||||
- cron: '0 5 * * 1'
|
- cron: '0 5 * * 1'
|
||||||
# Manual run for an on-demand sweep from the Actions tab.
|
# Manual run for an on-demand sweep from the Actions tab.
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
[submodule "floret-kit"]
|
[submodule "floret-kit"]
|
||||||
path = floret-kit
|
path = floret-kit
|
||||||
url = https://codeberg.org/jlmakiola/floret-kit.git
|
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||||
branch = feat/clockula
|
branch = main
|
||||||
+113
-233
@@ -4,240 +4,120 @@ All notable changes to Clockula are documented here.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
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).
|
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]
|
## [Unreleased]
|
||||||
|
|
||||||
|
The first release of Clockula: alarms, timers, a stopwatch and a world clock.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Project skeleton: Gradle build, version catalog, Hilt, Compose and the Material 3
|
- **Alarms.** Alarms are scheduled as real alarm clocks, exempt from
|
||||||
Expressive theme over floret-kit's `identity` module, seeded `#6B7A5C` — the
|
battery saving and shown on the lock screen and in the status bar. They
|
||||||
third 120° hue rotation of Calendula's slate, completing the family's palette.
|
survive a reboot, a process kill and a change of the device's clock, and
|
||||||
- floret-kit as a git submodule wired in as a Gradle composite build.
|
daylight-saving nights neither skip nor double one. If the full-screen or
|
||||||
- Crash capture and the report surface via floret-kit's `core-crash`; per-app
|
notification permission is denied, an alarm still rings, and when no sound
|
||||||
language support via `core-locale`.
|
can play it vibrates.
|
||||||
- Launcher and notification icons: a line-art clock face carrying the family's
|
- An alarm list that shows when each alarm will ring and marks the next one.
|
||||||
Calendula bloom badge.
|
Changes apply as you make them, with no Save button.
|
||||||
- Full release pipeline, Codeberg-canonical from the first commit — CI,
|
- Setting an alarm, switching it on or changing its time or repeat days says
|
||||||
translations, release (F-Droid repo + Codeberg release + Google Play) and
|
how long until it rings, such as "Alarm set for 7 hours and 12 minutes from
|
||||||
Renovate, with the F-Droid reproducibility rules intact.
|
now", so an AM/PM or wrong-day slip shows at once.
|
||||||
- The locked specification: `docs/PLAN.md` and `docs/ROADMAP.md`.
|
- Swipe an alarm away to delete it, or use its Delete action with a screen
|
||||||
- Preferences over floret-kit's new `core-prefs`: `SettingsPrefs` holds the
|
reader. Deleting an alarm, or a timer from its card, doesn't ask first:
|
||||||
appearance slice (theme mode, dynamic colour) in a DataStore of Clockula's own,
|
Undo brings it back with all its settings, and a snoozed alarm comes back
|
||||||
under the family's key names. The theme now follows the stored preference —
|
snoozed.
|
||||||
light/dark override and dynamic colour persist across launches instead of
|
- Repeat days that start on your locale's first day of the week, and a "skip
|
||||||
always following the system — and an unrelated preference write no longer
|
next alarm" switch so you can sleep in once without turning the alarm off.
|
||||||
re-emits it.
|
- An "Upcoming alarm" notification two hours before an alarm rings, with a
|
||||||
- floret-kit's `core-di` supplies the `@IoDispatcher` the preference store runs
|
Dismiss button, so you can turn off today's alarm when you're already awake.
|
||||||
on, so Clockula no longer declares its own dispatcher qualifier.
|
A repeating alarm skips just that once; a one-time alarm is switched off.
|
||||||
- Clockula's own storage, headless: a Room database with `alarms`, `timers`,
|
The same Dismiss shows on the alarm in the list. The notification is silent,
|
||||||
`world_clocks` and `stopwatch_laps`, its schema exported and committed at
|
and turning it off in the system settings doesn't affect the alarm.
|
||||||
every version so each migration is reviewable and testable.
|
- Schedule a one-off alarm for a date, such as a flight next Thursday, and
|
||||||
- Plain-Kotlin alarms, timers, world clocks and stopwatch runs behind four
|
pause a repeating alarm for a range of days, such as a holiday. The list
|
||||||
repository interfaces exposing Flows — nothing above the data layer knows Room
|
shows "Scheduled for Wed, Oct 28" or "Paused Oct 26 – 28", and a pause ends
|
||||||
exists, and a test fails the build if anyone reaches through. A corrupt row
|
on its own.
|
||||||
degrades on read (a clamped time, an unknown ringtone, a rotted zone id)
|
- Per-alarm sound, vibration, gradual volume, snooze length and limit, and a
|
||||||
rather than crashing the list it appears in.
|
dismiss challenge (a quick sum or a two-second hold). Each one can follow the
|
||||||
- Alarm and timer settings are inherited, not copied: a per-alarm ringtone,
|
app default, so changing a default later still reaches it.
|
||||||
snooze, vibrate, volume ramp or dismiss challenge is an *override*, so
|
- Your own sounds: the device's alarm tones, the "Timer expired" sound from
|
||||||
changing an app default later reaches every alarm the user never customised.
|
Android's own Clock, a file you pick, Silent (which still vibrates), or
|
||||||
- The clock defaults and the stopwatch's running state now live in DataStore
|
music from an app that shares its library, such as VLC or AntennaPod. If
|
||||||
alongside the appearance preferences, clamped on read as well as on write, so
|
the music can't play, or might not be heard, the alarm rings with the
|
||||||
a hand-edited or restored file cannot produce a setting the user could never
|
default sound instead.
|
||||||
have chosen.
|
- A ring screen over the lock screen with snooze and dismiss. The dismiss
|
||||||
- The wall-clock / elapsed-realtime split, made explicit: alarm records follow
|
challenge always gives you a way out, works with a screen reader, and Back
|
||||||
the system clock, while running timers and the stopwatch are anchored to time
|
can't silence an alarm. It also opens when the alarm or a timer goes off
|
||||||
since boot. Changing the device's time — forwards or backwards — cannot warp
|
while you're using Clockula, and with "Ring screen over other apps" turned
|
||||||
either, and a timer that survives a reboot falls back to its wall-clock
|
on it opens over any app instead of a notification.
|
||||||
estimate and says so instead of vanishing.
|
- Choose what the volume buttons do while an alarm rings: change the volume
|
||||||
- Alarms that ring. A repeat schedule that is re-resolved against the device's
|
(as before), snooze, dismiss or nothing. Dismiss still respects the dismiss
|
||||||
zone every time anything moves, so the clock going forwards, backwards or
|
challenge by snoozing instead, and a snooze press with no snoozes left
|
||||||
through a daylight-saving transition cannot lose one: a 02:30 alarm on a
|
changes the volume rather than ending the alarm. It works while the ring
|
||||||
spring-forward night rings at 03:30 rather than vanishing, and on a fall-back
|
screen is showing.
|
||||||
night rings once rather than twice.
|
- **Timers.** Run several at once, each with its own label, length and sound,
|
||||||
- Skip-next-occurrence that skips exactly one occurrence, snooze with a
|
set from a keypad with saved presets. Pause, resume, reset and +1 min work
|
||||||
per-alarm interval and limit, and a snooze that is still kept after a reboot.
|
from the timer, from the live pill and from the notification. A finished
|
||||||
- An alarm that keeps ringing through a reboot or a process kill — the ring is
|
timer turns the screen on and opens over the lock screen with Stop and
|
||||||
rebuilt from storage, not from memory — and one that still rings when the
|
+1 min. Timers keep counting through a reboot and aren't affected by changes
|
||||||
full-screen-intent or notification permission is denied. The chain of
|
to the clock.
|
||||||
fallbacks ends in vibration, never in silence.
|
- Timers have their own vibration switch and an optional gradual volume, both
|
||||||
- The post-reboot repair: a running timer no longer counts down from an anchor
|
apart from the alarms'. A kitchen timer can just beep while alarms still
|
||||||
the reboot killed, and the stopwatch no longer invents a segment it never ran.
|
vibrate; a silent timer still vibrates. Timer vibration starts out matching
|
||||||
- The app shell: four tabs — alarms, timers, stopwatch, world clock — under a
|
the alarm vibration setting, so nothing changes until you change it.
|
||||||
navigation bar that becomes a rail on a tablet, chosen by the window's size
|
- **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause
|
||||||
rather than by hand. Back from a tab goes home to Alarms; back from Alarms
|
in the notification. It keeps counting while the app is closed.
|
||||||
leaves the app, and the predictive-back gesture previews whichever of the two
|
- **World clock.** An analog face for your own time, plus cities from the
|
||||||
is actually about to happen.
|
device's time-zone database, searchable in your language. Each city shows
|
||||||
- The live pill: whatever is counting is visible from every tab, and pausable
|
the offset from your time and whether it's a different day there. Reorder
|
||||||
and stoppable without going to find the row that owns it. It stays up while
|
them by dragging or with a screen reader, and set a home time zone or follow
|
||||||
paused — a control that vanishes under the thumb that pressed it is no control
|
the device. "Display time with seconds" in Settings > Clock adds seconds to
|
||||||
— shows how many other timers are running behind it, and flips a timer that
|
every time on the tab and a second hand to the face.
|
||||||
reaches zero to "finished" the moment it does, rather than counting into
|
- An automatic local clock while travelling. When you've set a home time zone
|
||||||
negative time. Stop resets; it never deletes a timer the user configured.
|
and your device is somewhere the time differs, the World clock tab adds a
|
||||||
- The real ring screen, over the lock screen with the display kept on for
|
"Local time" row under the home face. It goes away again once you're back
|
||||||
exactly as long as the window lives: the time, the label, snooze, and an
|
on home time, and stays hidden if one of your cities already shows that
|
||||||
optional dismiss challenge — a two-term sum, or a two-second hold — that can
|
time. It's Google Clock's "automatic home clock" turned round: Clockula's
|
||||||
never strand anyone. Three wrong answers or sixty seconds of ringing, either
|
face already shows home, so the clock that goes missing on a trip is the
|
||||||
one, opens a way out; snooze is never gated; solving the challenge dismisses
|
local one. Turn it off in Settings › Clock.
|
||||||
on the spot instead of asking twice; and the hold completes from an
|
- A live pill that shows whatever is counting from every tab.
|
||||||
accessibility click, so a screen reader is not locked out of its own alarm.
|
- Swipe between tabs on phones. Tablets and foldables get a side rail.
|
||||||
Back cannot silence an alarm.
|
- Other apps can set, show, snooze and dismiss alarms and timers through
|
||||||
- The first Compose motion: fade-through between peer tabs, the kit's springs for
|
Android's standard alarm intents, so assistants and automation apps work
|
||||||
the pill coming and going and for the challenge block opening, all of which
|
with Clockula. A voice-set alarm cleans up after itself once dismissed.
|
||||||
already stand down when the system's "remove animations" setting is on.
|
- Settings: theme, dynamic colour, language, and the defaults every new alarm
|
||||||
- Notification permission is asked for once, on first launch, and the fact that
|
and timer inherits.
|
||||||
it was asked is recorded rather than guessed at.
|
- A Black theme next to Light and Dark: dark with pure black backgrounds,
|
||||||
- The alarms tab, for real: a list of your alarms that says when each one will
|
navigation bar included, for AMOLED screens.
|
||||||
ring — "in 9h 12m" — and marks the next one to go off, with the repeat days
|
- Choose how long a ringing alarm or a finished timer keeps going before it
|
||||||
and a switch on every row. A switched-off alarm stays where it is, faded
|
stops on its own: 1, 5, 10, 15, 20 or 25 minutes, or Never, separately for
|
||||||
rather than shuffled to the bottom, and the countdown keeps up on its own
|
alarms and timers. Ten minutes stays the default, and Never still stops
|
||||||
without anything being saved.
|
after an hour so a phone left at home can't ring its battery flat.
|
||||||
- Create, edit, delete, enable and disable an alarm, applied as you make them
|
- Back up alarms, timers, world clocks and settings to a JSON file, and restore
|
||||||
rather than behind a Save button: tapping + creates an alarm at the next whole
|
them on this device or another one.
|
||||||
hour and opens it, every control writes at once, and back is just back.
|
- "Why might my alarm not ring?", a self-check that reads your device's real
|
||||||
- The Material time picker, with a keypad to type a time and the dial behind one
|
state: permissions, battery optimisation, Do Not Disturb, alarm volume, and
|
||||||
toggle, following the device's own 12- or 24-hour setting.
|
whether the system has Clockula's next alarm. It links straight to the
|
||||||
- A repeat-day selector that starts on your locale's first day of the week, not
|
setting that fixes each problem.
|
||||||
on a hardcoded Monday, and a "skip next alarm" switch for a repeating one —
|
- **Bedtime**, under Settings: Clockula can turn on a Do Not Disturb mode of
|
||||||
so you can sleep in on Thursday without switching the alarm off and
|
its own at bedtime and off at your wake-up time, on the nights you choose,
|
||||||
forgetting to switch it back.
|
with an optional reminder before it starts. On Android 15 and later it is a
|
||||||
- A per-alarm sound, vibration, gradual volume, snooze length, snooze limit and
|
real mode in Settings › Modes and can also turn the screen grayscale, dim the
|
||||||
dismiss challenge — each of which can say "follow the app default" rather than
|
wallpaper, switch to the dark theme and hide the always-on display. It needs
|
||||||
silently copying today's default and drifting away from it later. Every row
|
Do Not Disturb access, which the screen helps you grant, and it always lets
|
||||||
shows what it currently resolves to.
|
alarms through. It is separate from Digital Wellbeing's own Bedtime mode,
|
||||||
- A ringtone picker with the device's own alarm sounds, a file you choose
|
which apps can't turn on.
|
||||||
yourself, and a Silent option that still vibrates — because an alarm that does
|
- Home-screen widgets: next alarm, timer (with controls), a quick timer, and
|
||||||
literally nothing is not an alarm. You can play a sound before taking it.
|
analog and digital clocks that show your world clocks at larger sizes. Each
|
||||||
- A sound Clockula cannot read right now says so under the row, and your choice
|
widget can follow the system theme or stay light or dark, and choose what a
|
||||||
is kept rather than quietly replaced with the default: an unmounted card comes
|
tap opens.
|
||||||
back. If a file you picked cannot be held onto permanently, that is disclosed
|
- **Screen saver.** A full-screen digital or analog clock with the date and
|
||||||
too instead of being hidden.
|
your next alarm, for a phone on a charger or a dock. Night mode turns it
|
||||||
- The timers tab, for real: several timers at once, each with its own label,
|
very dim for a dark bedroom, and the clock moves a little every minute so
|
||||||
length and sound, and a keypad that sets one in a couple of taps — with saved
|
nothing burns into the screen. Pick it in Android's screen saver settings,
|
||||||
presets you can add to and remove from as you go. A timer starts the moment
|
or open it any time from the menu on each tab. Its settings are in Settings
|
||||||
you press Start, so an accidental tap leaves nothing behind to delete.
|
→ Clock and are included in backups.
|
||||||
- Pause, resume, reset and "+1 min" on every timer, from the row, from the live
|
- Quick Settings tiles: "Next alarm" shows the day and time of your next alarm
|
||||||
pill and from the notification — and "+1 min" on a timer that has just rung
|
and opens the Alarms tab, and "Quick timer" starts a timer with one tap,
|
||||||
starts it again with a whole minute, rather than handing back a paused timer
|
shows when it ends, and resets it on a second tap. Long-press the quick
|
||||||
you have to press Start on.
|
timer tile to choose its length.
|
||||||
- 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.
|
|
||||||
+27
-24
@@ -1,8 +1,8 @@
|
|||||||
# Contributing to Clockula
|
# 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
|
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
|
(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
|
[`docs/PLAN.md`](docs/PLAN.md) (the design rationale). This file covers the
|
||||||
practical how.
|
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
|
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
|
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
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- JDK 17
|
- JDK 17
|
||||||
- Android SDK: compileSdk 37, build-tools 36.0.0 (the Gradle wrapper handles AGP/Kotlin)
|
- 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
|
- 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.
|
and that is the only question that matters about an alarm.
|
||||||
|
|
||||||
## Build, test, lint
|
## 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
|
owned by a self-hosted Weblate that writes to this repository directly, and a
|
||||||
hand-edit is overwritten on the next sync.
|
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
|
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
|
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
|
```sh
|
||||||
python3 scripts/check_translations.py
|
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.
|
does, and it's what the `Translations` check runs on every PR.
|
||||||
|
|
||||||
A new language also needs one `<locale>` line in
|
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.
|
truth for both the in-app picker and the Android 13+ per-app language setting.
|
||||||
|
|
||||||
## Where to put code
|
## Where to put code
|
||||||
|
|
||||||
| Layer | Lives in | Rule of thumb |
|
| Layer | Lives in | Rule of thumb |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Pure logic (models, filtering, sorting, form validation, date maths) | `domain/` | No Android imports — must be JVM-unit-testable. |
|
| Pure logic (models, scheduling and ring policies, formatting, the self-check rules) | `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`. |
|
| 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. |
|
||||||
| Reminders, prefs, DI, demo data | `data/reminders/`, `data/prefs/`, `data/di/`, `data/demo/` | |
|
| 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. |
|
| 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
|
## Code style & conventions
|
||||||
|
|
||||||
- **Kotlin**, 4-space indent, LF line endings, final newline, no trailing
|
- **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.
|
Match the surrounding code.
|
||||||
- **Material 3 Expressive** for all UI: use `MaterialExpressiveTheme`, the
|
- **Material 3 Expressive** for all UI: use `MaterialExpressiveTheme`, the
|
||||||
colour-scheme tokens (never hardcoded colours), and canonical M3 components
|
colour-scheme tokens (never hardcoded colours), and canonical M3 components
|
||||||
(e.g. `ListItem` for rows). Consult the `material-3` skill before designing a
|
(e.g. `ListItem` for rows). Consult the `material-3` skill before designing a
|
||||||
new screen or component.
|
new screen or component.
|
||||||
- Prefer the domain layer for anything testable; keep `AndroidTasksDataSource`
|
- Prefer the domain layer for anything testable; keep Android APIs behind the
|
||||||
the only Android-coupled data implementation so the rest stays JVM-testable.
|
seams in each engine's `android/` subpackage so the rest stays JVM-testable.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
- New domain logic (mappers, filters, sorting, forms, value mapping) **must**
|
- New domain logic (scheduling, ring policies, formatting, mappers) **must**
|
||||||
come with JVM unit tests under `app/src/test/`. The data source is the
|
come with JVM unit tests under `app/src/test/`. Repositories and engine seams
|
||||||
JVM-testable seam — mock or fake it rather than reaching for instrumentation.
|
are the JVM-testable boundary: fake them rather than reaching for
|
||||||
- Add an instrumented test only when a path genuinely needs a real
|
instrumentation.
|
||||||
`ContentResolver`.
|
- Add an instrumented test only when a path genuinely needs a real device
|
||||||
|
(Room migrations, AlarmManager, a foreground service).
|
||||||
|
|
||||||
## Commits & PRs
|
## Commits & PRs
|
||||||
|
|
||||||
- Write focused commits with clear messages (the existing history uses short,
|
- 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
|
- 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)).
|
New" (see [`docs/RELEASING.md`](docs/RELEASING.md)).
|
||||||
- If your change shifts the architecture or completes a milestone, update
|
- If your change shifts the architecture or completes a milestone, update
|
||||||
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
|
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) / [`docs/ROADMAP.md`](docs/ROADMAP.md)
|
||||||
in the same PR.
|
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
|
`versionName` is bumped only when **cutting a release** (that bump reaching
|
||||||
`main` is what triggers the release; the pipeline then mints the tag). See
|
`main` is what triggers the release; the pipeline then mints the tag). See
|
||||||
[`docs/RELEASING.md`](docs/RELEASING.md).
|
[`docs/RELEASING.md`](docs/RELEASING.md).
|
||||||
|
|
||||||
## Scope
|
## 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
|
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
|
schedule are all wanted, and all deliberately deferred until v1 is finished; see
|
||||||
[`docs/ROADMAP.md`](docs/ROADMAP.md).
|
[`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
|
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
|
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.
|
rings, it loses, however nice the feature is.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
source "https://rubygems.org"
|
source "https://rubygems.org"
|
||||||
|
|
||||||
# fastlane is used ONLY to upload the release bundle to Google Play
|
# fastlane is used ONLY as the Google Play Developer API client (see
|
||||||
# (see fastlane/Fastfile). It is not part of the build or the signing path, so
|
# fastlane/Fastfile). It never builds and never signs. Pinned exactly, no
|
||||||
# it never runs on a PR — only in release.yaml's `play` job.
|
# Gemfile.lock: it resolves an uploader's deps, not the app's.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
gem "fastlane", "2.237.0"
|
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/)
|
[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
|
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
|
## Privacy
|
||||||
|
|
||||||
|
|||||||
+23
-5
@@ -27,11 +27,13 @@ android {
|
|||||||
// These committed values ARE the source of truth for a release: merging
|
// These committed values ARE the source of truth for a release: merging
|
||||||
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
// a bumped versionName into main triggers .gitea/workflows/release.yaml,
|
||||||
// which builds this version and then creates the matching vX.Y.Z tag +
|
// which builds this version and then creates the matching vX.Y.Z tag +
|
||||||
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
|
// release itself. A versionName of X.Y.Z-beta.N pushed to a release/*
|
||||||
// PATCH from versionName, e.g. 0.2.0 -> 200). The Gitea release is marked
|
// branch instead cuts a Codeberg-only pre-release (beta.yaml).
|
||||||
// as a pre-release while MAJOR is 0. See docs/RELEASING.md.
|
// versionCode is derived from versionName by scripts/version_info.sh
|
||||||
versionCode = 100
|
// (1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099), and CI fails if the
|
||||||
versionName = "0.1.0"
|
// committed one doesn't match. See docs/RELEASING.md.
|
||||||
|
versionCode = 1000007
|
||||||
|
versionName = "1.0.0-beta.7"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -110,6 +112,12 @@ android {
|
|||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
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 {
|
lint {
|
||||||
@@ -146,6 +154,16 @@ ksp {
|
|||||||
arg("room.schemaLocation", "$projectDir/schemas")
|
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 {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
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
|
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
|
* user's alarms, so it is validated against the exported schema rather than
|
||||||
* eyeballed. Device only; the build gate compiles it.
|
* eyeballed. Device only; the build gate compiles it.
|
||||||
*/
|
*/
|
||||||
@@ -138,6 +138,47 @@ class MigrationTest {
|
|||||||
assertThat(timer!!.deleteAfterUse).isFalse()
|
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(
|
private fun ringStateFor(alarmId: Long) = AlarmStateEntity(
|
||||||
alarmId = alarmId,
|
alarmId = alarmId,
|
||||||
snoozedUntil = 1_700_000_100_000L,
|
snoozedUntil = 1_700_000_100_000L,
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package de.jeanlucmakiola.clockula.ui
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.text.format.DateFormat
|
import android.text.format.DateFormat
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsOff
|
import androidx.compose.ui.test.assertIsOff
|
||||||
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
import androidx.compose.ui.test.junit4.v2.createEmptyComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performCustomAccessibilityActionWithLabel
|
||||||
import androidx.compose.ui.test.performTextReplacement
|
import androidx.compose.ui.test.performTextReplacement
|
||||||
import androidx.test.core.app.ActivityScenario
|
import androidx.test.core.app.ActivityScenario
|
||||||
import androidx.test.espresso.Espresso
|
import androidx.test.espresso.Espresso
|
||||||
@@ -205,12 +207,38 @@ class AlarmsInstrumentedTest {
|
|||||||
composeTestRule.onNodeWithText("Work").performClick()
|
composeTestRule.onNodeWithText("Work").performClick()
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
|
// No confirmation: the list underneath offers the Undo instead.
|
||||||
composeTestRule.onNodeWithContentDescription(string(R.string.editor_delete)).performClick()
|
composeTestRule.onNodeWithContentDescription(string(R.string.editor_delete)).performClick()
|
||||||
composeTestRule.waitForIdle()
|
// The delete runs on the application scope, which Compose's idling
|
||||||
composeTestRule.onNodeWithText(string(R.string.action_delete)).performClick()
|
// does not track, so the test waits for storage rather than for idle.
|
||||||
|
composeTestRule.waitUntil(STORAGE_TIMEOUT_MS) { stored(id) == null }
|
||||||
composeTestRule.waitForIdle()
|
composeTestRule.waitForIdle()
|
||||||
|
|
||||||
composeTestRule.onNodeWithText("Work").assertDoesNotExist()
|
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. -->
|
heads-up notification that still sounds — never to silence. -->
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<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
|
<!-- The ring notification. Denied, the alarm still sounds: the audio belongs
|
||||||
to the foreground service, not to the notification. -->
|
to the foreground service, not to the notification. -->
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<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. -->
|
<!-- The alarm vibrates, and vibrates on its own when no audio source opens. -->
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<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
|
<!-- "Choose from a music app…": package visibility for the apps that share
|
||||||
a library through Android's standard MediaBrowserService, and for
|
a library through Android's standard MediaBrowserService, and for
|
||||||
nothing else. A visibility declaration, not a permission — and no
|
nothing else. A visibility declaration, not a permission — and no
|
||||||
@@ -183,6 +194,18 @@
|
|||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:turnScreenOn="true" />
|
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
|
<!-- The ring itself. systemExempted per D17: not mediaPlayback (an alarm
|
||||||
is not the user's media session) and emphatically not shortService,
|
is not the user's media session) and emphatically not shortService,
|
||||||
which caps at ~3 minutes against a 10-minute ring window. -->
|
which caps at ~3 minutes against a 10-minute ring window. -->
|
||||||
@@ -191,7 +214,7 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="systemExempted" />
|
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. -->
|
app created is delivered regardless, and nothing else may fake a fire. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".alarm.receiver.AlarmFireReceiver"
|
android:name=".alarm.receiver.AlarmFireReceiver"
|
||||||
@@ -257,9 +280,61 @@
|
|||||||
<action android:name="android.intent.action.TIME_SET" />
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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
|
<!-- Persists the per-app language on API < 33, where the platform
|
||||||
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
per-app-languages API is unavailable. On 33+ this is a no-op. -->
|
||||||
<service
|
<service
|
||||||
@@ -354,7 +429,7 @@
|
|||||||
updatePeriodMillis is 0 in every provider-info XML — WidgetSync
|
updatePeriodMillis is 0 in every provider-info XML — WidgetSync
|
||||||
is the one refresh trigger for the data widgets, and the analog
|
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
|
widget's date is redrawn at midnight and on a clock or zone
|
||||||
change (widget/AnalogDateRefresh.kt). -->
|
change (widget/MidnightRefresh.kt). -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".widget.NextAlarmWidgetReceiver"
|
android:name=".widget.NextAlarmWidgetReceiver"
|
||||||
android:label="@string/widget_next_alarm_label"
|
android:label="@string/widget_next_alarm_label"
|
||||||
@@ -426,6 +501,58 @@
|
|||||||
android:name="android.appwidget.provider"
|
android:name="android.appwidget.provider"
|
||||||
android:resource="@xml/widget_quick_timer_info" />
|
android:resource="@xml/widget_quick_timer_info" />
|
||||||
</receiver>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -4,6 +4,11 @@ import android.app.Application
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||||
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
|
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.StopwatchEngine
|
||||||
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
|
import de.jeanlucmakiola.clockula.stopwatch.service.StopwatchNotifications
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
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.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -38,9 +44,15 @@ class ClockulaApp : Application() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var stopwatchEngine: StopwatchEngine
|
lateinit var stopwatchEngine: StopwatchEngine
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var bedtimeEngine: BedtimeEngine
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var widgetSync: WidgetSync
|
lateinit var widgetSync: WidgetSync
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: SettingsPrefs
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@ApplicationScope
|
@ApplicationScope
|
||||||
lateinit var scope: CoroutineScope
|
lateinit var scope: CoroutineScope
|
||||||
@@ -58,32 +70,61 @@ class ClockulaApp : Application() {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ForegroundRingScreen.track(this)
|
||||||
RingNotifications.createChannels(this)
|
RingNotifications.createChannels(this)
|
||||||
|
UpcomingNotifications.createChannel(this)
|
||||||
TimerNotifications.createChannel(this)
|
TimerNotifications.createChannel(this)
|
||||||
StopwatchNotifications.createChannel(this)
|
StopwatchNotifications.createChannel(this)
|
||||||
|
AndroidBedtimeNotifier.createChannel(this)
|
||||||
|
|
||||||
// The same three steps as a BOOT_COMPLETED, and taken through the engine
|
// 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:
|
// 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
|
// a cold start caused by the fire broadcast must not interleave its
|
||||||
// read-modify-write over `alarm_states` with the fire's own.
|
// read-modify-write over `alarm_states` with the fire's own.
|
||||||
scope.launch {
|
scope.launch {
|
||||||
engine.onBootCompleted()
|
try {
|
||||||
// After the alarm engine's pass, and idempotent within a boot: the
|
launchPasses()
|
||||||
// boot-id gate makes the second repair a no-op, so neither engine
|
} finally {
|
||||||
// depends on the other's ordering (M6 D21). Process death costs the
|
// Last and guarded, like every bedtime call on a shared path,
|
||||||
// timers nothing — the anchors and the AlarmManager slot survive it —
|
// and in a finally so a failure above cannot cost it its pass:
|
||||||
// but the service may not have, so this brings it back.
|
// a force stop clears its AlarmManager slot too, and may have
|
||||||
timerEngine.onBootCompleted()
|
// slept through a bedtime or wake-up, so both are caught up here.
|
||||||
// And the third, after the other two. Process death costs the
|
runCatching { bedtimeEngine.resync() }
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never completes on its own — it is the widgets' one refresh trigger
|
// Never completes on its own — it is the widgets' and the Quick
|
||||||
// for as long as the process lives (M10, docs/PLAN.md §9).
|
// Settings tiles' one refresh trigger for as long as the process
|
||||||
|
// lives (M10, docs/PLAN.md §9; #26).
|
||||||
scope.launch { widgetSync.run() }
|
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.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import de.jeanlucmakiola.clockula.bedtime.BedtimeIntents
|
||||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.clockula.interop.InteropIntents
|
import de.jeanlucmakiola.clockula.interop.InteropIntents
|
||||||
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
|
import de.jeanlucmakiola.clockula.ui.crash.CrashReportActivity
|
||||||
@@ -29,6 +30,7 @@ import de.jeanlucmakiola.floret.crash.CrashReporter
|
|||||||
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
import de.jeanlucmakiola.floret.crash.submitCrashReport
|
||||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||||
import de.jeanlucmakiola.floret.prefs.isDark
|
import de.jeanlucmakiola.floret.prefs.isDark
|
||||||
|
import de.jeanlucmakiola.floret.prefs.isPureBlack
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,14 +101,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
// flag: with the device dark and ThemeMode.LIGHT stored, deriving the
|
// flag: with the device dark and ThemeMode.LIGHT stored, deriving the
|
||||||
// icon colours from the system would paint white icons onto a light
|
// icon colours from the system would paint white icons onto a light
|
||||||
// background. Re-applied whenever the stored preference changes.
|
// background. Re-applied whenever the stored preference changes.
|
||||||
LaunchedEffect(darkTheme) { applyEdgeToEdge(darkTheme) }
|
val pureBlack = darkTheme && appearance.themeMode.isPureBlack
|
||||||
|
LaunchedEffect(darkTheme, pureBlack) { applyEdgeToEdge(darkTheme, pureBlack) }
|
||||||
|
|
||||||
ClockulaTheme(
|
ClockulaTheme(
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
dynamicColor = appearance.dynamicColor,
|
dynamicColor = appearance.dynamicColor,
|
||||||
|
pureBlack = pureBlack,
|
||||||
) {
|
) {
|
||||||
ClockulaShell(
|
ClockulaShell(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
pureBlack = pureBlack,
|
||||||
openRequest = openRequest,
|
openRequest = openRequest,
|
||||||
// Consumed once: a request left standing would re-select the
|
// Consumed once: a request left standing would re-select the
|
||||||
// tab on every recomposition and fight the user's taps.
|
// tab on every recomposition and fight the user's taps.
|
||||||
@@ -133,10 +138,11 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun applyEdgeToEdge(darkTheme: Boolean) {
|
private fun applyEdgeToEdge(darkTheme: Boolean, pureBlack: Boolean) {
|
||||||
|
val darkScrim = if (pureBlack) NAV_BAR_BLACK_SCRIM else NAV_BAR_DARK_SCRIM
|
||||||
enableEdgeToEdge(
|
enableEdgeToEdge(
|
||||||
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme },
|
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { darkTheme },
|
||||||
navigationBarStyle = SystemBarStyle.auto(NAV_BAR_LIGHT_SCRIM, NAV_BAR_DARK_SCRIM) { darkTheme },
|
navigationBarStyle = SystemBarStyle.auto(NAV_BAR_LIGHT_SCRIM, darkScrim) { darkTheme },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +171,12 @@ class MainActivity : ComponentActivity() {
|
|||||||
.getOrNull()
|
.getOrNull()
|
||||||
?.toList()
|
?.toList()
|
||||||
.orEmpty()
|
.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() {
|
override fun onResume() {
|
||||||
@@ -179,3 +190,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
/** The scrims androidx uses for a three-button navigation bar, matched exactly. */
|
/** The scrims androidx uses for a three-button navigation bar, matched exactly. */
|
||||||
private val NAV_BAR_LIGHT_SCRIM = Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
|
private val NAV_BAR_LIGHT_SCRIM = Color.argb(0xe6, 0xFF, 0xFF, 0xFF)
|
||||||
private val NAV_BAR_DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
|
private val NAV_BAR_DARK_SCRIM = Color.argb(0x80, 0x1b, 0x1b, 0x1b)
|
||||||
|
|
||||||
|
/** The Black theme's: the dark one's alpha over black, so the bar reads black (#28). */
|
||||||
|
private val NAV_BAR_BLACK_SCRIM = Color.argb(0x80, 0x00, 0x00, 0x00)
|
||||||
@@ -5,28 +5,36 @@ import de.jeanlucmakiola.clockula.data.alarms.AlarmStateRepository
|
|||||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||||
import de.jeanlucmakiola.clockula.domain.AlarmSettings
|
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.AlarmResolution
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolver
|
import de.jeanlucmakiola.clockula.domain.alarm.AlarmResolver
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
|
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRing
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.AlarmRingState
|
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.DismissOutcome
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.FireOutcome
|
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.RingSession
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.ScheduledFire
|
import de.jeanlucmakiola.clockula.domain.alarm.ScheduledFire
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
|
import de.jeanlucmakiola.clockula.domain.alarm.SnoozeOutcome
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingAlarm
|
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.interop.InteropLimits
|
||||||
import de.jeanlucmakiola.clockula.domain.resolveSettings
|
import de.jeanlucmakiola.clockula.domain.resolveSettings
|
||||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||||
import de.jeanlucmakiola.clockula.system.RebootRepair
|
import de.jeanlucmakiola.clockula.system.RebootRepair
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -78,16 +86,55 @@ class AlarmEngine @Inject constructor(
|
|||||||
|
|
||||||
suspend fun onAutoSilence(alarmId: Long): Unit = lock.withLock {
|
suspend fun onAutoSilence(alarmId: Long): Unit = lock.withLock {
|
||||||
val state = states.state(alarmId)
|
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
|
// Missed, not snoozed: the ring stops, the cycle closes, the next
|
||||||
// occurrence is scheduled. There is no missed-alarm notification in v1.
|
// occurrence is scheduled. There is no missed-alarm notification in v1.
|
||||||
closeCycleLocked(alarmId, state)
|
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 =
|
* Writes the alarms' "Silence after" — the engine is its only writer outside
|
||||||
lock.withLock { snoozeLocked(alarmId, minutesOverride) }
|
* 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).
|
* 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.
|
// is written and the caller is told so.
|
||||||
if (!alarm.enabled) return@withLock DismissOutcome.NotFound
|
if (!alarm.enabled) return@withLock DismissOutcome.NotFound
|
||||||
|
|
||||||
// "Dismissing a repeating alarm only prevents the upcoming instance from
|
dismissComingLocked(alarm)
|
||||||
// ringing"; dismissing a single occurrence "effectively disables" it.
|
}
|
||||||
if (alarm.repeatDays.isRepeating) {
|
|
||||||
alarms.setSkipNextOccurrence(alarmId, true)
|
|
||||||
rescheduleLocked()
|
|
||||||
return@withLock DismissOutcome.SkippedNext(alarmId)
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
rescheduleLocked()
|
||||||
DismissOutcome.Disabled(alarmId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun dismiss(alarmId: Long): Unit =
|
suspend fun dismiss(alarmId: Long): Unit =
|
||||||
lock.withLock { closeCycleLocked(alarmId, states.state(alarmId)) }
|
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. */
|
/** Boot repair (timers + stopwatch), then resume any interrupted ring, then reschedule. */
|
||||||
suspend fun onBootCompleted(): ScheduledFire? = lock.withLock {
|
suspend fun onBootCompleted(): ScheduledFire? = lock.withLock {
|
||||||
rebootRepair.repairIfRebooted()
|
rebootRepair.repairIfRebooted()
|
||||||
@@ -135,28 +276,56 @@ class AlarmEngine @Inject constructor(
|
|||||||
rescheduleLocked()
|
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()
|
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. */
|
/** Restarts a ring the process death or reboot interrupted. Returns the alarm id, or null. */
|
||||||
suspend fun resumeRingingIfAny(): Long? = lock.withLock { resumeRingingIfAnyLocked() }
|
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. */
|
/** What the ringing service needs. Null when nothing is ringing. */
|
||||||
suspend fun ringSession(): RingSession? = lock.withLock {
|
suspend fun ringSession(): RingSession? = lock.withLock {
|
||||||
val state = states.ringing() ?: return@withLock null
|
val state = states.ringing() ?: return@withLock null
|
||||||
val since = state.ringingSince ?: return@withLock null
|
val since = state.ringingSince ?: return@withLock null
|
||||||
val alarm = alarms.find(state.alarmId) ?: 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(
|
RingSession(
|
||||||
alarm = alarm,
|
alarm = alarm,
|
||||||
settings = resolved,
|
settings = resolved,
|
||||||
startedAt = since,
|
startedAt = since,
|
||||||
canSnooze = state.snoozeCount < resolved.snoozeLimit,
|
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.
|
* 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).
|
* emission; a live readout passes a `Ticker`'s flow (M5 D8).
|
||||||
*/
|
*/
|
||||||
fun upcoming(refresh: Flow<Unit> = flowOf(Unit)): Flow<List<UpcomingAlarm>> =
|
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 now = wallClock.now()
|
||||||
val zone = zones.current()
|
val zone = zones.current()
|
||||||
val byId = states.associateBy { it.alarmId }
|
val byId = states.associateBy { it.alarmId }
|
||||||
@@ -178,6 +352,7 @@ class AlarmEngine @Inject constructor(
|
|||||||
byId[alarm.id] ?: AlarmRingState.initial(alarm.id),
|
byId[alarm.id] ?: AlarmRingState.initial(alarm.id),
|
||||||
now,
|
now,
|
||||||
zone,
|
zone,
|
||||||
|
silenceAfter,
|
||||||
)
|
)
|
||||||
UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
|
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 now = wallClock.now()
|
||||||
val zone = zones.current()
|
val zone = zones.current()
|
||||||
|
val silenceAfter = settings.currentDefaults().alarmSilenceAfter
|
||||||
|
|
||||||
var earliest: Pair<Alarm, AlarmResolution>? = null
|
var earliest: Pair<Alarm, AlarmResolution>? = null
|
||||||
|
val resolved = mutableListOf<UpcomingAlarm>()
|
||||||
|
val pendingSkips = mutableListOf<Instant>()
|
||||||
for (alarm in alarms.alarms().first()) {
|
for (alarm in alarms.alarms().first()) {
|
||||||
val state = states.state(alarm.id)
|
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)
|
persist(alarm, state, resolution)
|
||||||
|
resolved += UpcomingAlarm(alarm, resolution.nextFire, resolution.source)
|
||||||
|
resolution.state.skippedOccurrence?.let { pendingSkips += it }
|
||||||
|
|
||||||
val fireAt = resolution.nextFire ?: continue
|
val fireAt = resolution.nextFire ?: continue
|
||||||
val best = earliest
|
val best = earliest
|
||||||
@@ -205,21 +390,78 @@ class AlarmEngine @Inject constructor(
|
|||||||
if (isEarlier) earliest = alarm to resolution
|
if (isEarlier) earliest = alarm to resolution
|
||||||
}
|
}
|
||||||
|
|
||||||
val winner = earliest ?: run {
|
val winner = earliest
|
||||||
|
val scheduled = if (winner == null) {
|
||||||
scheduler.cancelNext()
|
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!!
|
// After the real registration, never before it.
|
||||||
val mode = scheduler.scheduleNext(fireAt, winner.first.id)
|
val upcoming = UpcomingSync(resolved, pendingSkips)
|
||||||
return ScheduledFire(winner.first.id, fireAt, winner.second.source, mode)
|
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 {
|
private suspend fun onFireLocked(alarmId: Long, scheduledFor: Instant): FireOutcome {
|
||||||
val now = wallClock.now()
|
val now = wallClock.now()
|
||||||
val alarm = alarms.find(alarmId)
|
val alarm = alarms.find(alarmId)
|
||||||
// Deleted, switched off, or a PendingIntent so stale the moment has gone:
|
val state = states.state(alarmId)
|
||||||
// re-resolve the slot and stay quiet.
|
val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor
|
||||||
if (alarm == null || !alarm.enabled || now - scheduledFor > AlarmRing.FIRE_GRACE) {
|
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()
|
rescheduleLocked()
|
||||||
return FireOutcome.Ignored
|
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
|
// 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
|
// it displaces keeps its handled-occurrence watermark, so it does not
|
||||||
// come back the moment the slot is re-resolved.
|
// 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 ->
|
states.ringing()?.takeIf { it.alarmId != alarmId }?.let { other ->
|
||||||
ring.stopRinging(other.alarmId)
|
ring.stopRinging(other.alarmId)
|
||||||
states.save(other.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0))
|
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)
|
// `ringingSince` is cleared too: this fire opens the ring afresh, and a
|
||||||
val isSnoozeFire = state.snoozedUntil != null && state.snoozedUntil == scheduledFor
|
// 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) {
|
val opened = if (isSnoozeFire) {
|
||||||
// The cycle is the same one: the count and the watermark carry over.
|
// The cycle is the same one: the count and the watermark carry over.
|
||||||
state.copy(snoozedUntil = null)
|
state.copy(ringingSince = null, snoozedUntil = null)
|
||||||
} else {
|
} 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
|
// 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
|
// 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.
|
// it holds this alarm's *next* occurrence rather than nothing at all.
|
||||||
states.save(opened)
|
states.save(opened)
|
||||||
notifier.clearSnoozed(alarmId)
|
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))
|
try {
|
||||||
ring.startRinging(alarmId)
|
states.save(states.state(alarmId).copy(ringingSince = now))
|
||||||
scheduler.scheduleAutoSilence(now + AlarmRing.AUTO_SILENCE_AFTER, alarmId)
|
ring.startRinging(alarmId)
|
||||||
|
scheduler.scheduleAutoSilence(now + silenceAfter, alarmId)
|
||||||
|
} finally {
|
||||||
|
upcoming.forEach { syncUpcomingLocked(it, now) }
|
||||||
|
}
|
||||||
return FireOutcome.Ringing(alarmId)
|
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)
|
val state = states.state(alarmId)
|
||||||
if (state.ringingSince == null) return SnoozeOutcome.NotRinging
|
if (state.ringingSince == null) return SnoozeOutcome.NotRinging
|
||||||
val alarm = alarms.find(alarmId) ?: 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())
|
val resolved = alarm.resolveSettings(settings.currentDefaults())
|
||||||
// A limit of 0 means snoozing is off, not unlimited — and a refused
|
// A limit of 0 means snoozing is off, not unlimited — and a refused
|
||||||
// snooze dismisses, so the dismiss path can never strand the user.
|
// 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 (state.snoozeCount >= resolved.snoozeLimit) {
|
||||||
|
if (!dismissIfRefused) return SnoozeOutcome.Refused(alarmId)
|
||||||
closeCycleLocked(alarmId, state)
|
closeCycleLocked(alarmId, state)
|
||||||
return SnoozeOutcome.LimitReached(alarmId)
|
return SnoozeOutcome.LimitReached(alarmId)
|
||||||
}
|
}
|
||||||
@@ -285,25 +546,68 @@ class AlarmEngine @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun resumeRingingIfAnyLocked(): Long? {
|
private suspend fun resumeRingingIfAnyLocked(): Long? {
|
||||||
val state = states.ringing() ?: return null
|
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
|
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)
|
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
|
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) {
|
private suspend fun persist(alarm: Alarm, before: AlarmRingState, resolution: AlarmResolution) {
|
||||||
if (resolution.state != before) states.save(resolution.state)
|
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)
|
if (resolution.clearSkipFlag) alarms.setSkipNextOccurrence(alarm.id, false)
|
||||||
// A transient alarm is deleted wherever a permanent one would be
|
// A transient alarm is deleted wherever a permanent one would be
|
||||||
// disabled: the cycle has closed either way (M9 D10).
|
// disabled: the cycle has closed either way (M9 D10).
|
||||||
if (resolution.disableAlarm) disableOrDeleteLocked(alarm)
|
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.
|
* with its backstop intact.
|
||||||
*/
|
*/
|
||||||
private suspend fun closeCycleLocked(alarmId: Long, state: AlarmRingState) {
|
private suspend fun closeCycleLocked(alarmId: Long, state: AlarmRingState) {
|
||||||
if (state.ringingSince != null) {
|
silenceLocked(alarmId, state)
|
||||||
ring.stopRinging(alarmId)
|
|
||||||
scheduler.cancelAutoSilence()
|
|
||||||
}
|
|
||||||
notifier.clearSnoozed(alarmId)
|
|
||||||
|
|
||||||
val closed = state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)
|
val closed = state.copy(ringingSince = null, snoozedUntil = null, snoozeCount = 0)
|
||||||
if (closed != state) states.save(closed)
|
if (closed != state) states.save(closed)
|
||||||
|
|
||||||
val alarm = alarms.find(alarmId)
|
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()
|
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_SNOOZE: String = PREFIX + "action.ALARM_SNOOZE"
|
||||||
const val ACTION_DISMISS: String = PREFIX + "action.ALARM_DISMISS"
|
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. */
|
/** 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"
|
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_SHOW: Int = 3
|
||||||
const val REQUEST_SNOOZE: Int = 4
|
const val REQUEST_SNOOZE: Int = 4
|
||||||
const val REQUEST_DISMISS: Int = 5
|
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(
|
val ALL_REQUEST_CODES: List<Int> = listOf(
|
||||||
REQUEST_FIRE,
|
REQUEST_FIRE,
|
||||||
@@ -32,5 +46,10 @@ object AlarmIntents {
|
|||||||
REQUEST_SHOW,
|
REQUEST_SHOW,
|
||||||
REQUEST_SNOOZE,
|
REQUEST_SNOOZE,
|
||||||
REQUEST_DISMISS,
|
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
|
package de.jeanlucmakiola.clockula.alarm
|
||||||
|
|
||||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice
|
||||||
import kotlin.time.Instant
|
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 {
|
interface AlarmNotifier {
|
||||||
fun showSnoozed(alarm: Alarm, until: Instant)
|
fun showSnoozed(alarm: Alarm, until: Instant)
|
||||||
|
|
||||||
fun clearSnoozed(alarmId: Long)
|
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 de.jeanlucmakiola.clockula.domain.alarm.ScheduleMode
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** The AlarmManager seam. Two slots, deliberately separate. */
|
/** The AlarmManager seam. Three slots, deliberately separate. */
|
||||||
interface AlarmScheduler {
|
interface AlarmScheduler {
|
||||||
/** Replaces the single next-alarm registration. Returns the mode actually used. */
|
/** Replaces the single next-alarm registration. Returns the mode actually used. */
|
||||||
fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode
|
fun scheduleNext(fireAt: Instant, alarmId: Long): ScheduleMode
|
||||||
@@ -14,6 +14,15 @@ interface AlarmScheduler {
|
|||||||
|
|
||||||
fun cancelAutoSilence()
|
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. */
|
/** `AlarmManager.getNextAlarmClock()`, as the *system* sees it. */
|
||||||
fun systemNextAlarm(): Instant?
|
fun systemNextAlarm(): Instant?
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.clockula.alarm.android
|
package de.jeanlucmakiola.clockula.alarm.android
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -9,15 +10,18 @@ import androidx.core.content.ContextCompat
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmNotifier
|
import de.jeanlucmakiola.clockula.alarm.AlarmNotifier
|
||||||
import de.jeanlucmakiola.clockula.alarm.ring.RingNotifications
|
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
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.UpcomingNotice
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The snoozed notification — the one surface the engine posts directly. A denied
|
* The snoozed and upcoming notifications — the surfaces the engine posts
|
||||||
* notification permission costs the user this line and nothing else: the snooze
|
* directly. A denied notification permission costs the user these lines and
|
||||||
* itself is a row in `alarm_states` and an AlarmManager registration.
|
* 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
|
@Singleton
|
||||||
class AndroidAlarmNotifier @Inject constructor(
|
class AndroidAlarmNotifier @Inject constructor(
|
||||||
@@ -47,4 +51,52 @@ class AndroidAlarmNotifier @Inject constructor(
|
|||||||
override fun clearSnoozed(alarmId: Long) {
|
override fun clearSnoozed(alarmId: Long) {
|
||||||
notifications.cancel(RingNotifications.snoozedNotificationId(alarmId))
|
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
|
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
|
* through `setAlarmClock`, which is the only variant that populates
|
||||||
* `getNextAlarmClock()` — the status-bar icon and the lockscreen line. The
|
* `getNextAlarmClock()` — the status-bar icon and the lockscreen line. The
|
||||||
* auto-silence backstop goes through its own PendingIntent, so an internal
|
* 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.
|
* 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))
|
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? =
|
override fun systemNextAlarm(): Instant? =
|
||||||
manager.nextAlarmClock?.let { Instant.fromEpochMilliseconds(it.triggerTime) }
|
manager.nextAlarmClock?.let { Instant.fromEpochMilliseconds(it.triggerTime) }
|
||||||
|
|
||||||
@@ -94,6 +117,15 @@ class AndroidAlarmScheduler @Inject constructor(
|
|||||||
PENDING_INTENT_FLAGS,
|
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
|
* 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
|
* 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
|
package de.jeanlucmakiola.clockula.alarm.android
|
||||||
|
|
||||||
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
|
||||||
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
|
import de.jeanlucmakiola.clockula.alarm.RingCoordinator
|
||||||
import de.jeanlucmakiola.clockula.alarm.ring.AlarmRingService
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -18,6 +22,7 @@ import javax.inject.Singleton
|
|||||||
@Singleton
|
@Singleton
|
||||||
class ServiceRingCoordinator @Inject constructor(
|
class ServiceRingCoordinator @Inject constructor(
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
|
private val capabilities: AlarmCapabilities,
|
||||||
) : RingCoordinator {
|
) : RingCoordinator {
|
||||||
|
|
||||||
private val state = MutableStateFlow<Long?>(null)
|
private val state = MutableStateFlow<Long?>(null)
|
||||||
@@ -26,11 +31,22 @@ class ServiceRingCoordinator @Inject constructor(
|
|||||||
|
|
||||||
override fun startRinging(alarmId: Long) {
|
override fun startRinging(alarmId: Long) {
|
||||||
state.value = alarmId
|
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) {
|
override fun stopRinging(alarmId: Long) {
|
||||||
if (state.value == alarmId) state.value = null
|
if (state.value == alarmId) state.value = null
|
||||||
AlarmRingService.stop(context)
|
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 dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||||
|
import de.jeanlucmakiola.clockula.alarm.ring.UpcomingNotifications
|
||||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Instant
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** The notification's Snooze and Dismiss buttons. */
|
/** The notifications' Snooze and Dismiss buttons, the upcoming notice's included. */
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AlarmActionReceiver : HiltBroadcastReceiver() {
|
class AlarmActionReceiver : HiltBroadcastReceiver() {
|
||||||
|
|
||||||
@@ -26,12 +28,21 @@ class AlarmActionReceiver : HiltBroadcastReceiver() {
|
|||||||
|
|
||||||
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
|
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
|
||||||
val action = intent.action ?: return
|
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()
|
val pendingResult = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
when (action) {
|
when (action) {
|
||||||
AlarmIntents.ACTION_SNOOZE -> engine.snooze(alarmId)
|
AlarmIntents.ACTION_SNOOZE -> engine.snooze(alarmId)
|
||||||
AlarmIntents.ACTION_DISMISS -> engine.dismiss(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 {
|
} finally {
|
||||||
pendingResult.finish()
|
pendingResult.finish()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.content.Intent
|
|||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||||
|
import de.jeanlucmakiola.clockula.ring.HandoffWakeLock
|
||||||
import de.jeanlucmakiola.floret.di.ApplicationScope
|
import de.jeanlucmakiola.floret.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -12,8 +13,9 @@ import kotlin.time.Instant
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two AlarmManager slots arriving. Thin by design: every decision the engine
|
* The AlarmManager slots arriving: the fire, the backstop and the upcoming
|
||||||
* could make is the engine's, so the receiver only unpacks and dispatches.
|
* check. Thin by design: every decision the engine could make is the
|
||||||
|
* engine's, so the receiver only unpacks and dispatches.
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class AlarmFireReceiver : HiltBroadcastReceiver() {
|
class AlarmFireReceiver : HiltBroadcastReceiver() {
|
||||||
@@ -32,6 +34,7 @@ class AlarmFireReceiver : HiltBroadcastReceiver() {
|
|||||||
|
|
||||||
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
|
val alarmId = intent.getLongExtra(AlarmIntents.EXTRA_ALARM_ID, 0L)
|
||||||
val action = intent.action ?: return
|
val action = intent.action ?: return
|
||||||
|
if (action == AlarmIntents.ACTION_FIRE) HandoffWakeLock.acquire(context, "clockula:alarm-fire")
|
||||||
val pendingResult = goAsync()
|
val pendingResult = goAsync()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -46,6 +49,8 @@ class AlarmFireReceiver : HiltBroadcastReceiver() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
AlarmIntents.ACTION_AUTO_SILENCE -> engine.onAutoSilence(alarmId)
|
AlarmIntents.ACTION_AUTO_SILENCE -> engine.onAutoSilence(alarmId)
|
||||||
|
|
||||||
|
AlarmIntents.ACTION_UPCOMING_CHECK -> engine.onUpcomingWindow()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
pendingResult.finish()
|
pendingResult.finish()
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import android.content.Intent
|
|||||||
import androidx.glance.appwidget.updateAll
|
import androidx.glance.appwidget.updateAll
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||||
|
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||||
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
import de.jeanlucmakiola.clockula.stopwatch.StopwatchEngine
|
||||||
|
import de.jeanlucmakiola.clockula.tile.requestAllTileUpdates
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||||
import de.jeanlucmakiola.clockula.widget.AnalogClockWidget
|
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 de.jeanlucmakiola.floret.di.ApplicationScope
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -17,9 +20,11 @@ import javax.inject.Inject
|
|||||||
/**
|
/**
|
||||||
* Everything that invalidates a registration: a reboot clears AlarmManager, an
|
* 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.
|
* 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
|
* reschedules nothing (their slot is elapsed-realtime anchored); it re-anchors
|
||||||
* the wall-clock fallback, which is what the notification's chronometer counts
|
* the wall-clock fallback, which is what the notification's chronometer counts
|
||||||
* to and what a reboot would fall back on.
|
* to and what a reboot would fall back on.
|
||||||
@@ -39,6 +44,9 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var stopwatchEngine: StopwatchEngine
|
lateinit var stopwatchEngine: StopwatchEngine
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var bedtimeEngine: BedtimeEngine
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
@ApplicationScope
|
@ApplicationScope
|
||||||
lateinit var scope: CoroutineScope
|
lateinit var scope: CoroutineScope
|
||||||
@@ -65,6 +73,11 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
|||||||
stopwatchEngine.resync()
|
stopwatchEngine.resync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ACTION_EXACT_ALARM_PERMISSION_CHANGED -> {
|
||||||
|
engine.reschedule()
|
||||||
|
timerEngine.resync()
|
||||||
|
}
|
||||||
|
|
||||||
Intent.ACTION_TIME_CHANGED,
|
Intent.ACTION_TIME_CHANGED,
|
||||||
Intent.ACTION_TIMEZONE_CHANGED,
|
Intent.ACTION_TIMEZONE_CHANGED,
|
||||||
-> {
|
-> {
|
||||||
@@ -85,12 +98,28 @@ class SystemEventReceiver : HiltBroadcastReceiver() {
|
|||||||
// The analog faces' date is drawn for the old day or
|
// The analog faces' date is drawn for the old day or
|
||||||
// zone, and the midnight redraw armed for the old one.
|
// zone, and the midnight redraw armed for the old one.
|
||||||
AnalogClockWidget().updateAll(context)
|
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 {
|
} 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()
|
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
|
package de.jeanlucmakiola.clockula.alarm.ring
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -12,16 +14,21 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
|
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
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.RingPresentationPolicy
|
||||||
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||||
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
|
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.RingAudioPlayer
|
||||||
import de.jeanlucmakiola.clockula.ring.RingVibrator
|
import de.jeanlucmakiola.clockula.ring.RingVibrator
|
||||||
|
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -43,6 +50,9 @@ class AlarmRingService : Service() {
|
|||||||
@Inject
|
@Inject
|
||||||
lateinit var capabilities: AlarmCapabilities
|
lateinit var capabilities: AlarmCapabilities
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var settings: SettingsPrefs
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
|
||||||
private val audio by lazy { RingAudioPlayer(this) }
|
private val audio by lazy { RingAudioPlayer(this) }
|
||||||
@@ -51,6 +61,12 @@ class AlarmRingService : Service() {
|
|||||||
private var wakeLock: PowerManager.WakeLock? = null
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
private var ringing: Job? = 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 onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -58,6 +74,16 @@ class AlarmRingService : Service() {
|
|||||||
stop()
|
stop()
|
||||||
return START_NOT_STICKY
|
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
|
// The whole ring is released, not just the coroutine: cancelling the job
|
||||||
// alone would orphan a MediaPlayer the previous start had already
|
// 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
|
// foreground service a few seconds to post its notification, and a
|
||||||
// database read is not something to spend them on.
|
// database read is not something to spend them on.
|
||||||
startForegroundPreparing()
|
startForegroundPreparing()
|
||||||
|
// Also before the read: the fire broadcast's own wake lock is gone by now.
|
||||||
|
acquireWakeLock()
|
||||||
ringing = scope.launch {
|
ringing = scope.launch {
|
||||||
val session = engine.ringSession()
|
val session = engine.ringSession()
|
||||||
if (session == null) {
|
if (session == null) {
|
||||||
@@ -75,6 +103,7 @@ class AlarmRingService : Service() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
ring(session)
|
ring(session)
|
||||||
|
openScreen(session)
|
||||||
}
|
}
|
||||||
// Re-delivered rather than dropped: a killed ring is resumed with the
|
// Re-delivered rather than dropped: a killed ring is resumed with the
|
||||||
// same intent, and the session is re-read from storage anyway.
|
// same intent, and the session is re-read from storage anyway.
|
||||||
@@ -89,7 +118,6 @@ class AlarmRingService : Service() {
|
|||||||
|
|
||||||
private fun ring(session: RingSession) {
|
private fun ring(session: RingSession) {
|
||||||
startForeground(session)
|
startForeground(session)
|
||||||
acquireWakeLock()
|
|
||||||
|
|
||||||
// Vibration first: the sound may take seconds to open when another app
|
// Vibration first: the sound may take seconds to open when another app
|
||||||
// plays it, and the ring must not be still until then.
|
// 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]
|
* The placeholder the service goes foreground with immediately; [ring]
|
||||||
* replaces it with the alarm's own notification as soon as the session is
|
* 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() {
|
private fun startForegroundPreparing() {
|
||||||
startForeground(RingNotifications.preparing(this))
|
startForeground(RingNotifications.preparing(this, presentation(), repostPendingIntent()))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startForeground(session: RingSession) {
|
private fun startForeground(session: RingSession) {
|
||||||
startForeground(
|
startForeground(
|
||||||
RingNotifications.ringing(
|
RingNotifications.ringing(
|
||||||
context = this,
|
context = this,
|
||||||
alarm = session.alarm,
|
session = session,
|
||||||
presentation = RingPresentationPolicy.forCapabilities(capabilities.snapshot()),
|
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) {
|
private fun startForeground(notification: Notification) {
|
||||||
|
posted = notification
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
// systemExempted: Android's own guidance names an app holding an
|
// systemExempted: Android's own guidance names an app holding an
|
||||||
// exact-alarm permission and continuing an alarm in the background.
|
// exact-alarm permission and continuing an alarm in the background.
|
||||||
@@ -159,25 +217,30 @@ class AlarmRingService : Service() {
|
|||||||
|
|
||||||
private fun stop() {
|
private fun stop() {
|
||||||
releaseRing()
|
releaseRing()
|
||||||
|
posted = null
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ACTION_STOP = AlarmIntents.ACTION_DISMISS + ".SERVICE_STOP"
|
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"
|
private const val WAKE_LOCK_TAG = "clockula:alarm-ring"
|
||||||
|
|
||||||
/** A backstop on the backstop: the ring window is ten minutes. */
|
/** A backstop on the backstop, shared with the other ring service. */
|
||||||
private const val WAKE_LOCK_TIMEOUT_MILLIS = 15L * 60L * 1_000L
|
private val WAKE_LOCK_TIMEOUT_MILLIS = SilenceAfter.WAKE_LOCK_TIMEOUT.inWholeMilliseconds
|
||||||
|
|
||||||
fun start(context: Context) {
|
fun start(context: Context) {
|
||||||
context.startForegroundService(Intent(context, AlarmRingService::class.java))
|
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) {
|
fun stop(context: Context) {
|
||||||
context.startService(
|
context.stopService(Intent(context, AlarmRingService::class.java))
|
||||||
Intent(context, AlarmRingService::class.java).setAction(ACTION_STOP),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,10 @@ import de.jeanlucmakiola.clockula.R
|
|||||||
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
import de.jeanlucmakiola.clockula.alarm.AlarmIntents
|
||||||
import de.jeanlucmakiola.clockula.alarm.receiver.AlarmActionReceiver
|
import de.jeanlucmakiola.clockula.alarm.receiver.AlarmActionReceiver
|
||||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
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.RingPresentation
|
||||||
|
import de.jeanlucmakiola.clockula.domain.alarm.RingSession
|
||||||
|
import de.jeanlucmakiola.clockula.ring.ForegroundRingScreen
|
||||||
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
import de.jeanlucmakiola.clockula.ui.ring.AlarmRingActivity
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
import java.time.LocalTime
|
import java.time.LocalTime
|
||||||
@@ -72,9 +75,17 @@ object RingNotifications {
|
|||||||
* The notification the service goes foreground with the instant it starts,
|
* The notification the service goes foreground with the instant it starts,
|
||||||
* before it has read anything: `startForeground` must not wait on a database
|
* before it has read anything: `startForeground` must not wait on a database
|
||||||
* read. [ringing] replaces it under the same id a moment later.
|
* 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 =
|
fun preparing(context: Context, presentation: RingPresentation, repost: PendingIntent): Notification {
|
||||||
NotificationCompat.Builder(context, RING_CHANNEL_ID)
|
val show = showPendingIntent(context)
|
||||||
|
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(context.getString(R.string.alarm_notification_title))
|
.setContentTitle(context.getString(R.string.alarm_notification_title))
|
||||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||||
@@ -82,15 +93,51 @@ object RingNotifications {
|
|||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setContentIntent(showPendingIntent(context))
|
.setContentIntent(show)
|
||||||
.build()
|
.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(
|
fun ringing(
|
||||||
context: Context,
|
context: Context,
|
||||||
alarm: Alarm,
|
session: RingSession,
|
||||||
presentation: RingPresentation,
|
presentation: RingPresentation,
|
||||||
|
repost: PendingIntent,
|
||||||
): Notification {
|
): Notification {
|
||||||
|
val alarm = session.alarm
|
||||||
val show = showPendingIntent(context)
|
val show = showPendingIntent(context)
|
||||||
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
|
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
@@ -102,16 +149,20 @@ object RingNotifications {
|
|||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setContentIntent(show)
|
.setContentIntent(show)
|
||||||
.addAction(
|
.setDeleteIntent(repost)
|
||||||
|
if (session.canSnooze) {
|
||||||
|
builder.addAction(
|
||||||
R.drawable.ic_notification,
|
R.drawable.ic_notification,
|
||||||
context.getString(R.string.alarm_action_snooze),
|
context.getString(R.string.alarm_action_snooze),
|
||||||
actionPendingIntent(context, AlarmIntents.ACTION_SNOOZE, AlarmIntents.REQUEST_SNOOZE, alarm.id),
|
actionPendingIntent(context, AlarmIntents.ACTION_SNOOZE, AlarmIntents.REQUEST_SNOOZE, alarm.id),
|
||||||
)
|
)
|
||||||
.addAction(
|
}
|
||||||
R.drawable.ic_notification,
|
val dismiss = if (session.settings.dismissChallenge == DismissChallenge.NONE) {
|
||||||
context.getString(R.string.alarm_action_dismiss),
|
actionPendingIntent(context, AlarmIntents.ACTION_DISMISS, AlarmIntents.REQUEST_DISMISS, alarm.id)
|
||||||
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)
|
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
|
||||||
return builder.build()
|
return builder.build()
|
||||||
@@ -140,9 +191,7 @@ object RingNotifications {
|
|||||||
private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
|
private fun showPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
AlarmIntents.REQUEST_SHOW,
|
AlarmIntents.REQUEST_SHOW,
|
||||||
Intent(context, AlarmRingActivity::class.java).apply {
|
ForegroundRingScreen.intent(context, AlarmRingActivity::class.java),
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -166,7 +215,8 @@ object RingNotifications {
|
|||||||
private fun formatTime(context: Context, hour: Int, minute: Int): String =
|
private fun formatTime(context: Context, hour: Int, minute: Int): String =
|
||||||
format(context, LocalTime.of(hour, minute))
|
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,
|
context,
|
||||||
JavaInstant.ofEpochMilli(instant.toEpochMilliseconds()).atZone(ZoneId.systemDefault()).toLocalTime(),
|
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
|
package de.jeanlucmakiola.clockula.backup
|
||||||
|
|
||||||
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
|
||||||
|
import de.jeanlucmakiola.clockula.bedtime.BedtimeEngine
|
||||||
import de.jeanlucmakiola.clockula.data.backup.BackupRepository
|
import de.jeanlucmakiola.clockula.data.backup.BackupRepository
|
||||||
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
|
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.BackupContent
|
||||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||||
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
import de.jeanlucmakiola.clockula.timer.TimerEngine
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -24,9 +25,9 @@ import javax.inject.Singleton
|
|||||||
class BackupEngine @Inject constructor(
|
class BackupEngine @Inject constructor(
|
||||||
private val repository: BackupRepository,
|
private val repository: BackupRepository,
|
||||||
private val settings: SettingsPrefs,
|
private val settings: SettingsPrefs,
|
||||||
private val timers: TimerRepository,
|
|
||||||
private val alarmEngine: AlarmEngine,
|
private val alarmEngine: AlarmEngine,
|
||||||
private val timerEngine: TimerEngine,
|
private val timerEngine: TimerEngine,
|
||||||
|
private val bedtimeEngine: BedtimeEngine,
|
||||||
private val wallClock: WallClock,
|
private val wallClock: WallClock,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@@ -39,24 +40,74 @@ class BackupEngine @Inject constructor(
|
|||||||
settings = settingsSnapshot(),
|
settings = settingsSnapshot(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/** Replace settings and rows, then reconcile the platform services. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
suspend fun import(content: BackupContent) {
|
suspend fun import(content: BackupContent) {
|
||||||
stopRingingAlarmIfAny()
|
val previousSettings = settingsSnapshot()
|
||||||
deleteRunningTimers()
|
var settingsApplied = false
|
||||||
|
var rowsReplaced = false
|
||||||
repository.replaceAll(content.alarms, content.timers, content.worldClocks)
|
var ringSuspended = false
|
||||||
applySettings(content.settings)
|
var failure: Throwable? = null
|
||||||
|
try {
|
||||||
alarmEngine.reschedule()
|
withContext(NonCancellable) {
|
||||||
timerEngine.resync()
|
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(
|
private suspend fun settingsSnapshot(): BackupSettings = BackupSettings(
|
||||||
@@ -64,29 +115,9 @@ class BackupEngine @Inject constructor(
|
|||||||
dynamicColor = settings.dynamicColor.first(),
|
dynamicColor = settings.dynamicColor.first(),
|
||||||
defaults = settings.currentDefaults(),
|
defaults = settings.currentDefaults(),
|
||||||
timerPresets = settings.timerPresets.first(),
|
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 de.jeanlucmakiola.floret.di.IoDispatcher
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -27,14 +29,29 @@ class SafBackupFiles @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun write(treeUri: String, content: String): Result<Unit> = withContext(io) {
|
override suspend fun write(treeUri: String, content: String): Result<Unit> = withContext(io) {
|
||||||
runCatching {
|
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())
|
val tree = DocumentFile.fromTreeUri(context, treeUri.toUri())
|
||||||
?: error("could not open the chosen folder")
|
?: error("could not open the chosen folder")
|
||||||
tree.findFile(BACKUP_FILE_NAME)?.delete()
|
val temporary = tree.createFile("application/json", "$BACKUP_FILE_NAME.${UUID.randomUUID()}.tmp")
|
||||||
val file = tree.createFile("application/json", BACKUP_FILE_NAME)
|
?: throw IOException("could not create a temporary backup")
|
||||||
?: error("could not create $BACKUP_FILE_NAME")
|
var keepTemporary = false
|
||||||
val stream = context.contentResolver.openOutputStream(file.uri)
|
try {
|
||||||
?: throw IOException("could not open an output stream for ${file.uri}")
|
val stream = context.contentResolver.openOutputStream(temporary.uri)
|
||||||
stream.use { it.write(content.toByteArray(Charsets.UTF_8)) }
|
?: 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 {
|
runCatching {
|
||||||
val stream = context.contentResolver.openInputStream(documentUri.toUri())
|
val stream = context.contentResolver.openInputStream(documentUri.toUri())
|
||||||
?: throw IOException("could not open an input stream for $documentUri")
|
?: 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.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.room.Update
|
import androidx.room.Update
|
||||||
@@ -24,6 +25,10 @@ abstract class AlarmDao {
|
|||||||
@Insert
|
@Insert
|
||||||
abstract suspend fun insert(alarm: AlarmEntity): Long
|
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
|
@Update
|
||||||
abstract suspend fun update(alarm: AlarmEntity)
|
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")
|
@Query("UPDATE alarms SET skip_next_occurrence = :skip, updated_at = :updatedAt WHERE id = :id")
|
||||||
abstract suspend fun setSkipNextOccurrence(id: Long, skip: Boolean, updatedAt: Long)
|
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
|
* Reads the row, applies [transform] and writes the result back inside one
|
||||||
* transaction. A missing row, or a null result, writes nothing. [transform]
|
* 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
|
* 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/domain translation lives in [AlarmMapper] where it can be tested.
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "alarms", indices = [Index("enabled")])
|
@Entity(tableName = "alarms", indices = [Index("enabled")])
|
||||||
@@ -28,4 +29,9 @@ data class AlarmEntity(
|
|||||||
@ColumnInfo(name = "created_at") val createdAt: Long,
|
@ColumnInfo(name = "created_at") val createdAt: Long,
|
||||||
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
@ColumnInfo(name = "updated_at") val updatedAt: Long,
|
||||||
@ColumnInfo(name = "delete_after_use", defaultValue = "0") val deleteAfterUse: Boolean = false,
|
@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
|
package de.jeanlucmakiola.clockula.data.alarms
|
||||||
|
|
||||||
import de.jeanlucmakiola.clockula.domain.Alarm
|
import de.jeanlucmakiola.clockula.domain.Alarm
|
||||||
|
import de.jeanlucmakiola.clockula.domain.AlarmDates
|
||||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||||
|
import de.jeanlucmakiola.clockula.domain.ClockLimits
|
||||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||||
|
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||||
|
import java.time.LocalDate
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** Entity ↔ domain. Reads are **forgiving**: a corrupt row degrades, never throws. */
|
/** Entity ↔ domain. Reads are **forgiving**: a corrupt row degrades, never throws. */
|
||||||
object AlarmMapper {
|
object AlarmMapper {
|
||||||
fun toDomain(entity: AlarmEntity): Alarm = Alarm(
|
fun toDomain(entity: AlarmEntity): Alarm {
|
||||||
id = entity.id,
|
val repeatDays = RepeatDays.fromMask(entity.repeatDays)
|
||||||
time = TimeOfDay.clamped(entity.hour, entity.minute),
|
return Alarm(
|
||||||
label = entity.label,
|
id = entity.id,
|
||||||
enabled = entity.enabled,
|
time = TimeOfDay.clamped(entity.hour, entity.minute),
|
||||||
repeatDays = RepeatDays.fromMask(entity.repeatDays),
|
label = entity.label,
|
||||||
skipNextOccurrence = entity.skipNextOccurrence,
|
enabled = entity.enabled,
|
||||||
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
|
repeatDays = repeatDays,
|
||||||
vibrate = entity.vibrate,
|
skipNextOccurrence = entity.skipNextOccurrence,
|
||||||
snoozeMinutes = entity.snoozeMinutes,
|
ringtoneUri = entity.ringtoneUri?.takeIf { it.isNotBlank() },
|
||||||
snoozeLimit = entity.snoozeLimit,
|
vibrate = entity.vibrate,
|
||||||
volumeRampSeconds = entity.volumeRampSeconds,
|
snoozeMinutes = entity.snoozeMinutes?.coerceIn(ClockLimits.SNOOZE_MINUTES),
|
||||||
// An absent challenge is an *override that isn't set*, so it stays null;
|
snoozeLimit = entity.snoozeLimit?.coerceIn(ClockLimits.SNOOZE_LIMIT),
|
||||||
// a stored name the app no longer knows degrades to NONE.
|
volumeRampSeconds = entity.volumeRampSeconds?.coerceIn(ClockLimits.VOLUME_RAMP_SECONDS),
|
||||||
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
|
// An absent challenge is an *override that isn't set*, so it stays null;
|
||||||
createdAt = Instant.fromEpochMilliseconds(entity.createdAt),
|
// a stored name the app no longer knows degrades to NONE.
|
||||||
updatedAt = Instant.fromEpochMilliseconds(entity.updatedAt),
|
dismissChallenge = entity.dismissChallenge?.toEnum(DismissChallenge.NONE),
|
||||||
deleteAfterUse = entity.deleteAfterUse,
|
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)
|
fun toDomain(entities: List<AlarmEntity>): List<Alarm> = entities.map(::toDomain)
|
||||||
|
|
||||||
@@ -49,6 +60,9 @@ object AlarmMapper {
|
|||||||
createdAt = alarm.createdAt.toEpochMilliseconds(),
|
createdAt = alarm.createdAt.toEpochMilliseconds(),
|
||||||
updatedAt = alarm.updatedAt.toEpochMilliseconds(),
|
updatedAt = alarm.updatedAt.toEpochMilliseconds(),
|
||||||
deleteAfterUse = alarm.deleteAfterUse,
|
deleteAfterUse = alarm.deleteAfterUse,
|
||||||
|
scheduledDate = alarm.scheduledDate?.toEpochDay(),
|
||||||
|
pauseStart = alarm.pause?.start?.toEpochDay(),
|
||||||
|
pauseEnd = alarm.pause?.end?.toEpochDay(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** id = 0, createdAt = updatedAt = [now]. */
|
/** id = 0, createdAt = updatedAt = [now]. */
|
||||||
@@ -70,4 +84,17 @@ object AlarmMapper {
|
|||||||
updatedAt = now.toEpochMilliseconds(),
|
updatedAt = now.toEpochMilliseconds(),
|
||||||
deleteAfterUse = draft.deleteAfterUse,
|
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.Alarm
|
||||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||||
|
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
interface AlarmRepository {
|
interface AlarmRepository {
|
||||||
fun alarms(): Flow<List<Alarm>>
|
fun alarms(): Flow<List<Alarm>>
|
||||||
@@ -12,9 +14,25 @@ interface AlarmRepository {
|
|||||||
suspend fun create(draft: AlarmDraft): Long
|
suspend fun create(draft: AlarmDraft): Long
|
||||||
suspend fun update(alarm: Alarm)
|
suspend fun update(alarm: Alarm)
|
||||||
suspend fun delete(id: Long)
|
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 setEnabled(id: Long, enabled: Boolean)
|
||||||
suspend fun setSkipNextOccurrence(id: Long, skip: 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
|
* Transactional read-modify-write. Stamps `updated_at` from the wall clock
|
||||||
* and keeps the id, so a concurrent `setEnabled` from the engine cannot be
|
* 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.Alarm
|
||||||
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
import de.jeanlucmakiola.clockula.domain.AlarmDraft
|
||||||
|
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.time.LocalDate
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -36,12 +38,21 @@ class AlarmRepositoryImpl @Inject constructor(
|
|||||||
|
|
||||||
override suspend fun delete(id: Long) = dao.deleteById(id)
|
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) =
|
override suspend fun setEnabled(id: Long, enabled: Boolean) =
|
||||||
dao.setEnabled(id, enabled, wallClock.now().toEpochMilliseconds())
|
dao.setEnabled(id, enabled, wallClock.now().toEpochMilliseconds())
|
||||||
|
|
||||||
override suspend fun setSkipNextOccurrence(id: Long, skip: Boolean) =
|
override suspend fun setSkipNextOccurrence(id: Long, skip: Boolean) =
|
||||||
dao.setSkipNextOccurrence(id, skip, wallClock.now().toEpochMilliseconds())
|
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) {
|
override suspend fun edit(id: Long, transform: (Alarm) -> Alarm) {
|
||||||
val updatedAt = wallClock.now().toEpochMilliseconds()
|
val updatedAt = wallClock.now().toEpochMilliseconds()
|
||||||
dao.updateWithin(id) { entity ->
|
dao.updateWithin(id) { entity ->
|
||||||
|
|||||||
@@ -1,26 +1,50 @@
|
|||||||
package de.jeanlucmakiola.clockula.data.backup
|
package de.jeanlucmakiola.clockula.data.backup
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.clockula.domain.AlarmDates
|
||||||
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
import de.jeanlucmakiola.clockula.domain.ClockDefaults
|
||||||
|
import de.jeanlucmakiola.clockula.domain.ClockLimits
|
||||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||||
|
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||||
import de.jeanlucmakiola.clockula.domain.Zones
|
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.BackupAlarm
|
||||||
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
|
||||||
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
|
||||||
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
import de.jeanlucmakiola.clockula.domain.backup.BackupTimer
|
||||||
import de.jeanlucmakiola.clockula.domain.backup.BackupWorldClock
|
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.ThemeMode
|
||||||
import de.jeanlucmakiola.floret.prefs.toEnum
|
import de.jeanlucmakiola.floret.prefs.toEnum
|
||||||
import kotlinx.serialization.SerializationException
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Instant
|
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 {
|
sealed interface BackupResult {
|
||||||
data class Success(val content: BackupContent) : 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 {
|
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 {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
@@ -41,12 +72,15 @@ object BackupCodec {
|
|||||||
json.encodeToString(BackupDocument.serializer(), content.toDocument())
|
json.encodeToString(BackupDocument.serializer(), content.toDocument())
|
||||||
|
|
||||||
fun decode(text: String): BackupResult {
|
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 {
|
val document = try {
|
||||||
json.decodeFromString(BackupDocument.serializer(), text)
|
json.decodeFromString(BackupDocument.serializer(), text)
|
||||||
} catch (e: SerializationException) {
|
} 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) {
|
} 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()
|
return document.toContent()
|
||||||
}
|
}
|
||||||
@@ -77,6 +111,9 @@ object BackupCodec {
|
|||||||
volumeRampSeconds = volumeRampSeconds,
|
volumeRampSeconds = volumeRampSeconds,
|
||||||
dismissChallenge = dismissChallenge?.name,
|
dismissChallenge = dismissChallenge?.name,
|
||||||
deleteAfterUse = deleteAfterUse,
|
deleteAfterUse = deleteAfterUse,
|
||||||
|
scheduledDate = scheduledDate?.toString(),
|
||||||
|
pauseStart = pause?.start?.toString(),
|
||||||
|
pauseEnd = pause?.end?.toString(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun BackupTimer.toDto() = TimerDto(
|
private fun BackupTimer.toDto() = TimerDto(
|
||||||
@@ -102,48 +139,125 @@ object BackupCodec {
|
|||||||
volumeRampSeconds = defaults.volumeRampSeconds,
|
volumeRampSeconds = defaults.volumeRampSeconds,
|
||||||
alarmRingtoneUri = defaults.alarmRingtoneUri,
|
alarmRingtoneUri = defaults.alarmRingtoneUri,
|
||||||
timerRingtoneUri = defaults.timerRingtoneUri,
|
timerRingtoneUri = defaults.timerRingtoneUri,
|
||||||
|
timerVibrate = defaults.timerVibrate,
|
||||||
|
timerVolumeRampSeconds = defaults.timerVolumeRampSeconds,
|
||||||
dismissChallenge = defaults.dismissChallenge.name,
|
dismissChallenge = defaults.dismissChallenge.name,
|
||||||
homeZoneId = defaults.homeZoneId,
|
homeZoneId = defaults.homeZoneId,
|
||||||
|
alarmSilenceAfterMinutes = defaults.alarmSilenceAfterMinutes,
|
||||||
|
timerSilenceAfterMinutes = defaults.timerSilenceAfterMinutes,
|
||||||
|
volumeButtons = defaults.volumeButtons.name,
|
||||||
|
automaticLocalClock = defaults.automaticLocalClock,
|
||||||
|
worldClockSeconds = worldClockSeconds,
|
||||||
timerPresets = timerPresets.map { it.inWholeMilliseconds },
|
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 ---
|
// --- decode + validate ---
|
||||||
|
|
||||||
private fun BackupDocument.toContent(): BackupResult {
|
private fun BackupDocument.toContent(): BackupResult {
|
||||||
if (format != BACKUP_FORMAT) return BackupResult.Failure("unknown format: '$format'")
|
if (format != BACKUP_FORMAT) {
|
||||||
if (version != BACKUP_VERSION) return BackupResult.Failure("unsupported backup version: $version")
|
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>()
|
val alarms = mutableListOf<BackupAlarm>()
|
||||||
for ((index, dto) in this.alarms.withIndex()) {
|
for ((index, dto) in this.alarms.withIndex()) {
|
||||||
if (dto.hour !in 0..23 || dto.minute !in 0..59) {
|
if (dto.hour !in 0..23 || dto.minute !in 0..59) {
|
||||||
return BackupResult.Failure(
|
return BackupResult.Failure(
|
||||||
|
BackupProblem.INVALID_DATA,
|
||||||
"alarms[$index]: hour/minute out of range (${dto.hour}:${dto.minute})",
|
"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(
|
alarms += BackupAlarm(
|
||||||
time = TimeOfDay(dto.hour, dto.minute),
|
time = TimeOfDay(dto.hour, dto.minute),
|
||||||
label = dto.label,
|
label = dto.label,
|
||||||
enabled = dto.enabled,
|
enabled = dto.enabled,
|
||||||
repeatDays = RepeatDays.fromMask(dto.repeatDays),
|
repeatDays = repeatDays,
|
||||||
skipNextOccurrence = dto.skipNextOccurrence,
|
skipNextOccurrence = dto.skipNextOccurrence,
|
||||||
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
|
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
|
||||||
vibrate = dto.vibrate,
|
vibrate = dto.vibrate,
|
||||||
snoozeMinutes = dto.snoozeMinutes,
|
// The same clamps the defaults pass through: an alarm's own
|
||||||
snoozeLimit = dto.snoozeLimit,
|
// override is no freer than the setting it overrides.
|
||||||
volumeRampSeconds = dto.volumeRampSeconds,
|
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),
|
dismissChallenge = dto.dismissChallenge?.toEnum(DismissChallenge.NONE),
|
||||||
deleteAfterUse = dto.deleteAfterUse,
|
deleteAfterUse = dto.deleteAfterUse,
|
||||||
|
scheduledDate = dates.scheduledDate,
|
||||||
|
pause = dates.pause,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val timers = mutableListOf<BackupTimer>()
|
val timers = mutableListOf<BackupTimer>()
|
||||||
for ((index, dto) in this.timers.withIndex()) {
|
for ((index, dto) in this.timers.withIndex()) {
|
||||||
if (dto.durationMillis < 0) {
|
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(
|
timers += BackupTimer(
|
||||||
label = dto.label,
|
label = dto.label,
|
||||||
duration = dto.durationMillis.milliseconds,
|
duration = dto.durationMillis.milliseconds.coerceIn(TimerPresets.MIN, TimerPresets.MAX),
|
||||||
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
|
ringtoneUri = dto.ringtoneUri?.takeIf { it.isNotBlank() },
|
||||||
sortOrder = dto.sortOrder,
|
sortOrder = dto.sortOrder,
|
||||||
deleteAfterUse = dto.deleteAfterUse,
|
deleteAfterUse = dto.deleteAfterUse,
|
||||||
@@ -153,7 +267,13 @@ object BackupCodec {
|
|||||||
val worldClocks = mutableListOf<BackupWorldClock>()
|
val worldClocks = mutableListOf<BackupWorldClock>()
|
||||||
for ((index, dto) in this.worldClocks.withIndex()) {
|
for ((index, dto) in this.worldClocks.withIndex()) {
|
||||||
if (!Zones.isValid(dto.zoneId)) {
|
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(
|
worldClocks += BackupWorldClock(
|
||||||
zoneId = dto.zoneId,
|
zoneId = dto.zoneId,
|
||||||
@@ -163,6 +283,12 @@ object BackupCodec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val settingsDto = settings
|
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(
|
val settingsContent = BackupSettings(
|
||||||
themeMode = settingsDto.themeMode.toEnum(ThemeMode.SYSTEM),
|
themeMode = settingsDto.themeMode.toEnum(ThemeMode.SYSTEM),
|
||||||
dynamicColor = settingsDto.dynamicColor,
|
dynamicColor = settingsDto.dynamicColor,
|
||||||
@@ -173,10 +299,24 @@ object BackupCodec {
|
|||||||
volumeRampSeconds = settingsDto.volumeRampSeconds,
|
volumeRampSeconds = settingsDto.volumeRampSeconds,
|
||||||
alarmRingtoneUri = settingsDto.alarmRingtoneUri?.takeIf { it.isNotBlank() },
|
alarmRingtoneUri = settingsDto.alarmRingtoneUri?.takeIf { it.isNotBlank() },
|
||||||
timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() },
|
timerRingtoneUri = settingsDto.timerRingtoneUri?.takeIf { it.isNotBlank() },
|
||||||
|
timerVibrate = settingsDto.timerVibrate ?: settingsDto.vibrate,
|
||||||
|
timerVolumeRampSeconds = settingsDto.timerVolumeRampSeconds,
|
||||||
dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE),
|
dismissChallenge = settingsDto.dismissChallenge.toEnum(DismissChallenge.NONE),
|
||||||
homeZoneId = settingsDto.homeZoneId?.let(Zones::normalise),
|
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(
|
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
|
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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
/** `docs/BACKUP.md`'s top-level constants. */
|
/** `docs/BACKUP.md`'s top-level constants. */
|
||||||
const val BACKUP_FORMAT: String = "clockula-backup"
|
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`. */
|
/** The wire shape of `clockula-backup-v1.json`. See `docs/BACKUP.md`. */
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -35,6 +40,10 @@ data class AlarmDto(
|
|||||||
@SerialName("volume_ramp_seconds") val volumeRampSeconds: Int? = null,
|
@SerialName("volume_ramp_seconds") val volumeRampSeconds: Int? = null,
|
||||||
@SerialName("dismiss_challenge") val dismissChallenge: String? = null,
|
@SerialName("dismiss_challenge") val dismissChallenge: String? = null,
|
||||||
@SerialName("delete_after_use") val deleteAfterUse: Boolean = false,
|
@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`. */
|
/** 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_volume_ramp_seconds") val volumeRampSeconds: Int = 15,
|
||||||
@SerialName("default_alarm_ringtone_uri") val alarmRingtoneUri: String? = null,
|
@SerialName("default_alarm_ringtone_uri") val alarmRingtoneUri: String? = null,
|
||||||
@SerialName("default_timer_ringtone_uri") val timerRingtoneUri: 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("default_dismiss_challenge") val dismissChallenge: String = "NONE",
|
||||||
@SerialName("home_zone_id") val homeZoneId: String? = null,
|
@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,
|
volumeRampSeconds = volumeRampSeconds,
|
||||||
dismissChallenge = dismissChallenge,
|
dismissChallenge = dismissChallenge,
|
||||||
deleteAfterUse = deleteAfterUse,
|
deleteAfterUse = deleteAfterUse,
|
||||||
|
scheduledDate = scheduledDate,
|
||||||
|
pause = pause,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun Timer.toBackup() = BackupTimer(
|
private fun Timer.toBackup() = BackupTimer(
|
||||||
@@ -100,6 +102,9 @@ private fun BackupAlarm.toEntity(nowMillis: Long) = AlarmEntity(
|
|||||||
createdAt = nowMillis,
|
createdAt = nowMillis,
|
||||||
updatedAt = nowMillis,
|
updatedAt = nowMillis,
|
||||||
deleteAfterUse = deleteAfterUse,
|
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`). */
|
/** 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
|
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.
|
* and committed, so every later migration is reviewable and testable.
|
||||||
*
|
*
|
||||||
* No `TypeConverters` — every column is a primitive and the whole translation
|
* No `TypeConverters` — every column is a primitive and the whole translation
|
||||||
@@ -30,7 +30,7 @@ import de.jeanlucmakiola.clockula.data.worldclocks.WorldClockEntity
|
|||||||
WorldClockEntity::class,
|
WorldClockEntity::class,
|
||||||
LapEntity::class,
|
LapEntity::class,
|
||||||
],
|
],
|
||||||
version = 3,
|
version = 4,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class ClockulaDatabase : RoomDatabase() {
|
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.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import de.jeanlucmakiola.clockula.data.time.AndroidBootIdProvider
|
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.RealTicker
|
||||||
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
|
import de.jeanlucmakiola.clockula.data.time.SystemElapsedRealtimeClock
|
||||||
import de.jeanlucmakiola.clockula.data.time.SystemWallClock
|
import de.jeanlucmakiola.clockula.data.time.SystemWallClock
|
||||||
import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider
|
import de.jeanlucmakiola.clockula.data.time.SystemZoneProvider
|
||||||
import de.jeanlucmakiola.clockula.domain.time.BootIdProvider
|
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.ElapsedRealtimeClock
|
||||||
|
import de.jeanlucmakiola.clockula.domain.time.HourFormat
|
||||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
||||||
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
import de.jeanlucmakiola.clockula.domain.time.WallClock
|
||||||
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
|
||||||
@@ -39,4 +43,10 @@ abstract class TimeModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
abstract fun bindTicker(impl: RealTicker): Ticker
|
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
|
package de.jeanlucmakiola.clockula.data.prefs
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.clockula.domain.ClockLimits
|
||||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||||
import de.jeanlucmakiola.clockula.domain.Zones
|
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.Pref
|
||||||
import de.jeanlucmakiola.floret.prefs.booleanPref
|
import de.jeanlucmakiola.floret.prefs.booleanPref
|
||||||
import de.jeanlucmakiola.floret.prefs.enumPref
|
import de.jeanlucmakiola.floret.prefs.enumPref
|
||||||
@@ -15,25 +18,80 @@ import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
|||||||
*/
|
*/
|
||||||
object ClockPrefs {
|
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 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 alarmRingtoneUri: Pref<String?> = nullableStringPref("default_alarm_ringtone_uri").blankAsAbsent()
|
||||||
|
|
||||||
val timerRingtoneUri: Pref<String?> = nullableStringPref("default_timer_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> =
|
val dismissChallenge: Pref<DismissChallenge> =
|
||||||
enumPref("default_dismiss_challenge", DismissChallenge.NONE)
|
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. */
|
/** Normalised through [Zones] both ways, so a zone the device dropped reads as absent. */
|
||||||
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
|
val homeZoneId: Pref<String?> = nullableStringPref("home_zone_id")
|
||||||
.map(decode = Zones::normalise, encode = Zones::normalise)
|
.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> =
|
private fun Pref<Int>.clampedTo(range: IntRange): Pref<Int> =
|
||||||
map(decode = { it.coerceIn(range) }, encode = { it.coerceIn(range) })
|
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
|
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.ClockDefaults
|
||||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
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.clockula.domain.timer.TimerPresets
|
||||||
import de.jeanlucmakiola.floret.prefs.Appearance
|
import de.jeanlucmakiola.floret.prefs.Appearance
|
||||||
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
|
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
|
||||||
import de.jeanlucmakiola.floret.prefs.PrefStore
|
import de.jeanlucmakiola.floret.prefs.PrefStore
|
||||||
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
import de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
import de.jeanlucmakiola.floret.prefs.appearance
|
import de.jeanlucmakiola.floret.prefs.appearance
|
||||||
|
import de.jeanlucmakiola.floret.prefs.set
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -60,19 +69,74 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
|||||||
dismissChallenge = dismissChallenge,
|
dismissChallenge = dismissChallenge,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
store.flow(ClockPrefs.alarmRingtoneUri),
|
combine(
|
||||||
store.flow(ClockPrefs.timerRingtoneUri),
|
store.flow(ClockPrefs.alarmSilenceAfterMinutes),
|
||||||
store.flow(ClockPrefs.homeZoneId),
|
store.flow(ClockPrefs.timerSilenceAfterMinutes),
|
||||||
) { partial, alarmRingtoneUri, timerRingtoneUri, homeZoneId ->
|
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(
|
partial.copy(
|
||||||
alarmRingtoneUri = alarmRingtoneUri,
|
alarmRingtoneUri = alarmRingtoneUri,
|
||||||
timerRingtoneUri = timerRingtoneUri,
|
timerRingtoneUri = timerRingtoneUri,
|
||||||
|
timerVibrate = timerVibrate,
|
||||||
|
timerVolumeRampSeconds = timerVolumeRampSeconds,
|
||||||
homeZoneId = homeZoneId,
|
homeZoneId = homeZoneId,
|
||||||
|
alarmSilenceAfterMinutes = alarmSilenceAfter,
|
||||||
|
timerSilenceAfterMinutes = timerSilenceAfter,
|
||||||
|
volumeButtons = volumeButtons,
|
||||||
|
automaticLocalClock = automaticLocalClock,
|
||||||
)
|
)
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
suspend fun currentDefaults(): ClockDefaults = defaults.first()
|
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 setSnoozeMinutes(minutes: Int) = store.set(ClockPrefs.snoozeMinutes, minutes)
|
||||||
|
|
||||||
suspend fun setSnoozeLimit(limit: Int) = store.set(ClockPrefs.snoozeLimit, limit)
|
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 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) =
|
suspend fun setDismissChallenge(challenge: DismissChallenge) =
|
||||||
store.set(ClockPrefs.dismissChallenge, challenge)
|
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 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) ---
|
// --- timer presets (M6) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,6 +217,91 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
|
|||||||
store.update(TimerPrefs.presets) { TimerPresets.minus(it, value) }
|
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) ---
|
// --- first-launch bookkeeping (M4) ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.clockula.data.prefs
|
package de.jeanlucmakiola.clockula.data.prefs
|
||||||
|
|
||||||
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
|
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.Pref
|
||||||
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
import de.jeanlucmakiola.floret.prefs.nullableLongPref
|
||||||
import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
import de.jeanlucmakiola.floret.prefs.nullableStringPref
|
||||||
@@ -41,4 +42,22 @@ object TimerPrefs {
|
|||||||
decode = { millis -> millis?.let(Instant::fromEpochMilliseconds) },
|
decode = { millis -> millis?.let(Instant::fromEpochMilliseconds) },
|
||||||
encode = { instant -> instant?.toEpochMilliseconds() },
|
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 = {},
|
onSuspended: () -> Unit = {},
|
||||||
): MediaBrowser? = suspendCancellableCoroutine { continuation ->
|
): MediaBrowser? = suspendCancellableCoroutine { continuation ->
|
||||||
var created: MediaBrowser? = null
|
var created: MediaBrowser? = null
|
||||||
|
var handedOff = false
|
||||||
|
fun disconnect() {
|
||||||
|
created?.let { runCatching { it.disconnect() } }
|
||||||
|
}
|
||||||
val callback = object : MediaBrowser.ConnectionCallback() {
|
val callback = object : MediaBrowser.ConnectionCallback() {
|
||||||
override fun onConnected() {
|
override fun onConnected() {
|
||||||
if (continuation.isActive) continuation.resume(created)
|
if (continuation.isActive) {
|
||||||
|
handedOff = true
|
||||||
|
continuation.resume(created)
|
||||||
|
} else {
|
||||||
|
disconnect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConnectionFailed() {
|
override fun onConnectionFailed() {
|
||||||
|
disconnect()
|
||||||
if (continuation.isActive) continuation.resume(null)
|
if (continuation.isActive) continuation.resume(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onConnectionSuspended() {
|
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()
|
val connecting = runCatching { MediaBrowser(context, component, callback, null) }.getOrNull()
|
||||||
created = connecting
|
created = connecting
|
||||||
if (connecting == null || runCatching { connecting.connect() }.isFailure) {
|
if (connecting == null || runCatching { connecting.connect() }.isFailure) {
|
||||||
connecting?.let { runCatching { it.disconnect() } }
|
disconnect()
|
||||||
continuation.resume(null)
|
continuation.resume(null)
|
||||||
return@suspendCancellableCoroutine
|
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
|
import de.jeanlucmakiola.clockula.domain.RingtoneOption
|
||||||
|
|
||||||
interface RingtoneCatalog {
|
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>
|
suspend fun alarmTones(): List<RingtoneOption>
|
||||||
|
|
||||||
/** A display title for [uri], or null when the device cannot name it. */
|
/** 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() }
|
}.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) {
|
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
|
// A sound another app plays carries its own title: naming it would
|
||||||
// otherwise mean connecting to that app on every editor render.
|
// otherwise mean connecting to that app on every editor render.
|
||||||
MediaSources.decode(uri)?.let { ref ->
|
MediaSources.decode(uri)?.let { ref ->
|
||||||
@@ -82,7 +86,7 @@ class SystemRingtoneCatalog @Inject constructor(
|
|||||||
true
|
true
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
}
|
}
|
||||||
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return@withContext false
|
val parsed = BundledTones.playable(context, uri) ?: return@withContext false
|
||||||
runCatching {
|
runCatching {
|
||||||
context.contentResolver.openInputStream(parsed)?.use { true } ?: false
|
context.contentResolver.openInputStream(parsed)?.use { true } ?: false
|
||||||
}.getOrDefault(false)
|
}.getOrDefault(false)
|
||||||
|
|||||||
+1
-1
@@ -65,7 +65,7 @@ class SystemRingtonePreviewer @Inject constructor(
|
|||||||
stop()
|
stop()
|
||||||
return
|
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
|
// Stop *before* taking the lock: what is sounding now stops now, and an
|
||||||
// in-flight `prepare` is told its preview has been superseded.
|
// in-flight `prepare` is told its preview has been superseded.
|
||||||
stop()
|
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
|
package de.jeanlucmakiola.clockula.data.time
|
||||||
|
|
||||||
import de.jeanlucmakiola.clockula.domain.time.Ticker
|
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.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
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. */
|
/** The [Ticker] seam over `delay`: emit, then wait [ticks]' period, forever. */
|
||||||
@Singleton
|
@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
|
* Cold, and per-collector: two readouts on different cadences get two
|
||||||
@@ -24,4 +26,18 @@ class RealTicker @Inject constructor() : Ticker {
|
|||||||
delay(period)
|
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.Dao
|
||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import androidx.room.Update
|
import androidx.room.Update
|
||||||
@@ -27,6 +28,10 @@ abstract class TimerDao {
|
|||||||
@Insert
|
@Insert
|
||||||
abstract suspend fun insert(timer: TimerEntity): Long
|
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
|
@Update
|
||||||
abstract suspend fun update(timer: TimerEntity)
|
abstract suspend fun update(timer: TimerEntity)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ interface TimerRepository {
|
|||||||
suspend fun rename(id: Long, label: String)
|
suspend fun rename(id: Long, label: String)
|
||||||
suspend fun delete(id: Long)
|
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. */
|
/** Starts an IDLE/EXPIRED timer from its configured duration, resumes a PAUSED one. No-op when RUNNING. */
|
||||||
suspend fun start(id: Long)
|
suspend fun start(id: Long)
|
||||||
suspend fun pause(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 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) {
|
override suspend fun start(id: Long) {
|
||||||
val now = elapsedRealtimeClock.elapsedRealtime()
|
val now = elapsedRealtimeClock.elapsedRealtime()
|
||||||
val wall = wallClock.now()
|
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
|
* 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
|
* same reason [entriesFor] is: the tab asks on every tick (once a second with
|
||||||
* dispatch and a lock per row would be a hundred and twenty-five of each.
|
* 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?> =
|
suspend fun displayNamesOf(zoneIds: List<String>, at: Instant): Map<String, String?> =
|
||||||
withContext(io) {
|
withContext(io) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.jeanlucmakiola.clockula.domain
|
package de.jeanlucmakiola.clockula.domain
|
||||||
|
|
||||||
import java.time.DayOfWeek
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
/** A local time of day. Always valid; use [clamped] at a data boundary. */
|
/** 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. */
|
/** The optional barrier between a ringing alarm and dismissing it. */
|
||||||
enum class DismissChallenge { NONE, MATH, HOLD }
|
enum class DismissChallenge { NONE, MATH, HOLD }
|
||||||
|
|
||||||
@@ -93,6 +127,13 @@ data class Alarm(
|
|||||||
val updatedAt: Instant,
|
val updatedAt: Instant,
|
||||||
/** A transient alarm the `AlarmClock` contract asked for: deleted once its cycle closes (M9 D10). */
|
/** A transient alarm the `AlarmClock` contract asked for: deleted once its cycle closes (M9 D10). */
|
||||||
val deleteAfterUse: Boolean,
|
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. */
|
/** Everything needed to create an alarm; the repository supplies id and timestamps. */
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
package de.jeanlucmakiola.clockula.domain
|
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.
|
* The app-wide defaults an alarm or timer inherits when it has no override.
|
||||||
* Backed by DataStore, edited in M10's settings screen.
|
* 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(
|
data class ClockDefaults(
|
||||||
val snoozeMinutes: Int = 10,
|
val snoozeMinutes: Int = 10,
|
||||||
@@ -11,6 +26,30 @@ data class ClockDefaults(
|
|||||||
val volumeRampSeconds: Int = 15,
|
val volumeRampSeconds: Int = 15,
|
||||||
val alarmRingtoneUri: String? = null,
|
val alarmRingtoneUri: String? = null,
|
||||||
val timerRingtoneUri: String? = null,
|
val timerRingtoneUri: String? = null,
|
||||||
|
val timerVibrate: Boolean = true,
|
||||||
|
val timerVolumeRampSeconds: Int = 0,
|
||||||
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
|
val dismissChallenge: DismissChallenge = DismissChallenge.NONE,
|
||||||
val homeZoneId: String? = null,
|
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
|
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. */
|
/** Trimmed; blank becomes null. [SILENT_URI] passes through unchanged. */
|
||||||
fun normalise(uri: String?): String? = uri?.trim()?.takeIf { it.isNotEmpty() }
|
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
|
* Snapshotted once. `ZoneId.getAvailableZoneIds()` builds a fresh ~600-entry
|
||||||
* `HashSet` on every call, and the world clock asks per row per 200 ms tick
|
* `HashSet` on every call, and the world clock asks per row per tick —
|
||||||
* — twenty-four cities would be a hundred and twenty-five throwaway sets a
|
* 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
|
* second. The device's tzdata arrives as an APEX module that takes effect
|
||||||
* at reboot, so the answer cannot change under a running process.
|
* 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
|
package de.jeanlucmakiola.clockula.domain.alarm
|
||||||
|
|
||||||
|
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -33,6 +34,13 @@ object AlarmOccurrences {
|
|||||||
* alarm is just "the next daily candidate", and what makes it one-shot is
|
* alarm is just "the next daily candidate", and what makes it one-shot is
|
||||||
* what happens after it rings.
|
* 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
|
* 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
|
* gap), so a candidate that did not advance is dropped and the caller may
|
||||||
* rely on `drop(1)` meaning "the one after".
|
* rely on `drop(1)` meaning "the one after".
|
||||||
@@ -42,13 +50,21 @@ object AlarmOccurrences {
|
|||||||
repeatDays: RepeatDays,
|
repeatDays: RepeatDays,
|
||||||
after: Instant,
|
after: Instant,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
|
notBefore: LocalDate? = null,
|
||||||
|
pause: PauseRange? = null,
|
||||||
): Sequence<Instant> = Sequence {
|
): Sequence<Instant> = Sequence {
|
||||||
iterator {
|
iterator {
|
||||||
// One day back, because a gap can push a candidate from the previous
|
// One day back, because a gap can push a candidate from the previous
|
||||||
// local date past [after].
|
// 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
|
var previous: Instant? = null
|
||||||
while (true) {
|
while (true) {
|
||||||
|
if (pause != null && date in pause) {
|
||||||
|
date = pause.end.plusDays(1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (!repeatDays.isRepeating || date.dayOfWeek in repeatDays) {
|
if (!repeatDays.isRepeating || date.dayOfWeek in repeatDays) {
|
||||||
val candidate = at(date, time, zone)
|
val candidate = at(date, time, zone)
|
||||||
val last = previous
|
val last = previous
|
||||||
@@ -67,7 +83,16 @@ object AlarmOccurrences {
|
|||||||
repeatDays: RepeatDays,
|
repeatDays: RepeatDays,
|
||||||
after: Instant,
|
after: Instant,
|
||||||
zone: ZoneId,
|
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 =
|
private fun Instant.localDate(zone: ZoneId): LocalDate =
|
||||||
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
java.time.Instant.ofEpochMilli(toEpochMilliseconds()).atZone(zone).toLocalDate()
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ data class AlarmResolution(
|
|||||||
val state: AlarmRingState,
|
val state: AlarmRingState,
|
||||||
val clearSkipFlag: Boolean = false,
|
val clearSkipFlag: Boolean = false,
|
||||||
val disableAlarm: 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 precedence is fixed and total: disabled, then ringing, then snoozed, then
|
||||||
* the natural occurrence.
|
* 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 {
|
object AlarmResolver {
|
||||||
fun resolve(
|
fun resolve(
|
||||||
@@ -34,8 +42,31 @@ object AlarmResolver {
|
|||||||
state: AlarmRingState,
|
state: AlarmRingState,
|
||||||
now: Instant,
|
now: Instant,
|
||||||
zone: ZoneId,
|
zone: ZoneId,
|
||||||
autoSilenceAfter: Duration = AlarmRing.AUTO_SILENCE_AFTER,
|
autoSilenceAfter: Duration,
|
||||||
fireGrace: Duration = AlarmRing.FIRE_GRACE,
|
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 {
|
): AlarmResolution {
|
||||||
// 1. Disabled: every volatile field goes, except the dismissal watermark
|
// 1. Disabled: every volatile field goes, except the dismissal watermark
|
||||||
// — re-enabling an alarm must not un-suppress an occurrence already rung.
|
// — 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
|
// 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.
|
// 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 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) {
|
if (snoozedUntil != null && !snoozePending) {
|
||||||
working = working.copy(snoozedUntil = null, snoozeCount = 0)
|
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.
|
// 4. The natural occurrence, with the skip watermark on top.
|
||||||
if (!alarm.skipNextOccurrence) {
|
if (!alarm.skipNextOccurrence) {
|
||||||
return AlarmResolution(
|
return AlarmResolution(
|
||||||
@@ -120,7 +170,17 @@ object AlarmResolver {
|
|||||||
// cannot ring on its way out through the grace window.
|
// cannot ring on its way out through the grace window.
|
||||||
nextFire = candidates(alarm, working, now, zone, fireGrace).first { it > watermark },
|
nextFire = candidates(alarm, working, now, zone, fireGrace).first { it > watermark },
|
||||||
source = FireSource.OCCURRENCE,
|
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,
|
clearSkipFlag = consumed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -139,7 +199,14 @@ object AlarmResolver {
|
|||||||
fireGrace: Duration,
|
fireGrace: Duration,
|
||||||
): Sequence<Instant> {
|
): Sequence<Instant> {
|
||||||
val handled = state.handledOccurrence
|
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 }
|
.filterNot { handled != null && it <= handled && it <= now }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,20 +4,27 @@ import kotlin.time.Duration
|
|||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The alarm ring cycle's constants. They are values rather than settings: v1
|
* The alarm ring cycle's constants. They are values rather than settings
|
||||||
* does not offer them to the user (docs/PLAN.md §4). The audio chain's own
|
* (docs/PLAN.md §4). How long an alarm rings before it is silenced as missed
|
||||||
* constants live in `domain/ring/RingAudio`, shared with the timer's ring.
|
* 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 {
|
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
|
* 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.
|
* boot or a `TIME_SET` nudge still rings; one missed by hours does not.
|
||||||
*/
|
*/
|
||||||
val FIRE_GRACE: Duration = 2.minutes
|
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 }
|
enum class ScheduleMode { EXACT_ALARM_CLOCK, INEXACT_FALLBACK }
|
||||||
|
|
||||||
object SchedulingPolicy {
|
object SchedulingPolicy {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ data class RingSession(
|
|||||||
val settings: AlarmSettings,
|
val settings: AlarmSettings,
|
||||||
val startedAt: Instant,
|
val startedAt: Instant,
|
||||||
val canSnooze: Boolean,
|
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 {
|
sealed interface FireOutcome {
|
||||||
@@ -43,6 +45,12 @@ sealed interface DismissOutcome {
|
|||||||
|
|
||||||
/** Unknown id, or an alarm with no upcoming instance. Nothing was written. */
|
/** Unknown id, or an alarm with no upcoming instance. Nothing was written. */
|
||||||
data object NotFound : DismissOutcome
|
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 {
|
sealed interface SnoozeOutcome {
|
||||||
@@ -50,5 +58,9 @@ sealed interface SnoozeOutcome {
|
|||||||
|
|
||||||
/** The limit was already reached; the alarm was dismissed instead. */
|
/** The limit was already reached; the alarm was dismissed instead. */
|
||||||
data class LimitReached(val alarmId: Long) : SnoozeOutcome
|
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
|
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.ClockDefaults
|
||||||
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
import de.jeanlucmakiola.clockula.domain.DismissChallenge
|
||||||
|
import de.jeanlucmakiola.clockula.domain.PauseRange
|
||||||
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
import de.jeanlucmakiola.clockula.domain.RepeatDays
|
||||||
import de.jeanlucmakiola.clockula.domain.TimeOfDay
|
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 de.jeanlucmakiola.floret.prefs.ThemeMode
|
||||||
|
import java.time.LocalDate
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
@@ -35,6 +40,8 @@ data class BackupAlarm(
|
|||||||
val volumeRampSeconds: Int?,
|
val volumeRampSeconds: Int?,
|
||||||
val dismissChallenge: DismissChallenge?,
|
val dismissChallenge: DismissChallenge?,
|
||||||
val deleteAfterUse: Boolean,
|
val deleteAfterUse: Boolean,
|
||||||
|
val scheduledDate: LocalDate? = null,
|
||||||
|
val pause: PauseRange? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class BackupTimer(
|
data class BackupTimer(
|
||||||
@@ -56,4 +63,11 @@ data class BackupSettings(
|
|||||||
val dynamicColor: Boolean,
|
val dynamicColor: Boolean,
|
||||||
val defaults: ClockDefaults,
|
val defaults: ClockDefaults,
|
||||||
val timerPresets: List<Duration>,
|
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
|
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). */
|
/** The bounds an extra from another process is read against (M9 D5, D19). */
|
||||||
object InteropLimits {
|
object InteropLimits {
|
||||||
const val MAX_LABEL_LENGTH: Int = 256
|
const val MAX_LABEL_LENGTH: Int = 256
|
||||||
@@ -12,7 +14,7 @@ object InteropLimits {
|
|||||||
val TIMER_SECONDS: IntRange = 1..86_400
|
val TIMER_SECONDS: IntRange = 1..86_400
|
||||||
|
|
||||||
/** `ClockPrefs`' own clamp: an intent cannot ask for a snooze the user could not choose. */
|
/** `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://")
|
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
|
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.
|
* 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
|
* Only total silence (`INTERRUPTION_FILTER_NONE`) is treated as blocking
|
||||||
* alarms: Priority and Alarms-only modes leave the system's own alarm
|
* alarms: Priority and Alarms-only modes leave the system's own alarm
|
||||||
* exemption in effect. Clockula never requests `ACCESS_NOTIFICATION_POLICY`
|
* exemption in effect. The check never changes DND itself — it only
|
||||||
* and never changes DND itself — it only reports what the user already has
|
* reports what the user already has (`docs/PLAN.md` §4). The opt-in
|
||||||
* (`docs/PLAN.md` §4).
|
* bedtime mode does change it, with a rule that always allows alarms.
|
||||||
*/
|
*/
|
||||||
private fun dnd(state: DeviceState): CheckResult {
|
private fun dnd(state: DeviceState): CheckResult {
|
||||||
val blocked = state.interruptionFilter == INTERRUPTION_FILTER_NONE
|
val blocked = state.interruptionFilter == INTERRUPTION_FILTER_NONE
|
||||||
@@ -186,21 +186,24 @@ object SelfCheck {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Compares Clockula's own computed next fire against what the system
|
* Compares Clockula's own computed next fire against what the system
|
||||||
* actually has registered. No alarm scheduled is not a problem; an
|
* actually has registered. The system reports only the earliest alarm
|
||||||
* unknown owner is given the benefit of the doubt (the platform does not
|
* clock of *any* app, so another app's alarm at or before Clockula's hides
|
||||||
* always expose it), but a *confirmed* foreign owner, a missing system
|
* Clockula's registration rather than disproving it, and is not a problem.
|
||||||
* registration, or a mismatched time all mean the registration this app
|
* No alarm scheduled is not a problem either. A missing registration, our
|
||||||
* made is not the one the system will honour.
|
* 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 {
|
private fun nextAlarm(state: DeviceState): CheckResult {
|
||||||
val expected = state.expectedNextFire
|
val expected = state.expectedNextFire
|
||||||
|
val system = state.systemNextAlarm
|
||||||
val owner = state.systemNextAlarmOwnerPackage
|
val owner = state.systemNextAlarmOwnerPackage
|
||||||
val ok = when {
|
val ok = when {
|
||||||
expected == null -> true
|
expected == null -> true
|
||||||
state.systemNextAlarm == null -> false
|
system == null -> false
|
||||||
owner != null && owner != state.ownPackageName -> false
|
system == expected -> true
|
||||||
state.systemNextAlarm != expected -> false
|
system > expected -> false
|
||||||
else -> true
|
// Earlier than ours: someone else's, unless the owner is confirmed as us.
|
||||||
|
else -> owner != state.ownPackageName
|
||||||
}
|
}
|
||||||
return CheckResult(
|
return CheckResult(
|
||||||
SelfCheckId.NEXT_ALARM,
|
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 {
|
interface Ticker {
|
||||||
fun ticks(period: Duration): Flow<Unit>
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 297 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user