Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48c2e68fa | ||
|
|
ec391e8675 | ||
|
|
d5016183ad | ||
|
|
6637554c9d | ||
|
|
67010a632c | ||
|
|
405374de7e | ||
|
|
c83c12cf64 | ||
|
|
badd7aaa54 | ||
|
|
2f39b00398 | ||
|
|
27df918da0 | ||
|
|
11ea9be468 | ||
|
|
61eff32f5f | ||
|
|
276c88942e | ||
|
|
e49afb182c | ||
|
|
c0e0d6269d | ||
|
|
4290892cb2 | ||
|
|
24098e8845 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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**.
|
||||
|
||||
[](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
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
+5
-1
@@ -52,9 +52,13 @@ class SystemRingtoneCatalog @Inject constructor(
|
||||
}
|
||||
}
|
||||
}.getOrDefault(emptyList()).sortedBy { it.title.lowercase() }
|
||||
// The app's own sounds first: they are always there, and the device
|
||||
// has no way to list them.
|
||||
.let { device -> BundledTones.options(context) + device }
|
||||
}
|
||||
|
||||
override suspend fun titleOf(uri: String): String? = withContext(io) {
|
||||
BundledTones.titleOf(context, uri)?.let { return@withContext it }
|
||||
// A sound another app plays carries its own title: naming it would
|
||||
// otherwise mean connecting to that app on every editor render.
|
||||
MediaSources.decode(uri)?.let { ref ->
|
||||
@@ -82,7 +86,7 @@ class SystemRingtoneCatalog @Inject constructor(
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return@withContext false
|
||||
val parsed = BundledTones.playable(context, uri) ?: return@withContext false
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(parsed)?.use { true } ?: false
|
||||
}.getOrDefault(false)
|
||||
|
||||
+1
-1
@@ -65,7 +65,7 @@ class SystemRingtonePreviewer @Inject constructor(
|
||||
stop()
|
||||
return
|
||||
}
|
||||
val parsed = runCatching { Uri.parse(uri) }.getOrNull() ?: return
|
||||
val parsed = BundledTones.playable(context, uri) ?: return
|
||||
// Stop *before* taking the lock: what is sounding now stops now, and an
|
||||
// in-flight `prepare` is told its preview has been superseded.
|
||||
stop()
|
||||
|
||||
@@ -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.
@@ -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 & 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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
+1
-1
Submodule floret-kit updated: 6ef77bd5b2...5561dd43f4
@@ -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"
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+4
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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%
|
||||
|
||||
@@ -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.
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user