Author SHA1 Message Date
makiolaj d48c2e68fa chore: 1.0.0-beta.3
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Successful in 11m36s
Adds the screen-on and timer ring screen fixes, the "Timer expired" sound, the hardened backup restore and the faster fallback when a music app refuses to play an alarm.
2026-10-21 15:31:38 +02:00
makiolaj ec391e8675 docs: tidy the changelog and provenance wording
Drops CODE_QUALITY_REVIEW.md as well: 11ea9be fixed all eight of its
findings, and git history keeps the review.
2026-10-21 15:31:38 +02:00
makiolaj d5016183ad fix: fall back at once when a music app refuses the alarm
The start waited for PLAYING and nothing else, so an app that answered
the play request with an error (YouTube without Premium) left the alarm
vibrating in silence for the full 15 s START_TIMEOUT before the default
sound took over. An error stamped at or after the request, or a lost
connection, now ends the wait straight away. An error left over from
the user's own listening still doesn't count.
2026-10-21 15:31:38 +02:00
makiolaj 6637554c9d ci: fail the Codeberg publish when an upload fails
The asset uploads and the release PATCH only printed their HTTP status,
so a failed upload still ended in "Published" and left the release
Obtainium follows without its APK or checksum. Each now retries with
backoff and fails the job if it never succeeds.
2026-10-21 14:16:59 +02:00
makiolaj 67010a632c refactor: restore settings through their typed prefs
applyBackup re-derived every key name, type and clamp by hand because
Pref.write was internal to floret-kit. floret-kit now has a typed
MutablePreferences[pref] = value, so a restored value is stored exactly
as its setter would, and a changed range or encoding reaches restore
too.
2026-10-21 14:16:58 +02:00
makiolaj 405374de7e fix: close the alarm screen when a restore replaces the ringing alarm
A backup restore stops the ring while its row still exists and deletes
the row afterwards, so the screen's one check came too early and left a
silent ring screen up for good. It now closes once the coordinator and
storage both say nothing rings, which still keeps it up if the import
fails and the ring resumes.
2026-10-21 14:16:58 +02:00
makiolaj c83c12cf64 feat: offer the "Timer expired" sound
The sound Google Clock and BlackyHawky's Clock use for timers is
timer_expire.ogg from AOSP DeskClock, bundled with those apps rather
than installed as a system ringtone, so RingtoneManager never lists it.
It is now bundled here too and listed first in every sound picker.

The choice is stored as clockula://tone/timer_expired and resolved to
the raw resource when played, so it survives a restore into another
build variant. The file is Apache-2.0, recorded in docs/PROVENANCE.md
with the licence text in licenses/.

Fixes #14
2026-10-21 14:16:58 +02:00
makiolaj badd7aaa54 fix: turn the screen on when an alarm or timer goes off
SystemUI launches a full-screen intent only when a notification is first
posted, never on an update. The alarm service went foreground with a
placeholder that had none and added it in the update, so a locked phone
could ring behind a black screen. The placeholder now carries it.

Timers had no full-screen screen at all. A finished timer now opens
TimerRingActivity over the lock screen with Stop and +1 min, posted
under a second notification id so the expiry is a fresh post. It only
does so while the timer is sounding, so it never covers a ringing
alarm's screen or wakes the display for an already auto-silenced timer.

