Compare commits

...

11 Commits

Author SHA1 Message Date
a1f091a88b feat(reminders): schedule and fire reminders in-house (#75)
Delivery no longer waits to be told. The scan reads `Instances` and `Reminders`,
plans every reminder, posts what has come due and arms one exact alarm for the
next — replacing both halves of what the provider used to do for us.

Reacting to `EVENT_REMINDER` could not be made reliable, only more hopeful. An
app that reacts cannot distinguish "nothing was due" from "the broadcast never
came", and AOSP's own unbundled calendar carries three workarounds for OEM
providers that retarget it or write the alert row late. Etar's fallback was not
the answer either: it replaces the alarm but still reads `CalendarAlerts` for
what to show, and it disables itself the moment one real broadcast arrives —
useless against a broadcast that arrives with no row behind it.

Keeping the old receiver alongside was rejected for the same reason it looks
attractive: on a healthy device both paths fire, and there is no honest way to
suppress one without the latch we just ruled out. So the provider path goes —
`EventReminderReceiver`, `ReminderAlertStore`, `ReminderRecovery` and the
`CalendarAlerts` writes with it. Reminder delivery no longer needs WRITE_CALENDAR.

One alarm exists at a time, re-planned on every firing, so an edit needs no alarm
bookkeeping to stay in sync. Every trigger runs the same idempotent scan: the
alarm, boot and package-replace (both wipe pending alarms), clock and timezone
changes, a provider change while the app is up, launch, and a daily worker for a
device that drops the alarm with nothing to announce it. RECEIVE_BOOT_COMPLETED
is new and load-bearing — without it reminders stop dead after a restart.

`ReminderAlert.alertId` becomes `key`, derived from the reminder rather than a
row id that no longer exists, and the notification tag and PendingIntent request
codes ride on it, so a re-posted reminder still replaces itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:24:45 +02:00
1515beff4c fix(reminders): fire all-day reminders at the hour the setting names (#75)
Taking the stored offset at face value is what puts an all-day reminder on the
wrong hour, and owning the alarm is what finally allows fixing it.

An all-day occurrence begins at UTC midnight, and the offset in `Reminders` is
not a plain lead time: `AllDayReminderEncoding` folds the wanted wall-clock hour
into it, sampled against one date's UTC offset. Fire at `begin - minutes` and
every occurrence in a different DST phase than the sampled one drifts by the
offset delta — an hour early one way, an hour late the other, which for a yearly
birthday is every second occurrence. Its KDoc documents that drift as inherent to
the provider model. It was, while the provider held the alarm.

Rows written by other calendar apps have the opposite problem: a conventional
1440 carries no hour at all, so it fires at UTC midnight — 02:00 local in summer
Berlin, and a day early west of UTC, where UTC midnight still falls on the
previous local date.

So the offset is now read only for which day it means, and the hour comes from
the one global all-day reminder setting, recomposed against each occurrence's own
date. Plain multiples of 1440 are read at face value, which covers foreign rows
and stays right for our own rows that land on a multiple; anything else keeps the
local-date recovery `fromProviderAllDayMinutes` already uses for display, so the
notification arrives on the day the event screen promises.

Timed reminders are untouched: `begin` is an absolute instant, so `begin -
minutes` is exact in any zone across any boundary. A test pins that too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:24:19 +02:00
0676a58d1a feat(reminders): work out reminder times ourselves (#75)
The reporter's silent events sit in a calendar Calendula created itself, so
`Calendars.VISIBLE` is 1 and the visibility fix cannot be what they hit. What is
left is the class AOSP's own unbundled calendar already carries three
workarounds for: OEM providers that retarget the `EVENT_REMINDER` broadcast, or
that only write the `CalendarAlerts` row at alert time. An app that can only
react to that broadcast cannot tell "no reminder was due" from "the broadcast
never came", so reacting has to stop being the whole design.

This is the decision layer, kept pure and off Android: reminder offsets and
occurrences in, fire instants out, plus which of them a given scan owes.

The watermark is what replaces the provider's `STATE_FIRED` bookkeeping. Due
means the fire instant falls in `(lastFired, now]` — half-open, so a scan that
runs twice cannot post the same reminder twice, while a scan that runs late
still posts everything the missed alarm would have. A reboot or an app update
that drops our alarm therefore costs nothing.

All-day offsets are measured from the raw `begin` with no timezone correction,
because `AllDayReminderEncoding` already folded the wanted wall-clock time into
the stored offset measured from exactly that UTC midnight.

The query horizon stretches past the longest offset any reminder row carries, so
a "two weeks before" reminder is planned before it comes due rather than firing
late — the limitation Etar's equivalent documents and lives with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 15:24:19 +02:00
Jean-Luc Makiola
1d07b64a28 fix(search): date all-day results in UTC, like every other view (#82) (#88)
Fixes [#82](https://codeberg.org/jlmakiola/calendula/issues/82) — found while investigating [#67](https://codeberg.org/jlmakiola/calendula/issues/67).

Search formatted a result's date with `ZoneId.systemDefault()`, while the month,
week, day, agenda and detail surfaces all resolve an all-day event's dates in
UTC. All-day events are stored at UTC midnight with an exclusive end, so west of
UTC that difference is a whole day: an event on the 19th came back from search
dated the 18th, contradicting the grid it was filed in. East of UTC the offset
lands on the same date, which is why it went unnoticed.

### What changed

The rule was already written down three times — a private helper in
`AgendaUiState`, inline in `coversDay`, inline in `formatWhen` — so rather than
add a fourth copy, `dateZone` / `spanFirstDay` / `spanLastDay` /
`spansMultipleDays` move into `domain/Models.kt`, where they are pure date logic
rather than agenda UI state. Search reads its date through `spanFirstDay`; the
clock time stays in the device zone, as it is only ever rendered for timed events.

### Testing

New `domain/EventInstanceSpanTest` pins both directions: the west-of-UTC case
from this issue and the east-of-UTC leak from #65, plus multi-day spans, timed
events following the device zone, and zero-length events.

Local sweep green: `test` (541), `lint`, `assembleDebug`, `check_translations.py`.

On-device reviewed on the Pixel 10 with the device timezone set to New York
(fix confirmed) and back to Berlin (no regression in search, month, week, day,
agenda or the agenda widget).

---
_Recreated on Codeberg from Gitea PR #102 (same head `7c94425`, unchanged) as part of the forge migration. Already on-device signed off._

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/88
2026-07-29 21:47:08 +02:00
d037492cf6 Merge remote-tracking branch 'origin/main' into release/v2.17.0
# Conflicts:
#	CHANGELOG.md
2026-07-29 21:38:42 +02:00
Jean-Luc Makiola
db7094c54e Move the canonical forge to Codeberg (#86)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 14s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Makes Codeberg canonical for git, issues, PRs, tags and releases. The self-hosted Gitea instance stays build infrastructure: signing key, F-Droid publishing, release pipeline.

**This PR is its own test.** It is the first PR opened on Codeberg, so a green `CI` check proves the new runner works *and* that the submodule resolves from its new home.

### 1 · floret-kit moved

Mirrored to `jlmakiola/floret-kit` (6 branches, 3 tags, every SHA verified identical) and `.gitmodules` repointed. This is what actually unblocks contributors — a clone previously could not resolve its submodule without reaching the personal Gitea instance.

The Gitea copy is **kept**: every existing tag records the old submodule URL, so rebuilds of past releases (including F-Droid reproducible rebuilds) still resolve.

### 2 · Workflows split by directory

Forgejo's lookup is first-match-wins across `.forgejo/` → `.gitea/` → `.github/`, and Gitea cannot see `.forgejo/` at all. So each forge sees exactly one set, with no duplicated files and no expression to keep in sync:

| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | all of them |

The line is drawn at **secrets, not CI-vs-release** — that is what makes fork PRs safe. Renovate deliberately does *not* move despite opening PRs here; it keeps running where its token already lives and merely talks to Codeberg's API.

### 3 · Two release-pipeline safety changes

- `release.yaml`'s `detect` gets an explicit `repository_owner` guard. The directory split only holds while `.forgejo/` is non-empty; empty it and Codeberg would fall back to `.gitea/` and start running the release pipeline on the contributor-facing runner, without secrets.
- `detect` now reads tags from **Codeberg**, not from the Gitea instance it runs on. Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by the next sync until the Codeberg tag push propagates back — asking Gitea inside that window reports "no tag" for an already-shipped release and would cut it twice. It also now fails on any status other than 200/404 rather than reading a transient error as "no tag": a failed job is recoverable, a duplicate release is not.

### 4 · Links repointed

In-app Source/License links, README badge, both F-Droid metadata files. **`Repo:` in `docs/fdroid-official/` deliberately stays on Gitea** — it keeps receiving `main` and every tag, so it remains a complete build source, and leaving it alone means no fdroiddata MR and no reproducible-build risk.

### Not in this PR

Renovate + Weblate repointing, and the Codeberg → Gitea push mirror (browser-side).

Supersedes Gitea PR #104.

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/calendula/pulls/86
2026-07-29 20:33:56 +02:00
c70412c782 Link the privacy policy from the about card (!103)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Adds a fourth link to the about card in settings, next to source and licence.

Play requires the privacy policy to be reachable from inside the app, not just from the store listing, for anything that touches calendar or contact data. Points at jeanlucmakiola.de/calendula/privacy.

Only the base string is added — Weblate owns the translations.

Reviewed-on: #103
2026-07-28 17:10:52 +00:00
8e2109d073 feat(calendars): explain a calendar's state, and why one is missing from the picker (#76, #78) (!101)
Follow-up to #97, on the same release line. After the visibility fix a calendar can be absent from the pickers for three different reasons the app knows and never said; this closes that gap.

**Settings → Calendars names the state** (#76, #78): read-only calendars are marked `Read-only`; ones whose account isn't syncing events to this device are marked `Not synced`, sorted to the bottom of their account, dimmed, and left **without a switch** — visibility can't reveal events that aren't on the device, so the control did nothing.

**Both pickers end in a "Missing a calendar?" row** (#76) opening the calendar manager, where those labels then say which reason applies. The manager overlay moved to the end of the host's overlay stack so it covers every surface that can open it (Settings, both event forms, the import picker).

Review notes:

- **Supporting text, not chips.** A row can carry several states at once next to a live switch; M3 supporting text composes there, static badges don't (and a non-interactive chip reads as a broken button).
- **`sync_events = 0` counts as "not synced" for account-backed calendars only.** Nothing syncs a device-local calendar by definition, and another app's local calendar can hold real events at 0 — the unsoundness that made the first #75 migration guard wrong. Covered by a test.
- **Excluded calendars stay out of the pickers** rather than being listed unpickable — a handful of read-only subscriptions would crowd out the ones you can actually choose.

541 JVM tests green (6 new), lint clean, check_translations clean. **On-device review owed**; the three new string keys need the Weblate backfill.

Reviewed-on: #101
2026-07-26 17:17:02 +00:00
ac0c43f930 Translations update from Weblate (!99)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Renovate / renovate (push) Successful in 1m13s
Translations update from [Weblate](https://weblate.dev.jeanlucmakiola.de) for [Calendula/Strings](https://weblate.dev.jeanlucmakiola.de/projects/calendula/strings/).

Current translation status:

![Weblate translation status](https://weblate.dev.jeanlucmakiola.de/widget/calendula/strings/horizontal-auto.svg)

Co-authored-by: Bazyli Cyran <bazyli@cyran.dev>
Co-authored-by: Sup99999 <alelmnour1o234@proton.me>
Reviewed-on: #99
Co-authored-by: Weblate <weblate-bot@jeanlucmakiola.de>
Co-committed-by: Weblate <weblate-bot@jeanlucmakiola.de>
2026-07-26 08:26:54 +00:00
314236ac0c fix(renovate): use a flexible internal-checks filter so ages resolve (!100)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 6s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Age came back `unknown` for every dependency after #98. That's the `internalChecksFilter: "none"` from that PR doing what it says on the tin — and rather more.

### Why

`lib/workers/repository/process/lookup/filter-checks.ts` short-circuits on `none`:

```js
if (internalChecksFilter === 'none') {
  release = sortedReleases.pop();     // returns here
} else {
  for (let candidateRelease of sortedReleases.reverse()) {
    ...
    const updatedCandidateRelease = await postprocessRelease(...)
```

`postprocessRelease` is the only caller that fetches a Maven artifact's `Last-Modified` header (`lib/modules/datasource/maven/index.ts:222`), so skipping the loop leaves `releaseTimestamp` unset. That empties the Age column — but it also silently voids `minimumReleaseAge` and the `renovate/stability-days` check, since both compare against that same timestamp. The cooling-off tiers were decorative.

### Fix

`flexible` keeps the intent — still prefers a version that has cleared its window, but opens the PR at the newest candidate when none has — while running the loop that populates the timestamps.

Verified with a local `--platform=local --dry-run=full`:

| dependency | new version | age |
| --- | --- | --- |
| `androidx.compose:compose-bom` | 2026.06.01 | 24 d |
| `androidx.compose.material3:material3` | 1.5.0-alpha24 | 10 d |
| `androidx.work:work-runtime-ktx` | 2.11.2 | 122 d |
| `com.android.application` (AGP) | 9.3.1 | 2 d |
| `gradle` | 9.6.1 | 29 d |

Only `ghcr.io/renovatebot/renovate` stays unknown, because the docker registry serves no timestamps at all — the run marks all 176 of its tags pending for that reason, and `flexible` is what still lets that PR through.

`Pending` rejoins the table: under a flexible filter it names the newer version being held back, rather than leaving it invisible.

### Also

Migrates the Gitea workflow manager off the deprecated `fileMatch`. Renovate's config migration was rewriting it to `managerFilePatterns` (delimited regex) on every run and warning about it; the dry run confirms the manager still picks up `.gitea/workflows` afterwards.

Validated with `renovate-config-validator` against the pinned 43.232.0.

Reviewed-on: #100
2026-07-25 20:31:33 +00:00
c6e83fc071 chore(renovate): show real release age and fetch changelogs (!98)
All checks were successful
Release — F-Droid repo + Gitea/Codeberg release / detect (push) Successful in 7s
Release — F-Droid repo + Gitea/Codeberg release / release (push) Has been skipped
Three gaps in the Renovate setup, all about having enough in front of you at review time.

### Age reads UNKNOWN

`config:recommended` already brings in `mergeConfidence:age-confidence-badges`, so the Age column existed — it just rendered grey. Fetching the badge SVGs from `developer.mend.io` directly shows why: Mend's index covers Maven Central but has nothing for Google's Maven repo, so every androidx/compose coordinate is blank. No token changes it; the JSON API behind the badges answers 401 for everyone.

| coordinate | registry | age badge |
| --- | --- | --- |
| `androidx.compose:compose-bom` | Google Maven | UNKNOWN |
| `androidx.core:core-ktx` | Google Maven | UNKNOWN |
| `com.google.truth:truth` | Maven Central | 2y |
| `org.jetbrains.kotlin:kotlin-stdlib` | Maven Central | 1y (confidence high, passing 99%) |

Age is now computed from `newVersionAgeInDays`, which Renovate derives itself to evaluate `minimumReleaseAge` — Google Maven serves `last-modified` on its POMs, so it's populated where Mend is blank, and the number agrees with the tiers it's read against. Mend keeps the Confidence column, which still resolves for the Maven Central half (Kotlin, Gradle, AGP, the test stack).

### Empty release notes

We run against Gitea, but the packages are *released* on GitHub, so changelog lookups were going out unauthenticated against a 60/h limit. `RENOVATE_GITHUB_COM_TOKEN` lifts that.

**Needs a secret before it does anything:** a github.com PAT with **no scopes ticked**, added as repo secret `GITHUB_COM_TOKEN`. Until then the var resolves empty, which is exactly today's behaviour.

### Nothing expressed how settled a release is

Cooling-off scaled by blast radius: 30 days major, 20 minor, 10 patch/digest.

Deliberately advisory. Renovate's default `internalChecksFilter: strict` would suppress the PR outright until the version aged in; `none` opens it at the highest version immediately, so merging ahead of the window stays a decision rather than a wait. A too-young release still gets a yellow `renovate/stability-days` check — `setStability` computes that from `minimumReleaseAge` + `releaseTimestamp` independently of the filter — and with `automerge: false` nothing acts on it.

### Notes

Validated with `renovate-config-validator` against the pinned 43.232.0. Config is read from the default branch, so the open bump PRs keep their current tables until the next run after this merges.

Reviewed-on: #98
2026-07-25 20:09:22 +00:00
53 changed files with 2365 additions and 530 deletions

View File

@@ -72,9 +72,14 @@ jobs:
distribution: 'zulu'
java-version: '17'
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
# setup-java and cache all exist there, but android-actions/setup-android
# does not, and the job dies with "repository not found". Gitea's instance
# defaults to GitHub, which is why this never surfaced before the split.
- name: Setup Android SDK
if: steps.scope.outputs.code == 'true'
uses: android-actions/setup-android@v3
uses: https://github.com/android-actions/setup-android@v3
with:
# Default ("tools platform-tools") drags in the Android Emulator
# (~300 MB) which the build never uses.

View File

@@ -27,6 +27,14 @@ jobs:
# whether this push actually cuts a new release (no tag for it yet). Keeps the
# heavy job from running on every merge to main.
detect:
# Gitea only. The workflow directory split already keeps this file invisible
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
# exists — but that only holds while .forgejo/ is non-empty. Move the last
# file out of it and Codeberg would fall back to .gitea/workflows and start
# running the release pipeline on the contributor-facing runner, with no
# 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
outputs:
is_release: ${{ steps.v.outputs.is_release }}
@@ -41,8 +49,16 @@ jobs:
- name: Resolve version and whether it is a new release
id: v
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
# Tags are read from Codeberg, which is canonical — deliberately NOT
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
# source does not have. A tag minted here on Gitea is therefore wiped
# by the next sync (Codeberg does not have it yet) and only reappears
# once the tag push at the end of this workflow propagates back.
# Asking Gitea inside that window would report "no tag" for a release
# that already shipped, and cut it a second time.
# Public repo, so this read needs no token.
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
run: |
set -e
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
@@ -60,15 +76,28 @@ jobs:
fi
# A tag for this version already existing means the release shipped on
# an earlier push; do nothing. Absent => this merge cuts the release.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
if [ "$STATUS" = "200" ]; then
echo "Tag v$VERSION already exists — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
else
echo "No tag for v$VERSION yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
fi
#
# Anything other than a clean 200/404 is treated as fatal rather than
# as "no tag". A Codeberg outage or a network blip would otherwise
# read as absent and re-cut a release that has already shipped —
# republishing to F-Droid and Play. Failing here is recoverable; a
# duplicate release is not.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
case "$STATUS" in
200)
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
echo "is_release=false" >> "$GITHUB_OUTPUT"
;;
404)
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
echo "is_release=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
exit 1
;;
esac
# Releases: build + sign + publish, then mint the tag and Gitea release.
# Also runs on manual dispatch, where it skips the build and just re-signs and

View File

@@ -29,14 +29,30 @@ jobs:
- name: Run Renovate
run: renovate
env:
# Self-hosted Gitea, not github.com.
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
# Renovate targets Codeberg (canonical) while still RUNNING on the
# Gitea runner. Moving the job to Codeberg would put a repo-write
# token on the contributor-facing runner, which is exactly what the
# .forgejo/ vs .gitea/ split exists to prevent — so the token stays
# where the other secrets live and only the API calls cross over.
#
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
# pinned image ships a distinct forgejo platform module.
RENOVATE_PLATFORM: forgejo
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
# PR scope on jlmakiola/calendula.
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
# Scope to this repo only — no org-wide autodiscovery.
RENOVATE_AUTODISCOVER: 'false'
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
# Commits/PRs authored as the bot, not a real maintainer.
RENOVATE_REPOSITORIES: '["jlmakiola/calendula"]'
# Commits/PRs authored as the bot, not a real maintainer. This address
# must be a verified email on the Codeberg bot account, otherwise the
# commits show up unattributed there.
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
# Read-only github.com PAT (no scopes needed). Unaffected by the forge
# move — nearly every dependency is *released* on GitHub, and without
# this,
# changelog/release-note lookups hit the 60/h anonymous rate limit
# and PRs arrive with an empty "Release Notes" section.
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
LOG_LEVEL: info

2
.gitmodules vendored
View File

@@ -1,3 +1,3 @@
[submodule "floret-kit"]
path = floret-kit
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
url = https://codeberg.org/jlmakiola/floret-kit.git

View File

@@ -7,7 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Settings → Calendars now says what is different about a calendar instead of
leaving you to guess. Ones you can only view — a subscribed calendar, a
calendar shared with you read-only — are marked **Read-only** ([#76]).
- Calendars your device isn't syncing are marked **Not synced**, moved to the
bottom of their account and left without a switch. None of their events are on
the device, so the switch they used to have could not have shown you anything
— the calendar simply looked broken. They are no longer offered when you pick
a calendar for a new or an imported event either: an event saved there would
never reach the account. Whether an account syncs a calendar stays that
account's own app's decision ([#78]).
- The birthday and anniversary calendars Calendula fills from your contacts are
marked **Filled from your contacts**, which is why they can't be picked for a
new event: anything you put there would be removed again on the next sync.
Deleting one is held back while special dates are switched on — Calendula
would simply create it again — and the calendar's editor says so; turn the
feature off under Settings → Special dates and the delete works as usual
([#76]).
- The calendar picker in the event form and in the .ics import screen now ends
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
those marks then explain why a calendar isn't offered ([#76]).
### Changed
- Calendula's source code now lives on **Codeberg**, where its issues already
were. The **Source code** and **License** links in Settings → About point
there, so reporting a bug and reading the code no longer land on two different
sites. Nothing about the app itself changes, and the F-Droid repository is
unaffected.
### Fixed
- Search results now show an all-day event's real date. West of UTC — anywhere in
the Americas, say — a search hit was dated one day early, disagreeing with the
day the month, week and agenda views file the same event under ([#82]).
- Reminders no longer depend on Android telling Calendula when they are due.
Calendula now works out each reminder's time itself and sets its own alarm for
it. On some phones — Samsung's among them — the system's calendar storage never
sends the signal a calendar app is meant to wake up on, and no amount of
battery or notification settings helps: the reminder is simply never announced.
None of that is visible from inside an app that waits to be told, which is why
it took a second pass to find ([#75]).
Reminders also survive things that used to lose them quietly. After a restart
or an app update Calendula re-arms its alarms, and a reminder whose moment
passed while the phone was off still arrives, as long as the event has not
ended yet.
- All-day reminders now arrive at the time you chose in **Settings →
Notifications**, on every occurrence. A yearly birthday could drift an hour
either way depending on daylight saving, and all-day reminders on calendars
from an account fired in the middle of the night instead of in the morning
([#75]).
- Reminders now arrive for every calendar you have switched on. A calendar that
was hidden at system level — switched off in another calendar app, or never
switched on after being added — still showed its events and listed their
@@ -1135,3 +1186,6 @@ automatically, with zero telemetry and no internet permission.
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82

View File

@@ -8,7 +8,7 @@
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
<p>
<a href="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions"><img src="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/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">

View File

@@ -33,6 +33,14 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!--
A reboot clears every pending alarm, including the one holding the next
reminder. Now that the app schedules that alarm itself (#75) rather than
leaning on the provider's, it has to hear about the reboot to re-arm it —
otherwise reminders simply stop after a restart.
-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
returns null and the calendar manager's per-account "manage" button can't
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
@@ -270,17 +278,22 @@
</intent-filter>
</service>
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
no notification itself — a calendar app must (v1.4, Etar model).
Exported: the broadcast arrives from the provider's process. -->
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
Instances + Reminders instead of waiting for the provider's
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
retarget or never send. This receiver takes our scan alarm plus
every outside event that invalidates it — boot and package-replace
wipe pending alarms, and a clock or timezone change moves every
reminder relative to the one that is armed.
Exported: the system broadcasts arrive from outside the app. -->
<receiver
android:name=".data.reminders.EventReminderReceiver"
android:name=".data.reminders.ReminderScheduleReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.EVENT_REMINDER" />
<data
android:host="com.android.calendar"
android:scheme="content" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>

View File

@@ -8,6 +8,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
import de.jeanlucmakiola.floret.crash.CrashConfig
import de.jeanlucmakiola.floret.crash.CrashReporter
import kotlinx.coroutines.CoroutineScope
@@ -41,6 +43,25 @@ class CalendulaApp : Application() {
reconcileAutoBackup()
reconcileSpecialDates()
reconcileCalendarVisibility()
startReminderDelivery()
}
/**
* Bring reminder delivery up with the process (#75). The app plans and arms
* its own reminder alarms now, so launch is one of the moments that has to
* re-check them: a scan re-arms whatever the system dropped, posts anything
* a missed alarm still owes, and starts watching the provider so an edit
* re-plans without waiting for the next pass. The daily worker is the
* backstop for a device that drops the alarm with no reboot to announce it.
*/
private fun startReminderDelivery() {
val deps = EntryPointAccessors.fromApplication(
this, ReminderMaintenanceWorker.Deps::class.java,
)
val scanner = deps.reminderScanner()
scanner.startWatchingProvider()
scanner.scanInBackground()
ReminderMaintenanceScheduler.apply(this)
}
/**

View File

@@ -67,6 +67,14 @@ class CalendarVisibilityReconciler @Inject constructor(
// drain and nothing left to decide, so don't pay for the query.
if (pending.isEmpty() && noticeSettled) return@withContext
val calendars = dataSource.calendars()
// An empty read means "couldn't read", not "no calendars": the data
// source turns a null cursor — a provider momentarily unavailable —
// into an empty list. Both decisions below are one-way, so taking
// that reading as the truth would drop the whole pending set without
// ever writing VISIBLE = 0 (switching the user's calendars back on,
// events and reminders with them) and settle the notice as "nothing
// to explain". Leave both to the next run.
if (calendars.isEmpty()) return@withContext
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
return@withContext

View File

@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Singleton
@@ -46,9 +46,9 @@ abstract class DataBindModule {
@Binds
@Singleton
abstract fun bindReminderAlertStore(
impl: AndroidReminderAlertStore,
): ReminderAlertStore
abstract fun bindReminderInstanceSource(
impl: ProviderReminderInstanceSource,
): ReminderInstanceSource
@Binds
@Singleton

View File

@@ -0,0 +1,42 @@
package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* How far reminder delivery has got. One number, and it replaces everything the
* provider's `CalendarAlerts.STATE` used to do for us (#75).
*
* A scan posts the reminders whose moment falls after this watermark and up to
* now, then moves it to now. That single rule gives both halves of what the
* retired path got from the provider: a scan that runs twice cannot post the
* same reminder again, and a scan that runs *late* — after a reboot, an app
* update or a doze window swallowed the alarm — still posts everything the
* missed alarm would have.
*
* Unset means "never scanned". It is deliberately not treated as zero: the first
* scan after an install or an upgrade would otherwise consider every reminder
* since the epoch overdue and bury the user in notifications.
*/
@Singleton
class ReminderStatePrefs @Inject constructor(
private val store: DataStore<Preferences>,
) {
/** The watermark, or `null` before the first scan has ever run. */
suspend fun lastScanMillis(): Long? = store.data.map { it[LAST_SCAN_KEY] }.first()
suspend fun setLastScanMillis(millis: Long) {
store.edit { prefs -> prefs[LAST_SCAN_KEY] = millis }
}
private companion object {
val LAST_SCAN_KEY = longPreferencesKey("reminder_last_scan_millis")
}
}

View File

@@ -1,30 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
/**
* Still relevant while the event has not ended: a reminder for an event that is
* already over is pointless to re-surface. Falls back to the begin time when the
* end is unknown (0L).
*/
internal fun ReminderAlert.isRelevantAt(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* The alerts [EventReminderReceiver] may mark handled (`STATE_FIRED`): the ones
* it posted, plus the ones it silenced whose event is already over.
*
* A silenced alert for an event still ahead is deliberately left
* `STATE_SCHEDULED`. Silencing is not handling — the calendar is switched off in
* Calendula while the provider still holds `VISIBLE = 1` (a read-only install,
* or an upgrade whose flush hasn't landed), so switching it back on before the
* event must still be able to surface the reminder. [ReminderAlertStore.dueAlerts]
* only ever returns scheduled rows, so marking them here would lose them for
* good; leaving them makes the provider's own table the stash
* ([ReminderRecovery]).
*/
internal fun handledAlertIds(
due: List<ReminderAlert>,
postedIds: Set<Long>,
nowMillis: Long,
): List<Long> = due
.filter { it.alertId in postedIds || !it.isRelevantAt(nowMillis) }
.map { it.alertId }

View File

@@ -1,67 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.CalendarContract
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Becomes the app that turns the calendar provider's reminder alarms into
* visible notifications (the Etar model — the provider broadcasts
* `EVENT_REMINDER` at reminder time but posts nothing itself).
*
* The broadcast's data URI only carries the alarm time, so it is ignored:
* we query every still-scheduled, due `CalendarAlerts` row ourselves, post
* them, and mark them fired. Posting happens before marking — a crash in
* between re-posts silently (same tag) rather than losing the reminder.
*
* There is no per-calendar filtering here: a calendar switched off in
* Settings → Calendars has `Calendars.VISIBLE = 0`, and the provider creates no
* alert rows for it in the first place (#75). The one case that flag can't
* cover — a read-only install, which keeps its switches app-side — is gated in
* [ReminderNotifier.post], where the snoozed re-show passes too. What that gate
* silences is *not* marked fired while the event is still ahead, so switching
* the calendar back on can still surface it (see [handledAlertIds]).
*/
@AndroidEntryPoint
class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var alertStore: ReminderAlertStore
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
val readGranted = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
if (!readGranted || !notifier.canPost()) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now)
val postedIds = due
.filter { notifier.post(it) }
.mapTo(mutableSetOf()) { it.alertId }
alertStore.markFired(handledAlertIds(due, postedIds, now), now)
}
} finally {
pendingResult.finish()
}
}
}
}

View File

@@ -18,14 +18,14 @@ import javax.inject.Inject
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
* alarm), so the receiver is not exported.
*
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
* already fired, so nothing re-posts it.
* - **Dismiss** just cancels the notification — the scan's watermark has moved
* past this reminder, so nothing re-posts it.
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
* it after the user's snooze delay.
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
* or dismiss it again — unless the calendar was switched off during the
* snooze, which [ReminderNotifier.post] catches (this alarm is ours, so no
* provider alert row stands between it and the notification).
* snooze, which [ReminderNotifier.post] catches this alarm is its own
* trigger, outside the ordinary scan.
*/
@AndroidEntryPoint
class ReminderActionReceiver : BroadcastReceiver() {
@@ -75,7 +75,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
private const val EXTRA_ALERT_ID = "alert_id"
/**
* Not handled here — the notification body opens the detail screen
* directly. It only claims a slot in [requestCode] so that intent stays
* distinct from the three this receiver does handle.
*/
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
private const val EXTRA_ALERT_KEY = "alert_key"
private const val EXTRA_EVENT_ID = "event_id"
private const val EXTRA_CALENDAR_ID = "calendar_id"
private const val EXTRA_BEGIN = "begin"
@@ -88,7 +95,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
Intent(context, ReminderActionReceiver::class.java).apply {
this.action = action
putExtra(EXTRA_ALERT_ID, alert.alertId)
putExtra(EXTRA_ALERT_KEY, alert.key)
putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
putExtra(EXTRA_BEGIN, alert.beginMillis)
@@ -99,23 +106,29 @@ class ReminderActionReceiver : BroadcastReceiver() {
}
/**
* A stable request code per (alert, action) so the three PendingIntents
* of one notification stay distinct and don't clobber each other.
* A stable request code per (alert, action) so one notification's
* PendingIntents stay distinct and don't clobber each other.
*
* The key is a hash now rather than a small row id, so the shift is what
* keeps the action slot intact; the top three bits it drops only make two
* *different* reminders collide, which the intent extras then separate
* (PendingIntents compare their intents too, not just the request code).
*/
fun requestCode(alert: ReminderAlert, action: String): Int {
val actionOffset = when (action) {
ACTION_SNOOZE -> 1
ACTION_DISMISS -> 2
ACTION_SHOW -> 3
ACTION_OPEN -> 4
else -> 0
}
return alert.alertId.toInt() * 8 + actionOffset
return (alert.key.toInt() shl 3) + actionOffset
}
private fun alertFrom(intent: Intent): ReminderAlert? {
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
return ReminderAlert(
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),

View File

@@ -0,0 +1,71 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* True on API < 31 (no restriction), and on 31+ when the exact-alarm capability
* is held — auto-granted via `USE_EXACT_ALARM` on API 33+ (Calendula is a
* calendar app), user-revocable on 3132.
*/
internal fun AlarmManager.canScheduleExactCompat(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || canScheduleExactAlarms()
/**
* Holds the app's own wake-up for the next reminder — the half of delivery that
* used to be the provider's (#75).
*
* Exactly **one** alarm exists at a time, for the earliest reminder still ahead.
* Every firing re-scans and re-arms, so a reminder added, moved or deleted in
* between is picked up on the next pass instead of needing an alarm per reminder
* to be kept in sync with the provider's tables.
*
* A reminder that lands late is a broken reminder, hence an *exact* alarm; the
* inexact allow-while-idle fallback only applies where the OS withholds the
* capability (API 3132 with the user's permission revoked).
*/
@Singleton
class ReminderAlarmScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
fun scheduleScan(triggerAtMillis: Long) {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
val pendingIntent = scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT)
if (alarmManager.canScheduleExactCompat()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent,
)
}
}
/** Drop the pending wake-up — reminders are off, or there is nothing to wait for. */
fun cancelScan() {
val alarmManager = context.getSystemService<AlarmManager>() ?: return
alarmManager.cancel(scanPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT))
}
private fun scanPendingIntent(flags: Int): PendingIntent = PendingIntent.getBroadcast(
context,
SCAN_REQUEST_CODE,
Intent(context, ReminderScheduleReceiver::class.java)
.setAction(ReminderScheduleReceiver.ACTION_SCAN),
flags or PendingIntent.FLAG_IMMUTABLE,
)
private companion object {
// Fixed: there is only ever one scan alarm, and re-arming must replace it.
const val SCAN_REQUEST_CODE = 0x5CA1
}
}

View File

@@ -0,0 +1,37 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.domain.reminders.PlannedReminder
/**
* One reminder as the notification layer needs it: what to show, and the stable
* [key] that identifies it across a reboot, a re-scan and a reinstall.
*
* [key] used to be the `CalendarAlerts` row id. It is now derived from the
* reminder itself (see [PlannedReminder.key]) because there is no row any more —
* in-house delivery reads `Instances` and `Reminders` and owns the alarm (#75).
* Everything downstream only ever needed it to be stable and unique, which it
* still is: it keys the notification tag, so a reminder posted twice replaces
* itself instead of stacking, and it keys the snooze/dismiss `PendingIntent`s.
*/
data class ReminderAlert(
val key: Long,
val eventId: Long,
val calendarId: Long,
val beginMillis: Long,
val endMillis: Long,
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
val title: String,
val location: String?,
val isAllDay: Boolean,
)
fun PlannedReminder.toAlert(): ReminderAlert = ReminderAlert(
key = key,
eventId = instance.eventId,
calendarId = instance.calendarId,
beginMillis = instance.beginMillis,
endMillis = instance.endMillis,
title = instance.title,
location = instance.location,
isAllDay = instance.isAllDay,
)

View File

@@ -1,115 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.ContentValues
import android.content.Context
import android.provider.CalendarContract
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* One due row of the provider's `CalendarAlerts` table (a join with Events).
* Stays in the data layer: alerts feed the notification path only and never
* reach a screen, so there is no domain model for them.
*/
data class ReminderAlert(
val alertId: Long,
val eventId: Long,
val calendarId: Long,
val beginMillis: Long,
val endMillis: Long,
/** Raw event title; may be blank — the notifier substitutes "(no title)". */
val title: String,
val location: String?,
val isAllDay: Boolean,
)
/**
* Seam over the `CalendarAlerts` table so the receiver logic can be exercised
* without a ContentResolver. The provider creates these rows itself — only
* for `METHOD_ALERT` reminders (verified in AOSP `CalendarAlarmManager`), so
* email reminders never show up here.
*/
interface ReminderAlertStore {
/** Alerts that are due (`ALARM_TIME` has passed) and still unhandled. */
fun dueAlerts(nowMillis: Long): List<ReminderAlert>
/**
* Mark the given alerts handled (`STATE_FIRED`) so a later broadcast does
* not surface them again. Best effort: this write needs `WRITE_CALENDAR`,
* which the user may have declined — then re-broadcasts silently replace
* the already-posted notifications instead (same tag, alert-once).
*/
fun markFired(alertIds: List<Long>, nowMillis: Long)
}
@Singleton
class AndroidReminderAlertStore @Inject constructor(
@ApplicationContext private val context: Context,
) : ReminderAlertStore {
override fun dueAlerts(nowMillis: Long): List<ReminderAlert> = context.contentResolver.query(
CalendarContract.CalendarAlerts.CONTENT_URI,
PROJECTION,
CalendarContract.CalendarAlerts.STATE + " = ? AND " +
CalendarContract.CalendarAlerts.ALARM_TIME + " <= ?",
arrayOf(
CalendarContract.CalendarAlerts.STATE_SCHEDULED.toString(),
nowMillis.toString(),
),
CalendarContract.CalendarAlerts.BEGIN + " ASC",
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
ReminderAlert(
alertId = c.getLong(0),
eventId = c.getLong(1),
calendarId = c.getLong(2),
beginMillis = c.getLong(3),
endMillis = c.getLong(4),
title = c.getString(5).orEmpty(),
location = c.getString(6)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(7) == 1,
),
)
}
}
} ?: emptyList()
override fun markFired(alertIds: List<Long>, nowMillis: Long) {
if (alertIds.isEmpty()) return
val values = ContentValues().apply {
put(CalendarContract.CalendarAlerts.STATE, CalendarContract.CalendarAlerts.STATE_FIRED)
put(CalendarContract.CalendarAlerts.RECEIVED_TIME, nowMillis)
put(CalendarContract.CalendarAlerts.NOTIFY_TIME, nowMillis)
}
try {
context.contentResolver.update(
CalendarContract.CalendarAlerts.CONTENT_URI,
values,
CalendarContract.CalendarAlerts._ID +
" IN (" + alertIds.joinToString(",") + ")",
null,
)
} catch (e: SecurityException) {
Log.w(TAG, "Cannot mark alerts fired without WRITE_CALENDAR", e)
}
}
private companion object {
const val TAG = "ReminderAlertStore"
val PROJECTION = arrayOf(
CalendarContract.CalendarAlerts._ID,
CalendarContract.CalendarAlerts.EVENT_ID,
CalendarContract.CalendarAlerts.CALENDAR_ID,
CalendarContract.CalendarAlerts.BEGIN,
CalendarContract.CalendarAlerts.END,
CalendarContract.CalendarAlerts.TITLE,
CalendarContract.CalendarAlerts.EVENT_LOCATION,
CalendarContract.CalendarAlerts.ALL_DAY,
)
}
}

View File

@@ -0,0 +1,128 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context
import android.content.ContentUris
import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import javax.inject.Inject
import javax.inject.Singleton
/**
* The read side of in-house reminder delivery: occurrences and the reminder
* offsets hanging off them, straight out of the provider's own tables.
*
* Deliberately *not* `CalendarAlerts`. That table is the provider's own working
* copy of this same information, and the whole point of #75's second round is
* that we can no longer assume it gets written. `Instances` and `Reminders` are
* plain data the app already reads everywhere else.
*
* An interface so [ReminderScanner] can be exercised on the JVM without a
* ContentResolver, in the shape the rest of `data/calendar` uses.
*/
interface ReminderInstanceSource {
/** Occurrences overlapping `[fromMillis, toMillis]`, of switched-on calendars. */
fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance>
/** `METHOD_ALERT` reminder offsets per event id, for the given events. */
fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>>
/**
* The largest `METHOD_ALERT` offset anywhere in the table, so the query
* window can be stretched to cover it and a long-lead reminder is planned
* before it comes due rather than firing late.
*/
fun longestReminderMinutes(): Int
}
@Singleton
class ProviderReminderInstanceSource @Inject constructor(
@ApplicationContext private val context: Context,
) : ReminderInstanceSource {
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
// `visible` is the calendar's system flag, which the app's one visibility
// model writes (#75) — a switched-off calendar must not plan reminders.
// The status clause mirrors CalendarDataSource.instances: a cancelled
// single occurrence of a series is a real row, and NULL means "normal",
// so a bare `!= CANCELED` would drop every ordinary event.
val selection = "${CalendarContract.Calendars.VISIBLE} = 1 AND " +
"(${CalendarContract.Instances.STATUS} IS NULL OR " +
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED})"
return context.contentResolver.query(
uri, OCCURRENCE_PROJECTION, selection, null, null,
)?.use { c ->
buildList {
while (c.moveToNext()) {
add(
ReminderEventInstance(
eventId = c.getLong(0),
calendarId = c.getLong(1),
beginMillis = c.getLong(2),
endMillis = if (c.isNull(3)) 0L else c.getLong(3),
title = c.getString(4).orEmpty(),
location = c.getString(5)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(6) == 1,
),
)
}
}
} ?: emptyList()
}
override fun reminderMinutes(eventIds: Collection<Long>): Map<Long, List<Int>> {
if (eventIds.isEmpty()) return emptyMap()
val out = mutableMapOf<Long, MutableList<Int>>()
// Batched because the ids go into the selection literally; an unbounded
// `IN (...)` on a busy calendar would grow the SQL past what SQLite takes.
eventIds.distinct().chunked(EVENT_ID_BATCH).forEach { batch ->
context.contentResolver.query(
CalendarContract.Reminders.CONTENT_URI,
REMINDER_PROJECTION,
"${CalendarContract.Reminders.METHOD} = " +
"${CalendarContract.Reminders.METHOD_ALERT} AND " +
"${CalendarContract.Reminders.EVENT_ID} IN (${batch.joinToString(",")})",
null,
null,
)?.use { c ->
while (c.moveToNext()) {
out.getOrPut(c.getLong(0)) { mutableListOf() } += c.getInt(1)
}
}
}
return out
}
override fun longestReminderMinutes(): Int = context.contentResolver.query(
CalendarContract.Reminders.CONTENT_URI,
arrayOf(CalendarContract.Reminders.MINUTES),
"${CalendarContract.Reminders.METHOD} = ${CalendarContract.Reminders.METHOD_ALERT}",
null,
// One row is enough: the provider passes the sort order to SQLite.
"${CalendarContract.Reminders.MINUTES} DESC",
)?.use { c -> if (c.moveToFirst()) c.getInt(0) else 0 } ?: 0
private companion object {
const val EVENT_ID_BATCH = 50
val OCCURRENCE_PROJECTION = arrayOf(
CalendarContract.Instances.EVENT_ID,
CalendarContract.Instances.CALENDAR_ID,
CalendarContract.Instances.BEGIN,
CalendarContract.Instances.END,
CalendarContract.Instances.TITLE,
CalendarContract.Instances.EVENT_LOCATION,
CalendarContract.Instances.ALL_DAY,
)
val REMINDER_PROJECTION = arrayOf(
CalendarContract.Reminders.EVENT_ID,
CalendarContract.Reminders.MINUTES,
)
}
}

View File

@@ -0,0 +1,67 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import java.util.concurrent.TimeUnit
/**
* The backstop under the alarm: a daily scan that runs whether or not the alarm
* survived.
*
* The scan alarm re-arms itself at most a day out, so in the steady state this
* finds nothing to do. It exists for the case the whole feature is about — a
* device that quietly drops the alarm without a reboot to announce it. The
* scheduling half of reminder delivery must not have a single point of failure,
* which is exactly what the provider's broadcast turned out to be.
*/
object ReminderMaintenanceScheduler {
private const val WORK_NAME = "reminder-scan-maintenance"
/** Enqueue the daily backstop; idempotent, so every launch may call it. */
fun apply(context: Context) {
val request = PeriodicWorkRequestBuilder<ReminderMaintenanceWorker>(1, TimeUnit.DAYS)
// The launch scan covers now; let the first periodic run wait.
.setInitialDelay(1, TimeUnit.DAYS)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
}
}
class ReminderMaintenanceWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
@EntryPoint
@InstallIn(SingletonComponent::class)
interface Deps {
fun reminderScanner(): ReminderScanner
}
override suspend fun doWork(): Result = try {
EntryPointAccessors.fromApplication(applicationContext, Deps::class.java)
.reminderScanner()
.scan()
Result.success()
} catch (e: Exception) {
// The scan swallows its own failures; anything reaching here is the
// entry point itself, which a retry will not mend. Never fail the chain.
Log.w(TAG, "Reminder maintenance scan failed", e)
Result.success()
}
private companion object {
const val TAG = "ReminderMaintenance"
}
}

View File

@@ -29,11 +29,13 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Posts one notification per due reminder alert on a dedicated channel.
* Tapping opens the event's detail screen; the tag is the alert id, so a
* re-broadcast of an alert we couldn't mark fired replaces its notification
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
* duplicating it.
* Posts one notification per due reminder on a dedicated channel. Tapping opens
* the event's detail screen.
*
* The tag is the reminder's stable key, so a scan that posts the same reminder
* again — a catch-up pass overlapping the alarm that already fired — replaces
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
* instead of stacking a second one.
*/
@Singleton
class ReminderNotifier @Inject constructor(
@@ -52,13 +54,12 @@ class ReminderNotifier @Inject constructor(
}
/**
* The single choke point for "this calendar is switched off". The provider
* side needs no help — with `VISIBLE = 0` it creates no alert rows at all —
* but two paths reach [post] without one: a snooze we re-show from our own
* exact alarm, scheduled before the calendar was switched off, and a
* read-only install whose switch lives app-side ([CalendarPrefs]) because it
* may not write the flag. Both are covered here rather than in either
* receiver.
* The single choke point for "this calendar is switched off". The scan
* already filters on `Calendars.VISIBLE`, but two paths reach [post] around
* it: a snooze re-shown from its own alarm, armed before the calendar was
* switched off, and a read-only install whose switch lives app-side
* ([CalendarPrefs]) because it may not write the flag. Both are covered here
* rather than in either receiver.
*/
private suspend fun isSilenced(calendarId: Long): Boolean =
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
@@ -66,8 +67,8 @@ class ReminderNotifier @Inject constructor(
/**
* Post [alert], unless its calendar is switched off. Returns whether the
* notification was put up: a silenced alert must stay unhandled so that
* switching the calendar back on can still surface it (see [handledAlertIds]).
* notification was put up, which the snooze re-show path uses to tell a
* silenced reminder from a delivered one.
*/
suspend fun post(alert: ReminderAlert): Boolean {
if (isSilenced(alert.calendarId)) return false
@@ -117,7 +118,7 @@ class ReminderNotifier @Inject constructor(
.build()
try {
NotificationManagerCompat.from(context)
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
} catch (e: SecurityException) {
// POST_NOTIFICATIONS was revoked between canPost() and here.
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
@@ -128,7 +129,7 @@ class ReminderNotifier @Inject constructor(
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
fun cancel(alert: ReminderAlert) {
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
}
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
@@ -141,7 +142,11 @@ class ReminderNotifier @Inject constructor(
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
context,
/* requestCode = */ alert.alertId.toInt(),
// Shares the per-(alert, action) request-code scheme with the buttons, so
// the key's wider value range can't collide one notification's intents.
/* requestCode = */ ReminderActionReceiver.requestCode(
alert, ReminderActionReceiver.ACTION_OPEN,
),
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)

View File

@@ -1,46 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Re-posts the reminders a switched-off calendar silenced, when it is switched
* back on while they still matter.
*
* Only the app-side switch needs this — a read-only install, or an upgrade the
* reconciler hasn't flushed yet. With `Calendars.VISIBLE = 0` the provider
* deletes the calendar's alert rows itself and re-creates them on the way back;
* app-side the rows stay, still `STATE_SCHEDULED`, because
* [EventReminderReceiver] deliberately leaves the ones it silenced unhandled
* (see [handledAlertIds]). So the provider's own table is the stash, and nothing
* is mirrored locally.
*
* Best effort at switch-on time: it mirrors the receiver's gates (reminders on,
* notifications postable) and there is no later re-scan, so an alert left
* unposted because those are closed is simply released.
*/
@Singleton
class ReminderRecovery @Inject constructor(
private val alertStore: ReminderAlertStore,
private val notifier: ReminderNotifier,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
) {
suspend fun rePostFor(calendarIds: Collection<Long>) = withContext(io) {
if (calendarIds.isEmpty()) return@withContext
if (!settingsPrefs.remindersEnabled.first() || !notifier.canPost()) return@withContext
val now = System.currentTimeMillis()
val ids = calendarIds.toSet()
val recovered = alertStore.dueAlerts(now)
.filter { it.calendarId in ids && it.isRelevantAt(now) }
if (recovered.isEmpty()) return@withContext
val postedIds = recovered.filter { notifier.post(it) }.map { it.alertId }
alertStore.markFired(postedIds, now)
}
}

View File

@@ -0,0 +1,170 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.prefs.ReminderStatePrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.reminders.planReminders
import de.jeanlucmakiola.calendula.domain.reminders.reminderQueryHorizon
import de.jeanlucmakiola.calendula.domain.reminders.reminderWatermark
import de.jeanlucmakiola.calendula.domain.reminders.scheduleReminders
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/**
* One pass of in-house reminder delivery: read what is planned, post what has
* come due, and arm the next wake-up.
*
* This is the whole loop. Every trigger — the alarm firing, boot, a clock or
* timezone change, an edit landing in the provider, the app starting, the daily
* safety net — runs the same [scan], so there is a single path to reason about
* and no ordering between triggers to get wrong. Re-running it is always safe:
* the watermark in [ReminderStatePrefs] decides what is owed, not the trigger.
*/
@Singleton
class ReminderScanner @Inject constructor(
@ApplicationContext private val context: Context,
private val source: ReminderInstanceSource,
private val calendarDataSource: CalendarDataSource,
private val notifier: ReminderNotifier,
private val alarms: ReminderAlarmScheduler,
private val state: ReminderStatePrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: kotlinx.coroutines.CoroutineDispatcher,
) {
// Triggers overlap freely (an alarm during a burst of edits); serialize so
// two passes can't both read the same watermark and post the same reminder.
private val scanLock = Mutex()
private val scope = CoroutineScope(SupervisorJob() + io)
private val providerChanges = MutableSharedFlow<Unit>(
replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private var watching = false
suspend fun scan() = withContext(io) {
scanLock.withLock {
try {
runScan()
} catch (e: SecurityException) {
// The calendar permission was revoked mid-flight. Nothing to
// re-arm and nothing to recover from — the next grant re-scans.
Log.w(TAG, "Reminder scan lacks the calendar permission", e)
} catch (e: Exception) {
Log.w(TAG, "Reminder scan failed", e)
}
}
}
private suspend fun runScan() {
val now = System.currentTimeMillis()
if (!hasReadCalendar()) return
if (!settingsPrefs.remindersEnabled.first()) {
// Reminders off: drop the wake-up, but keep the watermark moving so
// switching them back on doesn't replay everything missed meanwhile.
alarms.cancelScan()
state.setLastScanMillis(now)
return
}
val lookahead = reminderQueryHorizon(LOOKAHEAD_MILLIS, source.longestReminderMinutes())
// Reach a little into the past as well: an event already under way can
// still owe a reminder (an all-day "at time of event" encodes to a
// negative offset, which fires after begin), and a catch-up pass needs
// to see the occurrences whose moment it missed.
val occurrences = source.occurrences(now - PAST_WINDOW_MILLIS, now + lookahead)
val planned = planReminders(
instances = occurrences,
minutesByEvent = source.reminderMinutes(occurrences.map { it.eventId }),
zone = ZoneId.systemDefault(),
allDayTimeMinutes = settingsPrefs.allDayReminderTimeMinutes.first(),
)
val schedule = scheduleReminders(
planned = planned,
lastFiredMillis = reminderWatermark(state.lastScanMillis(), now),
nowMillis = now,
horizonMillis = now + MAX_ALARM_INTERVAL_MILLIS,
)
if (notifier.canPost()) {
schedule.due.forEach { notifier.post(it.toAlert()) }
}
// Advance regardless of whether anything could be posted: a user who
// muted notifications is not owed a backlog when they unmute.
state.setLastScanMillis(now)
alarms.scheduleScan(schedule.nextAlarmMillis)
}
private fun hasReadCalendar(): Boolean = ContextCompat.checkSelfPermission(
context, Manifest.permission.READ_CALENDAR,
) == PackageManager.PERMISSION_GRANTED
/**
* Re-scan when the provider changes, so an event saved or deleted in the app
* re-arms the alarm immediately rather than waiting for the next pass.
*
* Debounced: a single save writes the event, its reminders and its
* attendees, and each lands as its own notification. Only useful while the
* process is alive — every other trigger covers the rest, which is why
* nothing here needs to survive it.
*/
fun startWatchingProvider() {
if (watching) return
watching = true
providerChanges
.debounce(PROVIDER_CHANGE_DEBOUNCE_MILLIS)
.onEach { scan() }
.launchIn(scope)
calendarDataSource.registerChangeListener { providerChanges.tryEmit(Unit) }
}
/** Fire-and-forget scan for callers that are not in a coroutine already. */
fun scanInBackground() {
scope.launch(start = CoroutineStart.DEFAULT) { scan() }
}
private companion object {
const val TAG = "ReminderScanner"
/**
* How far ahead occurrences are read. Stretched further by the longest
* reminder offset in the table, so this is only the floor.
*/
const val LOOKAHEAD_MILLIS = 7L * 24 * 60 * 60 * 1000
/** How far back to look for occurrences that may still owe a reminder. */
const val PAST_WINDOW_MILLIS = 24L * 60 * 60 * 1000
/**
* Never wait longer than a day for the next pass, even with nothing
* pending: it rolls the lookahead window forward and re-arms an alarm
* the system may have dropped.
*/
const val MAX_ALARM_INTERVAL_MILLIS = 24L * 60 * 60 * 1000
const val PROVIDER_CHANGE_DEBOUNCE_MILLIS = 2_000L
}
}

View File

@@ -0,0 +1,63 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Every reason to re-run a reminder scan that arrives from outside the process.
*
* All of them do the same thing, because [ReminderScanner.scan] is idempotent
* and works out what is owed from its watermark rather than from why it was
* called:
*
* - **our own alarm** ([ACTION_SCAN]) — the ordinary case, a reminder is due;
* - **boot** and **package replaced** — both wipe pending alarms, so the app has
* to re-arm or reminders stop silently, which is the failure #75 is about;
* - **time and timezone changes** — they move every reminder relative to the
* armed alarm, and an all-day reminder's fire hour is recomposed in the
* current zone, so both need a fresh plan.
*
* Exported because the system broadcasts arrive from outside. [ACTION_SCAN] is
* ours and always sent as an explicit intent; another app triggering a scan
* early would only make it re-read the provider and re-arm, which is harmless.
*/
@AndroidEntryPoint
class ReminderScheduleReceiver : BroadcastReceiver() {
@Inject lateinit var scanner: ReminderScanner
override fun onReceive(context: Context, intent: Intent) {
// Every action here does the same thing, but the filter still has to be
// checked: the receiver is exported, and the system broadcasts it takes
// are protected, so an intent arriving with any other action did not
// come from where it claims to.
if (intent.action !in HANDLED_ACTIONS) return
val pendingResult = goAsync()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
try {
scanner.scan()
} finally {
pendingResult.finish()
}
}
}
companion object {
const val ACTION_SCAN = "de.jeanlucmakiola.calendula.reminders.SCAN"
private val HANDLED_ACTIONS = setOf(
ACTION_SCAN,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
)
}
}

View File

@@ -12,9 +12,12 @@ import javax.inject.Singleton
/**
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
*
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
* Separate from [ReminderAlarmScheduler]'s scan alarm, and deliberately so: a
* snooze is pinned to one reminder at a time the user chose, while the scan
* alarm is a single moving wake-up for whatever comes next. A re-show also has
* to outlive the scan's watermark moving past that reminder, so it carries the
* reminder in its own intent rather than re-deriving it.
*
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
* back to an inexact allow-while-idle alarm only if the OS withholds the
* exact-alarm capability (API 3132 where the user revoked it).

View File

@@ -0,0 +1,76 @@
package de.jeanlucmakiola.calendula.domain
/**
* The ways a calendar can behave unlike a plain, writable one — each of them a
* reason it is missing from the event and import pickers, and each of them
* something the app knows and used to keep to itself (#76).
*/
enum class CalendarStateLabel {
/**
* A special-dates mirror the app fills from contacts. Writable and visible,
* yet no event target: anything authored here is deleted by the next sync,
* which is why it is the one exclusion with nothing else to give it away.
*/
MANAGED,
/** Contents can't be modified: a WebCal subscription, a read-only share. */
READ_ONLY,
/** The account holds the events, but this device isn't syncing them down. */
NOT_SYNCED,
}
/**
* Whether the account this calendar belongs to keeps its events off the device
* (`Calendars.SYNC_EVENTS = 0`) — an "empty by construction" calendar: the rows
* simply aren't here, so nothing can display them and no reminder can fire.
*
* Device-local calendars are excluded deliberately. Nothing syncs them by
* definition, so the flag says nothing about them, and a local calendar from
* another app can hold real events at `sync_events = 0` — the same unsoundness
* that made the #75 migration guard wrong.
*/
val CalendarSource.isNotSynced: Boolean
get() = !syncsEvents && !isLocal
/**
* Whether a visibility switch on this calendar can change anything the user
* would see. It can't for a non-syncing one: there are no events on the device
* to reveal, so the switch would be a control that does nothing.
*/
val CalendarSource.hasVisibilitySwitch: Boolean
get() = !isNotSynced
/**
* Whether this calendar can be offered as a target for a new or imported event.
* The one predicate behind both pickers, so the states [CalendarStateLabel]
* names on a manager row are exactly the states that keep a calendar out of
* them (#76):
*
* - read-only has nowhere to write;
* - switched off would hide the event the moment it was saved;
* - a managed mirror has the next contact sync delete it;
* - a non-syncing one never carries the event up to the account, and
* `CalendarProvider2` wipes the calendar's rows outright when the
* subscription is switched back on — a saved event is a dead end either way.
*
* This is the test for *targets*. An event already living in an excluded
* calendar keeps it; the editor adds that calendar back to its picker.
*/
val CalendarSource.isEventTarget: Boolean
get() = canModifyContents && isVisibleInSystem && !isManaged && !isNotSynced
/** Every state worth naming on this calendar's row, in reading order. */
fun CalendarSource.stateLabels(): List<CalendarStateLabel> = buildList {
if (isManaged) add(CalendarStateLabel.MANAGED)
if (!canModifyContents) add(CalendarStateLabel.READ_ONLY)
if (isNotSynced) add(CalendarStateLabel.NOT_SYNCED)
}
/**
* Calendar-manager order within one group: the ones you can actually act on
* first, the non-syncing ones after them. Stable otherwise, so the provider's
* display-name ordering survives.
*/
fun List<CalendarSource>.orderedForManager(): List<CalendarSource> =
sortedBy { it.isNotSynced }

View File

@@ -1,5 +1,9 @@
package de.jeanlucmakiola.calendula.domain
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
data class CalendarSource(
@@ -72,6 +76,35 @@ data class EventInstance(
*/
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
/**
* The zone this event's calendar dates live in. Timed events are resolved in the
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
* resolving them anywhere else shifts the day boundaries — east of UTC the end
* leaks onto the following day (#65), west of UTC the start pulls back onto the
* previous one (#82). Every surface that has to name an all-day event's date
* goes through here, so grid, agenda, detail and search cannot disagree.
*/
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
if (isAllDay) TimeZone.UTC else zone
/** The first calendar day this event occupies. */
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
start.toLocalDateTime(dateZone(zone)).date
/**
* The last calendar day this event actually occupies. An event ending exactly at
* midnight (all-day events end at the exclusive next-midnight) does not reach
* into that boundary day, so resolve the instant just before [EventInstance.end].
*/
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
val lastInstant = if (end > start) end - 1.milliseconds else start
return lastInstant.toLocalDateTime(dateZone(zone)).date
}
/** Whether this event occupies more than one calendar day in [zone]. */
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
spanFirstDay(zone) != spanLastDay(zone)
data class EventDetail(
val instance: EventInstance,
val description: String?,

View File

@@ -0,0 +1,219 @@
package de.jeanlucmakiola.calendula.domain.reminders
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.temporal.ChronoUnit
/**
* Works out *when* each reminder has to fire and *which* ones are due, with no
* provider and no clock of its own — the whole decision layer of in-house
* reminder delivery (#75).
*
* Calendula used to leave both halves to the calendar provider: it scheduled the
* alarms, wrote the `CalendarAlerts` rows, and broadcast `EVENT_REMINDER` at the
* right moment. That chain is intact on stock Android but demonstrably not on
* every device — AOSP's own unbundled calendar carries three separate
* workarounds for OEMs that retarget the broadcast, or that only write the alert
* row at alert time. An app that can only *react* to that broadcast has no way
* to notice it never came.
*
* So the offsets in `CalendarContract.Reminders` are now read as data and turned
* into alarms we own. Everything here is pure: instances and reminder offsets in,
* fire instants out.
*/
/** An occurrence that reminders can hang off, flattened out of `Instances`. */
data class ReminderEventInstance(
val eventId: Long,
val calendarId: Long,
val beginMillis: Long,
val endMillis: Long,
val title: String,
val location: String?,
val isAllDay: Boolean,
)
/**
* One occurrence paired with one of its reminder offsets, and the instant that
* pairing has to fire at.
*/
data class PlannedReminder(
val instance: ReminderEventInstance,
val minutes: Int,
val alarmMillis: Long,
) {
/**
* Stable identity of this reminder, derived from what defines it rather
* than from a provider row id (there is none any more). It keys the
* notification tag and the snooze/dismiss `PendingIntent`s, so it has to
* survive a reboot, a re-scan and a reinstall — the same reminder must land
* on the same notification instead of stacking a second one.
*/
val key: Long = key(instance.eventId, instance.beginMillis, minutes)
private companion object {
fun key(eventId: Long, beginMillis: Long, minutes: Int): Long {
var h = eventId * 1_000_003L
h = (h xor beginMillis) * 31L
return h + minutes
}
}
}
/** What one scan concluded: post these now, and wake up again at [nextAlarmMillis]. */
data class ReminderSchedule(
val due: List<PlannedReminder>,
val nextAlarmMillis: Long,
)
private const val MILLIS_PER_MINUTE = 60_000L
private const val MINUTES_PER_DAY = 1_440
/**
* Pair every instance with each of its event's reminder offsets.
*
* A **timed** occurrence is trivial: `begin` is an absolute instant, so
* `begin minutes` is exact by construction, in any timezone, across any DST
* boundary.
*
* An **all-day** occurrence is not, and taking the offset at face value is what
* makes reminders land at the wrong hour. Its `begin` is UTC midnight, and the
* stored offset is not a plain lead time — `AllDayReminderEncoding` folds the
* wanted wall-clock hour into it, sampled against *one* date's UTC offset. Fire
* at `begin minutes` and every occurrence in a different DST phase than the one
* that was sampled drifts by the offset delta, an hour early in one direction and
* an hour late in the other. Rows written by other apps carry no wall-clock at
* all — a conventional `1440` fires at UTC midnight, which is 01:00 or 02:00
* local in Berlin and the wrong day west of UTC.
*
* So the offset is only read for *which day* it means, via [allDayLeadDays], and
* the hour comes from [allDayTimeMinutes] — the one global "show all-day
* reminders at" setting — recomposed against each occurrence's own date in
* [zone]. 09:00 Berlin is then 09:00 Berlin on every occurrence, whatever the
* offset was when the row was written.
*
* [minutesByEvent] may hold duplicate offsets (two identical reminder rows on one
* event); they collapse, because they would otherwise fight over one notification.
*/
fun planReminders(
instances: List<ReminderEventInstance>,
minutesByEvent: Map<Long, List<Int>>,
zone: ZoneId,
allDayTimeMinutes: Int,
): List<PlannedReminder> = instances.flatMap { instance ->
minutesByEvent[instance.eventId].orEmpty().distinct().map { minutes ->
PlannedReminder(
instance = instance,
minutes = minutes,
alarmMillis = if (instance.isAllDay) {
allDayAlarmMillis(instance.beginMillis, minutes, zone, allDayTimeMinutes)
} else {
instance.beginMillis - minutes * MILLIS_PER_MINUTE
},
)
}
}
/** UTC midnight of an all-day occurrence, as the calendar date it stands for. */
private fun allDayDate(beginMillis: Long): LocalDate =
Instant.ofEpochMilli(beginMillis).atZone(ZoneOffset.UTC).toLocalDate()
/**
* How many whole days before its occurrence a raw all-day offset means.
*
* A plain multiple of 1440 is read at face value. That covers rows from other
* calendar apps, which carry no encoded hour — and it stays right for our own
* rows that happen to land on a multiple, because those encode a wall-clock hour
* equal to the sampled UTC offset, so the day count is the same either way.
*
* Anything else is one of ours, with an hour folded in: recover the day count the
* way [de.jeanlucmakiola.calendula.data.calendar.fromProviderAllDayMinutes]
* does for display, by asking which local date the encoded instant falls on.
* Keeping the two in step is what makes the notification arrive on the day the
* event screen says it will.
*/
internal fun allDayLeadDays(rawMinutes: Int, beginMillis: Long, zone: ZoneId): Long {
if (rawMinutes % MINUTES_PER_DAY == 0) return (rawMinutes / MINUTES_PER_DAY).toLong()
val encoded = Instant.ofEpochMilli(beginMillis - rawMinutes * MILLIS_PER_MINUTE)
return ChronoUnit.DAYS.between(encoded.atZone(zone).toLocalDate(), allDayDate(beginMillis))
}
private fun allDayAlarmMillis(
beginMillis: Long,
rawMinutes: Int,
zone: ZoneId,
allDayTimeMinutes: Int,
): Long = allDayDate(beginMillis)
.minusDays(allDayLeadDays(rawMinutes, beginMillis, zone))
.atTime(LocalTime.of(allDayTimeMinutes / 60, allDayTimeMinutes % 60))
.atZone(zone)
.toInstant()
.toEpochMilli()
/**
* Split [planned] into what is due now and when to wake up next.
*
* Due means the fire instant falls in `(lastFiredMillis, nowMillis]` — a
* half-open watermark, so a scan triggered twice cannot post the same reminder
* twice, while a scan that runs late still catches everything the missed alarm
* would have posted. That catch-up is the point: an alarm dropped by a reboot,
* an app update or a doze window is recovered by the next scan rather than lost.
*
* A reminder whose event has already ended is dropped rather than posted late —
* see [isStillRelevant].
*
* [nextAlarmMillis] is capped at [horizonMillis] even when nothing is pending, so
* the scan re-runs at least that often and the lookahead window rolls forward.
*/
fun scheduleReminders(
planned: List<PlannedReminder>,
lastFiredMillis: Long,
nowMillis: Long,
horizonMillis: Long,
): ReminderSchedule {
val due = planned
.filter { it.alarmMillis in (lastFiredMillis + 1)..nowMillis }
.filter { it.instance.isStillRelevant(nowMillis) }
.distinctBy { it.key }
.sortedWith(compareBy({ it.instance.beginMillis }, { it.key }))
val nextPending = planned
.filter { it.alarmMillis > nowMillis }
.minOfOrNull { it.alarmMillis }
return ReminderSchedule(
due = due,
nextAlarmMillis = minOf(nextPending ?: horizonMillis, horizonMillis),
)
}
/**
* Still worth showing while the occurrence has not ended. Falls back to the
* begin time when the end is unknown (0L).
*/
fun ReminderEventInstance.isStillRelevant(nowMillis: Long): Boolean =
(endMillis.takeIf { it > 0L } ?: beginMillis) >= nowMillis
/**
* The watermark a scan at [nowMillis] should measure against, given what the
* last one recorded.
*
* A first-ever scan ([lastScanMillis] `null`) claims the present, so an install
* or an upgrade onto in-house delivery does not treat every reminder since the
* epoch as overdue and bury the user in notifications. A watermark in the
* *future* — the clock was moved back, or the user travelled across the date
* line — is clamped for the mirror-image reason: left alone it would silence
* every reminder until real time caught up with it.
*/
fun reminderWatermark(lastScanMillis: Long?, nowMillis: Long): Long =
lastScanMillis?.coerceAtMost(nowMillis) ?: nowMillis
/**
* How far ahead instances must be queried for [scheduleReminders] to see every
* reminder in time: the plain lookahead plus the longest offset any reminder row
* carries, so a "two weeks before" reminder is planned before it comes due
* instead of firing late (the limitation Etar's equivalent documents).
*/
fun reminderQueryHorizon(lookaheadMillis: Long, maxReminderMinutes: Int): Long =
lookaheadMillis + maxOf(0L, maxReminderMinutes * MILLIS_PER_MINUTE)

View File

@@ -415,6 +415,7 @@ fun CalendarHost(
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
onClose = { createDateIso = null },
onSaved = { createDateIso = null },
onManageCalendars = { showCalendars = true },
)
}
}
@@ -434,6 +435,7 @@ fun CalendarHost(
editKey = null
detailKey = null
},
onManageCalendars = { showCalendars = true },
)
}
}
@@ -450,18 +452,6 @@ fun CalendarHost(
)
}
// Calendar manager — slides over Settings.
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(
onBack = { showCalendars = false },
onImport = { importUri = it; importForceMany = true },
)
}
// Import flow for an opened/received .ics file. A single event routes
// into the create form (prefilled, for review); many open the picker.
importUri?.let { uri ->
@@ -469,6 +459,7 @@ fun CalendarHost(
uri = uri,
forceMany = importForceMany,
onClose = { importUri = null },
onManageCalendars = { showCalendars = true },
onOpenSingle = { form ->
importUri = null
importFormSource = ImportSource.File
@@ -483,6 +474,27 @@ fun CalendarHost(
initialFormSource = importFormSource,
onClose = { importForm = null },
onSaved = { importForm = null },
onManageCalendars = { showCalendars = true },
)
}
// Calendar manager — declared last so it covers every overlay that can
// open it: Settings, both event forms, and the .ics import picker (#76).
// Coming back from it leaves the caller exactly as it was, with the
// calendar list already refreshed by the provider's notification.
AnimatedVisibility(
visible = showCalendars,
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
) {
CalendarsScreen(
onBack = { showCalendars = false },
// The manager opens the import too (restore from backup), and
// that way round it has to step aside: declared above the import
// overlays, it would otherwise cover the screen it just asked
// for. Closing it hands the user back to whatever opened the
// manager once the import is done.
onImport = { importUri = it; importForceMany = true; showCalendars = false },
)
}
}

View File

@@ -2,41 +2,14 @@ package de.jeanlucmakiola.calendula.ui.agenda
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.FailureReason
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.domain.spanLastDay
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
/**
* The zone the event's calendar dates live in. Timed events are resolved in the
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
* a one-day event onto its next day. Matches the Week view and detail card.
*/
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
if (isAllDay) TimeZone.UTC else zone
/** The first calendar day this event occupies. */
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
start.toLocalDateTime(dateZone(zone)).date
/**
* The last calendar day this event actually occupies. An event ending exactly at
* midnight (all-day events end at the exclusive next-midnight) does not reach
* into that boundary day, so resolve the instant just before [end].
*/
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
val lastInstant = if (end > start) end - 1.milliseconds else start
return lastInstant.toLocalDateTime(dateZone(zone)).date
}
/** Whether this event occupies more than one calendar day in [zone]. */
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
spanFirstDay(zone) != spanLastDay(zone)
/**
* What an agenda row's time line should convey for an event on a given day —
* the part of a multi-day span that [day] falls in. Pure and shared so the

View File

@@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.FileDownload
import androidx.compose.material.icons.filled.FileUpload
import androidx.compose.material.icons.filled.MoreVert
@@ -91,6 +92,12 @@ import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.domain.isNotSynced
import de.jeanlucmakiola.calendula.domain.orderedForManager
import de.jeanlucmakiola.calendula.domain.stateLabels
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
@@ -139,6 +146,7 @@ fun CalendarsScreen(
viewModel: CalendarsViewModel = hiltViewModel(),
) {
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
@@ -157,6 +165,7 @@ fun CalendarsScreen(
initialName = editing?.displayName.orEmpty(),
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
initialDescription = editing?.description.orEmpty(),
deleteLocked = editing != null && editing.id in deleteLockedIds,
onSave = { name, color, description ->
val id = editorId
if (id == null || id == NEW_CALENDAR_ID) {
@@ -305,7 +314,7 @@ private fun CalendarsList(
val disabled = !calendar.isVisibleInSystem
GroupedRow(
title = calendar.displayName,
summary = calendar.description,
summary = calendarRowSummary(calendar),
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
@@ -327,9 +336,9 @@ private fun CalendarsList(
// safety net. Offered only when there is something exportable: the user's
// own local calendars (managed special-dates mirrors don't count).
val exportable = local.filter { it.canModifyContents && !it.isManaged }
// Restore/import can target any writable, non-managed calendar (local or
// synced), so its availability is broader than export's.
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
// Restore/import can target any calendar the import picker would offer
// (local or synced), so its availability is broader than export's.
val canImport = (local + synced).any { it.isEventTarget }
if (exportable.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
SectionHeader(stringResource(R.string.calendars_backup_header))
@@ -412,7 +421,11 @@ private fun CalendarsList(
.forEach { (account, cals) ->
val expanded = account !in collapsedAccounts
val accountType = cals.first().accountType
val accountDisabled = cals.none { it.isVisibleInSystem }
// A non-syncing calendar has no switch, so it neither counts
// towards "the whole account is off" nor moves with toggle-all.
val switchable = cals.filter { it.hasVisibilitySwitch }
val accountDisabled = switchable.isNotEmpty() &&
switchable.none { it.isVisibleInSystem }
Spacer(Modifier.height(16.dp))
CalendarGroup(
title = account,
@@ -432,24 +445,36 @@ private fun CalendarsList(
collapsedAccounts - account
}
},
showToggleAll = true,
allEnabled = cals.all { it.isVisibleInSystem },
onToggleAll = { enabled -> onSetAccountVisible(cals.map { it.id }, enabled) },
showToggleAll = switchable.isNotEmpty(),
allEnabled = switchable.all { it.isVisibleInSystem },
onToggleAll = { enabled ->
onSetAccountVisible(switchable.map { it.id }, enabled)
},
) {
cals.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem
// Calendars you can act on first; the ones this device isn't
// syncing sit at the bottom, dimmed and switchless.
val ordered = cals.orderedForManager()
ordered.forEachIndexed { index, calendar ->
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
GroupedRow(
title = calendar.displayName,
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
summary = calendarRowSummary(calendar),
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
container = MaterialTheme.colorScheme.surfaceContainerHighest,
dimmed = disabled,
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
trailing = {
EnableSwitch(
calendarName = calendar.displayName,
enabled = !disabled,
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
)
trailing = if (calendar.hasVisibilitySwitch) {
{
EnableSwitch(
calendarName = calendar.displayName,
enabled = calendar.isVisibleInSystem,
onToggle = { enabled ->
onSetVisible(calendar.id, enabled)
},
)
}
} else {
null
},
)
}
@@ -557,6 +582,7 @@ private fun CalendarEditor(
onSave: (name: String, color: Int, description: String?) -> Unit,
onDelete: () -> Unit,
onClose: () -> Unit,
deleteLocked: Boolean = false,
) {
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
@@ -590,11 +616,23 @@ private fun CalendarEditor(
},
actions = {
if (!isNew) {
IconButton(onClick = { confirmDelete = true }) {
// Kept in place while the special-dates sync owns this
// calendar, rather than hidden: the button is where you
// expect it, disabled, with the card below saying why —
// and it comes back to life the moment the feature is
// off, when the delete would actually stick.
IconButton(
onClick = { confirmDelete = true },
enabled = !deleteLocked,
) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.event_detail_delete),
tint = MaterialTheme.colorScheme.error,
tint = if (deleteLocked) {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
} else {
MaterialTheme.colorScheme.error
},
)
}
}
@@ -623,6 +661,19 @@ private fun CalendarEditor(
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (deleteLocked) {
EditorCard(
icon = Icons.Default.Info,
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
iconAtTop = true,
) {
Text(
text = stringResource(R.string.calendars_managed_delete_locked),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
InlineTextField(
value = name,
@@ -694,6 +745,27 @@ private fun CalendarEditor(
}
}
/**
* The row's supporting line: the states that make this calendar behave unlike a
* plain writable one (#76), then its own description. Text rather than badges —
* a row can carry several of these at once next to a switch, which is exactly
* what M3 supporting text composes and a row of static chips doesn't.
*/
@Composable
private fun calendarRowSummary(calendar: CalendarSource): String? {
val states = calendar.stateLabels().map { label ->
stringResource(
when (label) {
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
},
)
}
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
return parts.joinToString(" · ").ifEmpty { null }
}
/**
* The per-row on/off control, writing the system's `Calendars.VISIBLE`: checked
* = the calendar is shown, unchecked = it drops out of every surface (events,

View File

@@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import de.jeanlucmakiola.calendula.data.reminders.ReminderRecovery
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
import kotlinx.coroutines.CoroutineDispatcher
@@ -44,7 +43,6 @@ class CalendarsViewModel @Inject constructor(
private val repository: CalendarRepository,
private val icsExporter: IcsExporter,
private val settingsPrefs: SettingsPrefs,
private val reminderRecovery: ReminderRecovery,
@IoDispatcher private val io: CoroutineDispatcher,
) : ViewModel() {
@@ -74,6 +72,33 @@ class CalendarsViewModel @Inject constructor(
initialValue = AutoBackupUiState(),
)
/**
* Managed special-dates calendars whose deletion would not stick. While the
* feature is on, the sync owns every mirror: it recreates a missing one for
* an enabled type on the next pass and deletes the leftover of a disabled
* one (`SpecialDatesSyncEngine.reconcileCalendars`), so either way the
* delete would appear to work and then undo itself. Turning special dates
* off empties this set, and deleting a leftover mirror is a real delete from
* then on.
*
* Read off each calendar's own durable marker ([CalendarSource.isManaged],
* the `CAL_SYNC2` one the editor lock already trusts) rather than the stored
* ids, which are only rewritten on the next sync pass — a preferences loss
* would otherwise unlock a live mirror until then.
*/
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
calendars,
settingsPrefs.specialDatesEnabled,
) { sources, enabled ->
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
}
.flowOn(io)
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000L),
initialValue = emptySet(),
)
private val _error = MutableStateFlow(false)
val error: StateFlow<Boolean> = _error.asStateFlow()
@@ -128,24 +153,21 @@ class CalendarsViewModel @Inject constructor(
* reminders. Nothing is patched by hand — the provider notifies and the
* observer re-queries.
*
* Switching one back on also re-posts the reminders it silenced while it was
* off and that are still relevant — those the app kept app-side because it
* may not write the flag ([ReminderRecovery]).
* Nothing has to be re-posted on the way back on: reminder delivery plans
* from `Instances` and `Reminders` on every scan (#75), and the provider
* change this write makes triggers one.
*/
fun setCalendarVisible(id: Long, visible: Boolean) = write {
repository.setCalendarsVisible(listOf(id), visible)
if (visible) reminderRecovery.rePostFor(listOf(id))
}
/**
* Switch every calendar of one account on or off — the "toggle all"
* affordance on an account header. Each row is written on its own (the
* provider only re-arms reminder alarms for a single-id update), in one
* affordance on an account header. Each row is written on its own, in one
* coroutine so the writes can't race each other.
*/
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
repository.setCalendarsVisible(ids, visible)
if (visible) reminderRecovery.rePostFor(ids)
}
// --- Automatic backup (issue #8) ------------------------------------

View File

@@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -42,12 +44,19 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
* account — with the calendars beneath it as a connected card, a colour chip on
* each and a check on the selected one. Emits into the caller's [ColumnScope]
* (a scrolling column), so the caller owns the surrounding chrome.
*
* The list holds event *targets* only, so a calendar that is switched off,
* read-only or managed is silently absent — which reads as a missing calendar
* rather than an excluded one (#76). [onManageCalendars], when given, adds the
* footer row that names the possible reasons and opens the calendar manager,
* where each row then says which one applies.
*/
@Composable
fun ColumnScope.CalendarPickerGroups(
calendars: List<CalendarSource>,
selectedId: Long?,
onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
val local = remember(calendars) { calendars.filter { it.isLocal } }
val syncedGroups = remember(calendars) {
@@ -75,6 +84,23 @@ fun ColumnScope.CalendarPickerGroups(
onSelect = onSelect,
)
}
if (onManageCalendars != null) {
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.calendar_picker_missing_title),
summary = stringResource(R.string.calendar_picker_missing_summary),
position = Position.Alone,
leading = { LeadingAvatar(Icons.Default.VisibilityOff) },
trailing = {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
onClick = onManageCalendars,
)
}
}
/** One account's category header (avatar + name) atop its selectable calendars. */

View File

@@ -89,6 +89,8 @@ import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
@@ -193,6 +195,7 @@ fun EventEditScreen(
initialStartMinutes: Int? = null,
initialForm: EventForm? = null,
initialFormSource: ImportSource = ImportSource.File,
onManageCalendars: (() -> Unit)? = null,
viewModel: EventEditViewModel = hiltViewModel(),
) {
LaunchedEffect(initialDateIso, editKey, initialForm) {
@@ -309,6 +312,7 @@ fun EventEditScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
onManageCalendars = onManageCalendars,
)
}
}
@@ -500,6 +504,7 @@ private fun EventEditContent(
state: EventEditUiState,
viewModel: EventEditViewModel,
modifier: Modifier = Modifier,
onManageCalendars: (() -> Unit)? = null,
) {
val form = state.form
val locale = currentLocale()
@@ -509,6 +514,10 @@ private fun EventEditContent(
// they're locked here; everything else (reminders, location, notes) is the
// user's to edit.
val locked = state.isManaged
// Read in the form's own window, not the picker's: the field holding focus
// lives here, so this is the controller that can put its keyboard away.
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
var picker by remember { mutableStateOf<PickerTarget?>(null) }
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
@@ -1114,6 +1123,17 @@ private fun EventEditContent(
null -> Unit
}
// A full-screen picker over the form is a change of place, so the form's
// keyboard has no business following it there — least of all onto the
// calendar manager, which the picker can hand off to. The form's own field
// keeps its text; only focus and the IME go.
LaunchedEffect(showCalendarPicker) {
if (showCalendarPicker) {
focusManager.clearFocus(force = true)
keyboardController?.hide()
}
}
if (showCalendarPicker) {
CalendarPicker(
calendars = state.calendars,
@@ -1122,6 +1142,17 @@ private fun EventEditContent(
viewModel.setCalendar(it)
showCalendarPicker = false
},
// Close the picker on the way out. It is a Compose Dialog — its own
// window, always above the activity's content — so the manager would
// otherwise open behind it and the tap would look dead. The form
// stays standing underneath, its calendar row one tap from a picker
// that re-queries on open.
onManageCalendars = onManageCalendars?.let { openManager ->
{
showCalendarPicker = false
openManager()
}
},
onDismiss = { showCalendarPicker = false },
)
}
@@ -2277,6 +2308,7 @@ private fun CalendarPicker(
selectedId: Long?,
onSelect: (Long) -> Unit,
onDismiss: () -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
FullScreenPicker(
title = stringResource(R.string.event_detail_calendar),
@@ -2286,6 +2318,7 @@ private fun CalendarPicker(
calendars = calendars,
selectedId = selectedId,
onSelect = onSelect,
onManageCalendars = onManageCalendars,
)
}
}

View File

@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.EventFormField
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.isEventTarget
import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
@@ -177,19 +178,16 @@ class EventEditViewModel @Inject constructor(
repository.calendars().catch { emit(emptyList()) }
/**
* Writable calendars — the only valid event targets. Calendars switched off
* in Settings → Calendars are excluded, so you can't create into one you've
* turned off; a last-used preselect landing on a now-off calendar falls back
* to the first remaining writable one (handled by [resolvedCalendarId] and
* [state]). Managed special-dates calendars are excluded too: their events
* are owned by the contact sync, which would delete any user event created
* there.
* The calendars a new event can be saved to ([isEventTarget]): writable,
* switched on, not a contact-filled mirror, not a non-syncing subscription.
* A last-used preselect landing on an excluded calendar falls back to the
* first remaining one (handled by [resolvedCalendarId] and [state]).
*
* This is the list of *targets*. An event already living in an excluded
* calendar keeps it — [state] adds it back to the picker.
*/
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
calendars.filter { it.canModifyContents && it.isVisibleInSystem && !it.isManaged }
calendars.filter { it.isEventTarget }
}
/** The target calendar id, resolved exactly as the form shows it. */

View File

@@ -75,6 +75,7 @@ fun ImportScreen(
onClose: () -> Unit,
onOpenSingle: (EventForm) -> Unit,
forceMany: Boolean = false,
onManageCalendars: (() -> Unit)? = null,
// Key the VM by the file uri. This screen has no nav backstack, so an
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
// across imports — its one-shot `load` guard would then show the *previous*
@@ -155,7 +156,12 @@ fun ImportScreen(
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
is ImportUiState.Many -> ManyContent(
state = s,
selected = selected,
onSelect = { selected = it },
onManageCalendars = onManageCalendars,
)
is ImportUiState.Done -> DoneContent(s, onClose)
}
}
@@ -163,10 +169,24 @@ fun ImportScreen(
}
@Composable
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
// No writable calendar to import into — tell the user honestly.
private fun ManyContent(
state: ImportUiState.Many,
selected: Long?,
onSelect: (Long) -> Unit,
onManageCalendars: (() -> Unit)? = null,
) {
// No calendar to import into — tell the user honestly, and carry the same
// way out the picker's footer offers below. This is the state that footer
// exists for: every writable calendar being switched off, read-only or
// contact-filled is exactly what empties this list (#76).
if (state.calendars.isEmpty()) {
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
CenteredMessage(
message = stringResource(R.string.import_no_calendar),
onClose = null,
actionLabel = stringResource(R.string.settings_manage_calendars)
.takeIf { onManageCalendars != null },
onAction = onManageCalendars,
)
return
}
@@ -178,6 +198,7 @@ private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (L
calendars = state.calendars,
selectedId = selected,
onSelect = onSelect,
onManageCalendars = onManageCalendars,
)
if (state.warnings.isNotEmpty()) {
Column(
@@ -333,7 +354,12 @@ private fun WarningText(warning: IcsParseWarning) {
}
@Composable
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
private fun CenteredMessage(
message: String,
onClose: (() -> Unit)?,
actionLabel: String? = null,
onAction: (() -> Unit)? = null,
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
Modifier.padding(24.dp),
@@ -344,6 +370,9 @@ private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
if (onClose != null) {
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
}
if (actionLabel != null && onAction != null) {
Button(onClick = onAction) { Text(actionLabel) }
}
}
}
}

View File

@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
import de.jeanlucmakiola.calendula.domain.isEventTarget
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -85,18 +86,14 @@ class ImportViewModel @Inject constructor(
warnings = parsed.warnings,
)
else -> {
// A calendar switched off in Settings → Calendars is off
// everywhere, so it can't be an import target — exclude it
// alongside the read-only ones. Managed special-dates
// calendars are contact-derived and editor-locked, so
// they're not a valid destination either.
// The same targets the event form offers ([isEventTarget]):
// an import is a bulk create, so a calendar that can't hold
// one event can't hold thirty.
ImportUiState.Many(
events = parsed.events,
warnings = parsed.warnings,
calendars = repository.calendars().first()
.filter {
it.canModifyContents && !it.isManaged && it.isVisibleInSystem
},
.filter { it.isEventTarget },
)
}
}

View File

@@ -57,9 +57,12 @@ import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.locale.currentLocale
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
import de.jeanlucmakiola.calendula.ui.common.eventFill
import de.jeanlucmakiola.calendula.domain.spanFirstDay
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
import de.jeanlucmakiola.floret.components.positionOf
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaLocalDate
import java.time.Instant as JavaInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -220,9 +223,14 @@ private fun searchSummary(event: EventInstance): String {
val start = remember(event.start, zone) {
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
}
val dateText = remember(locale) {
// The date comes from the shared span rule, not from [start]: an all-day
// event sits at UTC midnight, so reading its date in the device zone names
// the day before west of UTC (#82). The clock time below stays in the device
// zone — it is only ever rendered for timed events.
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
}.format(start)
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
}
val use24Hour = LocalUse24HourFormat.current
val timeText = if (event.isAllDay) {
stringResource(R.string.event_detail_all_day)

View File

@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Translate
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.UploadFile
@@ -408,7 +409,9 @@ private fun LanguageRow(position: Position) {
@Composable
private fun AboutCard() {
// The card layout lives in floret-kit (components.AboutCard); Calendula
// supplies its own logo, author and the source / licence / support links.
// supplies its own logo, author and the source / licence / privacy / support
// links. The privacy policy has to be reachable from inside the app, not just
// from the store listing, because Calendula touches calendar and contact data.
AboutCard(
logo = { AppLogo() },
appName = stringResource(R.string.app_name),
@@ -424,6 +427,11 @@ private fun AboutCard() {
label = stringResource(R.string.settings_license),
url = stringResource(R.string.about_license_url),
),
AboutLink(
icon = Icons.Default.PrivacyTip,
label = stringResource(R.string.settings_about_privacy),
url = stringResource(R.string.about_privacy_url),
),
),
highlightLink = AboutLink(
icon = Icons.Default.Favorite,

View File

@@ -53,11 +53,11 @@
<string name="event_share_failed">تعذّر مشاركة هذا الحدث.</string>
<string name="event_delete_title">حذف الحدث؟</string>
<string name="event_delete_body">الحدث أُزيل من تقويمك ومن كل جهاز تتم مزامنته معه.</string>
<string name="event_delete_recurring_title">حذف الحدث المتكرر</string>
<string name="event_delete_recurring_title">حذف الحدث المتكرّر</string>
<string name="event_delete_option_occurrence">فقط هذا الحدث</string>
<string name="event_delete_option_following">هذا وجميع الأحداث اللاحقة</string>
<string name="event_delete_option_series">جميع الأحداث في السلسلة</string>
<string name="event_edit_recurring_title">تعديل الحدث المتكرر</string>
<string name="event_edit_recurring_title">تعديل الحدث المتكرّر</string>
<string name="event_delete_failed">تعذّر حذف الحدث</string>
<string name="event_delete_write_denied">Calendula يحتاج إلى صلاحية للكتابة لحذف الأحداث</string>
<string name="dialog_cancel">إلغاء</string>
@@ -108,7 +108,7 @@
<string name="event_edit_conflict_discard_hint">الحدث يبقى كما هو الآن</string>
<string name="import_reminder_prompt_title">هل تريد تطبيق تذكيرك الافتراضي؟</string>
<string name="event_edit_gone_body">هذا الحدث حُذف في هذه الأثناء، على سبيل المثال على جهاز آخر. لم يعد من الممكن حفظ تغييراتك.</string>
<string name="event_edit_more_fields">المزيد من الخيارات</string>
<string name="event_edit_more_fields">المزيد من الخانات</string>
<string name="event_access_public">علني</string>
<string name="event_access_default">الافتراضي</string>
<string name="event_availability_busy">مشغول</string>
@@ -153,7 +153,7 @@
<string name="recurrence_with_until">%1$s حتى %2$s</string>
<string name="recurrence_with_count">%1$s، %2$d مرّات</string>
<string name="import_reminder_prompt_apply">طَبِّق الافتراضي</string>
<string name="event_detail_recurring">حدث مُتكرر</string>
<string name="event_detail_recurring">حدث متكرّر</string>
<string name="recurrence_on_days">%1$s في %2$s</string>
<string name="event_attendee_unknown"></string>
<string name="event_attendee_accepted">تم القُبول</string>
@@ -166,7 +166,7 @@
<string name="event_status_cancelled">ملغي</string>
<string name="event_availability_free">متاح</string>
<string name="event_access_private">خاص</string>
<string name="event_access_confidential">سري</string>
<string name="event_access_confidential">سرّي</string>
<string name="event_attendee_organizer">المُنظِّم</string>
<string name="event_attendee_optional">اختياري</string>
<string name="reminder_default">التذكير الافتراضي</string>
@@ -180,7 +180,7 @@
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
<string name="reminder_benefit_duplicates_body">إذا كان تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
<string name="reminder_benefit_duplicates_body">إذا تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
<string name="reminder_onboarding_enable_button">شَغِّل التذكيرات</string>
<string name="reminder_onboarding_skip_button">ليس الآن</string>
@@ -200,7 +200,7 @@
<string name="search_hint">البحث عن الأحداث</string>
<string name="search_clear">مسح</string>
<string name="agenda_no_more_today">لا مزيد من الأحداث اليوم</string>
<string name="search_empty">لا توجد أحداث مطابقة ”%1$s“.</string>
<string name="search_empty">لا أحداث مطابقة ”%1$s“.</string>
<string name="search_idle_hint">ابحث عن أحداثك بالعنوان، الموقع أو الملاحظات.</string>
<string name="search_back">الرجوع</string>
<string name="search_action">البحث</string>
@@ -217,7 +217,7 @@
<string name="back">رجوع</string>
<string name="settings_section_appearance">المظهر</string>
<string name="settings_dynamic_color">اللون الديناميكي</string>
<string name="settings_dynamic_color_unavailable">يتطلب Android 12 أو أحدث</string>
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
<string name="settings_default_view">طريقة العرض الافتراضية</string>
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
@@ -227,7 +227,7 @@
<string name="settings_font_choose_file">اختر ملفًا…</string>
<string name="settings_font_custom_selected">خط مخصص</string>
<string name="settings_font_import_failed">تعذّر قراءة هذا الملف كخط</string>
<string name="settings_week_start">يبدأ الأسبوع في</string>
<string name="settings_week_start">الأسبوع يبدأ في</string>
<string name="settings_week_start_auto">تلقائي</string>
<string name="settings_time_format_auto">تلقائي</string>
<string name="settings_time_format">تنسيق الوقت</string>
@@ -243,4 +243,58 @@
<string name="settings_past_events_show">إظهار</string>
<string name="settings_past_events_hide">إخفاء</string>
<string name="settings_agenda_header">جدول</string>
<string name="event_edit_timezone_device">المنطقة الزمنية للجهاز</string>
<string name="event_edit_timezone_search">البحث عن المناطق الزمنية</string>
<string name="event_edit_timezone_all">جميع المناطق الزمنية</string>
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق \"%1$s\"</string>
<string name="event_edit_timezone_local_time">%1$s توقيتك</string>
<string name="event_edit_timezone_recent">الأحدث</string>
<string name="event_edit_recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
<plurals name="duration_minutes">
<item quantity="zero">(%d) لا دقائق</item>
<item quantity="one">(%d) دقيقة واحده</item>
<item quantity="two">%d دقيقتان</item>
<item quantity="few">%d دقائق</item>
<item quantity="many">%d دقيقة</item>
<item quantity="other">%d دقيقة</item>
</plurals>
<plurals name="duration_hours">
<item quantity="zero">(%d) لا ساعات</item>
<item quantity="one">(%d) ساعة واحده</item>
<item quantity="two">%d ساعتان</item>
<item quantity="few">%d ساعات</item>
<item quantity="many">%d ساعة</item>
<item quantity="other">%d ساعة</item>
</plurals>
<plurals name="duration_days">
<item quantity="zero">(%d) لا أيام</item>
<item quantity="one">(%d) يوم واحد</item>
<item quantity="two">%d يومان</item>
<item quantity="few">%d أيام</item>
<item quantity="many">%d يوم</item>
<item quantity="other">%d يوم</item>
</plurals>
<plurals name="duration_weeks">
<item quantity="zero">(%d) لا أسابيع</item>
<item quantity="one">(%d) أسبوع واحد</item>
<item quantity="two">%d أسبوعان</item>
<item quantity="few">%d أسابيع</item>
<item quantity="many">%d أسبوع</item>
<item quantity="other">%d أسبوع</item>
</plurals>
<string name="agenda_span_starts">يبدأ %1$s</string>
<string name="agenda_span_ends">ينتهي %1$s</string>
<string name="today_jump_action">الانتقال إلى اليوم</string>
<string name="widget_refresh">تحديث</string>
<string name="settings_app_name">اسم التطبيق</string>
<string name="settings_dim_completed">تعتيم الأحداث المكتملة</string>
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
<string name="settings_today_toolbar">زر اليوم في شريط الأدوات</string>
<string name="settings_today_toolbar_summary">إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم</string>
<string name="settings_app_name_summary">اعرض Calendula كـ \"Calendar\" في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
<string name="settings_past_events_dim">تعتيم</string>
<string name="settings_past_events">الأحداث السابقة</string>
<string name="settings_agenda_range">مدى الجدول</string>
<string name="agenda_range_custom">مخصص…</string>
<string name="agenda_range_custom_hint">أيام</string>
</resources>

View File

@@ -354,7 +354,7 @@
<string name="settings_translate">Pomóż w tłumaczeniu</string>
<string name="settings_translate_hint">Dodaj lub ulepsz tłumaczenie w Weblate</string>
<string name="settings_appearance_subtitle">Motyw, domyślny widok, pierwszy dzień tygodnia</string>
<string name="settings_views_subtitle">Kolejność przycisku szybkiego przełączania oraz menu</string>
<string name="settings_views_subtitle">Układ widoku miesiąca, przycisk szybkiego przełączania, kolejność menu</string>
<string name="settings_event_form_subtitle">Domyślne pola dla nowych wydarzeń</string>
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</string>
<string name="settings_special_dates_subtitle">Urodziny i rocznice kontaktów</string>
@@ -515,4 +515,32 @@
<string name="settings_agenda_show_today_hint">Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań.</string>
<string name="special_dates_calendar_birthday">Urodziny</string>
<string name="special_dates_calendar_anniversary">Rocznice</string>
<string name="event_edit_timezone_device">Strefa czasowa urządzenia</string>
<string name="event_edit_timezone_device_summary">Podąża za Twoją lokalizacją</string>
<string name="event_edit_timezone_search">Znajdź strefę czasową</string>
<string name="event_edit_timezone_recent">Ostatnie</string>
<string name="event_edit_timezone_all">Wszystkie strefy czasowe</string>
<string name="event_edit_timezone_none">Żadna strefa czasowa nie pasuje do „%1$s”</string>
<string name="event_edit_timezone_local_time">%1$s Twojego czasu</string>
<string name="event_edit_recurrence_incomplete">Wpisz liczbę od 1 do 999</string>
<string name="agenda_span_starts">Początek o %1$s</string>
<string name="agenda_span_ends">Koniec o %1$s</string>
<string name="today_jump_action">Dzisiaj</string>
<string name="settings_today_toolbar">Przycisk „Dzisiaj” na pasku narzędzi</string>
<string name="settings_today_toolbar_summary">Pokaż przycisk skoku do dzisiaj na pasku narzędzi zamiast przycisku pływającego</string>
<string name="settings_app_name">Nazwa aplikacji</string>
<string name="settings_app_name_summary">Wyświetlaj Calendula jako „Kalendarz” w menu aplikacji. Zmieni się tylko nazwa w menu; po przełączeniu ikona może pojawić się w innym miejscu.</string>
<string name="settings_month_header">Widok miesiąca</string>
<string name="settings_month_view_style">Styl widoku miesiąca</string>
<string name="month_style_paged">Strony</string>
<string name="month_style_paged_summary">Jeden miesiąc wypełnia ekran. Przesuń w lewo lub w prawo, aby zmienić miesiąc.</string>
<string name="month_style_continuous">Przewijanie miesięcy</string>
<string name="month_style_continuous_summary">Każdy miesiąc znajduje się pod własnym nagłówkiem, oddzielony od następnego niewielkim odstępem.</string>
<string name="month_style_dense">Tygodnie bez przerw</string>
<string name="month_style_dense_summary">Tygodnie są ułożone jeden po drugim, każdy miesiąc płynnie łączy się z następnym, bez odstępu pomiędzy.</string>
<string name="month_style_split">Podzielony</string>
<string name="month_style_split_summary">Zwięzła siatka wyróżnia dni z wydarzeniami a lista dla dnia wybranego dotknięciem wyświetla się poniżej.</string>
<string name="month_split_no_events">Brak planów</string>
<string name="month_split_expand">Pokaż cały miesiąc</string>
<string name="month_split_collapse">Pokaż wydarzenia dnia</string>
</resources>

View File

@@ -462,6 +462,7 @@
<string name="settings_license_value">MIT</string>
<string name="settings_about_author">by Jean-Luc Makiola</string>
<string name="settings_about_source">Source</string>
<string name="settings_about_privacy">Privacy policy</string>
<string name="settings_about_support">Support development</string>
<string name="settings_about_version">Version %1$s</string>
<string name="settings_about_logo_desc">Calendula app icon</string>
@@ -477,6 +478,13 @@
<string name="calendars_visibility_a11y">Show \"%1$s\"</string>
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
<string name="calendars_visibility_notice_message">Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars.</string>
<!-- Footer row under the event-form and .ics import calendar pickers. -->
<string name="calendar_picker_missing_title">Missing a calendar?</string>
<string name="calendar_picker_missing_summary">It may be switched off, read-only, or filled from your contacts — manage your calendars here.</string>
<string name="calendars_state_read_only">Read-only</string>
<string name="calendars_state_not_synced">Not synced to this device</string>
<string name="calendars_state_managed">Filled from your contacts</string>
<string name="calendars_managed_delete_locked">This calendar is filled from your contacts, so Calendula would create it again on the next sync. Turn special dates off under Settings → Special dates to delete it.</string>
<string name="calendars_synced_header">Synced calendars</string>
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
<string name="calendars_manage_in_app">Manage in app</string>
@@ -572,8 +580,9 @@
<string name="settings_qs_tile">Add Quick Settings tile</string>
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</string>
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
<string name="about_source_url" translatable="false">https://codeberg.org/jlmakiola/calendula</string>
<string name="about_license_url" translatable="false">https://codeberg.org/jlmakiola/calendula/src/branch/main/LICENSE</string>
<string name="about_privacy_url" translatable="false">https://jeanlucmakiola.de/calendula/privacy</string>
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>

View File

@@ -1,66 +0,0 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* What the reminder receiver may mark handled. The silenced-but-still-ahead case
* is the one that matters: those rows are the only copy of the reminder (#75).
*/
class AlertHandlingTest {
private val now = 1_700_000_000_000L
private fun alert(
id: Long,
calendarId: Long = 1L,
beginMillis: Long = now + 60_000L,
endMillis: Long = now + 3_600_000L,
) = ReminderAlert(
alertId = id,
eventId = id * 10,
calendarId = calendarId,
beginMillis = beginMillis,
endMillis = endMillis,
title = "E $id",
location = null,
isAllDay = false,
)
@Test
fun `posted alerts are handled`() {
val due = listOf(alert(1L), alert(2L))
assertThat(handledAlertIds(due, postedIds = setOf(1L, 2L), nowMillis = now))
.containsExactly(1L, 2L)
}
@Test
fun `a silenced alert whose event is still ahead stays unhandled`() {
// Switching its calendar back on before the event has to bring it back,
// and dueAlerts only ever returns STATE_SCHEDULED rows.
val due = listOf(alert(1L), alert(2L))
assertThat(handledAlertIds(due, postedIds = setOf(1L), nowMillis = now))
.containsExactly(1L)
}
@Test
fun `a silenced alert whose event is over is handled`() {
// Nothing left to re-surface, so it must not linger as scheduled.
val over = alert(2L, beginMillis = now - 7_200_000L, endMillis = now - 3_600_000L)
val due = listOf(alert(1L), over)
assertThat(handledAlertIds(due, postedIds = setOf(1L), nowMillis = now))
.containsExactly(1L, 2L)
}
@Test
fun `an unknown end time falls back to the begin time`() {
val started = alert(1L, beginMillis = now - 1L, endMillis = 0L)
val notYet = alert(2L, beginMillis = now + 1L, endMillis = 0L)
assertThat(handledAlertIds(listOf(started, notYet), postedIds = emptySet(), nowMillis = now))
.containsExactly(1L)
}
}

View File

@@ -0,0 +1,110 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class CalendarRowStateTest {
private fun cal(
id: Long = 1L,
name: String = "Cal $id",
writable: Boolean = true,
syncsEvents: Boolean = true,
local: Boolean = false,
managed: Boolean = false,
) = CalendarSource(
id = id,
displayName = name,
accountName = "account",
accountType = if (local) "LOCAL" else "com.google",
color = 0,
isVisibleInSystem = true,
canModifyContents = writable,
isLocal = local,
syncsEvents = syncsEvents,
isManaged = managed,
)
@Test
fun `a plain writable calendar carries no state labels`() {
assertThat(cal().stateLabels()).isEmpty()
assertThat(cal().hasVisibilitySwitch).isTrue()
}
@Test
fun `a read-only calendar is labelled`() {
assertThat(cal(writable = false).stateLabels())
.containsExactly(CalendarStateLabel.READ_ONLY)
}
@Test
fun `a non-syncing account calendar is labelled and loses its switch`() {
val calendar = cal(syncsEvents = false)
assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.NOT_SYNCED)
assertThat(calendar.hasVisibilitySwitch).isFalse()
}
@Test
fun `both states can hold at once, read-only first`() {
assertThat(cal(writable = false, syncsEvents = false).stateLabels())
.containsExactly(CalendarStateLabel.READ_ONLY, CalendarStateLabel.NOT_SYNCED)
.inOrder()
}
@Test
fun `a managed special-dates mirror is labelled although it is writable`() {
// Writable, visible, syncing — nothing else on the row would hint at why
// it can't be picked as an event target.
val calendar = cal(local = true, managed = true)
assertThat(calendar.stateLabels()).containsExactly(CalendarStateLabel.MANAGED)
assertThat(calendar.hasVisibilitySwitch).isTrue()
}
@Test
fun `a local calendar is never called not-synced`() {
// Nothing syncs a device-local calendar, so sync_events says nothing
// about it — and another app's local calendar can hold real events at 0.
val calendar = cal(syncsEvents = false, local = true)
assertThat(calendar.isNotSynced).isFalse()
assertThat(calendar.stateLabels()).isEmpty()
assertThat(calendar.hasVisibilitySwitch).isTrue()
}
@Test
fun `every named state keeps a calendar out of the pickers`() {
// The labels and the picker exclusion are the same set, stated twice —
// a labelled row the pickers still offered would make the footer's
// "manage your calendars to see why" a lie (#76).
assertThat(cal().isEventTarget).isTrue()
listOf(
cal(writable = false),
cal(syncsEvents = false),
cal(local = true, managed = true),
).forEach { calendar ->
assertThat(calendar.stateLabels()).isNotEmpty()
assertThat(calendar.isEventTarget).isFalse()
}
}
@Test
fun `a switched-off calendar is no target although it carries no label`() {
// The switch is right there on the row, so the state speaks for itself.
val calendar = cal().copy(isVisibleInSystem = false)
assertThat(calendar.stateLabels()).isEmpty()
assertThat(calendar.isEventTarget).isFalse()
}
@Test
fun `manager order puts non-syncing calendars last and is otherwise stable`() {
val ordered = listOf(
cal(id = 1L, name = "Anna", syncsEvents = false),
cal(id = 2L, name = "Bert"),
cal(id = 3L, name = "Cleo", syncsEvents = false),
cal(id = 4L, name = "Dana"),
).orderedForManager()
assertThat(ordered.map { it.displayName })
.containsExactly("Bert", "Dana", "Anna", "Cleo")
.inOrder()
}
}

View File

@@ -0,0 +1,93 @@
package de.jeanlucmakiola.calendula.domain
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlin.time.Instant
import org.junit.jupiter.api.Test
/**
* The all-day day-boundary rule every surface shares. All-day events are stored
* at UTC midnights with an exclusive end, so their dates must be resolved in UTC
* whatever the device zone is — reading them in the device zone names the wrong
* day on both sides of the meridian (#65 east, #82 west).
*/
class EventInstanceSpanTest {
private val berlin = TimeZone.of("Europe/Berlin") // UTC+2 in July
private val newYork = TimeZone.of("America/New_York") // UTC-4 in July
/** 19 July 2026, all day: UTC midnight to the exclusive next UTC midnight. */
private fun allDayJul19(): EventInstance = instance(
start = utc(2026, 7, 19),
end = utc(2026, 7, 20),
isAllDay = true,
)
private fun utc(y: Int, mo: Int, d: Int, h: Int = 0): Instant =
LocalDateTime(y, mo, d, h, 0).toInstant(TimeZone.UTC)
private fun instance(start: Instant, end: Instant, isAllDay: Boolean) = EventInstance(
instanceId = 1L,
eventId = 1L,
calendarId = 1L,
title = "Event",
start = start,
end = end,
isAllDay = isAllDay,
color = 0xFF000000.toInt(),
location = null,
)
@Test
fun `all-day event keeps its date west of UTC`() {
// Regression for #82: 00:00 UTC on the 19th is 20:00 on the 18th in New
// York, so resolving in the device zone would name the 18th.
val event = allDayJul19()
assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
assertThat(event.spansMultipleDays(newYork)).isFalse()
}
@Test
fun `all-day event keeps its date east of UTC`() {
// Regression for #65: the exclusive end dips past local midnight in
// Berlin, which would leak the event onto the 20th.
val event = allDayJul19()
assertThat(event.spanFirstDay(berlin)).isEqualTo(LocalDate(2026, 7, 19))
assertThat(event.spanLastDay(berlin)).isEqualTo(LocalDate(2026, 7, 19))
assertThat(event.spansMultipleDays(berlin)).isFalse()
}
@Test
fun `multi-day all-day event ends on its last covered day`() {
val event = instance(utc(2026, 7, 19), utc(2026, 7, 22), isAllDay = true)
assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 21))
assertThat(event.spansMultipleDays(newYork)).isTrue()
}
@Test
fun `timed event resolves in the device zone`() {
// 23:30 UTC on the 19th is already the 20th in Berlin and still the 19th
// in New York — a timed event follows the device zone, unlike all-day.
val event = instance(utc(2026, 7, 19, 23), utc(2026, 7, 20, 1), isAllDay = false)
assertThat(event.spanFirstDay(berlin)).isEqualTo(LocalDate(2026, 7, 20))
assertThat(event.spanFirstDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
}
@Test
fun `zero-length event occupies its start day`() {
val event = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 12), isAllDay = false)
assertThat(event.spanLastDay(newYork)).isEqualTo(LocalDate(2026, 7, 19))
}
@Test
fun `dateZone pins all-day events to UTC and leaves timed events alone`() {
assertThat(allDayJul19().dateZone(newYork)).isEqualTo(TimeZone.UTC)
val timed = instance(utc(2026, 7, 19, 12), utc(2026, 7, 19, 13), isAllDay = false)
assertThat(timed.dateZone(newYork)).isEqualTo(newYork)
}
}

View File

@@ -0,0 +1,465 @@
package de.jeanlucmakiola.calendula.domain.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
/**
* The decision layer of in-house reminder delivery (#75). The watermark rules are
* the load-bearing part: they are what makes a dropped alarm recoverable and a
* double scan harmless, now that no provider row records "already fired".
*/
class ReminderPlanTest {
private val now = 1_700_000_000_000L
private val minute = 60_000L
private val day = 24 * 60 * minute
private fun instance(
eventId: Long,
beginMillis: Long = now + 30 * minute,
endMillis: Long = now + 90 * minute,
calendarId: Long = 7L,
isAllDay: Boolean = false,
) = ReminderEventInstance(
eventId = eventId,
calendarId = calendarId,
beginMillis = beginMillis,
endMillis = endMillis,
title = "Event $eventId",
location = null,
isAllDay = isAllDay,
)
/** [planReminders] with the fixtures' zone and all-day hour filled in. */
private fun plan(
instances: List<ReminderEventInstance>,
minutesByEvent: Map<Long, List<Int>>,
zone: ZoneId = berlin,
allDayTimeMinutes: Int = nineAm,
) = planReminders(instances, minutesByEvent, zone, allDayTimeMinutes)
@Test
fun `a reminder fires its offset before the occurrence begins`() {
val begin = now + 30 * minute
val planned = plan(
instances = listOf(instance(1L, beginMillis = begin)),
minutesByEvent = mapOf(1L to listOf(10)),
)
assertThat(planned.map { it.alarmMillis }).containsExactly(begin - 10 * minute)
}
// --- all-day: the hour the user picked, on every occurrence -------------
private val berlin = ZoneId.of("Europe/Berlin")
private val nineAm = 540
/** UTC midnight of [date] — how the provider stores an all-day occurrence. */
private fun allDayBegin(date: String): Long =
LocalDate.parse(date).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
private fun firedAt(alarmMillis: Long, zone: ZoneId = berlin): ZonedDateTime =
java.time.Instant.ofEpochMilli(alarmMillis).atZone(zone)
/**
* The offset AllDayReminderEncoding would store for "[days] before, at
* [timeOfDayMinutes]" when sampled against [eventDate] — i.e. exactly the row
* the app writes today.
*/
private fun encodedAllDayMinutes(
eventDate: String,
days: Long,
timeOfDayMinutes: Int = nineAm,
zone: ZoneId = berlin,
): Int {
val date = LocalDate.parse(eventDate)
val utcMidnight = date.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli()
val fire = date.minusDays(days)
.atTime(LocalTime.of(timeOfDayMinutes / 60, timeOfDayMinutes % 60))
.atZone(zone).toInstant().toEpochMilli()
return ((utcMidnight - fire) / minute).toInt()
}
private fun allDayAlarm(eventDate: String, rawMinutes: Int, zone: ZoneId = berlin): Long =
plan(
instances = listOf(
instance(1L, beginMillis = allDayBegin(eventDate), isAllDay = true),
),
minutesByEvent = mapOf(1L to listOf(rawMinutes)),
zone = zone,
allDayTimeMinutes = nineAm,
).single().alarmMillis
@Test
fun `an all-day reminder fires at the hour the setting names`() {
// Winter: Berlin is UTC+1. "1 day before" on the 15th means the 14th, 09:00.
val alarm = allDayAlarm("2026-01-15", encodedAllDayMinutes("2026-01-15", days = 1))
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-01-14").atTime(9, 0))
}
@Test
fun `a summer occurrence of a row written in winter is not an hour early`() {
// The drift this replaces: the offset was sampled at UTC+1, the occurrence
// falls at UTC+2, and firing at `begin - minutes` would land at 08:00.
val winterRow = encodedAllDayMinutes("2026-01-15", days = 1)
val alarm = allDayAlarm("2026-07-15", winterRow)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `a winter occurrence of a row written in summer is not an hour late`() {
// The same drift in the other direction: sampled at UTC+2, fires at UTC+1.
val summerRow = encodedAllDayMinutes("2026-07-15", days = 1)
val alarm = allDayAlarm("2026-01-15", summerRow)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-01-14").atTime(9, 0))
}
@Test
fun `every occurrence of a yearly all-day series fires at the same wall clock`() {
// A birthday's offset is sampled once; the series must not walk off it.
val row = encodedAllDayMinutes("2026-07-15", days = 1)
val fired = listOf("2026-07-15", "2027-01-15", "2027-07-15", "2028-01-15")
.map { firedAt(allDayAlarm(it, row)).toLocalTime() }
assertThat(fired.toSet()).containsExactly(LocalTime.of(9, 0))
}
@Test
fun `an all-day reminder on the day itself fires that morning`() {
// "At time of event" on an all-day event encodes to a negative offset.
val sameDay = encodedAllDayMinutes("2026-07-15", days = 0)
assertThat(sameDay).isLessThan(0)
val alarm = allDayAlarm("2026-07-15", sameDay)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-15").atTime(9, 0))
}
@Test
fun `a plain 1440 row from another calendar app means one day before`() {
// Foreign rows carry no encoded hour. Read at face value they would fire
// at UTC midnight — 02:00 local in summer Berlin.
val alarm = allDayAlarm("2026-07-15", rawMinutes = 1_440)
assertThat(firedAt(alarm).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `a plain 1440 row west of UTC still means one day before`() {
// UTC midnight of the 15th is the evening of the 14th in New York, so a
// local-date reading of the offset would put this two days out.
val newYork = ZoneId.of("America/New_York")
val alarm = allDayAlarm("2026-07-15", rawMinutes = 1_440, zone = newYork)
assertThat(firedAt(alarm, newYork).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `an encoded row west of UTC fires at the named hour`() {
val newYork = ZoneId.of("America/New_York")
val row = encodedAllDayMinutes("2026-07-15", days = 1, zone = newYork)
val alarm = allDayAlarm("2026-07-15", row, zone = newYork)
assertThat(firedAt(alarm, newYork).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-07-14").atTime(9, 0))
}
@Test
fun `a zero-day row fires on the event's own date`() {
assertThat(allDayLeadDays(rawMinutes = 0, beginMillis = allDayBegin("2026-07-15"), zone = berlin))
.isEqualTo(0L)
}
@Test
fun `a timed reminder is exact across a DST boundary`() {
// Nothing to re-anchor: begin is an absolute instant either way.
val begin = LocalDate.parse("2026-03-29").atTime(14, 0)
.atZone(berlin).toInstant().toEpochMilli()
val planned = plan(
instances = listOf(instance(1L, beginMillis = begin, isAllDay = false)),
minutesByEvent = mapOf(1L to listOf(30)),
zone = berlin,
allDayTimeMinutes = nineAm,
)
assertThat(firedAt(planned.single().alarmMillis).toLocalDateTime())
.isEqualTo(LocalDate.parse("2026-03-29").atTime(13, 30))
}
@Test
fun `every occurrence of a series gets its own reminder`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + day),
instance(1L, beginMillis = now + 2 * day),
),
minutesByEvent = mapOf(1L to listOf(15)),
)
assertThat(planned.map { it.alarmMillis })
.containsExactly(now + day - 15 * minute, now + 2 * day - 15 * minute)
assertThat(planned.map { it.key }.toSet()).hasSize(2)
}
@Test
fun `duplicate reminder rows collapse to one`() {
val planned = plan(
instances = listOf(instance(1L)),
minutesByEvent = mapOf(1L to listOf(10, 10)),
)
assertThat(planned).hasSize(1)
}
@Test
fun `an event with no reminders plans nothing`() {
val planned = plan(
instances = listOf(instance(1L)),
minutesByEvent = emptyMap(),
)
assertThat(planned).isEmpty()
}
@Test
fun `the same reminder keeps its key across scans`() {
val plan = {
plan(listOf(instance(1L)), mapOf(1L to listOf(10))).single().key
}
assertThat(plan()).isEqualTo(plan())
}
@Test
fun `occurrences of one series get different keys`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + day),
instance(1L, beginMillis = now + 2 * day),
),
minutesByEvent = mapOf(1L to listOf(15)),
)
assertThat(planned[0].key).isNotEqualTo(planned[1].key)
}
@Test
fun `two reminders on one occurrence get different keys`() {
val planned = plan(
instances = listOf(instance(1L)),
minutesByEvent = mapOf(1L to listOf(10, 30)),
)
assertThat(planned[0].key).isNotEqualTo(planned[1].key)
}
@Test
fun `a reminder whose moment has passed since the last scan is due`() {
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 10 * minute, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).hasSize(1)
}
@Test
fun `a reminder already covered by the watermark does not fire twice`() {
// The scan runs again (a provider change, a reboot) after the alarm that
// already posted this one. Nothing records "fired" but the watermark.
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 4 * minute, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `a reminder exactly on the watermark does not fire again`() {
val begin = now + 5 * minute
val planned = plan(
instances = listOf(instance(1L, beginMillis = begin)),
minutesByEvent = mapOf(1L to listOf(10)),
)
val alarm = planned.single().alarmMillis
val schedule = scheduleReminders(
planned, lastFiredMillis = alarm, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `a missed alarm is caught up by a much later scan`() {
// The device was off over the reminder; the scan on boot must still post it
// while the event is ahead. This is what the provider path could never do.
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 5 * minute)),
minutesByEvent = mapOf(1L to listOf(60)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 5 * day, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).hasSize(1)
}
@Test
fun `a reminder for an occurrence that already ended is dropped`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now - 3 * 60 * minute, endMillis = now - 2 * 60 * minute),
),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - 5 * day, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `an occurrence with no end falls back to its begin for relevance`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now - minute, endMillis = 0L),
),
minutesByEvent = mapOf(1L to listOf(10)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.due).isEmpty()
}
@Test
fun `due reminders come out in occurrence order`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + 20 * minute),
instance(2L, beginMillis = now + 5 * minute),
),
minutesByEvent = mapOf(1L to listOf(30), 2L to listOf(30)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now - day, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.due.map { it.instance.eventId }).containsExactly(2L, 1L).inOrder()
}
@Test
fun `the next wake-up is the earliest reminder still ahead`() {
val planned = plan(
instances = listOf(
instance(1L, beginMillis = now + 20 * minute),
instance(2L, beginMillis = now + 90 * minute),
),
minutesByEvent = mapOf(1L to listOf(5), 2L to listOf(5)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.nextAlarmMillis).isEqualTo(now + 15 * minute)
}
@Test
fun `with nothing pending the scan still re-runs at the horizon`() {
val schedule = scheduleReminders(
planned = emptyList(), lastFiredMillis = now, nowMillis = now,
horizonMillis = now + day,
)
assertThat(schedule.nextAlarmMillis).isEqualTo(now + day)
}
@Test
fun `a reminder beyond the horizon waits for the next scan`() {
// Capping keeps the rolling window honest: the far-off reminder is picked
// up by a later scan rather than pinned to an alarm we may never re-check.
val planned = plan(
instances = listOf(instance(1L, beginMillis = now + 30 * day)),
minutesByEvent = mapOf(1L to listOf(5)),
)
val schedule = scheduleReminders(
planned, lastFiredMillis = now, nowMillis = now, horizonMillis = now + day,
)
assertThat(schedule.nextAlarmMillis).isEqualTo(now + day)
}
@Test
fun `the query horizon stretches past the longest reminder offset`() {
// A "2 weeks before" reminder has to be planned while its event is still
// outside the plain lookahead, or it fires late.
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = 20_160))
.isEqualTo(7 * day + 14 * day)
}
@Test
fun `a first-ever scan starts from now, not from the epoch`() {
// Otherwise an install — or the upgrade onto in-house delivery — treats
// every reminder ever set as overdue and posts the lot.
assertThat(reminderWatermark(lastScanMillis = null, nowMillis = now)).isEqualTo(now)
}
@Test
fun `an ordinary watermark is used as recorded`() {
assertThat(reminderWatermark(lastScanMillis = now - day, nowMillis = now))
.isEqualTo(now - day)
}
@Test
fun `a watermark left in the future by a clock change is clamped`() {
// Left alone it would silence every reminder until real time caught up.
assertThat(reminderWatermark(lastScanMillis = now + 5 * day, nowMillis = now))
.isEqualTo(now)
}
@Test
fun `a negative longest offset does not shrink the query horizon`() {
assertThat(reminderQueryHorizon(lookaheadMillis = 7 * day, maxReminderMinutes = -420))
.isEqualTo(7 * day)
}
}

