Compare commits
11 Commits
b91c13030b
...
a1f091a88b
| Author | SHA1 | Date | |
|---|---|---|---|
| a1f091a88b | |||
| 1515beff4c | |||
| 0676a58d1a | |||
|
|
1d07b64a28 | ||
| d037492cf6 | |||
|
|
db7094c54e | ||
| c70412c782 | |||
| 8e2109d073 | |||
| ac0c43f930 | |||
| 314236ac0c | |||
| c6e83fc071 |
@@ -72,9 +72,14 @@ jobs:
|
|||||||
distribution: 'zulu'
|
distribution: 'zulu'
|
||||||
java-version: '17'
|
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
|
- name: Setup Android SDK
|
||||||
if: steps.scope.outputs.code == 'true'
|
if: steps.scope.outputs.code == 'true'
|
||||||
uses: android-actions/setup-android@v3
|
uses: https://github.com/android-actions/setup-android@v3
|
||||||
with:
|
with:
|
||||||
# Default ("tools platform-tools") drags in the Android Emulator
|
# Default ("tools platform-tools") drags in the Android Emulator
|
||||||
# (~300 MB) which the build never uses.
|
# (~300 MB) which the build never uses.
|
||||||
@@ -27,6 +27,14 @@ jobs:
|
|||||||
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
||||||
# heavy job from running on every merge to main.
|
# heavy job from running on every merge to main.
|
||||||
detect:
|
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
|
runs-on: docker
|
||||||
outputs:
|
outputs:
|
||||||
is_release: ${{ steps.v.outputs.is_release }}
|
is_release: ${{ steps.v.outputs.is_release }}
|
||||||
@@ -41,8 +49,16 @@ jobs:
|
|||||||
- name: Resolve version and whether it is a new release
|
- name: Resolve version and whether it is a new release
|
||||||
id: v
|
id: v
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
# 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: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||||
@@ -60,15 +76,28 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
# A tag for this version already existing means the release shipped on
|
# A tag for this version already existing means the release shipped on
|
||||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
#
|
||||||
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
|
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||||
if [ "$STATUS" = "200" ]; then
|
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||||
echo "Tag v$VERSION already exists — nothing to release."
|
# read as absent and re-cut a release that has already shipped —
|
||||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
# republishing to F-Droid and Play. Failing here is recoverable; a
|
||||||
else
|
# duplicate release is not.
|
||||||
echo "No tag for v$VERSION yet — cutting the release."
|
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
case "$STATUS" in
|
||||||
fi
|
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.
|
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||||
|
|||||||
@@ -29,14 +29,30 @@ jobs:
|
|||||||
- name: Run Renovate
|
- name: Run Renovate
|
||||||
run: renovate
|
run: renovate
|
||||||
env:
|
env:
|
||||||
# Self-hosted Gitea, not github.com.
|
# Renovate targets Codeberg (canonical) while still RUNNING on the
|
||||||
RENOVATE_PLATFORM: gitea
|
# Gitea runner. Moving the job to Codeberg would put a repo-write
|
||||||
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
|
# token on the contributor-facing runner, which is exactly what the
|
||||||
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
|
# .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 }}
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
# Scope to this repo only — no org-wide autodiscovery.
|
# Scope to this repo only — no org-wide autodiscovery.
|
||||||
RENOVATE_AUTODISCOVER: 'false'
|
RENOVATE_AUTODISCOVER: 'false'
|
||||||
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
|
RENOVATE_REPOSITORIES: '["jlmakiola/calendula"]'
|
||||||
# Commits/PRs authored as the bot, not a real maintainer.
|
# 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>'
|
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
|
LOG_LEVEL: info
|
||||||
|
|||||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
|||||||
[submodule "floret-kit"]
|
[submodule "floret-kit"]
|
||||||
path = floret-kit
|
path = floret-kit
|
||||||
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||||
|
|||||||
54
CHANGELOG.md
54
CHANGELOG.md
@@ -7,7 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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
|
- 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
|
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
|
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
|
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
||||||
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
||||||
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
[#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
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||||
|
|
||||||
<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/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/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">
|
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
<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
|
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||||
returns null and the calendar manager's per-account "manage" button can't
|
returns null and the calendar manager's per-account "manage" button can't
|
||||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||||
@@ -270,17 +278,22 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
||||||
no notification itself — a calendar app must (v1.4, Etar model).
|
Instances + Reminders instead of waiting for the provider's
|
||||||
Exported: the broadcast arrives from the provider's process. -->
|
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
|
<receiver
|
||||||
android:name=".data.reminders.EventReminderReceiver"
|
android:name=".data.reminders.ReminderScheduleReceiver"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.EVENT_REMINDER" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
<data
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
android:host="com.android.calendar"
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
android:scheme="content" />
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
|||||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
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.CrashConfig
|
||||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -41,6 +43,25 @@ class CalendulaApp : Application() {
|
|||||||
reconcileAutoBackup()
|
reconcileAutoBackup()
|
||||||
reconcileSpecialDates()
|
reconcileSpecialDates()
|
||||||
reconcileCalendarVisibility()
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ class CalendarVisibilityReconciler @Inject constructor(
|
|||||||
// drain and nothing left to decide, so don't pay for the query.
|
// drain and nothing left to decide, so don't pay for the query.
|
||||||
if (pending.isEmpty() && noticeSettled) return@withContext
|
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||||
val calendars = dataSource.calendars()
|
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))
|
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
||||||
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||||
return@withContext
|
return@withContext
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
|
|||||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -46,9 +46,9 @@ abstract class DataBindModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindReminderAlertStore(
|
abstract fun bindReminderInstanceSource(
|
||||||
impl: AndroidReminderAlertStore,
|
impl: ProviderReminderInstanceSource,
|
||||||
): ReminderAlertStore
|
): ReminderInstanceSource
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,14 +18,14 @@ import javax.inject.Inject
|
|||||||
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
* intents (notification action buttons and our own [ReminderSnoozeScheduler]
|
||||||
* alarm), so the receiver is not exported.
|
* alarm), so the receiver is not exported.
|
||||||
*
|
*
|
||||||
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||||
* already fired, so nothing re-posts it.
|
* past this reminder, so nothing re-posts it.
|
||||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||||
* it after the user's snooze delay.
|
* it after the user's snooze delay.
|
||||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
* - **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
|
* or dismiss it again — unless the calendar was switched off during the
|
||||||
* snooze, which [ReminderNotifier.post] catches (this alarm is ours, so no
|
* snooze, which [ReminderNotifier.post] catches — this alarm is its own
|
||||||
* provider alert row stands between it and the notification).
|
* trigger, outside the ordinary scan.
|
||||||
*/
|
*/
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class ReminderActionReceiver : BroadcastReceiver() {
|
class ReminderActionReceiver : BroadcastReceiver() {
|
||||||
@@ -75,7 +75,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
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_EVENT_ID = "event_id"
|
||||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||||
private const val EXTRA_BEGIN = "begin"
|
private const val EXTRA_BEGIN = "begin"
|
||||||
@@ -88,7 +95,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||||
this.action = action
|
this.action = action
|
||||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||||
@@ -99,23 +106,29 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stable request code per (alert, action) so the three PendingIntents
|
* A stable request code per (alert, action) so one notification's
|
||||||
* of one notification stay distinct and don't clobber each other.
|
* 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 {
|
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||||
val actionOffset = when (action) {
|
val actionOffset = when (action) {
|
||||||
ACTION_SNOOZE -> 1
|
ACTION_SNOOZE -> 1
|
||||||
ACTION_DISMISS -> 2
|
ACTION_DISMISS -> 2
|
||||||
ACTION_SHOW -> 3
|
ACTION_SHOW -> 3
|
||||||
|
ACTION_OPEN -> 4
|
||||||
else -> 0
|
else -> 0
|
||||||
}
|
}
|
||||||
return alert.alertId.toInt() * 8 + actionOffset
|
return (alert.key.toInt() shl 3) + actionOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||||
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||||
return ReminderAlert(
|
return ReminderAlert(
|
||||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||||
|
|||||||
@@ -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 31–32.
|
||||||
|
*/
|
||||||
|
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 31–32 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,11 +29,13 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Posts one notification per due reminder alert on a dedicated channel.
|
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||||
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
* the event's detail screen.
|
||||||
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
*
|
||||||
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||||
* duplicating it.
|
* 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
|
@Singleton
|
||||||
class ReminderNotifier @Inject constructor(
|
class ReminderNotifier @Inject constructor(
|
||||||
@@ -52,13 +54,12 @@ class ReminderNotifier @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single choke point for "this calendar is switched off". The provider
|
* The single choke point for "this calendar is switched off". The scan
|
||||||
* side needs no help — with `VISIBLE = 0` it creates no alert rows at all —
|
* already filters on `Calendars.VISIBLE`, but two paths reach [post] around
|
||||||
* but two paths reach [post] without one: a snooze we re-show from our own
|
* it: a snooze re-shown from its own alarm, armed before the calendar was
|
||||||
* exact alarm, scheduled before the calendar was switched off, and a
|
* switched off, and a read-only install whose switch lives app-side
|
||||||
* read-only install whose switch lives app-side ([CalendarPrefs]) because it
|
* ([CalendarPrefs]) because it may not write the flag. Both are covered here
|
||||||
* may not write the flag. Both are covered here rather than in either
|
* rather than in either receiver.
|
||||||
* receiver.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||||
@@ -66,8 +67,8 @@ class ReminderNotifier @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Post [alert], unless its calendar is switched off. Returns whether the
|
* Post [alert], unless its calendar is switched off. Returns whether the
|
||||||
* notification was put up: a silenced alert must stay unhandled so that
|
* notification was put up, which the snooze re-show path uses to tell a
|
||||||
* switching the calendar back on can still surface it (see [handledAlertIds]).
|
* silenced reminder from a delivered one.
|
||||||
*/
|
*/
|
||||||
suspend fun post(alert: ReminderAlert): Boolean {
|
suspend fun post(alert: ReminderAlert): Boolean {
|
||||||
if (isSilenced(alert.calendarId)) return false
|
if (isSilenced(alert.calendarId)) return false
|
||||||
@@ -117,7 +118,7 @@ class ReminderNotifier @Inject constructor(
|
|||||||
.build()
|
.build()
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context)
|
NotificationManagerCompat.from(context)
|
||||||
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
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). */
|
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||||
fun cancel(alert: ReminderAlert) {
|
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 =
|
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||||
@@ -141,7 +142,11 @@ class ReminderNotifier @Inject constructor(
|
|||||||
|
|
||||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||||
context,
|
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),
|
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,9 +12,12 @@ import javax.inject.Singleton
|
|||||||
/**
|
/**
|
||||||
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
* Schedules a one-off exact alarm that re-shows a snoozed reminder.
|
||||||
*
|
*
|
||||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
* Separate from [ReminderAlarmScheduler]'s scan alarm, and deliberately so: a
|
||||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
* snooze is pinned to one reminder at a time the user chose, while the scan
|
||||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
* 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
|
* 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
|
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||||
* exact-alarm capability (API 31–32 where the user revoked it).
|
* exact-alarm capability (API 31–32 where the user revoked it).
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
package de.jeanlucmakiola.calendula.domain
|
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
|
import kotlin.time.Instant
|
||||||
|
|
||||||
data class CalendarSource(
|
data class CalendarSource(
|
||||||
@@ -72,6 +76,35 @@ data class EventInstance(
|
|||||||
*/
|
*/
|
||||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
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(
|
data class EventDetail(
|
||||||
val instance: EventInstance,
|
val instance: EventInstance,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -415,6 +415,7 @@ fun CalendarHost(
|
|||||||
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
initialStartMinutes = createStartMinutes ?: heldCreateMinutes,
|
||||||
onClose = { createDateIso = null },
|
onClose = { createDateIso = null },
|
||||||
onSaved = { createDateIso = null },
|
onSaved = { createDateIso = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,6 +435,7 @@ fun CalendarHost(
|
|||||||
editKey = null
|
editKey = null
|
||||||
detailKey = 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
|
// Import flow for an opened/received .ics file. A single event routes
|
||||||
// into the create form (prefilled, for review); many open the picker.
|
// into the create form (prefilled, for review); many open the picker.
|
||||||
importUri?.let { uri ->
|
importUri?.let { uri ->
|
||||||
@@ -469,6 +459,7 @@ fun CalendarHost(
|
|||||||
uri = uri,
|
uri = uri,
|
||||||
forceMany = importForceMany,
|
forceMany = importForceMany,
|
||||||
onClose = { importUri = null },
|
onClose = { importUri = null },
|
||||||
|
onManageCalendars = { showCalendars = true },
|
||||||
onOpenSingle = { form ->
|
onOpenSingle = { form ->
|
||||||
importUri = null
|
importUri = null
|
||||||
importFormSource = ImportSource.File
|
importFormSource = ImportSource.File
|
||||||
@@ -483,6 +474,27 @@ fun CalendarHost(
|
|||||||
initialFormSource = importFormSource,
|
initialFormSource = importFormSource,
|
||||||
onClose = { importForm = null },
|
onClose = { importForm = null },
|
||||||
onSaved = { 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 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,14 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
|||||||
|
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
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.DateTimeUnit
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
import kotlinx.datetime.plus
|
import kotlinx.datetime.plus
|
||||||
import kotlinx.datetime.toLocalDateTime
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import kotlin.time.Instant
|
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 —
|
* 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
|
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.CalendarMonth
|
|||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Cloud
|
import androidx.compose.material.icons.filled.Cloud
|
||||||
import androidx.compose.material.icons.filled.Delete
|
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.FileDownload
|
||||||
import androidx.compose.material.icons.filled.FileUpload
|
import androidx.compose.material.icons.filled.FileUpload
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
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.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
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.data.calendar.CalendarColorPalette
|
||||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
@@ -139,6 +146,7 @@ fun CalendarsScreen(
|
|||||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||||
|
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||||
@@ -157,6 +165,7 @@ fun CalendarsScreen(
|
|||||||
initialName = editing?.displayName.orEmpty(),
|
initialName = editing?.displayName.orEmpty(),
|
||||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||||
initialDescription = editing?.description.orEmpty(),
|
initialDescription = editing?.description.orEmpty(),
|
||||||
|
deleteLocked = editing != null && editing.id in deleteLockedIds,
|
||||||
onSave = { name, color, description ->
|
onSave = { name, color, description ->
|
||||||
val id = editorId
|
val id = editorId
|
||||||
if (id == null || id == NEW_CALENDAR_ID) {
|
if (id == null || id == NEW_CALENDAR_ID) {
|
||||||
@@ -305,7 +314,7 @@ private fun CalendarsList(
|
|||||||
val disabled = !calendar.isVisibleInSystem
|
val disabled = !calendar.isVisibleInSystem
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
title = calendar.displayName,
|
||||||
summary = calendar.description,
|
summary = calendarRowSummary(calendar),
|
||||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
@@ -327,9 +336,9 @@ private fun CalendarsList(
|
|||||||
// safety net. Offered only when there is something exportable: the user's
|
// safety net. Offered only when there is something exportable: the user's
|
||||||
// own local calendars (managed special-dates mirrors don't count).
|
// own local calendars (managed special-dates mirrors don't count).
|
||||||
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
||||||
// Restore/import can target any writable, non-managed calendar (local or
|
// Restore/import can target any calendar the import picker would offer
|
||||||
// synced), so its availability is broader than export's.
|
// (local or synced), so its availability is broader than export's.
|
||||||
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
|
val canImport = (local + synced).any { it.isEventTarget }
|
||||||
if (exportable.isNotEmpty()) {
|
if (exportable.isNotEmpty()) {
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||||
@@ -412,7 +421,11 @@ private fun CalendarsList(
|
|||||||
.forEach { (account, cals) ->
|
.forEach { (account, cals) ->
|
||||||
val expanded = account !in collapsedAccounts
|
val expanded = account !in collapsedAccounts
|
||||||
val accountType = cals.first().accountType
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
CalendarGroup(
|
CalendarGroup(
|
||||||
title = account,
|
title = account,
|
||||||
@@ -432,24 +445,36 @@ private fun CalendarsList(
|
|||||||
collapsedAccounts - account
|
collapsedAccounts - account
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showToggleAll = true,
|
showToggleAll = switchable.isNotEmpty(),
|
||||||
allEnabled = cals.all { it.isVisibleInSystem },
|
allEnabled = switchable.all { it.isVisibleInSystem },
|
||||||
onToggleAll = { enabled -> onSetAccountVisible(cals.map { it.id }, enabled) },
|
onToggleAll = { enabled ->
|
||||||
|
onSetAccountVisible(switchable.map { it.id }, enabled)
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
cals.forEachIndexed { index, calendar ->
|
// Calendars you can act on first; the ones this device isn't
|
||||||
val disabled = !calendar.isVisibleInSystem
|
// syncing sit at the bottom, dimmed and switchless.
|
||||||
|
val ordered = cals.orderedForManager()
|
||||||
|
ordered.forEachIndexed { index, calendar ->
|
||||||
|
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||||
GroupedRow(
|
GroupedRow(
|
||||||
title = calendar.displayName,
|
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,
|
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||||
dimmed = disabled,
|
dimmed = disabled,
|
||||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||||
trailing = {
|
trailing = if (calendar.hasVisibilitySwitch) {
|
||||||
EnableSwitch(
|
{
|
||||||
calendarName = calendar.displayName,
|
EnableSwitch(
|
||||||
enabled = !disabled,
|
calendarName = calendar.displayName,
|
||||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
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,
|
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
|
deleteLocked: Boolean = false,
|
||||||
) {
|
) {
|
||||||
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||||
@@ -590,11 +616,23 @@ private fun CalendarEditor(
|
|||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (!isNew) {
|
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(
|
Icon(
|
||||||
Icons.Default.Delete,
|
Icons.Default.Delete,
|
||||||
contentDescription = stringResource(R.string.event_detail_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),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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)) {
|
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
||||||
InlineTextField(
|
InlineTextField(
|
||||||
value = name,
|
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 per-row on/off control, writing the system's `Calendars.VISIBLE`: checked
|
||||||
* = the calendar is shown, unchecked = it drops out of every surface (events,
|
* = the calendar is shown, unchecked = it drops out of every surface (events,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
|||||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
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.CalendarSource
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -44,7 +43,6 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
private val repository: CalendarRepository,
|
private val repository: CalendarRepository,
|
||||||
private val icsExporter: IcsExporter,
|
private val icsExporter: IcsExporter,
|
||||||
private val settingsPrefs: SettingsPrefs,
|
private val settingsPrefs: SettingsPrefs,
|
||||||
private val reminderRecovery: ReminderRecovery,
|
|
||||||
@IoDispatcher private val io: CoroutineDispatcher,
|
@IoDispatcher private val io: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -74,6 +72,33 @@ class CalendarsViewModel @Inject constructor(
|
|||||||
initialValue = AutoBackupUiState(),
|
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)
|
private val _error = MutableStateFlow(false)
|
||||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
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
|
* reminders. Nothing is patched by hand — the provider notifies and the
|
||||||
* observer re-queries.
|
* observer re-queries.
|
||||||
*
|
*
|
||||||
* Switching one back on also re-posts the reminders it silenced while it was
|
* Nothing has to be re-posted on the way back on: reminder delivery plans
|
||||||
* off and that are still relevant — those the app kept app-side because it
|
* from `Instances` and `Reminders` on every scan (#75), and the provider
|
||||||
* may not write the flag ([ReminderRecovery]).
|
* change this write makes triggers one.
|
||||||
*/
|
*/
|
||||||
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
||||||
repository.setCalendarsVisible(listOf(id), visible)
|
repository.setCalendarsVisible(listOf(id), visible)
|
||||||
if (visible) reminderRecovery.rePostFor(listOf(id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch every calendar of one account on or off — the "toggle all"
|
* 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
|
* affordance on an account header. Each row is written on its own, in one
|
||||||
* provider only re-arms reminder alarms for a single-id update), in one
|
|
||||||
* coroutine so the writes can't race each other.
|
* coroutine so the writes can't race each other.
|
||||||
*/
|
*/
|
||||||
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
||||||
repository.setCalendarsVisible(ids, visible)
|
repository.setCalendarsVisible(ids, visible)
|
||||||
if (visible) reminderRecovery.rePostFor(ids)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Automatic backup (issue #8) ------------------------------------
|
// --- Automatic backup (issue #8) ------------------------------------
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
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.Cloud
|
||||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
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
|
* 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]
|
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||||
* (a scrolling column), so the caller owns the surrounding chrome.
|
* (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
|
@Composable
|
||||||
fun ColumnScope.CalendarPickerGroups(
|
fun ColumnScope.CalendarPickerGroups(
|
||||||
calendars: List<CalendarSource>,
|
calendars: List<CalendarSource>,
|
||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||||
val syncedGroups = remember(calendars) {
|
val syncedGroups = remember(calendars) {
|
||||||
@@ -75,6 +84,23 @@ fun ColumnScope.CalendarPickerGroups(
|
|||||||
onSelect = onSelect,
|
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. */
|
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ import androidx.compose.ui.graphics.isSpecified
|
|||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@@ -193,6 +195,7 @@ fun EventEditScreen(
|
|||||||
initialStartMinutes: Int? = null,
|
initialStartMinutes: Int? = null,
|
||||||
initialForm: EventForm? = null,
|
initialForm: EventForm? = null,
|
||||||
initialFormSource: ImportSource = ImportSource.File,
|
initialFormSource: ImportSource = ImportSource.File,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
viewModel: EventEditViewModel = hiltViewModel(),
|
viewModel: EventEditViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||||
@@ -309,6 +312,7 @@ fun EventEditScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -500,6 +504,7 @@ private fun EventEditContent(
|
|||||||
state: EventEditUiState,
|
state: EventEditUiState,
|
||||||
viewModel: EventEditViewModel,
|
viewModel: EventEditViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val form = state.form
|
val form = state.form
|
||||||
val locale = currentLocale()
|
val locale = currentLocale()
|
||||||
@@ -509,6 +514,10 @@ private fun EventEditContent(
|
|||||||
// they're locked here; everything else (reminders, location, notes) is the
|
// they're locked here; everything else (reminders, location, notes) is the
|
||||||
// user's to edit.
|
// user's to edit.
|
||||||
val locked = state.isManaged
|
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 picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||||
@@ -1114,6 +1123,17 @@ private fun EventEditContent(
|
|||||||
null -> Unit
|
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) {
|
if (showCalendarPicker) {
|
||||||
CalendarPicker(
|
CalendarPicker(
|
||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
@@ -1122,6 +1142,17 @@ private fun EventEditContent(
|
|||||||
viewModel.setCalendar(it)
|
viewModel.setCalendar(it)
|
||||||
showCalendarPicker = false
|
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 },
|
onDismiss = { showCalendarPicker = false },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2277,6 +2308,7 @@ private fun CalendarPicker(
|
|||||||
selectedId: Long?,
|
selectedId: Long?,
|
||||||
onSelect: (Long) -> Unit,
|
onSelect: (Long) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
FullScreenPicker(
|
FullScreenPicker(
|
||||||
title = stringResource(R.string.event_detail_calendar),
|
title = stringResource(R.string.event_detail_calendar),
|
||||||
@@ -2286,6 +2318,7 @@ private fun CalendarPicker(
|
|||||||
calendars = calendars,
|
calendars = calendars,
|
||||||
selectedId = selectedId,
|
selectedId = selectedId,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
|||||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||||
import de.jeanlucmakiola.calendula.domain.problems
|
import de.jeanlucmakiola.calendula.domain.problems
|
||||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||||
@@ -177,19 +178,16 @@ class EventEditViewModel @Inject constructor(
|
|||||||
repository.calendars().catch { emit(emptyList()) }
|
repository.calendars().catch { emit(emptyList()) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writable calendars — the only valid event targets. Calendars switched off
|
* The calendars a new event can be saved to ([isEventTarget]): writable,
|
||||||
* in Settings → Calendars are excluded, so you can't create into one you've
|
* switched on, not a contact-filled mirror, not a non-syncing subscription.
|
||||||
* turned off; a last-used preselect landing on a now-off calendar falls back
|
* A last-used preselect landing on an excluded calendar falls back to the
|
||||||
* to the first remaining writable one (handled by [resolvedCalendarId] and
|
* first remaining one (handled by [resolvedCalendarId] and [state]).
|
||||||
* [state]). Managed special-dates calendars are excluded too: their events
|
|
||||||
* are owned by the contact sync, which would delete any user event created
|
|
||||||
* there.
|
|
||||||
*
|
*
|
||||||
* This is the list of *targets*. An event already living in an excluded
|
* This is the list of *targets*. An event already living in an excluded
|
||||||
* calendar keeps it — [state] adds it back to the picker.
|
* calendar keeps it — [state] adds it back to the picker.
|
||||||
*/
|
*/
|
||||||
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
|
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. */
|
/** The target calendar id, resolved exactly as the form shows it. */
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ fun ImportScreen(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onOpenSingle: (EventForm) -> Unit,
|
onOpenSingle: (EventForm) -> Unit,
|
||||||
forceMany: Boolean = false,
|
forceMany: Boolean = false,
|
||||||
|
onManageCalendars: (() -> Unit)? = null,
|
||||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
// 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
|
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||||
// across imports — its one-shot `load` guard would then show the *previous*
|
// 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.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), 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)
|
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,10 +169,24 @@ fun ImportScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
private fun ManyContent(
|
||||||
// No writable calendar to import into — tell the user honestly.
|
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()) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +198,7 @@ private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (L
|
|||||||
calendars = state.calendars,
|
calendars = state.calendars,
|
||||||
selectedId = selected,
|
selectedId = selected,
|
||||||
onSelect = onSelect,
|
onSelect = onSelect,
|
||||||
|
onManageCalendars = onManageCalendars,
|
||||||
)
|
)
|
||||||
if (state.warnings.isNotEmpty()) {
|
if (state.warnings.isNotEmpty()) {
|
||||||
Column(
|
Column(
|
||||||
@@ -333,7 +354,12 @@ private fun WarningText(warning: IcsParseWarning) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(
|
Column(
|
||||||
Modifier.padding(24.dp),
|
Modifier.padding(24.dp),
|
||||||
@@ -344,6 +370,9 @@ private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
|||||||
if (onClose != null) {
|
if (onClose != null) {
|
||||||
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||||
}
|
}
|
||||||
|
if (actionLabel != null && onAction != null) {
|
||||||
|
Button(onClick = onAction) { Text(actionLabel) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
|||||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||||
|
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -85,18 +86,14 @@ class ImportViewModel @Inject constructor(
|
|||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
)
|
)
|
||||||
else -> {
|
else -> {
|
||||||
// A calendar switched off in Settings → Calendars is off
|
// The same targets the event form offers ([isEventTarget]):
|
||||||
// everywhere, so it can't be an import target — exclude it
|
// an import is a bulk create, so a calendar that can't hold
|
||||||
// alongside the read-only ones. Managed special-dates
|
// one event can't hold thirty.
|
||||||
// calendars are contact-derived and editor-locked, so
|
|
||||||
// they're not a valid destination either.
|
|
||||||
ImportUiState.Many(
|
ImportUiState.Many(
|
||||||
events = parsed.events,
|
events = parsed.events,
|
||||||
warnings = parsed.warnings,
|
warnings = parsed.warnings,
|
||||||
calendars = repository.calendars().first()
|
calendars = repository.calendars().first()
|
||||||
.filter {
|
.filter { it.isEventTarget },
|
||||||
it.canModifyContents && !it.isManaged && it.isVisibleInSystem
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,12 @@ import de.jeanlucmakiola.floret.components.Position
|
|||||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
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.LocalUse24HourFormat
|
||||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||||
import de.jeanlucmakiola.floret.components.positionOf
|
import de.jeanlucmakiola.floret.components.positionOf
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import java.time.Instant as JavaInstant
|
import java.time.Instant as JavaInstant
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
@@ -220,9 +223,14 @@ private fun searchSummary(event: EventInstance): String {
|
|||||||
val start = remember(event.start, zone) {
|
val start = remember(event.start, zone) {
|
||||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(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)
|
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||||
}.format(start)
|
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||||
|
}
|
||||||
val use24Hour = LocalUse24HourFormat.current
|
val use24Hour = LocalUse24HourFormat.current
|
||||||
val timeText = if (event.isAllDay) {
|
val timeText = if (event.isAllDay) {
|
||||||
stringResource(R.string.event_detail_all_day)
|
stringResource(R.string.event_detail_all_day)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Keyboard
|
|||||||
import androidx.compose.material.icons.filled.Language
|
import androidx.compose.material.icons.filled.Language
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Palette
|
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.Translate
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material.icons.filled.UploadFile
|
import androidx.compose.material.icons.filled.UploadFile
|
||||||
@@ -408,7 +409,9 @@ private fun LanguageRow(position: Position) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AboutCard() {
|
private fun AboutCard() {
|
||||||
// The card layout lives in floret-kit (components.AboutCard); Calendula
|
// 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(
|
AboutCard(
|
||||||
logo = { AppLogo() },
|
logo = { AppLogo() },
|
||||||
appName = stringResource(R.string.app_name),
|
appName = stringResource(R.string.app_name),
|
||||||
@@ -424,6 +427,11 @@ private fun AboutCard() {
|
|||||||
label = stringResource(R.string.settings_license),
|
label = stringResource(R.string.settings_license),
|
||||||
url = stringResource(R.string.about_license_url),
|
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(
|
highlightLink = AboutLink(
|
||||||
icon = Icons.Default.Favorite,
|
icon = Icons.Default.Favorite,
|
||||||
|
|||||||
@@ -53,11 +53,11 @@
|
|||||||
<string name="event_share_failed">تعذّر مشاركة هذا الحدث.</string>
|
<string name="event_share_failed">تعذّر مشاركة هذا الحدث.</string>
|
||||||
<string name="event_delete_title">حذف الحدث؟</string>
|
<string name="event_delete_title">حذف الحدث؟</string>
|
||||||
<string name="event_delete_body">الحدث أُزيل من تقويمك ومن كل جهاز تتم مزامنته معه.</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_occurrence">فقط هذا الحدث</string>
|
||||||
<string name="event_delete_option_following">هذا وجميع الأحداث اللاحقة</string>
|
<string name="event_delete_option_following">هذا وجميع الأحداث اللاحقة</string>
|
||||||
<string name="event_delete_option_series">جميع الأحداث في السلسلة</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_failed">تعذّر حذف الحدث</string>
|
||||||
<string name="event_delete_write_denied">Calendula يحتاج إلى صلاحية للكتابة لحذف الأحداث</string>
|
<string name="event_delete_write_denied">Calendula يحتاج إلى صلاحية للكتابة لحذف الأحداث</string>
|
||||||
<string name="dialog_cancel">إلغاء</string>
|
<string name="dialog_cancel">إلغاء</string>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
<string name="event_edit_conflict_discard_hint">الحدث يبقى كما هو الآن</string>
|
<string name="event_edit_conflict_discard_hint">الحدث يبقى كما هو الآن</string>
|
||||||
<string name="import_reminder_prompt_title">هل تريد تطبيق تذكيرك الافتراضي؟</string>
|
<string name="import_reminder_prompt_title">هل تريد تطبيق تذكيرك الافتراضي؟</string>
|
||||||
<string name="event_edit_gone_body">هذا الحدث حُذف في هذه الأثناء، على سبيل المثال على جهاز آخر. لم يعد من الممكن حفظ تغييراتك.</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_public">علني</string>
|
||||||
<string name="event_access_default">الافتراضي</string>
|
<string name="event_access_default">الافتراضي</string>
|
||||||
<string name="event_availability_busy">مشغول</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_until">%1$s حتى %2$s</string>
|
||||||
<string name="recurrence_with_count">%1$s، %2$d مرّات</string>
|
<string name="recurrence_with_count">%1$s، %2$d مرّات</string>
|
||||||
<string name="import_reminder_prompt_apply">طَبِّق الافتراضي</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="recurrence_on_days">%1$s في %2$s</string>
|
||||||
<string name="event_attendee_unknown">—</string>
|
<string name="event_attendee_unknown">—</string>
|
||||||
<string name="event_attendee_accepted">تم القُبول</string>
|
<string name="event_attendee_accepted">تم القُبول</string>
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
<string name="event_status_cancelled">ملغي</string>
|
<string name="event_status_cancelled">ملغي</string>
|
||||||
<string name="event_availability_free">متاح</string>
|
<string name="event_availability_free">متاح</string>
|
||||||
<string name="event_access_private">خاص</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_organizer">المُنظِّم</string>
|
||||||
<string name="event_attendee_optional">اختياري</string>
|
<string name="event_attendee_optional">اختياري</string>
|
||||||
<string name="reminder_default">التذكير الافتراضي</string>
|
<string name="reminder_default">التذكير الافتراضي</string>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
||||||
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
|
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
|
||||||
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</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_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
|
||||||
<string name="reminder_onboarding_enable_button">شَغِّل التذكيرات</string>
|
<string name="reminder_onboarding_enable_button">شَغِّل التذكيرات</string>
|
||||||
<string name="reminder_onboarding_skip_button">ليس الآن</string>
|
<string name="reminder_onboarding_skip_button">ليس الآن</string>
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
<string name="search_hint">البحث عن الأحداث</string>
|
<string name="search_hint">البحث عن الأحداث</string>
|
||||||
<string name="search_clear">مسح</string>
|
<string name="search_clear">مسح</string>
|
||||||
<string name="agenda_no_more_today">لا مزيد من الأحداث اليوم</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_idle_hint">ابحث عن أحداثك بالعنوان، الموقع أو الملاحظات.</string>
|
||||||
<string name="search_back">الرجوع</string>
|
<string name="search_back">الرجوع</string>
|
||||||
<string name="search_action">البحث</string>
|
<string name="search_action">البحث</string>
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
<string name="back">رجوع</string>
|
<string name="back">رجوع</string>
|
||||||
<string name="settings_section_appearance">المظهر</string>
|
<string name="settings_section_appearance">المظهر</string>
|
||||||
<string name="settings_dynamic_color">اللون الديناميكي</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_default_view">طريقة العرض الافتراضية</string>
|
||||||
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
|
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
|
||||||
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
<string name="settings_font_choose_file">اختر ملفًا…</string>
|
<string name="settings_font_choose_file">اختر ملفًا…</string>
|
||||||
<string name="settings_font_custom_selected">خط مخصص</string>
|
<string name="settings_font_custom_selected">خط مخصص</string>
|
||||||
<string name="settings_font_import_failed">تعذّر قراءة هذا الملف كخط</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_week_start_auto">تلقائي</string>
|
||||||
<string name="settings_time_format_auto">تلقائي</string>
|
<string name="settings_time_format_auto">تلقائي</string>
|
||||||
<string name="settings_time_format">تنسيق الوقت</string>
|
<string name="settings_time_format">تنسيق الوقت</string>
|
||||||
@@ -243,4 +243,58 @@
|
|||||||
<string name="settings_past_events_show">إظهار</string>
|
<string name="settings_past_events_show">إظهار</string>
|
||||||
<string name="settings_past_events_hide">إخفاء</string>
|
<string name="settings_past_events_hide">إخفاء</string>
|
||||||
<string name="settings_agenda_header">جدول</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>
|
</resources>
|
||||||
|
|||||||
@@ -354,7 +354,7 @@
|
|||||||
<string name="settings_translate">Pomóż w tłumaczeniu</string>
|
<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_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_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_event_form_subtitle">Domyślne pola dla nowych wydarzeń</string>
|
||||||
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</string>
|
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</string>
|
||||||
<string name="settings_special_dates_subtitle">Urodziny i rocznice kontaktów</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="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_birthday">Urodziny</string>
|
||||||
<string name="special_dates_calendar_anniversary">Rocznice</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>
|
</resources>
|
||||||
|
|||||||
@@ -462,6 +462,7 @@
|
|||||||
<string name="settings_license_value">MIT</string>
|
<string name="settings_license_value">MIT</string>
|
||||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||||
<string name="settings_about_source">Source</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_support">Support development</string>
|
||||||
<string name="settings_about_version">Version %1$s</string>
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
<string name="settings_about_logo_desc">Calendula app icon</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_a11y">Show \"%1$s\"</string>
|
||||||
<string name="calendars_visibility_notice_title">Some calendars are switched off</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>
|
<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_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_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>
|
<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">Add Quick Settings tile</string>
|
||||||
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</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_source_url" translatable="false">https://codeberg.org/jlmakiola/calendula</string>
|
||||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</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_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>
|
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
|||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||||
|
import de.jeanlucmakiola.calendula.domain.spansMultipleDays
|
||||||
import kotlinx.datetime.LocalDate
|
import kotlinx.datetime.LocalDate
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
import kotlinx.datetime.TimeZone
|
import kotlinx.datetime.TimeZone
|
||||||
|
|||||||
@@ -45,9 +45,14 @@ class EventEditViewModelTest {
|
|||||||
private val beginMillis = 1_781_164_800_000L
|
private val beginMillis = 1_781_164_800_000L
|
||||||
private val endMillis = beginMillis + 3_600_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",
|
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||||
color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
|
color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
|
||||||
|
syncsEvents = syncsEvents,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
||||||
@@ -108,6 +113,27 @@ class EventEditViewModelTest {
|
|||||||
job.cancel()
|
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
|
@Test
|
||||||
fun `editing an event in a switched-off calendar keeps it in the picker`(
|
fun `editing an event in a switched-off calendar keeps it in the picker`(
|
||||||
@TempDir tempDir: Path,
|
@TempDir tempDir: Path,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ flowchart TD
|
|||||||
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
|
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
|
||||||
DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"]
|
DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"]
|
||||||
Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"]
|
Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"]
|
||||||
Rem["reminders/\nReminderAlertStore + ReminderNotifier"]
|
Rem["reminders/\nReminderScanner + ReminderNotifier"]
|
||||||
end
|
end
|
||||||
Provider[("CalendarContract\n(system calendar provider)")]
|
Provider[("CalendarContract\n(system calendar provider)")]
|
||||||
|
|
||||||
@@ -137,29 +137,56 @@ can't fake a conflict.
|
|||||||
|
|
||||||
## Reminder delivery
|
## Reminder delivery
|
||||||
|
|
||||||
The provider schedules reminder alarms (for `METHOD_ALERT` rows only) and
|
Calendula plans and fires its own reminders. It reads the offsets in
|
||||||
broadcasts `EVENT_REMINDER` — but posts no notification; a calendar app
|
`Reminders` as data, works out when each occurrence's reminder is due, and holds
|
||||||
must (the Etar model):
|
**one** exact alarm for the earliest one still ahead:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant P as CalendarProvider
|
participant T as Trigger (alarm / boot / time change / edit / launch / daily worker)
|
||||||
participant R as EventReminderReceiver
|
participant Sc as ReminderScanner
|
||||||
participant S as ReminderAlertStore
|
participant Src as ReminderInstanceSource
|
||||||
|
participant P as ReminderPlan (pure)
|
||||||
participant N as ReminderNotifier
|
participant N as ReminderNotifier
|
||||||
P->>R: EVENT_REMINDER broadcast (manifest receiver, exported)
|
participant A as ReminderAlarmScheduler
|
||||||
R->>S: dueAlerts(now) — CalendarAlerts: SCHEDULED, alarmTime ≤ now
|
T->>Sc: scan()
|
||||||
S-->>R: due alerts
|
Sc->>Src: occurrences(window) + reminderMinutes(ids)
|
||||||
R->>N: post(alert) — one notification per alert, tag = alert id
|
Src-->>Sc: Instances ⋈ Reminders
|
||||||
R->>S: markFired(ids) — best effort, needs WRITE_CALENDAR
|
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
|
**Why not the provider's broadcast.** It used to schedule the alarms, write the
|
||||||
tag + `setOnlyAlertOnce`) rather than losing a reminder. Swiped
|
`CalendarAlerts` rows and broadcast `EVENT_REMINDER`, and the app only reacted.
|
||||||
notifications never return because `FIRED` rows are never re-queried.
|
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
|
**The watermark replaces `CalendarAlerts.STATE`.** A scan posts the reminders
|
||||||
`Calendars.VISIBLE = 1`, so that flag *is* the app's on/off switch: Settings →
|
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
|
Calendars writes it (one calendar per update — `CalendarProvider2` skips its own
|
||||||
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
|
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
|
||||||
display predicate reads `CalendarSource.isVisibleInSystem`. The reconciliation
|
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,
|
pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
|
||||||
and until it does, the repository and `ReminderNotifier.post` honour it. That
|
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
|
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
|
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
|
||||||
`SCHEDULED` state while its event is still ahead (`handledAlertIds`), so
|
separate in-app declutter that never touches reminders.
|
||||||
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.
|
|
||||||
|
|
||||||
Deliberately absent until real devices prove it necessary: own alarm
|
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
|
||||||
scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption
|
Keeping both would double-post wherever the provider works, and Etar's way out —
|
||||||
prompts.
|
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
|
## Testing
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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:
|
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
|
a Trivy scan), once per PR. Docs/metadata-only PRs skip the Android build but
|
||||||
still report a green `CI` check.
|
still report a green `CI` check.
|
||||||
- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap
|
- **`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
|
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.
|
(`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
|
Codeberg **push-mirrors** branches and tags to Gitea, but releases aren't git
|
||||||
aren't git objects and don't sync, so the pipeline creates the release over the
|
objects and don't sync in either direction, so the pipeline creates the release
|
||||||
Codeberg API and attaches `calendula_v<version>.apk` + its `.sha256`. It's the
|
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
|
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
|
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
|
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
|
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.
|
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)
|
## Secrets (Gitea → repo Settings → Actions → Secrets)
|
||||||
|
|
||||||
| Secret | Purpose |
|
| Secret | Purpose |
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ Categories:
|
|||||||
- Calendar & Agenda
|
- Calendar & Agenda
|
||||||
License: MIT
|
License: MIT
|
||||||
AuthorName: Jean-Luc Makiola
|
AuthorName: Jean-Luc Makiola
|
||||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
SourceCode: https://codeberg.org/jlmakiola/calendula
|
||||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
||||||
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
|
Changelog: https://codeberg.org/jlmakiola/calendula/src/branch/main/CHANGELOG.md
|
||||||
Donate: https://ko-fi.com/jeanlucmakiola
|
Donate: https://ko-fi.com/jeanlucmakiola
|
||||||
|
|
||||||
AutoName: Calendula
|
AutoName: Calendula
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ Summary: A modern Material 3 Expressive calendar for Android.
|
|||||||
Categories:
|
Categories:
|
||||||
- Time
|
- Time
|
||||||
|
|
||||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
SourceCode: https://codeberg.org/jlmakiola/calendula
|
||||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
||||||
Donate: https://ko-fi.com/jeanlucmakiola
|
Donate: https://ko-fi.com/jeanlucmakiola
|
||||||
|
|||||||
@@ -7,6 +7,23 @@
|
|||||||
":semanticCommits",
|
":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
|
// No automerge: a dependency bump goes through the same review (and, for
|
||||||
// anything touching the build, the same on-device check) as a feature
|
// anything touching the build, the same on-device check) as a feature
|
||||||
// before it can ride a release — see docs/RELEASING.md and the
|
// 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.
|
// One reviewable surface; the dashboard issue lists everything pending.
|
||||||
dependencyDashboard: true,
|
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"],
|
labels: ["dependencies"],
|
||||||
prConcurrentLimit: 5,
|
prConcurrentLimit: 5,
|
||||||
prHourlyLimit: 0,
|
prHourlyLimit: 0,
|
||||||
@@ -25,11 +58,28 @@
|
|||||||
|
|
||||||
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
|
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
|
||||||
// the github-actions manager (same syntax) to watch them too.
|
// 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": {
|
"github-actions": {
|
||||||
fileMatch: ["^\\.gitea/workflows/[^/]+\\.ya?ml$"],
|
managerFilePatterns: ["/^\\.gitea/workflows/[^/]+\\.ya?ml$/"],
|
||||||
},
|
},
|
||||||
|
|
||||||
packageRules: [
|
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
|
// material3 is deliberately pinned to the 1.5 *alpha* line for the
|
||||||
// Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
|
// 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
|
// but keep it in its own PR, reviewed in isolation; revisit the pin when
|
||||||
@@ -52,5 +102,18 @@
|
|||||||
],
|
],
|
||||||
groupName: "test dependencies",
|
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"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user