Fixes #15
2026-10-21 14:16:47 +02:00
makiolaj 2f39b00398 feat: link translators to Weblate from the README and settings
Beta — Codeberg pre-release / detect (push) Successful in 4s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-10 14:30:03 +02:00
makiolaj 27df918da0 ci: move release and beta jobs to the light and gradle runners 2026-10-10 14:30:03 +02:00
makiolaj 11ea9be468 fix: harden backup restore and alarm playback
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped
2026-10-08 16:48:44 +02:00
makiolaj 61eff32f5f chore: 1.0.0-beta.2
Beta — Codeberg pre-release / detect (push) Successful in 26s
Beta — Codeberg pre-release / beta (push) Successful in 13m35s
Brings in main's move to floret-kit 0.7.0 and the fix for the crash it
caused on every tab (#10).
2026-10-08 09:25:43 +02:00
makiolaj 276c88942e Merge branch 'main' into release/v1.0.0
# Conflicts:
#	README.md
#	gradle/libs.versions.toml
2026-10-08 09:24:01 +02:00
makiolaj e49afb182c fix: compile against the material3 that ships, ending the tab crash (#10)
floret-kit 0.7.0 pulls in material3 1.5.0-alpha29 on the runtime
classpath while the app still compiled against alpha21. alpha29 removed
the composable-content ButtonGroup overload, so the stopwatch and timer
controls threw NoSuchMethodError as soon as a tab opened.

Align the Compose BOM, material3, core-ktx and appcompat with
floret-kit 0.7.0, and make each variant's compile classpath resolve
consistently with its runtime classpath, so a future skew fails the
build instead of a user's app. The two ButtonGroups never used
animateWidth, so they become a Row with the group's spacing; the
removed ToggleButtonDefaults.shapes() was the default anyway.
2026-10-08 09:23:46 +02:00
Jean-Luc Makiola c0e0d6269d Merge pull request 'Move to floret-kit 0.7.0' (#9) from chore/floret-kit-0.7.0 into main
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 19m22s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Successful in 7s
Reviewed-on: https://codeberg.org/jlmakiola/clockula/pulls/9
2026-10-07 10:17:01 +02:00
makiolaj 4290892cb2 chore: move to floret-kit 0.7.0 and its toolchain
floret-kit main now needs Gradle >= 9.6, so bump to Gradle 9.8.0,
AGP 9.4.1, Kotlin 2.4.20, and Hilt 2.60.1 / KSP 2.3.11 to read
Kotlin 2.4 metadata.
2026-10-07 09:54:01 +02:00
makiolaj 24098e8845 docs(readme): restructure along Calendula and Agendula's READMEs 2026-10-06 22:53:28 +02:00
63 changed files with 1592 additions and 269 deletions
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
detect:
# Gitea only; see the same guard in release.yaml.
if: github.repository_owner == 'makiolaj'
runs-on: docker
runs-on: light
outputs:
is_beta: ${{ steps.v.outputs.is_beta }}
version: ${{ steps.v.outputs.version }}
@@ -89,7 +89,7 @@ jobs:
beta:
needs: detect
if: needs.detect.outputs.is_beta == 'true'
runs-on: docker
runs-on: gradle
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
+2 -2
View File
@@ -48,7 +48,7 @@ jobs:
# secrets. repository_owner differs between the two forges regardless of
# URL, proxy or instance rename, so this closes it permanently.
if: github.repository_owner == 'makiolaj'
runs-on: docker
runs-on: light
outputs:
is_release: ${{ steps.v.outputs.is_release }}
version: ${{ steps.v.outputs.version }}
@@ -139,7 +139,7 @@ jobs:
release:
needs: detect
if: needs.detect.outputs.is_release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: docker
runs-on: gradle
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
+12 -9
View File
@@ -11,12 +11,12 @@ Tag sections feed the release notes; see [`docs/RELEASING.md`](docs/RELEASING.md
The first release of Clockula: alarms, timers, a stopwatch and a world clock.
### Added
- **Alarms that ring.** Alarms are scheduled as real alarm clocks, exempt from
- **Alarms.** Alarms are scheduled as real alarm clocks, exempt from
battery saving and shown on the lock screen and in the status bar. They
survive a reboot, a process kill and a change of the device's clock, and
daylight-saving nights neither skip nor double one. If the full-screen or
notification permission is denied, an alarm still rings. The last fallback
is vibration, never silence.
notification permission is denied, an alarm still rings, and when no sound
can play it vibrates.
- An alarm list that shows when each alarm will ring and marks the next one.
Changes apply as you make them, with no Save button.
- Repeat days that start on your locale's first day of the week, and a "skip
@@ -24,17 +24,20 @@ The first release of Clockula: alarms, timers, a stopwatch and a world clock.
- Per-alarm sound, vibration, gradual volume, snooze length and limit, and a
dismiss challenge (a quick sum or a two-second hold). Each one can follow the
app default, so changing a default later still reaches it.
- Your own sounds: the device's alarm tones, a file you pick, Silent (which
still vibrates), or music from an app that shares its library, such as VLC
or AntennaPod. If the music can't play, or might not be heard, the alarm
rings with the default sound instead.
- Your own sounds: the device's alarm tones, the "Timer expired" sound from
Android's own Clock, a file you pick, Silent (which still vibrates), or
music from an app that shares its library, such as VLC or AntennaPod. If
the music can't play, or might not be heard, the alarm rings with the
default sound instead.
- A ring screen over the lock screen with snooze and dismiss. The dismiss
challenge always gives you a way out, works with a screen reader, and Back
can't silence an alarm.
- **Timers.** Run several at once, each with its own label, length and sound,
set from a keypad with saved presets. Pause, resume, reset and +1 min work
from the timer, from the live pill and from the notification. Timers keep
counting through a reboot and aren't affected by changes to the clock.
from the timer, from the live pill and from the notification. A finished
timer turns the screen on and opens over the lock screen with Stop and
+1 min. Timers keep counting through a reboot and aren't affected by changes
to the clock.
- **Stopwatch** with laps, the fastest and slowest marked, and Lap and Pause
in the notification. It keeps counting while the app is closed.
- **World clock.** An analog face for your own time, plus cities from the
+144 -49
View File
@@ -1,83 +1,132 @@
<div align="center">
<img src="fastlane/metadata/android/en-US/images/icon.png" width="112" alt="Clockula icon">
<h1>Clockula</h1>
<p><strong>A modern Material 3 Expressive clock app for Android.</strong><br>
Alarms, timers, stopwatch and world clock. Open standards, no account required.</p>
<p><strong>A Material 3 Expressive clock app for Android.</strong><br>
Alarms, timers, stopwatch and world clock. No account and no network access.</p>
<p>
<a href="https://codeberg.org/jlmakiola/clockula/actions"><img src="https://codeberg.org/jlmakiola/clockula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License"></a>
</p>
<p>
<a href="https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/clockula"><img src="https://github.com/ImranR98/Obtainium/blob/main/assets/graphics/badge_obtainium.png?raw=true" alt="Get it on Obtainium" height="56"></a>
&nbsp;
<a href="https://ko-fi.com/jeanlucmakiola"><img src="https://storage.ko-fi.com/cdn/brandasset/v2/support_me_on_kofi_badge_beige.png" alt="Support me on Ko-fi" height="56"></a>
</p>
</div>
Clockula is the clock sibling to [Calendula](https://codeberg.org/jlmakiola/calendula)
(calendar) and [Agendula](https://codeberg.org/jlmakiola/agendula) (tasks). The
name follows theirs: *clock*, given Calendula's `-ula` ending. A Calendula flower
name follows theirs: *clock* with Calendula's `-ula` ending. A Calendula flower
head is botanically a cluster of many small *florets*, so the three apps are
florets of one bloom. They also share one library, [floret-kit](https://codeberg.org/jlmakiola/floret-kit).
florets of one bloom. They share one library,
[floret-kit](https://codeberg.org/jlmakiola/floret-kit).
> 🧪 **Beta.** Clockula 1.0.0 is in beta: feature-complete, and being tested
> before the stable release. See [Install](#install) to try it.
1.0.0 is in its last betas before the stable release.
## The honest difference from its siblings
## Features
Calendula is a pure front-end over Android's `CalendarContract`. Agendula speaks
the dmfs `TaskContract`. Both put a good interface on a standard someone else
already maintains.
### Alarms
There is no open provider behind a clock. Alarms, timers and laps are
Clockula's own data, so Clockula is the first app in the family to own its
storage, and does so by design. The first section of
[`docs/PLAN.md`](docs/PLAN.md) sets out what takes the place of a shared
standard, and it comes down to three things:
- Repeat schedules, snooze, your own ringtones and per-alarm settings
- Scheduled as real alarm clocks: exempt from Doze, and shown on the lock
screen and in the status bar
- Re-resolved against your current time zone whenever it changes, so DST and
travel don't shift them
- A self-check screen that reads your device's actual settings. If one of them
would stop an alarm ringing, Clockula names it and opens that settings page,
instead of failing quietly at 7am.
- Clockula speaks Android's standard alarm contract. It implements
`android.provider.AlarmClock` in full, so your assistant and your automation
apps can set alarms and timers in it exactly as they would in any stock clock,
and the system status bar and lock screen show its next alarm.
- World clocks are IANA time zones, not a bespoke city list that goes stale.
- Your data exports to a documented, versioned JSON file whenever you want
it, because data you can't take with you isn't really yours.
### Timers, stopwatch and world clock
## Alarms that actually ring
- Several timers at once, controllable from the notification shade
- A stopwatch with laps
- World clocks from the IANA time-zone database built into Android, so the
city list doesn't go stale
- A running timer or stopwatch stays reachable from every tab
An alarm app is only worth having if you can trust it, so Clockula treats reliability
as the main feature. Alarms are scheduled as real alarm clocks (doze-exempt and
visible to the system) and re-resolved against your current time
zone whenever anything moves underneath them, so DST and travel don't silently
shift them.
### Widgets
When something on your device *would* stop an alarm ringing, Clockula tells you
which setting it is and takes you there, instead of failing quietly at 7am.
- Three home-screen widgets: next alarm, timer and clock
- Each widget picks its own theme, or takes its colours from your wallpaper
### Works with other apps
- Clockula implements Android's standard `AlarmClock` intents in full. Your
voice assistant and automation apps can set alarms and timers in it the same
way they would in the stock clock.
- Alarms, timers, world clocks and settings export to a JSON file whose format
is documented in [`docs/BACKUP.md`](docs/BACKUP.md), and import back from it.
### Design and privacy
- Material 3 Expressive throughout, with dynamic colour on Android 12+, light
and dark themes, and expressive motion and shapes
- A per-app language setting. Clockula ships in English for now;
[help translate](#translations).
- No telemetry, no analytics and no internet permission
## Where your data lives
Calendula works on top of Android's `CalendarContract`, and Agendula can work on
top of the dmfs `TaskContract`. Both put an interface on a standard someone else
maintains. Clocks have no such provider, so Clockula keeps alarms, timers and
laps in its own database, the first app in the family to do so. The first
section of [`docs/PLAN.md`](docs/PLAN.md) explains how it makes up for the
missing standard: the `AlarmClock` intents, IANA time zones and the documented
backup format listed above.
## Install
Betas are published as pre-releases on
[Codeberg](https://codeberg.org/jlmakiola/clockula/releases), with the signed
APK and a `.sha256` checksum attached. Download the APK there, or add
`https://codeberg.org/jlmakiola/clockula` to
[Obtainium](https://github.com/ImranR98/Obtainium) and switch on *Include
prereleases* for it so it finds the beta. A beta updates in place to later betas
and to the stable release.
| Channel | Available | Notes |
| --- | --- | --- |
| [Codeberg release / Obtainium](#codeberg-release--obtainium) | Now, as betas | Plain APK download, or automatic with Obtainium |
| [Self-hosted F-Droid repo](#self-hosted-f-droid-repo) | From 1.0.0 | Add the repo once |
| [Build from source](#build-from-source) | Now | Full control |
From the stable 1.0.0 on, every release also goes to the self-hosted F-Droid
repository that serves Calendula and Agendula.
### Codeberg release / Obtainium
## Documentation
Every release is on
[Codeberg](https://codeberg.org/jlmakiola/clockula/releases) with the signed
APK and a `.sha256` checksum. Betas are published as pre-releases. Download the
APK and install it directly.
Start with [`docs/`](docs/): [`PLAN.md`](docs/PLAN.md) is the spec and the
reasoning, [`ROADMAP.md`](docs/ROADMAP.md) is the status,
[`ARCHITECTURE.md`](docs/ARCHITECTURE.md) is how the code is put together, and
[`RELEASING.md`](docs/RELEASING.md) is the release runbook.
For automatic updates, use [Obtainium](https://github.com/ImranR98/Obtainium).
On your phone,
[add Clockula in one tap](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://codeberg.org/jlmakiola/clockula),
or add it by hand: *Add App*, paste `https://codeberg.org/jlmakiola/clockula`,
then *Add*. While Clockula is in beta, switch on *Include prereleases* for it,
or Obtainium won't find anything. A beta updates in place to later betas and to
the stable release.
## Building
### Self-hosted F-Droid repo
From the stable 1.0.0 on, every release is also published to the self-hosted
F-Droid repository that serves Calendula and Agendula. If you already have it,
Clockula will show up there. Otherwise add it once:
1. In your F-Droid client, open *Settings → Repositories → Add* (or open the
link below on your phone):
```
https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo?fingerprint=C2C0640402BF458FC0ED957AF0B37AA4C14022E72F89CE90B5965B458CF73425
```
<sub>Repo: `https://apps.dev.jeanlucmakiola.de/dev/fdroid/repo` ·
fingerprint (SHA-256):
`C2C0 6404 02BF 458F C0ED 957A F0B3 7AA4 C140 22E7 2F89 CE90 B596 5B45 8CF7 3425`</sub>
2. Refresh, search for Clockula and install it.
### Build from source
```sh
git clone --recurse-submodules https://codeberg.org/jlmakiola/clockula.git
@@ -86,10 +135,56 @@ cd clockula
```
floret-kit is a submodule built from source as a Gradle composite build, so
`--recurse-submodules` is not optional. The included build needs to find the
Android SDK on its own: either export `ANDROID_HOME`, or drop a `sdk.dir=…` line
in a (gitignored) `floret-kit/local.properties`.
`--recurse-submodules` is required. The included build has to find the Android
SDK on its own: either export `ANDROID_HOME`, or put a `sdk.dir=…` line in a
(gitignored) `floret-kit/local.properties`. [CONTRIBUTING.md](CONTRIBUTING.md)
covers tests and lint.
<sub>The F-Droid repo and the Codeberg releases share the same signing key, so
you can switch between them without reinstalling.</sub>
## Documentation
- [Contributing](CONTRIBUTING.md): the workflow, the build and the
architectural rule changes are reviewed against
- [Plan](docs/PLAN.md): the spec and the reasoning behind it
- [Roadmap](docs/ROADMAP.md): what has shipped and what's next
- [Architecture](docs/ARCHITECTURE.md): how the code is put together
- [Backup format](docs/BACKUP.md): the JSON export schema
- [Releasing](docs/RELEASING.md): the release runbook
- [Privacy policy](docs/PRIVACY.md): the source of the page at
[jeanlucmakiola.de/clockula/privacy](https://jeanlucmakiola.de/clockula/privacy)
## Contributing
Bug reports, ideas and patches are welcome on
[Codeberg](https://codeberg.org/jlmakiola/clockula/issues). Translations go
through [Weblate](#translations), not pull requests. Read
[CONTRIBUTING.md](CONTRIBUTING.md) before writing code.
## Translations
Clockula is written in English. Translations are managed on a self-hosted
Weblate, and partial ones are fine: untranslated strings fall back to English.
[Help translate Clockula](https://weblate.dev.jeanlucmakiola.de/engage/clockula/)
You don't need to code. Register on the Weblate server, pick a language (or
request one) and translate in your browser. The app links here too, from
**Settings → About → Help translate**.
[![Translation status](https://weblate.dev.jeanlucmakiola.de/widget/clockula/clockula/matrix-auto.svg)](https://weblate.dev.jeanlucmakiola.de/engage/clockula/)
## Privacy
Clockula has no analytics, tracking, ads, crash-reporting SDK or third-party
services. It doesn't have the internet permission, so it can't send anything
anywhere. Your alarms, timers and world clocks stay in the app's private
storage on your device.
[Privacy policy](https://jeanlucmakiola.de/clockula/privacy), written in
[`docs/PRIVACY.md`](docs/PRIVACY.md), which is what that page renders.
## Licence
MIT. See [`LICENSE`](LICENSE).
[MIT](LICENSE), Jean-Luc Makiola, 2026
+12 -2
View File
@@ -32,8 +32,8 @@ android {
// versionCode is derived from versionName by scripts/version_info.sh
// (1.0.0-beta.1 -> 1000001, 1.0.0 -> 1000099), and CI fails if the
// committed one doesn't match. See docs/RELEASING.md.
versionCode = 1000001
versionName = "1.0.0-beta.1"
versionCode = 1000003
versionName = "1.0.0-beta.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -154,6 +154,16 @@ ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
// Compile against the library versions the APK actually ships. floret-kit's
// `implementation` deps reach only the runtime classpath, so a newer material3
// there once won at runtime while the app compiled against an older one, and
// a removed ButtonGroup overload crashed every tab with NoSuchMethodError (#10).
androidComponents {
onVariants { variant ->
variant.compileConfiguration.shouldResolveConsistentlyWith(variant.runtimeConfiguration)
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+12
View File
@@ -183,6 +183,18 @@
android:taskAffinity=""
android:turnScreenOn="true" />
<!-- The timer's ring screen: the alarm's window settings, opened by the
expired-timer notification's full-screen intent (issue #15). -->
<activity
android:name=".ui.ring.TimerRingActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:showOnLockScreen="true"
android:showWhenLocked="true"
android:taskAffinity=""
android:turnScreenOn="true" />
<!-- The ring itself. systemExempted per D17: not mediaPlayback (an alarm
is not the user's media session) and emphatically not shortService,
which caps at ~3 minutes against a 10-minute ring window. -->
@@ -23,8 +23,10 @@ import de.jeanlucmakiola.clockula.domain.time.ZoneProvider
import de.jeanlucmakiola.clockula.system.RebootRepair
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.time.Duration.Companion.minutes
@@ -142,6 +144,14 @@ class AlarmEngine @Inject constructor(
/** Restarts a ring the process death or reboot interrupted. Returns the alarm id, or null. */
suspend fun resumeRingingIfAny(): Long? = lock.withLock { resumeRingingIfAnyLocked() }
/**
* Whether storage holds a ringing alarm, re-read on every write. The ring
* screen watches this as well as [RingCoordinator.ringing]: a backup restore
* stops the coordinator first and deletes the ringing row afterwards.
*/
fun ringStored(): Flow<Boolean> =
states.states().map { all -> all.any { it.ringingSince != null } }.distinctUntilChanged()
/** What the ringing service needs. Null when nothing is ringing. */
suspend fun ringSession(): RingSession? = lock.withLock {
val state = states.ringing() ?: return@withLock null
@@ -157,6 +167,13 @@ class AlarmEngine @Inject constructor(
)
}
/** 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.
*
@@ -109,10 +109,12 @@ class AlarmRingService : Service() {
/**
* The placeholder the service goes foreground with immediately; [ring]
* replaces it with the alarm's own notification as soon as the session is
* read. Same channel and same id, so the user sees one notification.
* read. Same channel and same id, so the user sees one notification — and
* the placeholder is the first post, so it is the one that opens the ring
* screen.
*/
private fun startForegroundPreparing() {
startForeground(RingNotifications.preparing(this))
startForeground(RingNotifications.preparing(this, presentation()))
}
private fun startForeground(session: RingSession) {
@@ -120,11 +122,13 @@ class AlarmRingService : Service() {
RingNotifications.ringing(
context = this,
alarm = session.alarm,
presentation = RingPresentationPolicy.forCapabilities(capabilities.snapshot()),
presentation = presentation(),
),
)
}
private fun presentation() = RingPresentationPolicy.forCapabilities(capabilities.snapshot())
private fun startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
// systemExempted: Android's own guidance names an app holding an
@@ -72,9 +72,17 @@ object RingNotifications {
* The notification the service goes foreground with the instant it starts,
* before it has read anything: `startForeground` must not wait on a database
* read. [ringing] replaces it under the same id a moment later.
*
* It already carries the full-screen intent. SystemUI launches one only
* when a notification is first *posted*, never on an update, so an intent
* that only [ringing] carried would leave a locked phone ringing behind a
* black screen (issue #15). The ring screen reads the session itself and
* closes when there is none, so a dismiss that lands before the service's
* read costs one brief screen wake, never a ring screen left up.
*/
fun preparing(context: Context): Notification =
NotificationCompat.Builder(context, RING_CHANNEL_ID)
fun preparing(context: Context, presentation: RingPresentation): Notification {
val show = showPendingIntent(context)
val builder = NotificationCompat.Builder(context, RING_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.alarm_notification_title))
.setCategory(NotificationCompat.CATEGORY_ALARM)
@@ -82,8 +90,10 @@ object RingNotifications {
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(showPendingIntent(context))
.build()
.setContentIntent(show)
if (presentation.useFullScreenIntent) builder.setFullScreenIntent(show, true)
return builder.build()
}
/** The ringing notification, presented as [presentation] says the platform allows. */
fun ringing(
@@ -3,13 +3,13 @@ package de.jeanlucmakiola.clockula.backup
import de.jeanlucmakiola.clockula.alarm.AlarmEngine
import de.jeanlucmakiola.clockula.data.backup.BackupRepository
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.backup.BackupContent
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.timer.TimerEngine
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
@@ -24,7 +24,6 @@ import javax.inject.Singleton
class BackupEngine @Inject constructor(
private val repository: BackupRepository,
private val settings: SettingsPrefs,
private val timers: TimerRepository,
private val alarmEngine: AlarmEngine,
private val timerEngine: TimerEngine,
private val wallClock: WallClock,
@@ -39,24 +38,64 @@ class BackupEngine @Inject constructor(
settings = settingsSnapshot(),
)
/**
* Replaces every alarm, timer and world clock, and every setting the
* backup carries. Before the replace: a ringing alarm is dismissed (the
* same `stopRingIfRinging` step `AlarmsViewModel` takes before disabling
* one) and every running timer is deleted through [TimerEngine], so the
* import can never strand a live ring session mid-replace. After it:
* both engines are told to re-resolve, so the imported state is live
* immediately rather than waiting for the next unrelated trigger.
*/
/** Replace settings and rows, then reconcile the platform services. */
suspend fun import(content: BackupContent) {
stopRingingAlarmIfAny()
deleteRunningTimers()
repository.replaceAll(content.alarms, content.timers, content.worldClocks)
applySettings(content.settings)
alarmEngine.reschedule()
timerEngine.resync()
val previousSettings = settingsSnapshot()
var settingsApplied = false
var rowsReplaced = false
var ringSuspended = false
var failure: Throwable? = null
try {
withContext(NonCancellable) {
settings.applyBackup(content.settings)
settingsApplied = true
}
// Pause actuators without deleting their rows. If Room rejects the
// replacement, the old ring and running timers can be resumed.
alarmEngine.suspendRingForImport()
ringSuspended = true
timerEngine.suspendForImport()
withContext(NonCancellable) {
repository.replaceAll(content.alarms, content.timers, content.worldClocks)
rowsReplaced = true
}
} catch (caught: Throwable) {
failure = caught
} finally {
withContext(NonCancellable) {
if (!rowsReplaced && settingsApplied) {
try {
settings.applyBackup(previousSettings)
} catch (rollbackFailure: Throwable) {
failure?.addSuppressed(rollbackFailure)
}
}
// The database transaction either committed in full or left its
// old rows. In both cases refresh every platform registration.
try {
if (!rowsReplaced && ringSuspended) alarmEngine.resumeRingingIfAny()
alarmEngine.reschedule()
} catch (reconcileFailure: Throwable) {
val reported = if (rowsReplaced) {
IllegalStateException("backup imported, but alarm scheduling could not be refreshed", reconcileFailure)
} else {
reconcileFailure
}
if (failure == null) failure = reported else failure?.addSuppressed(reported)
}
try {
timerEngine.resync()
} catch (reconcileFailure: Throwable) {
val reported = if (rowsReplaced) {
IllegalStateException("backup imported, but timer scheduling could not be refreshed", reconcileFailure)
} else {
reconcileFailure
}
if (failure == null) failure = reported else failure?.addSuppressed(reported)
}
}
}
failure?.let { throw it }
}
private suspend fun settingsSnapshot(): BackupSettings = BackupSettings(
@@ -65,28 +104,4 @@ class BackupEngine @Inject constructor(
defaults = settings.currentDefaults(),
timerPresets = settings.timerPresets.first(),
)
private suspend fun applySettings(backup: BackupSettings) {
settings.setThemeMode(backup.themeMode)
settings.setDynamicColor(backup.dynamicColor)
settings.setSnoozeMinutes(backup.defaults.snoozeMinutes)
settings.setSnoozeLimit(backup.defaults.snoozeLimit)
settings.setVibrate(backup.defaults.vibrate)
settings.setVolumeRampSeconds(backup.defaults.volumeRampSeconds)
settings.setAlarmRingtoneUri(backup.defaults.alarmRingtoneUri)
settings.setTimerRingtoneUri(backup.defaults.timerRingtoneUri)
settings.setDismissChallenge(backup.defaults.dismissChallenge)
settings.setHomeZoneId(backup.defaults.homeZoneId)
settings.setTimerPresets(backup.timerPresets)
}
private suspend fun stopRingingAlarmIfAny() {
val ringingId = alarmEngine.ringSession()?.alarm?.id ?: return
alarmEngine.dismiss(ringingId)
}
private suspend fun deleteRunningTimers() {
val runningIds = timers.timers().first().filter { it.state == TimerState.RUNNING }.map { it.id }
for (id in runningIds) timerEngine.delete(id)
}
}
@@ -9,7 +9,9 @@ import de.jeanlucmakiola.clockula.backup.BackupFiles
import de.jeanlucmakiola.floret.di.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -27,14 +29,29 @@ class SafBackupFiles @Inject constructor(
override suspend fun write(treeUri: String, content: String): Result<Unit> = withContext(io) {
runCatching {
val bytes = content.toByteArray(Charsets.UTF_8)
if (bytes.size > MAX_BACKUP_BYTES) throw IOException("backup exceeds the 8 MiB limit")
val tree = DocumentFile.fromTreeUri(context, treeUri.toUri())
?: error("could not open the chosen folder")
tree.findFile(BACKUP_FILE_NAME)?.delete()
val file = tree.createFile("application/json", BACKUP_FILE_NAME)
?: error("could not create $BACKUP_FILE_NAME")
val stream = context.contentResolver.openOutputStream(file.uri)
?: throw IOException("could not open an output stream for ${file.uri}")
stream.use { it.write(content.toByteArray(Charsets.UTF_8)) }
val temporary = tree.createFile("application/json", "$BACKUP_FILE_NAME.${UUID.randomUUID()}.tmp")
?: throw IOException("could not create a temporary backup")
var keepTemporary = false
try {
val stream = context.contentResolver.openOutputStream(temporary.uri)
?: throw IOException("could not open an output stream for ${temporary.uri}")
stream.use { it.write(bytes) }
val existing = tree.findFile(BACKUP_FILE_NAME)
if (existing != null && !existing.delete()) throw IOException("could not remove the previous backup")
// Some SAF providers cannot rename. Once the old file has been
// removed, retain the complete temporary file if rename fails.
keepTemporary = true
if (!temporary.renameTo(BACKUP_FILE_NAME)) {
throw IOException("backup was written as ${temporary.name}; the provider could not rename it")
}
} catch (failure: Throwable) {
if (!keepTemporary) runCatching { temporary.delete() }
throw failure
}
}
}
@@ -42,7 +59,23 @@ class SafBackupFiles @Inject constructor(
runCatching {
val stream = context.contentResolver.openInputStream(documentUri.toUri())
?: throw IOException("could not open an input stream for $documentUri")
stream.use { it.readBytes().toString(Charsets.UTF_8) }
stream.use { input ->
val output = ByteArrayOutputStream()
val buffer = ByteArray(8192)
var total = 0
while (true) {
val count = input.read(buffer)
if (count < 0) break
total += count
if (total > MAX_BACKUP_BYTES) throw IOException("backup exceeds the 8 MiB limit")
output.write(buffer, 0, count)
}
output.toString(Charsets.UTF_8.name())
}
}
}
private companion object {
const val MAX_BACKUP_BYTES = 8 * 1024 * 1024
}
}
@@ -32,6 +32,13 @@ sealed interface BackupResult {
*/
object BackupCodec {
private const val MAX_DOCUMENT_CHARS = 8 * 1024 * 1024
private const val MAX_ALARMS = 10_000
private const val MAX_TIMERS = 10_000
private const val MAX_WORLD_CLOCKS = 1_000
private const val MAX_LABEL_CHARS = 64 * 1_024
private const val MAX_URI_CHARS = 64 * 1_024
private val json = Json {
ignoreUnknownKeys = true
prettyPrint = true
@@ -41,6 +48,7 @@ object BackupCodec {
json.encodeToString(BackupDocument.serializer(), content.toDocument())
fun decode(text: String): BackupResult {
if (text.length > MAX_DOCUMENT_CHARS) return BackupResult.Failure("backup exceeds the 8 MiB limit")
val document = try {
json.decodeFromString(BackupDocument.serializer(), text)
} catch (e: SerializationException) {
@@ -112,6 +120,9 @@ object BackupCodec {
private fun BackupDocument.toContent(): BackupResult {
if (format != BACKUP_FORMAT) return BackupResult.Failure("unknown format: '$format'")
if (version != BACKUP_VERSION) return BackupResult.Failure("unsupported backup version: $version")
if (alarms.size > MAX_ALARMS || timers.size > MAX_TIMERS || worldClocks.size > MAX_WORLD_CLOCKS) {
return BackupResult.Failure("backup contains too many records")
}
val alarms = mutableListOf<BackupAlarm>()
for ((index, dto) in this.alarms.withIndex()) {
@@ -120,6 +131,9 @@ object BackupCodec {
"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("alarms[$index]: label or ringtone URI is too long")
}
alarms += BackupAlarm(
time = TimeOfDay(dto.hour, dto.minute),
label = dto.label,
@@ -141,6 +155,9 @@ object BackupCodec {
if (dto.durationMillis < 0) {
return BackupResult.Failure("timers[$index]: negative duration")
}
if (dto.label.length > MAX_LABEL_CHARS || (dto.ringtoneUri?.length ?: 0) > MAX_URI_CHARS) {
return BackupResult.Failure("timers[$index]: label or ringtone URI is too long")
}
timers += BackupTimer(
label = dto.label,
duration = dto.durationMillis.milliseconds,
@@ -155,6 +172,9 @@ object BackupCodec {
if (!Zones.isValid(dto.zoneId)) {
return BackupResult.Failure("worldClocks[$index]: not an IANA zone id: '${dto.zoneId}'")
}
if ((dto.label?.length ?: 0) > MAX_LABEL_CHARS) {
return BackupResult.Failure("worldClocks[$index]: label is too long")
}
worldClocks += BackupWorldClock(
zoneId = dto.zoneId,
label = dto.label?.takeIf { it.isNotBlank() },
@@ -163,6 +183,12 @@ object BackupCodec {
}
val settingsDto = settings
if ((settingsDto.alarmRingtoneUri?.length ?: 0) > MAX_URI_CHARS ||
(settingsDto.timerRingtoneUri?.length ?: 0) > MAX_URI_CHARS ||
settingsDto.timerPresets.size > 1_000
) {
return BackupResult.Failure("settings contain oversized values")
}
val settingsContent = BackupSettings(
themeMode = settingsDto.themeMode.toEnum(ThemeMode.SYSTEM),
dynamicColor = settingsDto.dynamicColor,
@@ -2,12 +2,14 @@ package de.jeanlucmakiola.clockula.data.prefs
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.DismissChallenge
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.clockula.domain.timer.TimerPresets
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.AppearancePrefs
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
import de.jeanlucmakiola.floret.prefs.appearance
import de.jeanlucmakiola.floret.prefs.set
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -73,6 +75,27 @@ class SettingsPrefs @Inject constructor(private val store: PrefStore) {
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.dismissChallenge] = backup.defaults.dismissChallenge
prefs[ClockPrefs.homeZoneId] = backup.defaults.homeZoneId
prefs[TimerPrefs.presets] = backup.timerPresets
}
}
suspend fun setSnoozeMinutes(minutes: Int) = store.set(ClockPrefs.snoozeMinutes, minutes)
suspend fun setSnoozeLimit(limit: Int) = store.set(ClockPrefs.snoozeLimit, limit)
@@ -0,0 +1,41 @@
package de.jeanlucmakiola.clockula.data.ringtones
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.clockula.domain.RingtoneOption
import de.jeanlucmakiola.clockula.domain.Ringtones
/**
* The sounds Clockula ships itself, and the one place their stored sentinel
* becomes something a `MediaPlayer` or a `ContentResolver` can open. Resolved
* against the running package every time, so the stored value never names one.
*/
object BundledTones {
/** The bundled sounds as picker rows, in the order the picker shows them. */
fun options(context: Context): List<RingtoneOption> = listOf(
RingtoneOption(Ringtones.TIMER_EXPIRED_URI, context.getString(R.string.ringtone_timer_expired)),
)
/** The bundled sound's title, or null when [uri] is not one. */
fun titleOf(context: Context, uri: String?): String? =
options(context).firstOrNull { it.uri == uri }?.title
/** An `android.resource://` URI for a bundled sound, or null when [uri] is not one. */
fun resolve(context: Context, uri: String?): Uri? = when (uri) {
Ringtones.TIMER_EXPIRED_URI -> resourceUri(context, R.raw.timer_expire)
else -> null
}
/** [resolve], or [uri] parsed as it is. Null only when it does not parse. */
fun playable(context: Context, uri: String): Uri? =
resolve(context, uri) ?: runCatching { Uri.parse(uri) }.getOrNull()
private fun resourceUri(context: Context, resId: Int): Uri = Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(context.packageName)
.appendPath(resId.toString())
.build()
}
@@ -21,25 +21,40 @@ internal suspend fun connectMediaBrowser(
onSuspended: () -> Unit = {},
): MediaBrowser? = suspendCancellableCoroutine { continuation ->
var created: MediaBrowser? = null
var handedOff = false
fun disconnect() {
created?.let { runCatching { it.disconnect() } }
}
val callback = object : MediaBrowser.ConnectionCallback() {
override fun onConnected() {
if (continuation.isActive) continuation.resume(created)
if (continuation.isActive) {
handedOff = true
continuation.resume(created)
} else {
disconnect()
}
}
override fun onConnectionFailed() {
disconnect()
if (continuation.isActive) continuation.resume(null)
}
override fun onConnectionSuspended() {
if (continuation.isActive) continuation.resume(null) else onSuspended()
if (handedOff) {
onSuspended()
} else {
disconnect()
if (continuation.isActive) continuation.resume(null)
}
}
}
val connecting = runCatching { MediaBrowser(context, component, callback, null) }.getOrNull()
created = connecting
if (connecting == null || runCatching { connecting.connect() }.isFailure) {
connecting?.let { runCatching { it.disconnect() } }
disconnect()
continuation.resume(null)
return@suspendCancellableCoroutine
}
continuation.invokeOnCancellation { runCatching { connecting.disconnect() } }
continuation.invokeOnCancellation { disconnect() }
}
@@ -3,7 +3,10 @@ package de.jeanlucmakiola.clockula.data.ringtones
import de.jeanlucmakiola.clockula.domain.RingtoneOption
interface RingtoneCatalog {
/** The device's alarm sounds, title-sorted. **Empty** rather than throwing. */
/**
* The sounds bundled with the app, then the device's alarm sounds,
* title-sorted. The device part is **empty** rather than throwing.
*/
suspend fun alarmTones(): List<RingtoneOption>
/** A display title for [uri], or null when the device cannot name it. */
@@ -52,9 +52,13 @@ class SystemRingtoneCatalog @Inject constructor(
}
}
}.getOrDefault(emptyList()).sortedBy { it.title.lowercase() }
// The app's own sounds first: they are always there, and the device
// has no way to list them.
.let { device -> BundledTones.options(context) + device }
}
override suspend fun titleOf(uri: String): String? = withContext(io) {
BundledTones.titleOf(context, uri)?.let { return@withContext it }
// A sound another app plays carries its own title: naming it would
// otherwise mean connecting to that app on every editor render.
MediaSources.decode(uri)?.let { ref ->
@@ -82,7 +86,7 @@ class SystemRingtoneCatalog @Inject constructor(
true
}.getOrDefault(false)
}
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return@withContext false
val parsed = BundledTones.playable(context, uri) ?: return@withContext false
runCatching {
context.contentResolver.openInputStream(parsed)?.use { true } ?: false
}.getOrDefault(false)
@@ -65,7 +65,7 @@ class SystemRingtonePreviewer @Inject constructor(
stop()
return
}
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return
val parsed = BundledTones.playable(context, uri) ?: return
// Stop *before* taking the lock: what is sounding now stops now, and an
// in-flight `prepare` is told its preview has been superseded.
stop()
@@ -9,6 +9,17 @@ object Ringtones {
fun isSilent(uri: String?): Boolean = uri == SILENT_URI
/**
* The "Timer expired" sound Clockula ships in its own resources. Stored under
* the private scheme rather than as an `android.resource://` URI, which
* carries the package name and so would break between a debug and a release
* build, or after a restore into the other one.
*/
const val TIMER_EXPIRED_URI: String = "clockula://tone/timer_expired"
/** True for a sound bundled with the app rather than one the device provides. */
fun isBundled(uri: String?): Boolean = uri == TIMER_EXPIRED_URI
/** Trimmed; blank becomes null. [SILENT_URI] passes through unchanged. */
fun normalise(uri: String?): String? = uri?.trim()?.takeIf { it.isNotEmpty() }
}
@@ -60,6 +60,17 @@ object MediaRingPolicy {
fun shouldFallBack(stall: Stall): Boolean = stall.stopped >= STALL_GRACE || stall.notPlaying >= BUFFER_GRACE
/**
* Whether the app turned down the ring's play request, so the start can
* fall back now instead of waiting out [START_TIMEOUT]. YouTube without
* Premium does this at once. Only an error reported at or after
* [requestedAtMillis] counts: one left over from the user's own listening
* says nothing about this request. Both times are `elapsedRealtime`, which
* is what `PlaybackState` stamps its updates with.
*/
fun refusedStart(isError: Boolean, updatedAtMillis: Long, requestedAtMillis: Long): Boolean =
isError && updatedAtMillis >= requestedAtMillis
/**
* The media-stream index a fully ramped ring plays at.
*
@@ -6,6 +6,7 @@ import android.media.AudioManager
import android.media.browse.MediaBrowser
import android.media.session.MediaController
import android.media.session.PlaybackState
import android.os.SystemClock
import de.jeanlucmakiola.clockula.data.ringtones.connectMediaBrowser
import de.jeanlucmakiola.clockula.domain.MediaSourceRef
import de.jeanlucmakiola.clockula.domain.ring.MediaPlayback
@@ -37,9 +38,6 @@ internal class MediaAppRing(private val context: Context, private val ref: Media
/** The fully ramped media index, fixed when the ring starts. */
private var target: Int = 0
/** The index last written, so an unchanged ramp step writes nothing. */
private var lastIndex: Int? = null
private var holding: Boolean = false
/** The app's service went away mid-ring: nothing it reports any more is trustworthy. */
@@ -89,6 +87,7 @@ internal class MediaAppRing(private val context: Context, private val ref: Media
}
}
if (stopped) return false
val requestedAt = SystemClock.elapsedRealtime()
runCatching {
if (ref.mediaId != null) controls.playFromMediaId(ref.mediaId, null) else controls.play()
}.onFailure { return false }
@@ -96,11 +95,23 @@ internal class MediaAppRing(private val context: Context, private val ref: Media
return withTimeoutOrNull(MediaRingPolicy.START_TIMEOUT) {
// Strictly PLAYING here: an app stuck buffering a stream it cannot
// reach must not count as a ring that started.
while (!stopped && playbackState() != PlaybackState.STATE_PLAYING) delay(POLL_MILLIS)
while (!stopped && playbackState() != PlaybackState.STATE_PLAYING) {
if (connectionLost || refused(requestedAt)) return@withTimeoutOrNull false
delay(POLL_MILLIS)
}
!stopped
} ?: false
}
private fun refused(requestedAt: Long): Boolean {
val state = runCatching { controller?.playbackState }.getOrNull() ?: return false
return MediaRingPolicy.refusedStart(
isError = state.state == PlaybackState.STATE_ERROR,
updatedAtMillis = state.lastPositionUpdateTime,
requestedAtMillis = requestedAt,
)
}
fun playback(): MediaPlayback {
if (connectionLost) return MediaPlayback.STOPPED
return when (playbackState()) {
@@ -114,9 +125,7 @@ internal class MediaAppRing(private val context: Context, private val ref: Media
fun setLevel(level: Float) {
if (!holding) return
val index = MediaRingPolicy.mediaVolumeIndex(level, target)
if (index == lastIndex) return
lastIndex = index
runCatching { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, index, 0) }
MediaRingRegistry.setLevel(index, audioManager)
}
/** Pauses the app (unless another ring still uses it), disconnects, releases the volume. Idempotent. */
@@ -14,6 +14,8 @@ internal object MediaRingRegistry {
/** The user's media volume from before the first live media ring; null while none is live. */
private var savedMusicVolume: Int? = null
private var lastRingVolume: Int? = null
private var userChangedVolume: Boolean = false
/** Live media rings per package. */
private val holders = mutableMapOf<String, Int>()
@@ -21,10 +23,29 @@ internal object MediaRingRegistry {
fun acquire(packageName: String, audioManager: AudioManager) {
if (holders.isEmpty()) {
savedMusicVolume = runCatching { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
lastRingVolume = null
userChangedVolume = false
}
holders[packageName] = (holders[packageName] ?: 0) + 1
}
/** Stop controlling the stream as soon as another actor changes it. */
fun setLevel(index: Int, audioManager: AudioManager) {
if (userChangedVolume) return
val current = runCatching { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull() ?: return
if (lastRingVolume != null && current != lastRingVolume) {
userChangedVolume = true
return
}
if (current == index) {
lastRingVolume = index
return
}
if (runCatching { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, index, 0) }.isSuccess) {
lastRingVolume = index
}
}
/**
* Drops one hold on [packageName]. True when no other live ring still uses
* that app, so it is the caller's to pause. The user's media volume comes
@@ -34,10 +55,15 @@ internal object MediaRingRegistry {
val count = holders[packageName] ?: return false
if (count > 1) holders[packageName] = count - 1 else holders.remove(packageName)
if (holders.isEmpty()) {
savedMusicVolume?.let { saved ->
runCatching { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, saved, 0) }
val current = runCatching { audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) }.getOrNull()
if (!userChangedVolume && current != null && current == lastRingVolume) {
savedMusicVolume?.let { saved ->
runCatching { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, saved, 0) }
}
}
savedMusicVolume = null
lastRingVolume = null
userChangedVolume = false
}
return packageName !in holders
}
@@ -7,6 +7,7 @@ import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.net.Uri
import de.jeanlucmakiola.clockula.data.ringtones.BundledTones
import de.jeanlucmakiola.clockula.domain.MediaSources
import de.jeanlucmakiola.clockula.domain.ring.AudioSourcePolicy
import de.jeanlucmakiola.clockula.domain.ring.MediaRingPolicy
@@ -14,6 +15,7 @@ import de.jeanlucmakiola.clockula.domain.ring.RingAudio
import de.jeanlucmakiola.clockula.domain.ring.RingtoneSourceKind
import de.jeanlucmakiola.clockula.domain.ring.VolumeRamp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -115,7 +117,15 @@ class RingAudioPlayer(private val context: Context) {
if (!routes.mediaAudible()) return false
val media = MediaAppRing(context, ref)
mediaRing = media
if (media.start() && ring == generation) {
val started = try {
media.start()
} catch (cancelled: CancellationException) {
media.stop()
throw cancelled
} catch (_: Exception) {
false
}
if (started && ring == generation) {
watchMedia(ring, scope, media, ringtoneUri, rampFor, onSilenced)
return true
}
@@ -136,26 +146,29 @@ class RingAudioPlayer(private val context: Context) {
rampFor: Duration,
): Boolean {
if (ring != generation) return false
val prepared = withContext(Dispatchers.IO) {
sources(ringtoneUri).firstNotNullOfOrNull { prepare(it) }
} ?: return false
// Stopped while we were preparing: release rather than start, or the ring
// would outlive the dismissal that asked for it.
if (ring != generation) {
release(prepared)
return false
for (uri in sources(ringtoneUri)) {
val prepared = withContext(Dispatchers.IO) { prepare(uri) } ?: continue
// Stopped while preparing: never start an obsolete ring.
if (ring != generation) {
release(prepared)
return false
}
requestFocus()
val initial = VolumeRamp.levelAt(Duration.ZERO, rampFor)
val started = runCatching {
prepared.setVolume(initial, initial)
prepared.start()
}.isSuccess
if (!started) {
release(prepared)
abandonFocus()
continue
}
player = prepared
ramp = launchRamp(scope, rampFor) { level -> runCatching { player?.setVolume(level, level) } }
return true
}
player = prepared
requestFocus()
// The level is set before the first sample plays, so a ramp from silence
// does not begin with a burst at full volume.
val initial = VolumeRamp.levelAt(Duration.ZERO, rampFor)
runCatching { prepared.setVolume(initial, initial) }
runCatching { prepared.start() }
ramp = launchRamp(scope, rampFor) { level -> runCatching { player?.setVolume(level, level) } }
return true
return false
}
/**
@@ -210,7 +223,7 @@ class RingAudioPlayer(private val context: Context) {
private fun sources(ringtoneUri: String?): List<Uri> =
AudioSourcePolicy.sourcesFor(ringtoneUri).mapNotNull { kind ->
when (kind) {
RingtoneSourceKind.ALARM_OWN -> ringtoneUri?.let(Uri::parse)
RingtoneSourceKind.ALARM_OWN -> ringtoneUri?.let { BundledTones.playable(context, it) }
// Not a URI a player can open: [start] has already tried it.
RingtoneSourceKind.MEDIA_APP -> null
RingtoneSourceKind.DEVICE_DEFAULT ->
@@ -69,6 +69,12 @@ class TimerEngine @Inject constructor(
/** Sweep what is due, re-register the earliest deadline, match the service to "anything active". */
suspend fun resync(): Duration? = lock.withLock { resyncLocked() }
/** Stop the current service and slot while a backup replaces its rows. */
suspend fun suspendForImport(): Unit = lock.withLock {
scheduler.cancelExpiry()
service.sync(false)
}
/**
* The slot arriving. The sweep is idempotent and carries no id (D5): a fire
* delivered late expires everything that came due while it was delayed, and
@@ -24,6 +24,7 @@ object TimerIntents {
const val REQUEST_RESUME: Int = 14
const val REQUEST_RESET: Int = 15
const val REQUEST_ADD_TIME: Int = 16
const val REQUEST_RING: Int = 17
val ALL_REQUEST_CODES: List<Int> = listOf(
REQUEST_EXPIRY,
@@ -32,6 +33,7 @@ object TimerIntents {
REQUEST_RESUME,
REQUEST_RESET,
REQUEST_ADD_TIME,
REQUEST_RING,
)
/** base * 100_000 + (timerId % 100_000) — the stride `RingNotifications` already uses. */
@@ -16,6 +16,7 @@ import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationAction
import de.jeanlucmakiola.clockula.domain.timer.TimerNotificationState
import de.jeanlucmakiola.clockula.timer.TimerIntents
import de.jeanlucmakiola.clockula.timer.receiver.TimerActionReceiver
import de.jeanlucmakiola.clockula.ui.ring.TimerRingActivity
/**
* One channel and one id for however many timers: the foreground service's
@@ -30,9 +31,20 @@ object TimerNotifications {
const val TIMER_CHANNEL_ID: String = "timers"
/** One notification for every timer (D17), so one id. */
/** One notification for every timer (D17), so one id at a time — of these two. */
const val TIMER_NOTIFICATION_ID: Int = 1_002
/**
* The id an expiry's alerting post swaps to, and back from on the next one.
* SystemUI launches a full-screen intent only for a newly *posted*
* notification, and an expiry is otherwise an update of the countdown's.
*/
const val TIMER_ALERT_NOTIFICATION_ID: Int = 1_004
/** The id an alerting post goes up under when [current] is on screen. */
fun alertingIdAfter(current: Int): Int =
if (current == TIMER_NOTIFICATION_ID) TIMER_ALERT_NOTIFICATION_ID else TIMER_NOTIFICATION_ID
/** Idempotent; called from the app class on every start. */
fun createChannel(context: Context) {
val channel = NotificationChannel(
@@ -75,8 +87,12 @@ object TimerNotifications {
.setContentIntent(showPendingIntent(context))
.build()
/** The subject, and a count. Two quick actions; the app row also offers subtraction. */
fun timer(context: Context, state: TimerNotificationState): Notification {
/**
* The subject, and a count. Two quick actions; the app row also offers
* subtraction. An expiry's one alerting post also opens [TimerRingActivity]
* over the lock screen when [fullScreen] allows it.
*/
fun timer(context: Context, state: TimerNotificationState, fullScreen: Boolean): Notification {
val builder = NotificationCompat.Builder(context, TIMER_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(
@@ -130,6 +146,11 @@ object TimerNotifications {
builder.setShowWhen(false).setContentText(bodyFor(context, state))
}
if (fullScreen && state.mode == TimerMode.EXPIRED && !state.alertOnce) {
builder.setPriority(NotificationCompat.PRIORITY_MAX)
.setFullScreenIntent(ringPendingIntent(context), true)
}
state.actions.forEach { action ->
builder.addAction(
R.drawable.ic_notification,
@@ -179,6 +200,14 @@ object TimerNotifications {
PENDING_INTENT_FLAGS,
)
private fun ringPendingIntent(context: Context): PendingIntent = PendingIntent.getActivity(
context,
TimerIntents.REQUEST_RING,
Intent(context, TimerRingActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
PENDING_INTENT_FLAGS,
)
private fun actionPendingIntent(
context: Context,
action: TimerNotificationAction,
@@ -10,6 +10,7 @@ import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationManagerCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.alarm.AlarmCapabilities
import de.jeanlucmakiola.clockula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.domain.ring.RingFallbackPolicy
@@ -71,6 +72,9 @@ class TimerService : Service() {
@Inject
lateinit var settings: SettingsPrefs
@Inject
lateinit var capabilities: AlarmCapabilities
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val audio by lazy { RingAudioPlayer(this) }
@@ -105,6 +109,13 @@ class TimerService : Service() {
*/
private var wentForeground: Boolean = false
/**
* The id the notification is up under. An alerting post moves it to the
* other of the two, so an expiry is a *new* notification and its
* full-screen intent fires — every other post keeps it where it is.
*/
private var notificationId: Int = TimerNotifications.TIMER_NOTIFICATION_ID
/** The last start command's id, so [stop] can decline to die on a newer one. */
private var lastStartId: Int = 0
@@ -170,7 +181,7 @@ class TimerService : Service() {
}
applyRing(state.ring)
post(notification)
post(notification, sounding = state.ring is TimerRingDecision.Sound)
scheduleDelays(state)
progressRefresh?.cancel()
progressRefresh = if (notification.mode == TimerMode.RUNNING) {
@@ -239,9 +250,20 @@ class TimerService : Service() {
}
}
private fun post(state: TimerNotificationState) {
if (!state.alertOnce) alreadyAlerted = true
startForeground(TimerNotifications.timer(this, state))
private fun post(state: TimerNotificationState, sounding: Boolean) {
var fullScreen = false
if (!state.alertOnce) {
alreadyAlerted = true
// Moving the foreground notification to a new id removes the old one,
// so the user still sees one notification.
notificationId = TimerNotifications.alertingIdAfter(notificationId)
// Only a sounding timer opens its ring screen: one suppressed by a
// ringing alarm would cover the alarm's screen (D9), and one that
// auto-silenced before a process restart would wake the display for
// nothing (D11).
fullScreen = sounding && capabilities.snapshot().canUseFullScreenIntent
}
startForeground(TimerNotifications.timer(this, state, fullScreen))
}
/**
@@ -279,12 +301,12 @@ class TimerService : Service() {
// ringing service: an app holding an exact-alarm permission,
// continuing an alarm-shaped event in the background (D6).
startForeground(
TimerNotifications.TIMER_NOTIFICATION_ID,
notificationId,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED,
)
} else {
startForeground(TimerNotifications.TIMER_NOTIFICATION_ID, notification)
startForeground(notificationId, notification)
}
}
@@ -314,7 +336,11 @@ class TimerService : Service() {
stopForeground(STOP_FOREGROUND_REMOVE)
// The placeholder is posted before anything is read, so a service that
// starts with nothing active must take its own notification with it.
NotificationManagerCompat.from(this).cancel(TimerNotifications.TIMER_NOTIFICATION_ID)
NotificationManagerCompat.from(this).apply {
cancel(TimerNotifications.TIMER_NOTIFICATION_ID)
cancel(TimerNotifications.TIMER_ALERT_NOTIFICATION_ID)
}
notificationId = TimerNotifications.TIMER_NOTIFICATION_ID
wentForeground = false
// Not `stopSelf()`: resetting the last timer and immediately starting a
// new one delivers a start command to this still-live instance, and
@@ -157,7 +157,7 @@ class AlarmEditorViewModel @Inject internal constructor(
loading = false,
options = RingtonePicker.optionsFor(tones, stored, editing.ringtone.title),
selectedUri = stored,
deviceHasNoSounds = tones.isEmpty(),
deviceHasNoSounds = tones.all { Ringtones.isBundled(it.uri) },
// Resolved, not looked up in the tone list: an app default that
// is a SAF-picked file is absent from that list, and the row
// would then claim the device default instead (D16).
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.material3.ToggleButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -59,7 +58,6 @@ internal fun RepeatDaySelector(
ToggleButton(
checked = day in repeatDays,
onCheckedChange = { onDayToggled(day) },
shapes = ToggleButtonDefaults.shapes(),
modifier = Modifier
.weight(1f)
.semantics { contentDescription = full },
@@ -15,6 +15,7 @@ import de.jeanlucmakiola.clockula.domain.time.WallClock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
import javax.inject.Inject
@@ -53,12 +54,13 @@ class AlarmRingViewModel @Inject constructor(
)
render()
}
// The ring ending is what closes this screen, whoever ended it — but the
// coordinator is seeded null in a fresh process, so storage settles it.
// The ring ending is what closes this screen, whoever ended it. Both
// sources count: the coordinator is seeded null in a fresh process, and a
// backup restore stops it before the ringing row is deleted, so only
// the two together say the ring is over.
viewModelScope.launch {
ring.ringing.collect { alarmId ->
if (alarmId == null && engine.ringSession() == null) _state.value = RingUiState.Finished
}
combine(ring.ringing, engine.ringStored()) { alarmId, stored -> alarmId == null && !stored }
.collect { over -> if (over) _state.value = RingUiState.Finished }
}
// The escape earns itself over time, so the gate needs the clock read
// again on a cadence rather than only when the user touches something.
@@ -0,0 +1,63 @@
package de.jeanlucmakiola.clockula.ui.ring
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.clockula.ui.theme.ClockulaTheme
/**
* The window an expired timer gets, opened by the timer notification's
* full-screen intent: over the lock screen with the display turned on, for as
* long as any timer has expired. Without it, a timer finishing on a locked
* phone rang behind a black screen (issue #15).
*
* Deliberately not [de.jeanlucmakiola.clockula.MainActivity]: that would put
* the whole app over the lock screen.
*/
@AndroidEntryPoint
class TimerRingActivity : ComponentActivity() {
private val viewModel: TimerRingViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// As AlarmRingActivity: shown over the lock screen, display on, and
// kept on for this window's life only.
setShowWhenLocked(true)
setTurnScreenOn(true)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
setContent {
ClockulaTheme {
BackHandler(enabled = true) { /* A timer is not stopped by leaving. */ }
val state by viewModel.state.collectAsStateWithLifecycle()
when (val current = state) {
TimerRingUiState.Loading ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {}
is TimerRingUiState.Ringing -> TimerRingScreen(
state = current,
onStop = viewModel::onStop,
onAddMinute = viewModel::onAddMinute,
)
// Stopped here, from the shade or from the Timers tab, or
// auto-silenced: whoever ended the ring closes this window.
TimerRingUiState.Finished -> LaunchedEffect(Unit) { finish() }
}
}
}
}
}
@@ -0,0 +1,112 @@
package de.jeanlucmakiola.clockula.ui.ring
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Timer
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.clockula.R
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
/**
* The timer ring screen: the label, "Finished", and the notification's two
* buttons. Back is swallowed at [TimerRingActivity]'s composition root, for the
* same reason the alarm's is.
*/
@Composable
internal fun TimerRingScreen(
state: TimerRingUiState.Ringing,
onStop: () -> Unit,
onAddMinute: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
Column(
modifier = Modifier.fillMaxSize().safeDrawingPadding().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
TimerBadge()
Text(
text = stringResource(R.string.timer_finished),
style = MaterialTheme.typography.displayMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = state.label.ifBlank { stringResource(R.string.timer_notification_title) },
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (state.otherExpired > 0) {
Text(
text = pluralStringResource(R.plurals.timer_more_active, state.otherExpired, state.otherExpired),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
OutlinedButton(onClick = onAddMinute) {
Text(stringResource(R.string.timer_add_minute))
}
Button(onClick = onStop) {
Text(stringResource(R.string.timer_stop))
}
}
}
}
}
/** The alarm screen's pulsing badge, with the timer's icon. */
@Composable
private fun TimerBadge() {
val reduceMotion = rememberReduceMotion()
val pulse = rememberInfiniteTransition(label = "timer-ring-pulse")
val scale by pulse.animateFloat(
initialValue = 1f,
targetValue = if (reduceMotion) 1f else 1.08f,
animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
label = "timer-ring-pulse-scale",
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(96.dp)
.scale(scale)
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
) {
Icon(
imageVector = Icons.Rounded.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(48.dp),
)
}
}
@@ -0,0 +1,18 @@
package de.jeanlucmakiola.clockula.ui.ring
sealed interface TimerRingUiState {
/** The timers have not been read yet. Every action is ignored. */
data object Loading : TimerRingUiState
data class Ringing(
/** The timer the screen speaks for: the first expired one, in subject order. */
val timerId: Long,
/** Verbatim; blank stays blank — the screen supplies the fallback string. */
val label: String,
/** Expired timers besides [timerId]; each gets its own Stop once this one stops. */
val otherExpired: Int,
) : TimerRingUiState
/** Nothing has expired any more. The activity finishes. */
data object Finished : TimerRingUiState
}
@@ -0,0 +1,88 @@
package de.jeanlucmakiola.clockula.ui.ring
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.clockula.data.timers.TimerRepository
import de.jeanlucmakiola.clockula.data.timers.TimerRingStateStore
import de.jeanlucmakiola.clockula.domain.time.ElapsedRealtimeClock
import de.jeanlucmakiola.clockula.domain.time.Ticker
import de.jeanlucmakiola.clockula.domain.time.WallClock
import de.jeanlucmakiola.clockula.domain.timer.TimerReadings
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.timer.TimerEngine
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.seconds
import javax.inject.Inject
/**
* The timer ring screen's behaviour: which expired timer it speaks for, and the
* same two verbs as the notification's buttons, through the engine — so the
* screen, the shade and the Timers tab can never disagree about what Stop does.
*/
@HiltViewModel
class TimerRingViewModel @Inject constructor(
timers: TimerRepository,
ringState: TimerRingStateStore,
private val engine: TimerEngine,
elapsed: ElapsedRealtimeClock,
wall: WallClock,
ticker: Ticker,
) : ViewModel() {
/**
* Re-read on a tick as well as on every write: a row stored RUNNING *reads*
* EXPIRED once its time is up, before the engine's sweep has written it, and
* the auto-silence window ends without anything being written at all.
*/
val state: StateFlow<TimerRingUiState> =
combine(timers.timers(), ringState.soundingSince, ticker.ticks(TICK)) { rows, soundingSince, _ ->
val now = wall.now()
val expired = TimerReadings.expired(
TimerReadings.active(rows, elapsed.elapsedRealtime(), now),
)
val subject = expired.firstOrNull()
// Auto-silence leaves the rows EXPIRED (D11) but ends the ring, and a
// screen held on over a silent timer is only a battery drain.
val silenced = soundingSince != null && now - soundingSince >= TimerRing.AUTO_SILENCE_AFTER
if (subject == null || silenced) {
TimerRingUiState.Finished
} else {
TimerRingUiState.Ringing(
timerId = subject.timer.id,
label = subject.timer.label,
otherExpired = expired.size - 1,
)
}
}
.distinctUntilChanged()
// Stops a while after the screen goes to the background, so a ring
// screen left behind Home does not tick forever; the grace period
// covers a rotation without flashing back to Loading.
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), TimerRingUiState.Loading)
/**
* "Stop": the notification's RESET on an expired timer. The guarded verb,
* because the id may be one emission old: a "+1 min" from here or the shade
* can have set the timer running again, and that countdown must survive.
*/
fun onStop() {
val ringing = state.value as? TimerRingUiState.Ringing ?: return
viewModelScope.launch { engine.dismissExpired(ringing.timerId) }
}
fun onAddMinute() {
val ringing = state.value as? TimerRingUiState.Ringing ?: return
viewModelScope.launch { engine.addTime(ringing.timerId) }
}
private companion object {
val TICK = 1.seconds
const val STOP_TIMEOUT_MS = 5_000L
}
}
@@ -8,6 +8,7 @@ import de.jeanlucmakiola.clockula.backup.BackupFiles
import de.jeanlucmakiola.clockula.data.backup.BackupCodec
import de.jeanlucmakiola.clockula.data.backup.BackupResult
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -37,30 +38,51 @@ class BackupViewModel @Inject internal constructor(
/** `null` means the folder picker was cancelled — a silent no-op. */
fun onExportFolderPicked(treeUri: String?) {
if (treeUri == null) return
if (treeUri == null || _busy.value) return
_busy.value = true
viewModelScope.launch {
_busy.value = true
val json = BackupCodec.encode(engine.export())
val event = files.write(treeUri, json).fold(
onSuccess = { BackupEvent.ExportSucceeded },
onFailure = { BackupEvent.ExportFailed(it.message ?: "unknown error") },
)
_busy.value = false
_events.trySend(event)
try {
val json = BackupCodec.encode(engine.export())
val event = files.write(treeUri, json).fold(
onSuccess = { BackupEvent.ExportSucceeded },
onFailure = {
if (it is CancellationException) throw it
BackupEvent.ExportFailed(it.message ?: "unknown error")
},
)
_events.trySend(event)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Exception) {
_events.trySend(BackupEvent.ExportFailed(failure.message ?: "unknown error"))
} finally {
_busy.value = false
}
}
}
/** `null` means the file picker was cancelled — a silent no-op. Call only after the user has confirmed the replace. */
fun onImportFilePicked(documentUri: String?) {
if (documentUri == null) return
if (documentUri == null || _busy.value) return
_busy.value = true
viewModelScope.launch {
_busy.value = true
val event = files.read(documentUri).fold(
onSuccess = { text -> applyImport(text) },
onFailure = { BackupEvent.ImportFailed(it.message ?: "unknown error") },
)
_busy.value = false
_events.trySend(event)
try {
val result = files.read(documentUri)
val event = if (result.isSuccess) {
applyImport(result.getOrThrow())
} else {
val failure = result.exceptionOrNull()
if (failure is CancellationException) throw failure
BackupEvent.ImportFailed(failure?.message ?: "unknown error")
}
_events.trySend(event)
} catch (cancelled: CancellationException) {
throw cancelled
} catch (failure: Exception) {
_events.trySend(BackupEvent.ImportFailed(failure.message ?: "unknown error"))
} finally {
_busy.value = false
}
}
}
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -257,6 +258,12 @@ private fun SettingsHub(
icon = ImageVector.vectorResource(R.drawable.ic_codeberg),
position = Position.Top,
)
AboutLinkRow(
label = stringResource(R.string.settings_translate),
url = stringResource(R.string.about_translate_url),
icon = Icons.Default.Translate,
position = Position.Middle,
)
AboutLinkRow(
label = stringResource(R.string.settings_license),
url = stringResource(R.string.about_license_url),
@@ -159,7 +159,7 @@ class SettingsViewModel @Inject internal constructor(
loading = false,
options = RingtonePicker.optionsFor(tones, stored, title),
selectedUri = stored,
deviceHasNoSounds = tones.isEmpty(),
deviceHasNoSounds = tones.all { Ringtones.isBundled(it.uri) },
defaultTitle = null,
)
}
@@ -11,7 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
@@ -180,7 +180,7 @@ private fun StopwatchControls(
) {
val lapLimit = stringResource(R.string.stopwatch_lap_limit)
ButtonGroup(modifier = modifier) {
Row(modifier = modifier, horizontalArrangement = ButtonGroupDefaults.HorizontalArrangement) {
val primaryInteraction = remember { MutableInteractionSource() }
val primary = when (mode) {
StopwatchState.RUNNING -> R.string.stopwatch_pause to onPause
@@ -134,7 +134,7 @@ class TimerEditorViewModel @Inject internal constructor(
loading = false,
options = RingtonePicker.optionsFor(tones, stored, editing.ringtone.title),
selectedUri = stored,
deviceHasNoSounds = tones.isEmpty(),
deviceHasNoSounds = tones.all { Ringtones.isBundled(it.uri) },
// Resolved, not looked up in the tone list: an app default that
// is a SAF-picked file is absent from that list (M5 D16).
defaultTitle = appDefault
@@ -18,7 +18,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.ButtonGroup
import androidx.compose.material3.ButtonGroupDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
@@ -248,7 +248,7 @@ private fun TimerControls(
TimerMode.IDLE -> R.string.timer_add_minute to onAddMinute
}
ButtonGroup(modifier = modifier) {
Row(modifier = modifier, horizontalArrangement = ButtonGroupDefaults.HorizontalArrangement) {
val actions = buildList {
add(primary)
if (mode == TimerMode.RUNNING || mode == TimerMode.PAUSED) {
Binary file not shown.
+4
View File
@@ -174,6 +174,8 @@
<string name="ringtone_none_available">This device has no alarm sounds. Clockula will use the system default.</string>
<string name="ringtone_unavailable">Unavailable \u2014 the default alarm sound will be used</string>
<string name="ringtone_unknown">Unknown sound</string>
<!-- The bundled sound Google Clock and AOSP DeskClock use for an expired timer. -->
<string name="ringtone_timer_expired">Timer expired</string>
<string name="ringtone_access_may_lapse">Clockula may lose access to this file later</string>
<string name="ringtone_pick_media_app">Choose from a music app\u2026</string>
<string name="ringtone_pick_media_app_summary">The app plays your alarm</string>
@@ -263,11 +265,13 @@
<string name="settings_about_logo_desc">Clockula app icon</string>
<string name="settings_about_support">Support development</string>
<string name="settings_about_source">Source</string>
<string name="settings_translate">Help translate</string>
<string name="settings_license">License</string>
<string name="settings_privacy">Privacy policy</string>
<string name="settings_about_version">Version %1$s</string>
<string name="about_source_url" translatable="false">https://codeberg.org/jlmakiola/clockula</string>
<string name="about_license_url" translatable="false">https://codeberg.org/jlmakiola/clockula/src/branch/main/LICENSE</string>
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/clockula/</string>
<string name="about_privacy_url" translatable="false">https://jeanlucmakiola.de/clockula/privacy</string>
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
<string name="settings_group_look">Look &amp; behaviour</string>
@@ -159,4 +159,27 @@ class BackupEngineTest {
assertThat(target.settings.timerPresets.first())
.containsExactly(1.minutes, 3.minutes, 9.minutes).inOrder()
}
@Test
fun `failed database replace restores settings and the previous ring`(@TempDir tempDir: Path) = runTest {
val harness = backupHarness(tempDir.resolve("failedReplace").also { it.toFile().mkdirs() })
harness.givenAlarm(alarmAt(id = 1L, hour = 7, minute = 0, repeatDays = RepeatDays.EVERY_DAY))
harness.givenRinging(1L, instantAt(BERLIN, "2026-06-10T07:00"))
harness.settings.setThemeMode(ThemeMode.DARK)
val replacement = harness.backupEngine.export().copy(
alarms = emptyList(),
settings = harness.backupEngine.export().settings.copy(themeMode = ThemeMode.LIGHT),
)
harness.backupDao.failureBeforeReplace = IllegalStateException("database unavailable")
val failure = runCatching { harness.backupEngine.import(replacement) }.exceptionOrNull()
assertThat(failure).isInstanceOf(IllegalStateException::class.java)
assertThat(harness.storedAlarms()).hasSize(1)
assertThat(harness.settings.themeMode.first()).isEqualTo(ThemeMode.DARK)
assertThat(harness.alarmEngine.ringSession()?.alarm?.id).isEqualTo(1L)
assertThat(harness.ring.events).contains(RingEvent.Stopped(1L))
assertThat(harness.ring.events.last()).isEqualTo(RingEvent.Started(1L))
}
}
@@ -238,6 +238,21 @@ class BackupCodecTest {
assertThat(BackupCodec.decode("not json at all")).isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `oversized document is rejected before parsing`() {
val result = BackupCodec.decode(" ".repeat(8 * 1024 * 1024 + 1))
assertThat(result).isInstanceOf(BackupResult.Failure::class.java)
}
@Test
fun `oversized alarm label is rejected`() {
val json = minimalDocument().replace(
"\"alarms\": []",
"\"alarms\": [{\"hour\":7,\"minute\":0,\"label\":\"${"x".repeat(64 * 1024 + 1)}\"}]",
)
assertThat(BackupCodec.decode(json)).isInstanceOf(BackupResult.Failure::class.java)
}
private fun minimalDocument(
extraTopLevel: String = "",
format: String = "clockula-backup",
@@ -6,6 +6,8 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.ClockDefaults
import de.jeanlucmakiola.clockula.domain.backup.BackupSettings
import de.jeanlucmakiola.floret.prefs.Appearance
import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.ThemeMode
@@ -20,6 +22,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
/**
* Clockula's appearance preferences over a real DataStore, one temp file per
@@ -64,6 +67,26 @@ class SettingsPrefsTest {
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
}
@Test
fun `backup settings are applied together using normal preference clamps`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
prefs.applyBackup(
BackupSettings(
themeMode = ThemeMode.DARK,
dynamicColor = false,
defaults = ClockDefaults(snoozeMinutes = 99, volumeRampSeconds = 99, alarmRingtoneUri = " "),
timerPresets = listOf(5.minutes, 1.minutes, 5.minutes),
),
)
assertThat(prefs.themeMode.first()).isEqualTo(ThemeMode.DARK)
assertThat(prefs.dynamicColor.first()).isFalse()
assertThat(prefs.currentDefaults().snoozeMinutes).isEqualTo(60)
assertThat(prefs.currentDefaults().volumeRampSeconds).isEqualTo(60)
assertThat(prefs.currentDefaults().alarmRingtoneUri).isNull()
assertThat(prefs.timerPresets.first()).containsExactly(1.minutes, 5.minutes).inOrder()
}
@Test
fun `the dynamic colour choice round-trips`(@TempDir tempDir: Path) = runTest {
val prefs = SettingsPrefs(PrefStore(newDataStore(tempDir)))
@@ -41,4 +41,19 @@ class RingtonesTest {
fun `normalise passes the sentinel through unchanged`() {
assertThat(Ringtones.normalise(Ringtones.SILENT_URI)).isEqualTo(Ringtones.SILENT_URI)
}
@Test
fun `only the bundled sentinel is bundled, and it is neither silent nor a media source`() {
assertThat(Ringtones.isBundled(Ringtones.TIMER_EXPIRED_URI)).isTrue()
assertThat(Ringtones.isBundled(null)).isFalse()
assertThat(Ringtones.isBundled("content://media/42")).isFalse()
assertThat(Ringtones.isSilent(Ringtones.TIMER_EXPIRED_URI)).isFalse()
assertThat(MediaSources.isMediaSource(Ringtones.TIMER_EXPIRED_URI)).isFalse()
}
@Test
fun `the bundled sentinel is the app's own scheme and names no package`() {
assertThat(Ringtones.TIMER_EXPIRED_URI).startsWith("clockula://")
assertThat(Ringtones.TIMER_EXPIRED_URI).doesNotContain("de.jeanlucmakiola")
}
}
@@ -29,6 +29,16 @@ class MediaRingPolicyTest {
assertThat(MediaRingPolicy.shouldFallBack(long)).isTrue()
}
@Test
fun `an error after the play request ends the start, an older one does not`() {
val requestedAt = 10_000L
assertThat(MediaRingPolicy.refusedStart(isError = true, updatedAtMillis = 10_050L, requestedAtMillis = requestedAt)).isTrue()
assertThat(MediaRingPolicy.refusedStart(isError = true, updatedAtMillis = requestedAt, requestedAtMillis = requestedAt)).isTrue()
assertThat(MediaRingPolicy.refusedStart(isError = true, updatedAtMillis = 9_000L, requestedAtMillis = requestedAt)).isFalse()
assertThat(MediaRingPolicy.refusedStart(isError = false, updatedAtMillis = 10_050L, requestedAtMillis = requestedAt)).isFalse()
}
@Test
fun `buffering forever is a failure too`() {
val brief = run(*repeat(MediaPlayback.TRANSITIONING, MediaRingPolicy.BUFFER_GRACE - tick))
@@ -102,7 +102,6 @@ class BackupHarness(
val backupEngine: BackupEngine = BackupEngine(
repository = backupRepository,
settings = settings,
timers = timers,
alarmEngine = alarmEngine,
timerEngine = timerEngine,
wallClock = wallClock,
@@ -252,6 +252,17 @@ class FakeBackupDao(
private val worldClockDao: FakeWorldClockDao,
) : BackupDao() {
var failureBeforeReplace: RuntimeException? = null
override suspend fun replaceAll(
alarms: List<AlarmEntity>,
timers: List<TimerEntity>,
worldClocks: List<WorldClockEntity>,
) {
failureBeforeReplace?.let { throw it }
super.replaceAll(alarms, timers, worldClocks)
}
override suspend fun deleteAllAlarms() {
for (row in alarmDao.stored) alarmDao.deleteById(row.id)
}
@@ -227,6 +227,27 @@ class AlarmRingViewModelTest {
.isEqualTo(null to RingUiState.Finished)
}
@Test
fun `a backup restore that replaces the ringing alarm finishes the screen`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val engine = engine(tempDir)
givenRinging(engine)
val viewModel = viewModel(engine)
advanceUntilIdle()
// The restore's order: the ring is stopped while its row still exists,
// so the screen must stay up in case the import fails and it resumes.
engine.suspendRingForImport()
advanceUntilIdle()
val whileSuspended = viewModel.state.value
// Replacing the rows cascades to the ring state.
stateDao.deleteAll()
advanceUntilIdle()
assertThat(whileSuspended).isInstanceOf(RingUiState.Ringing::class.java)
assertThat(viewModel.state.value).isEqualTo(RingUiState.Finished)
}
@Test
fun `dismiss does nothing while the maths gate is closed`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
@@ -0,0 +1,134 @@
package de.jeanlucmakiola.clockula.ui.ring
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.domain.TimerState
import de.jeanlucmakiola.clockula.domain.timer.TimerRing
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.T0
import de.jeanlucmakiola.clockula.testing.TimerEngineHarness
import de.jeanlucmakiola.clockula.testing.expiredTimer
import de.jeanlucmakiola.clockula.testing.runningTimerWith
import de.jeanlucmakiola.clockula.testing.timerEngineHarness
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
/**
* The timer ring screen over the **real** timer engine: which timer it speaks
* for, that its buttons are the notification's verbs, and that it closes
* whenever the ring ends — including the auto-silence that leaves rows EXPIRED.
*/
class TimerRingViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
/** Subscribed for the whole test, as the visible screen is: the state is shared while observed. */
private fun TestScope.viewModel(harness: TimerEngineHarness) = TimerRingViewModel(
timers = harness.timers,
ringState = harness.ringState,
engine = harness.engine,
elapsed = harness.elapsed,
wall = harness.wallClock,
ticker = harness.ticker,
).also { viewModel -> backgroundScope.launch { viewModel.state.collect {} } }
@Test
fun `an expired timer rings, with the other expired ones counted`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, label = "Pasta", sortOrder = 0))
harness.given(expiredTimer(id = 2L, label = "Eggs", sortOrder = 1))
val viewModel = viewModel(harness)
advanceUntilIdle()
assertThat(viewModel.state.value)
.isEqualTo(TimerRingUiState.Ringing(timerId = 1L, label = "Pasta", otherExpired = 1))
}
@Test
fun `nothing expired finishes the screen`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(runningTimerWith(id = 1L, left = 3.minutes, elapsedNow = harness.elapsed.value))
val viewModel = viewModel(harness)
advanceUntilIdle()
assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
}
@Test
fun `stop resets the timer and the screen moves on to the next expired one`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L, label = "Pasta", sortOrder = 0))
harness.given(expiredTimer(id = 2L, label = "Eggs", sortOrder = 1))
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onStop()
advanceUntilIdle()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.IDLE)
assertThat(viewModel.state.value)
.isEqualTo(TimerRingUiState.Ringing(timerId = 2L, label = "Eggs", otherExpired = 0))
}
@Test
fun `stop leaves a timer that is running again alone`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
val viewModel = viewModel(harness)
advanceUntilIdle()
// "+1 min" from the shade lands before the screen has re-read the rows.
harness.engine.addTime(1L)
viewModel.onStop()
advanceUntilIdle()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.RUNNING)
}
@Test
fun `plus one minute runs the timer again and finishes the screen`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
val viewModel = viewModel(harness)
advanceUntilIdle()
viewModel.onAddMinute()
advanceUntilIdle()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.RUNNING)
assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
}
@Test
fun `an auto-silenced ring finishes the screen though the row stays expired`(@TempDir tempDir: Path) =
runTest(main.dispatcher) {
val harness = timerEngineHarness(tempDir)
harness.given(expiredTimer(id = 1L))
harness.ringState.set(T0)
val viewModel = viewModel(harness)
advanceUntilIdle()
assertThat(viewModel.state.value).isInstanceOf(TimerRingUiState.Ringing::class.java)
harness.wallClock.advance(TimerRing.AUTO_SILENCE_AFTER + 1.seconds)
harness.ticker.tick()
advanceUntilIdle()
assertThat(harness.stored(1L)!!.state).isEqualTo(TimerState.EXPIRED)
assertThat(viewModel.state.value).isEqualTo(TimerRingUiState.Finished)
}
}
@@ -0,0 +1,55 @@
package de.jeanlucmakiola.clockula.ui.settings
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.clockula.backup.BackupFiles
import de.jeanlucmakiola.clockula.data.backup.BackupCodec
import de.jeanlucmakiola.clockula.testing.MainDispatcherExtension
import de.jeanlucmakiola.clockula.testing.backupHarness
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
class BackupViewModelTest {
@JvmField
@RegisterExtension
val main = MainDispatcherExtension()
@Test
fun `a thrown export failure reports an error and clears busy`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = backupHarness(tempDir)
val files = object : BackupFiles {
override suspend fun write(treeUri: String, content: String): Result<Unit> =
throw IllegalStateException("provider unavailable")
override suspend fun read(documentUri: String): Result<String> = error("unused")
}
val viewModel = BackupViewModel(harness.backupEngine, files)
viewModel.onExportFolderPicked("content://folder")
assertThat(viewModel.events.first()).isEqualTo(BackupEvent.ExportFailed("provider unavailable"))
assertThat(viewModel.busy.value).isFalse()
}
@Test
fun `a failed restore reports an error and clears busy`(@TempDir tempDir: Path) = runTest(main.dispatcher) {
val harness = backupHarness(tempDir)
val json = BackupCodec.encode(harness.backupEngine.export())
harness.backupDao.failureBeforeReplace = IllegalStateException("database unavailable")
val files = object : BackupFiles {
override suspend fun write(treeUri: String, content: String): Result<Unit> = error("unused")
override suspend fun read(documentUri: String): Result<String> = Result.success(json)
}
val viewModel = BackupViewModel(harness.backupEngine, files)
viewModel.onImportFilePicked("content://backup")
assertThat(viewModel.events.first()).isEqualTo(BackupEvent.ImportFailed("database unavailable"))
assertThat(viewModel.busy.value).isFalse()
}
}
+16 -5
View File
@@ -42,15 +42,26 @@ written here.
challenge` or `theme_mode` from a future version, say) degrades to that
field's default, the same forgiving-read posture `AlarmMapper` already uses
for a corrupt Room row. It does not fail the import.
- Import and export are limited to 8 MiB. Import also rejects more than 10,000
alarms, 10,000 timers, 1,000 world clocks, oversized labels or ringtone URIs,
and more than 1,000 timer presets. These limits keep a picked document from
exhausting memory or creating an impractical number of rows.
## Import semantics
Import **replaces all state**: alarms, timers and world clocks are deleted
and re-created from the file, and every setting the file carries is written
over the current value. This is not a merge, and the backup screen says so
before the user confirms. A ringing alarm is dismissed and any running timer
is deleted *before* the replace, so the import never leaves an orphaned ring
session; alarm/timer/stopwatch scheduling is re-resolved immediately after.
before the user confirms. Settings are written in one DataStore transaction;
the three Room tables are replaced in one database transaction. The live ring
and timer services are paused while the tables change. If the database replace
fails, the old rows remain, the previous settings are restored, and the ring
and timer registrations are resumed. Scheduling is reconciled after either
outcome. An imported timer always starts idle.
Export writes a complete temporary document before removing an older backup.
If the provider refuses the final rename, the complete temporary document is
kept in the chosen folder and the app reports the failure with its filename.
Every imported alarm and timer gets a **fresh id and fresh timestamps**.
The backup carries no id and no `created_at`/`updated_at`, since those are
@@ -127,8 +138,8 @@ Field names mirror `ClockPrefs`'/`SettingsPrefs`' own on-disk keys. Older
files may also carry `default_timer_duration_millis`, from a default timer
length setting that has since been removed; it is ignored on import like any
other unknown field. Every
numeric/duration value is clamped on *apply*, through the existing pref
setters, never on parse.
numeric/duration value is clamped on *apply* using the same ranges and encoding
as the existing preference setters, never on parse.
## Ringtone URIs
+18
View File
@@ -0,0 +1,18 @@
# Provenance
Third-party material carried in the Clockula tree, and the licence it is under.
Everything not listed here is Clockula's own and MIT-licensed (`../LICENSE`).
`PLAN.md` §11 explains why this file exists and when something may be added
to it.
| Path | What it is | Source | Licence |
|---|---|---|---|
| `app/src/main/res/raw/timer_expire.ogg` | The "Timer expired" sound, offered in the ringtone picker | AOSP DeskClock, [`res/raw/timer_expire.ogg`](https://android.googlesource.com/platform/packages/apps/DeskClock/+/refs/heads/main/res/raw/timer_expire.ogg), unmodified | Apache-2.0, © The Android Open Source Project ([`../licenses/Apache-2.0.txt`](../licenses/Apache-2.0.txt)) |
## Notes
- **`timer_expire.ogg`.** The default timer sound in Google Clock and in AOSP
DeskClock's forks, such as [BlackyHawky/Clock](https://github.com/BlackyHawky/Clock).
It is bundled with the app, not a system ringtone, so `RingtoneManager` never
lists it. Users asked for it in issue #14. The copy here is byte-identical to
AOSP's (SHA-256 `a82f24b8…a95a2a`).
+1 -1
View File
@@ -18,7 +18,7 @@ is the first section of [`PLAN.md`](PLAN.md).
| [`ROADMAP.md`](ROADMAP.md) | **Status and the work queue**: milestones M0–M11 and post-v1. What the build loop works through. |
| [`ARCHITECTURE.md`](ARCHITECTURE.md) | How Clockula is built **today**: layers, the data seam, the alarm engine, DI, build/tooling, manifest. Written from M2 onward. |
| [`RELEASING.md`](RELEASING.md) | How to cut a release. Written at M0 alongside the pipeline. |
| [`PROVENANCE.md`](PROVENANCE.md) | Any third-party code carried in-tree and under what licence. Created only if we actually lift something (`PLAN.md` §11). |
| [`PROVENANCE.md`](PROVENANCE.md) | Third-party code and assets carried in-tree, and under what licence (`PLAN.md` §11). |
Also: [`../CHANGELOG.md`](../CHANGELOG.md) (Keep a Changelog format; tag sections
feed the release notes).
+8 -8
View File
@@ -1,17 +1,17 @@
[versions]
agp = "9.2.1"
kotlin = "2.3.21"
ksp = "2.3.9"
hilt = "2.59.2"
coreKtx = "1.19.0"
appcompat = "1.7.1"
agp = "9.4.1"
kotlin = "2.4.20"
ksp = "2.3.11"
hilt = "2.60.1"
coreKtx = "1.19.1"
appcompat = "1.8.0"
lifecycleRuntime = "2.10.0"
activityCompose = "1.13.0"
composeBom = "2026.05.01"
composeBom = "2026.09.00"
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
# Pin explicitly to override the BOM (which ships stable 1.4.0).
# Re-evaluate when 1.5.0 stable lands.
material3 = "1.5.0-alpha21"
material3 = "1.5.0-alpha29"
datastore = "1.2.1"
# SAF directory writing for export/backup (DocumentFile).
documentfile = "1.1.0"
Binary file not shown.
+4 -2
View File
@@ -1,8 +1,10 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
distributionSha256Sum=bafd5ce9cfaea0fbccfdc8439a1ac42fbd4cd9c89dc9a988228d8a2639a58e6c
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+4 -7
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
Vendored
+52 -34
View File
@@ -19,12 +19,39 @@
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -45,13 +72,14 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
goto fail
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -59,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
goto fail
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21 -10
View File
@@ -69,10 +69,12 @@ 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 \
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d @"$WORK/payload.json" "$API/releases/$EXIST"
ID="$EXIST"; break
-d @"$WORK/payload.json" "$API/releases/$EXIST")
echo "release PATCH attempt $attempt HTTP $CODE"
case "$CODE" in 2??) ID="$EXIST"; break ;; esac
sleep $((attempt * 10)); continue
fi
CODE=$(curl -s -o "$WORK/response.json" -w "%{http_code}" -X POST \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
@@ -84,13 +86,22 @@ for attempt in 1 2 3 4 5 6; do
done
if [ -z "$ID" ]; then echo "Could not resolve Codeberg release id after retries." >&2; exit 1; fi
# Attach APK + checksum, replacing any prior asset of the same name.
# Attach APK + checksum, replacing any prior asset of the same name. The same
# flakiness as above, so each upload retries, and a release left without its
# APK or checksum (what Obtainium follows) fails the job rather than passing.
for A in "$ASSET_APK" "$ASSET_SUM"; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id')
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$WORK/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "asset $A HTTP %{http_code}\n"
UPLOADED=""
for attempt in 1 2 3 4 5 6; do
OLD=$(curl -s -H "Authorization: token $TOKEN" "$API/releases/$ID/assets" \
| jq -r --arg n "$A" '.[] | select(.name==$n) | .id' 2>/dev/null || true)
[ -n "$OLD" ] && curl -s -X DELETE -H "Authorization: token $TOKEN" "$API/releases/$ID/assets/$OLD" >/dev/null || true
CODE=$(curl -s -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$WORK/$A" \
"$API/releases/$ID/assets?name=$A" -o /dev/null -w "%{http_code}")
echo "asset $A attempt $attempt HTTP $CODE"
case "$CODE" in 2??) UPLOADED=1; break ;; esac
sleep $((attempt * 10))
done
if [ -z "$UPLOADED" ]; then echo "Could not upload $A after retries." >&2; exit 1; fi
done
echo "Published $TAG to Codeberg."