View File

@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.agenda
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.spansMultipleDays
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone

View File

@@ -45,9 +45,14 @@ class EventEditViewModelTest {
private val beginMillis = 1_781_164_800_000L
private val endMillis = beginMillis + 3_600_000L
private fun cal(id: Long, visible: Boolean = true): CalendarSource = CalendarSource(
private fun cal(
id: Long,
visible: Boolean = true,
syncsEvents: Boolean = true,
): CalendarSource = CalendarSource(
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
syncsEvents = syncsEvents,
)
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
@@ -108,6 +113,27 @@ class EventEditViewModelTest {
job.cancel()
}
@Test
fun `a calendar whose account is not synced to this device is not a target`(
@TempDir tempDir: Path,
) = runTest(dispatcher) {
// Writable and switched on, but the account keeps its events off the
// device: nothing saved here ever reaches it, and the provider drops the
// rows when the subscription comes back (#76).
val fake = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L), cal(2L, syncsEvents = false))
eventDetailResult = { detail(calendarId = 1L) }
}
val vm = viewModel(tempDir, fake)
val job = activate(vm)
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
advanceUntilIdle()
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L)
job.cancel()
}
@Test
fun `editing an event in a switched-off calendar keeps it in the picker`(
@TempDir tempDir: Path,

View File

@@ -33,7 +33,7 @@ flowchart TD
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"]
Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"]
Rem["reminders/\nReminderAlertStore + ReminderNotifier"]
Rem["reminders/\nReminderScanner + ReminderNotifier"]
end
Provider[("CalendarContract\n(system calendar provider)")]
@@ -137,29 +137,56 @@ can't fake a conflict.
## Reminder delivery
The provider schedules reminder alarms (for `METHOD_ALERT` rows only) and
broadcasts `EVENT_REMINDER` — but posts no notification; a calendar app
must (the Etar model):
Calendula plans and fires its own reminders. It reads the offsets in
`Reminders` as data, works out when each occurrence's reminder is due, and holds
**one** exact alarm for the earliest one still ahead:
```mermaid
sequenceDiagram
participant P as CalendarProvider
participant R as EventReminderReceiver
participant S as ReminderAlertStore
participant T as Trigger (alarm / boot / time change / edit / launch / daily worker)
participant Sc as ReminderScanner
participant Src as ReminderInstanceSource
participant P as ReminderPlan (pure)
participant N as ReminderNotifier
P->>R: EVENT_REMINDER broadcast (manifest receiver, exported)
R->>S: dueAlerts(now) — CalendarAlerts: SCHEDULED, alarmTime ≤ now
S-->>R: due alerts
R->>N: post(alert) — one notification per alert, tag = alert id
R->>S: markFired(ids) — best effort, needs WRITE_CALENDAR
participant A as ReminderAlarmScheduler
T->>Sc: scan()
Sc->>Src: occurrences(window) + reminderMinutes(ids)
Src-->>Sc: Instances ⋈ Reminders
Sc->>P: planReminders / scheduleReminders(watermark, now)
P-->>Sc: due + next alarm
Sc->>N: post(alert) — tag = reminder key
Sc->>A: scheduleScan(next)
```
Posting happens before marking: a crash in between re-posts silently (same
tag + `setOnlyAlertOnce`) rather than losing a reminder. Swiped
notifications never return because `FIRED` rows are never re-queried.
**Why not the provider's broadcast.** It used to schedule the alarms, write the
`CalendarAlerts` rows and broadcast `EVENT_REMINDER`, and the app only reacted.
That chain holds on stock Android and demonstrably not everywhere: AOSP's own
unbundled calendar carries three separate workarounds for OEM providers that
retarget the broadcast or only write the alert row at alert time. A reacting app
cannot tell "nothing was due" from "the broadcast never came" (#75) — and the
reporter's silent events were in a calendar Calendula created itself, so
`VISIBLE` was never the cause there.
**One visibility model.** The provider only schedules alarms for calendars with
`Calendars.VISIBLE = 1`, so that flag *is* the app's on/off switch: Settings →
**The watermark replaces `CalendarAlerts.STATE`.** A scan posts the reminders
whose moment falls in `(lastScan, now]`, then moves the mark
(`ReminderStatePrefs`). Half-open, so a scan that runs twice cannot post twice,
while a scan that runs *late* still posts what the missed alarm owed — a reboot,
an app update or a doze window costs nothing. A first-ever scan claims the
present rather than the epoch, and a watermark left in the future by a clock
change is clamped. Every trigger runs the same idempotent `scan()`, so there is
no ordering between them to get wrong; `BOOT_COMPLETED` and `MY_PACKAGE_REPLACED`
matter because both wipe pending alarms.
**All-day reminders fire at the hour the setting names.** The stored offset is
not a plain lead time — `AllDayReminderEncoding` folds a wall-clock hour into it,
sampled against one date's UTC offset — so taking it at face value drifts by the
offset delta across a DST boundary, and rows from other apps carry no hour at
all. The offset is therefore read only for *which day* it means; the hour comes
from the global all-day reminder setting, recomposed against each occurrence's
own date. Timed reminders need none of this: `begin` is an absolute instant.
**One visibility model.** The scan only plans occurrences of calendars with
`Calendars.VISIBLE = 1`, and that flag *is* the app's on/off switch: Settings →
Calendars writes it (one calendar per update — `CalendarProvider2` skips its own
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
display predicate reads `CalendarSource.isVisibleInSystem`. The reconciliation
@@ -171,16 +198,14 @@ the app has not been allowed to write yet (read-only permission grant, or a
pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
and until it does, the repository and `ReminderNotifier.post` honour it. That
gate also covers a snooze re-shown from our own alarm after its calendar was
switched off. Silencing is not handling: an alert the gate drops keeps its
`SCHEDULED` state while its event is still ahead (`handledAlertIds`), so
switching the calendar back on re-posts it (`ReminderRecovery`) instead of
losing it — the provider's own table is the stash. The drawer's filter sheet
(`CalendarPrefs.hiddenCalendarIds`) is a separate in-app declutter that never
touches reminders.
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
separate in-app declutter that never touches reminders.
Deliberately absent until real devices prove it necessary: own alarm
scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption
prompts.
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
Keeping both would double-post wherever the provider works, and Etar's way out —
a latch that disables its own scheduling once a real broadcast arrives — cannot
be copied, because our failure mode includes a broadcast that arrives with no
alert row behind it.
## Testing

View File

@@ -73,7 +73,8 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
CI and release are split so a change is built once on its PR and only does
release work when a merge actually cuts a release:
- **`ci.yaml`** (on `pull_request`) — lint + unit tests + a debug assemble (and
- **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) — lint +
unit tests + a debug assemble (and
a Trivy scan), once per PR. Docs/metadata-only PRs skip the Android build but
still report a green `CI` check.
- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap
@@ -91,9 +92,9 @@ release work when a merge actually cuts a release:
Alongside F-Droid, each release is mirrored to the Codeberg repo
(`jlmakiola/calendula`) as a plain download for users who don't want F-Droid.
Gitea already **push-mirrors** branches and tags to Codeberg, but releases
aren't git objects and don't sync, so the pipeline creates the release over the
Codeberg API and attaches `calendula_v<version>.apk` + its `.sha256`. It's the
Codeberg **push-mirrors** branches and tags to Gitea, but releases aren't git
objects and don't sync in either direction, so the pipeline creates the release
over the Codeberg API and attaches `calendula_v<version>.apk` + its `.sha256`. It's the
same APK the F-Droid repo serves (same **app key**), so it adds no trust
surface. The step is best-effort: a Codeberg outage never fails an
already-published F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is
@@ -109,6 +110,39 @@ build, the version bump, and tag/release creation, and just re-signs the
existing F-Droid index with the configured repo key and re-uploads. Use this
for key rotation or repo recovery without publishing a new app version.
## Two forges, one repo
**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and
releases. The self-hosted Gitea instance is build infrastructure: it holds the
signing key, publishes the F-Droid repo, and runs the release pipeline. Codeberg
push-mirrors `main` and tags to Gitea, and a bumped `versionName` arriving there
triggers `release.yaml` exactly as before.
Workflows are separated by **directory**, not by conditionals. Forgejo looks in
`.forgejo/workflows` → `.gitea/workflows` → `.github/workflows` and stops at the
first that exists; Gitea doesn't know `.forgejo/` at all:
| Directory | Runs on | Contains | Secrets |
| --- | --- | --- | --- |
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | signing key, F-Droid, Play, bot tokens |
The line is drawn at **secrets, not at CI-vs-release**. That's what makes fork
PRs safe: everything a contributor can trigger lives in `.forgejo/` and can
reference no secret. Renovate stays on the Gitea runner *even though it opens
PRs on Codeberg* — it talks to Codeberg's API rather than moving its token onto
the contributor-facing runner.
Two consequences worth remembering:
- **`detect` reads tags from Codeberg**, not from the Gitea instance it runs on.
Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by
the next sync until the Codeberg tag push propagates back. Asking Gitea inside
that window would re-cut a shipped release.
- **Any ref that exists only on Gitea gets deleted** by the mirror. That's
correct under Codeberg-canonical, but don't debug a "vanished" branch without
remembering it.
## Secrets (Gitea → repo Settings → Actions → Secrets)
| Secret | Purpose |

View File

@@ -18,9 +18,9 @@ Categories:
- Calendar & Agenda
License: MIT
AuthorName: Jean-Luc Makiola
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
SourceCode: https://codeberg.org/jlmakiola/calendula
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
Changelog: https://codeberg.org/jlmakiola/calendula/src/branch/main/CHANGELOG.md
Donate: https://ko-fi.com/jeanlucmakiola
AutoName: Calendula

View File

@@ -6,6 +6,6 @@ Summary: A modern Material 3 Expressive calendar for Android.
Categories:
- Time
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
SourceCode: https://codeberg.org/jlmakiola/calendula
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
Donate: https://ko-fi.com/jeanlucmakiola

View File

@@ -7,6 +7,23 @@
":semanticCommits",
],
// `config:recommended` brings in mergeConfidence:age-confidence-badges, whose
// Age column is a Mend badge. Mend's Merge Confidence index only covers Maven
// Central: org.jetbrains.kotlin, junit, truth, turbine et al resolve, but
// every androidx/compose artifact lives on Google's Maven repo and comes back
// as a grey UNKNOWN — i.e. most of this project. Renovate already knows the
// real answer, since it derives release timestamps itself for the
// minimumReleaseAge rules below (Google Maven serves `last-modified` on its
// POMs), so take the age from there and leave Mend to the Confidence column,
// which still carries signal for the Maven Central half.
prBodyDefinitions: {
Age: "{{#if releaseTimestamp}}{{{newVersionAgeInDays}}} d{{else}}unknown{{/if}}",
},
// Default heading links to the Merge Confidence docs; this column is ours now.
prBodyHeadingDefinitions: {
Age: "Age",
},
// No automerge: a dependency bump goes through the same review (and, for
// anything touching the build, the same on-device check) as a feature
// before it can ride a release — see docs/RELEASING.md and the
@@ -15,6 +32,22 @@
// One reviewable surface; the dashboard issue lists everything pending.
dependencyDashboard: true,
// The cooling-off periods below are advisory, not a gate. "flexible" still
// prefers a version that has cleared its window, but when every candidate is
// too young it opens the PR at the newest one anyway, so merging early stays
// a judgement call. (The default, "strict", would suppress the PR entirely
// until a release aged in.) A still-young branch carries a yellow
// `renovate/stability-days` check so it's visible which side of the line
// it's on; with automerge off, nothing acts on that check by itself.
//
// NOT "none": that short-circuits the candidate loop in filter-checks.ts, and
// that loop is what calls postprocessRelease — the only thing that fetches a
// Maven artifact's Last-Modified header. Skipping it leaves releaseTimestamp
// unset, which empties the Age column and quietly makes minimumReleaseAge and
// the stability check no-ops, since both need that timestamp to compare.
internalChecksFilter: "flexible",
labels: ["dependencies"],
prConcurrentLimit: 5,
prHourlyLimit: 0,
@@ -25,11 +58,28 @@
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
// the github-actions manager (same syntax) to watch them too.
// `fileMatch` is deprecated; the replacement takes the regex delimited, and
// Renovate's config migration was already rewriting this on every run.
"github-actions": {
fileMatch: ["^\\.gitea/workflows/[^/]+\\.ya?ml$"],
managerFilePatterns: ["/^\\.gitea/workflows/[^/]+\\.ya?ml$/"],
},
packageRules: [
// Cooling-off period, scaled by blast radius: how long a release should
// have been out (and un-yanked, un-hotfixed) before it's considered
// settled. Advisory only — see `internalChecksFilter` above.
{
matchUpdateTypes: ["major"],
minimumReleaseAge: "30 days",
},
{
matchUpdateTypes: ["minor"],
minimumReleaseAge: "20 days",
},
{
matchUpdateTypes: ["patch", "digest", "pin", "rollback"],
minimumReleaseAge: "10 days",
},
// material3 is deliberately pinned to the 1.5 *alpha* line for the
// Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
// but keep it in its own PR, reviewed in isolation; revisit the pin when
@@ -52,5 +102,18 @@
],
groupName: "test dependencies",
},
// Last word on the PR table. The merge-confidence preset sets prBodyColumns
// from inside a packageRule of its own, and only for the datasources Mend
// supports — so a plain top-level prBodyColumns would lose to it for maven
// deps, and the Gradle wrapper / Actions / container bumps would keep the
// default columns and show no age at all. A rule declared after it wins,
// and gives every PR the same table.
// "Pending" earns its place under a flexible filter: when the bump lands on
// a version that has cleared its window but a newer one hasn't, that newer
// version is named here rather than silently withheld.
{
matchPackageNames: ["*"],
prBodyColumns: ["Package", "Type", "Change", "Age", "Pending", "Confidence"],
},
],
}