Renovate / renovate (push) Successful in 2m21s
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Has been skipped
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Has been skipped
Release v2.19.4. **Fixed** - A deleted occurrence stays deleted through a series re-timing, and through backup/restore ([#225]). - "Only this event" saves the edit on calendars that never synced, and on a row that doesn't recur it edits or deletes the event itself instead of filing an exception against nothing. - A reminder left on the account's default is no longer read as a lead time of Calendula's own. - The widgets turn the page at midnight; the re-arm backstop for the inexact rollover alarm is back, throttled to once per 15 minutes on calendar changes. - An event running past midnight can be dragged by either half ([#253]). - One event the provider refuses no longer costs the rest of the import, and an import cut short reports honestly how far it got. - EXDATE zone prefixes are read in the bare `<zone>;` form the provider and DAVx5 actually write, not only the iCalendar `TZID=` spelling; a list holding a stamp we can't parse is kept whole rather than re-emitted stripped of its zone; RDATE travels with RRULE when the recurrence set is rewritten. **Added** - Belarusian, Hungarian and Slovak, from community translators on Weblate. **Changed** - Arabic, Polish and Simplified Chinese are complete; Czech grew, and Russian is now at about half. Bumps `versionName` to 2.19.4, so merging this triggers the release pipeline. Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/259
190 lines
8.2 KiB
Kotlin
190 lines
8.2 KiB
Kotlin
package de.jeanlucmakiola.calendula.widget
|
|
|
|
import android.content.Intent
|
|
import com.google.common.truth.Truth.assertThat
|
|
import kotlinx.datetime.LocalDate
|
|
import kotlinx.datetime.LocalDateTime
|
|
import kotlinx.datetime.TimeZone
|
|
import kotlinx.datetime.atStartOfDayIn
|
|
import kotlinx.datetime.toInstant
|
|
import kotlinx.datetime.toLocalDateTime
|
|
import org.junit.jupiter.api.Test
|
|
import kotlin.time.Duration
|
|
import kotlin.time.Duration.Companion.hours
|
|
import kotlin.time.Duration.Companion.minutes
|
|
import kotlin.time.Instant
|
|
|
|
/**
|
|
* The rollover alarm is what makes the widgets stop highlighting yesterday
|
|
* (#228), so the "when is the next local midnight" arithmetic is the one piece
|
|
* worth pinning down — especially where midnight is not 00:00.
|
|
*/
|
|
class WidgetRolloverSchedulerTest {
|
|
|
|
private val berlin = TimeZone.of("Europe/Berlin")
|
|
|
|
private fun at(local: String, zone: TimeZone): Instant =
|
|
LocalDateTime.parse(local).toInstant(zone)
|
|
|
|
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
|
|
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
|
|
|
|
// --- the ordinary day ----------------------------------------------------
|
|
|
|
@Test
|
|
fun `midday rolls over at the coming midnight`() {
|
|
val next = nextRollover("2026-08-27T12:00:00")
|
|
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
|
}
|
|
|
|
@Test
|
|
fun `a second before midnight still targets tonight, not tomorrow night`() {
|
|
val next = nextRollover("2026-08-27T23:59:59")
|
|
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
|
|
}
|
|
|
|
@Test
|
|
fun `at midnight exactly the target is the next day, never the current instant`() {
|
|
// Re-arming after a firing must move a whole day on, or the widget wakes
|
|
// itself in a tight loop.
|
|
val now = at("2026-08-28T00:00:00", berlin)
|
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
|
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
|
|
assertThat(next - now).isGreaterThan(Duration.ZERO)
|
|
}
|
|
|
|
@Test
|
|
fun `re-arming from the slack instant itself moves a full day on`() {
|
|
// What actually happens in practice: the receiver runs at midnight + slack.
|
|
val now = at("2026-08-28T00:00:05", berlin)
|
|
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
|
|
.isEqualTo(at("2026-08-29T00:00:05", berlin))
|
|
}
|
|
|
|
@Test
|
|
fun `the result is always in the future for every minute of a day`() {
|
|
val zone = berlin
|
|
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
|
|
// in the past, i.e. an alarm that fires immediately, forever.
|
|
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
|
|
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
|
|
while (probe < end) {
|
|
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
|
|
probe += 1.minutes
|
|
}
|
|
}
|
|
|
|
// --- daylight saving -----------------------------------------------------
|
|
|
|
@Test
|
|
fun `spring forward keeps the rollover one day away, not one hour short`() {
|
|
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
|
|
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
|
|
val now = at("2024-03-30T12:00:00", berlin)
|
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
|
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
|
|
assertThat(next - now).isLessThan(24.hours)
|
|
}
|
|
|
|
@Test
|
|
fun `fall back does not overshoot into the repeated hour`() {
|
|
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
|
|
// "now + 24h" would land at 23:00 on the 26th and never roll over.
|
|
val now = at("2024-10-26T12:00:00", berlin)
|
|
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
|
|
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
|
|
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
|
|
}
|
|
|
|
@Test
|
|
fun `a zone that repeats midnight takes the first start of day`() {
|
|
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
|
|
// began twice. Pinned as the accepted trade: the widget runs an hour
|
|
// ahead until the next redraw. No live zone does this since 2019.
|
|
val saoPaulo = TimeZone.of("America/Sao_Paulo")
|
|
val next = WidgetRolloverScheduler.nextRolloverAt(
|
|
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
|
|
)
|
|
val local = next.toLocalDateTime(saoPaulo)
|
|
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
|
|
assertThat(local.hour).isEqualTo(0)
|
|
}
|
|
|
|
@Test
|
|
fun `a zone where midnight does not exist rolls over at the real start of day`() {
|
|
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
|
|
// Targeting a literal 00:00 there would arm an instant on the wrong day.
|
|
val havana = TimeZone.of("America/Havana")
|
|
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
|
|
val local = next.toLocalDateTime(havana)
|
|
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
|
|
assertThat(local.hour).isEqualTo(1)
|
|
assertThat(local.minute).isEqualTo(0)
|
|
// And it is genuinely the first instant of that date, not a guess.
|
|
assertThat(next).isEqualTo(
|
|
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
|
|
WidgetRolloverScheduler.ROLLOVER_SLACK,
|
|
)
|
|
}
|
|
|
|
// --- timezone changes ----------------------------------------------------
|
|
|
|
@Test
|
|
fun `the same instant rolls over at different times in different zones`() {
|
|
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
|
|
// follows the zone, not a cached offset.
|
|
val instant = at("2026-08-27T12:00:00", berlin)
|
|
val tokyo = TimeZone.of("Asia/Tokyo")
|
|
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
|
|
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
|
|
assertThat(tokyoNext).isNotEqualTo(berlinNext)
|
|
assertThat(tokyoNext).isLessThan(berlinNext)
|
|
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
|
|
}
|
|
|
|
@Test
|
|
fun `a half-hour offset zone still lands on its own midnight`() {
|
|
val kathmandu = TimeZone.of("Asia/Kathmandu")
|
|
val next = WidgetRolloverScheduler.nextRolloverAt(
|
|
at("2026-08-27T12:00:00", kathmandu), kathmandu,
|
|
)
|
|
val local = next.toLocalDateTime(kathmandu)
|
|
assertThat(local.date.toString()).isEqualTo("2026-08-28")
|
|
assertThat(local.hour).isEqualTo(0)
|
|
}
|
|
|
|
// --- the wiring ----------------------------------------------------------
|
|
|
|
@Test
|
|
fun `the receiver actually handles the action the alarm is sent with`() {
|
|
// The single point where the whole fix would die silently: the alarm
|
|
// fires, the receiver drops it on the action guard, nothing redraws.
|
|
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
|
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
|
|
}
|
|
|
|
@Test
|
|
fun `a data change is handled, and re-arms only on the throttle`() {
|
|
// PROVIDER_CHANGED fires on every calendar write — each event of a bulk
|
|
// import, each drop of a drag — so it is not an unconditional re-arm.
|
|
// It is not dropped either: the alarm is inexact and the system may lose
|
|
// it, and this is the only signal that keeps arriving to notice.
|
|
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
|
|
.containsAtLeast(Intent.ACTION_PROVIDER_CHANGED, Intent.ACTION_DATE_CHANGED)
|
|
assertThat(WidgetUpdateReceiver.REARM_ACTIONS)
|
|
.doesNotContain(Intent.ACTION_PROVIDER_CHANGED)
|
|
}
|
|
|
|
@Test
|
|
fun `everything that loses the alarm re-arms it`() {
|
|
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
|
|
WidgetUpdateReceiver.ACTION_ROLLOVER,
|
|
Intent.ACTION_BOOT_COMPLETED,
|
|
Intent.ACTION_MY_PACKAGE_REPLACED,
|
|
Intent.ACTION_TIME_CHANGED,
|
|
Intent.ACTION_TIMEZONE_CHANGED,
|
|
Intent.ACTION_DATE_CHANGED,
|
|
)
|
|
}
|
|
}
|