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'
|
||||
java-version: '17'
|
||||
|
||||
# Fully qualified on purpose. Codeberg resolves bare `uses:` refs against
|
||||
# data.forgejo.org, Forgejo's own action mirror — actions/checkout,
|
||||
# setup-java and cache all exist there, but android-actions/setup-android
|
||||
# does not, and the job dies with "repository not found". Gitea's instance
|
||||
# defaults to GitHub, which is why this never surfaced before the split.
|
||||
- name: Setup Android SDK
|
||||
if: steps.scope.outputs.code == 'true'
|
||||
uses: android-actions/setup-android@v3
|
||||
uses: https://github.com/android-actions/setup-android@v3
|
||||
with:
|
||||
# Default ("tools platform-tools") drags in the Android Emulator
|
||||
# (~300 MB) which the build never uses.
|
||||
@@ -27,6 +27,14 @@ jobs:
|
||||
# whether this push actually cuts a new release (no tag for it yet). Keeps the
|
||||
# heavy job from running on every merge to main.
|
||||
detect:
|
||||
# Gitea only. The workflow directory split already keeps this file invisible
|
||||
# to Codeberg — Forgejo's lookup is first-match-wins, and .forgejo/workflows
|
||||
# exists — but that only holds while .forgejo/ is non-empty. Move the last
|
||||
# file out of it and Codeberg would fall back to .gitea/workflows and start
|
||||
# running the release pipeline on the contributor-facing runner, with no
|
||||
# secrets. repository_owner differs between the two forges regardless of
|
||||
# URL, proxy or instance rename, so this closes it permanently.
|
||||
if: github.repository_owner == 'makiolaj'
|
||||
runs-on: docker
|
||||
outputs:
|
||||
is_release: ${{ steps.v.outputs.is_release }}
|
||||
@@ -41,8 +49,16 @@ jobs:
|
||||
- name: Resolve version and whether it is a new release
|
||||
id: v
|
||||
env:
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
# Tags are read from Codeberg, which is canonical — deliberately NOT
|
||||
# from the Gitea API this workflow runs on. The Codeberg -> Gitea sync
|
||||
# is a push mirror, i.e. `git push --mirror`, which deletes refs the
|
||||
# source does not have. A tag minted here on Gitea is therefore wiped
|
||||
# by the next sync (Codeberg does not have it yet) and only reappears
|
||||
# once the tag push at the end of this workflow propagates back.
|
||||
# Asking Gitea inside that window would report "no tag" for a release
|
||||
# that already shipped, and cut it a second time.
|
||||
# Public repo, so this read needs no token.
|
||||
TAG_API: https://codeberg.org/api/v1/repos/jlmakiola/calendula
|
||||
run: |
|
||||
set -e
|
||||
VERSION=$(grep -oP 'versionName\s*=\s*"\K[^"]+' app/build.gradle.kts)
|
||||
@@ -60,15 +76,28 @@ jobs:
|
||||
fi
|
||||
# A tag for this version already existing means the release shipped on
|
||||
# an earlier push; do nothing. Absent => this merge cuts the release.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $TOKEN" "$API/git/refs/tags/v$VERSION")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "Tag v$VERSION already exists — nothing to release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No tag for v$VERSION yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
#
|
||||
# Anything other than a clean 200/404 is treated as fatal rather than
|
||||
# as "no tag". A Codeberg outage or a network blip would otherwise
|
||||
# read as absent and re-cut a release that has already shipped —
|
||||
# republishing to F-Droid and Play. Failing here is recoverable; a
|
||||
# duplicate release is not.
|
||||
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$TAG_API/git/refs/tags/v$VERSION" || echo 000)
|
||||
case "$STATUS" in
|
||||
200)
|
||||
echo "Tag v$VERSION already exists on Codeberg — nothing to release."
|
||||
echo "is_release=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
404)
|
||||
echo "No tag for v$VERSION on Codeberg yet — cutting the release."
|
||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "Codeberg tag lookup for v$VERSION returned HTTP $STATUS." >&2
|
||||
echo "Refusing to guess: treating this as 'no tag' could re-cut a shipped release." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Releases: build + sign + publish, then mint the tag and Gitea release.
|
||||
# Also runs on manual dispatch, where it skips the build and just re-signs and
|
||||
|
||||
@@ -29,14 +29,30 @@ jobs:
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
env:
|
||||
# Self-hosted Gitea, not github.com.
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: https://gitea.jeanlucmakiola.de/api/v1
|
||||
# Bot-account token (Gitea secret). Needs repo read/write + PR scope.
|
||||
# Renovate targets Codeberg (canonical) while still RUNNING on the
|
||||
# Gitea runner. Moving the job to Codeberg would put a repo-write
|
||||
# token on the contributor-facing runner, which is exactly what the
|
||||
# .forgejo/ vs .gitea/ split exists to prevent — so the token stays
|
||||
# where the other secrets live and only the API calls cross over.
|
||||
#
|
||||
# Platform is `forgejo`, not `gitea`: Codeberg runs Forgejo, and the
|
||||
# pinned image ships a distinct forgejo platform module.
|
||||
RENOVATE_PLATFORM: forgejo
|
||||
RENOVATE_ENDPOINT: https://codeberg.org/api/v1
|
||||
# Codeberg bot-account token (Gitea secret). Needs repo read/write +
|
||||
# PR scope on jlmakiola/calendula.
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
# Scope to this repo only — no org-wide autodiscovery.
|
||||
RENOVATE_AUTODISCOVER: 'false'
|
||||
RENOVATE_REPOSITORIES: '["makiolaj/calendula"]'
|
||||
# Commits/PRs authored as the bot, not a real maintainer.
|
||||
RENOVATE_REPOSITORIES: '["jlmakiola/calendula"]'
|
||||
# Commits/PRs authored as the bot, not a real maintainer. This address
|
||||
# must be a verified email on the Codeberg bot account, otherwise the
|
||||
# commits show up unattributed there.
|
||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@jeanlucmakiola.de>'
|
||||
# Read-only github.com PAT (no scopes needed). Unaffected by the forge
|
||||
# move — nearly every dependency is *released* on GitHub, and without
|
||||
# this,
|
||||
# changelog/release-note lookups hit the 60/h anonymous rate limit
|
||||
# and PRs arrive with an empty "Release Notes" section.
|
||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GITHUB_COM_TOKEN }}
|
||||
LOG_LEVEL: info
|
||||
|
||||
2
.gitmodules
vendored
2
.gitmodules
vendored
@@ -1,3 +1,3 @@
|
||||
[submodule "floret-kit"]
|
||||
path = floret-kit
|
||||
url = https://gitea.jeanlucmakiola.de/makiolaj/floret-kit.git
|
||||
url = https://codeberg.org/jlmakiola/floret-kit.git
|
||||
|
||||
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]
|
||||
|
||||
### Added
|
||||
- Settings → Calendars now says what is different about a calendar instead of
|
||||
leaving you to guess. Ones you can only view — a subscribed calendar, a
|
||||
calendar shared with you read-only — are marked **Read-only** ([#76]).
|
||||
- Calendars your device isn't syncing are marked **Not synced**, moved to the
|
||||
bottom of their account and left without a switch. None of their events are on
|
||||
the device, so the switch they used to have could not have shown you anything
|
||||
— the calendar simply looked broken. They are no longer offered when you pick
|
||||
a calendar for a new or an imported event either: an event saved there would
|
||||
never reach the account. Whether an account syncs a calendar stays that
|
||||
account's own app's decision ([#78]).
|
||||
- The birthday and anniversary calendars Calendula fills from your contacts are
|
||||
marked **Filled from your contacts**, which is why they can't be picked for a
|
||||
new event: anything you put there would be removed again on the next sync.
|
||||
Deleting one is held back while special dates are switched on — Calendula
|
||||
would simply create it again — and the calendar's editor says so; turn the
|
||||
feature off under Settings → Special dates and the delete works as usual
|
||||
([#76]).
|
||||
- The calendar picker in the event form and in the .ics import screen now ends
|
||||
with a **"Missing a calendar?"** row that opens Settings → Calendars, where
|
||||
those marks then explain why a calendar isn't offered ([#76]).
|
||||
|
||||
### Changed
|
||||
- Calendula's source code now lives on **Codeberg**, where its issues already
|
||||
were. The **Source code** and **License** links in Settings → About point
|
||||
there, so reporting a bug and reading the code no longer land on two different
|
||||
sites. Nothing about the app itself changes, and the F-Droid repository is
|
||||
unaffected.
|
||||
|
||||
### Fixed
|
||||
- Search results now show an all-day event's real date. West of UTC — anywhere in
|
||||
the Americas, say — a search hit was dated one day early, disagreeing with the
|
||||
day the month, week and agenda views file the same event under ([#82]).
|
||||
- Reminders no longer depend on Android telling Calendula when they are due.
|
||||
Calendula now works out each reminder's time itself and sets its own alarm for
|
||||
it. On some phones — Samsung's among them — the system's calendar storage never
|
||||
sends the signal a calendar app is meant to wake up on, and no amount of
|
||||
battery or notification settings helps: the reminder is simply never announced.
|
||||
None of that is visible from inside an app that waits to be told, which is why
|
||||
it took a second pass to find ([#75]).
|
||||
|
||||
Reminders also survive things that used to lose them quietly. After a restart
|
||||
or an app update Calendula re-arms its alarms, and a reminder whose moment
|
||||
passed while the phone was off still arrives, as long as the event has not
|
||||
ended yet.
|
||||
|
||||
- All-day reminders now arrive at the time you chose in **Settings →
|
||||
Notifications**, on every occurrence. A yearly birthday could drift an hour
|
||||
either way depending on daylight saving, and all-day reminders on calendars
|
||||
from an account fired in the middle of the night instead of in the morning
|
||||
([#75]).
|
||||
|
||||
- Reminders now arrive for every calendar you have switched on. A calendar that
|
||||
was hidden at system level — switched off in another calendar app, or never
|
||||
switched on after being added — still showed its events and listed their
|
||||
@@ -1135,3 +1186,6 @@ automatically, with zero telemetry and no internet permission.
|
||||
[#44]: https://codeberg.org/jlmakiola/calendula/issues/44
|
||||
[#70]: https://codeberg.org/jlmakiola/calendula/issues/70
|
||||
[#75]: https://codeberg.org/jlmakiola/calendula/issues/75
|
||||
[#76]: https://codeberg.org/jlmakiola/calendula/issues/76
|
||||
[#78]: https://codeberg.org/jlmakiola/calendula/issues/78
|
||||
[#82]: https://codeberg.org/jlmakiola/calendula/issues/82
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
Reads, writes, and reminds — on top of the system calendar, with zero network access.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions"><img src="https://gitea.jeanlucmakiola.de/makiolaj/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||
<a href="https://codeberg.org/jlmakiola/calendula/actions"><img src="https://codeberg.org/jlmakiola/calendula/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI"></a>
|
||||
<img src="https://img.shields.io/badge/Android-10%2B-3DDC84?logo=android&logoColor=white" alt="Android 10+">
|
||||
<img src="https://img.shields.io/badge/Kotlin-Compose-7F52FF?logo=kotlin&logoColor=white" alt="Kotlin + Compose">
|
||||
<img src="https://img.shields.io/badge/Material%203-Expressive-4285F4" alt="Material 3 Expressive">
|
||||
|
||||
@@ -33,6 +33,14 @@
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
|
||||
<!--
|
||||
A reboot clears every pending alarm, including the one holding the next
|
||||
reminder. Now that the app schedules that alarm itself (#75) rather than
|
||||
leaning on the provider's, it has to hear about the reboot to re-arm it —
|
||||
otherwise reminders simply stop after a restart.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<!-- Package visibility (Android 11+): without this, getLaunchIntentForPackage
|
||||
returns null and the calendar manager's per-account "manage" button can't
|
||||
open the source sync app (DAVx5, ICSx5, Google Calendar, …). The LAUNCHER
|
||||
@@ -270,17 +278,22 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<!-- The provider broadcasts EVENT_REMINDER at reminder time but posts
|
||||
no notification itself — a calendar app must (v1.4, Etar model).
|
||||
Exported: the broadcast arrives from the provider's process. -->
|
||||
<!-- Reminder delivery is the app's own (#75): it plans the alarms from
|
||||
Instances + Reminders instead of waiting for the provider's
|
||||
EVENT_REMINDER broadcast, which OEM-modified providers demonstrably
|
||||
retarget or never send. This receiver takes our scan alarm plus
|
||||
every outside event that invalidates it — boot and package-replace
|
||||
wipe pending alarms, and a clock or timezone change moves every
|
||||
reminder relative to the one that is armed.
|
||||
Exported: the system broadcasts arrive from outside the app. -->
|
||||
<receiver
|
||||
android:name=".data.reminders.EventReminderReceiver"
|
||||
android:name=".data.reminders.ReminderScheduleReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.EVENT_REMINDER" />
|
||||
<data
|
||||
android:host="com.android.calendar"
|
||||
android:scheme="content" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import de.jeanlucmakiola.calendula.data.calendar.CalendarVisibilityReconciler
|
||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceScheduler
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderMaintenanceWorker
|
||||
import de.jeanlucmakiola.floret.crash.CrashConfig
|
||||
import de.jeanlucmakiola.floret.crash.CrashReporter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -41,6 +43,25 @@ class CalendulaApp : Application() {
|
||||
reconcileAutoBackup()
|
||||
reconcileSpecialDates()
|
||||
reconcileCalendarVisibility()
|
||||
startReminderDelivery()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring reminder delivery up with the process (#75). The app plans and arms
|
||||
* its own reminder alarms now, so launch is one of the moments that has to
|
||||
* re-check them: a scan re-arms whatever the system dropped, posts anything
|
||||
* a missed alarm still owes, and starts watching the provider so an edit
|
||||
* re-plans without waiting for the next pass. The daily worker is the
|
||||
* backstop for a device that drops the alarm with no reboot to announce it.
|
||||
*/
|
||||
private fun startReminderDelivery() {
|
||||
val deps = EntryPointAccessors.fromApplication(
|
||||
this, ReminderMaintenanceWorker.Deps::class.java,
|
||||
)
|
||||
val scanner = deps.reminderScanner()
|
||||
scanner.startWatchingProvider()
|
||||
scanner.scanInBackground()
|
||||
ReminderMaintenanceScheduler.apply(this)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -67,6 +67,14 @@ class CalendarVisibilityReconciler @Inject constructor(
|
||||
// drain and nothing left to decide, so don't pay for the query.
|
||||
if (pending.isEmpty() && noticeSettled) return@withContext
|
||||
val calendars = dataSource.calendars()
|
||||
// An empty read means "couldn't read", not "no calendars": the data
|
||||
// source turns a null cursor — a provider momentarily unavailable —
|
||||
// into an empty list. Both decisions below are one-way, so taking
|
||||
// that reading as the truth would drop the whole pending set without
|
||||
// ever writing VISIBLE = 0 (switching the user's calendars back on,
|
||||
// events and reminders with them) and settle the notice as "nothing
|
||||
// to explain". Leave both to the next run.
|
||||
if (calendars.isEmpty()) return@withContext
|
||||
settleNoticeOnce(hasSystemHiddenCalendars(calendars, pending))
|
||||
if (pending.isEmpty() || !hasPermission(Manifest.permission.WRITE_CALENDAR)) {
|
||||
return@withContext
|
||||
|
||||
@@ -18,8 +18,8 @@ import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataS
|
||||
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
|
||||
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesCalendarSpec
|
||||
import de.jeanlucmakiola.calendula.data.reminders.AndroidReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderAlertStore
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ProviderReminderInstanceSource
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderInstanceSource
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Singleton
|
||||
@@ -46,9 +46,9 @@ abstract class DataBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindReminderAlertStore(
|
||||
impl: AndroidReminderAlertStore,
|
||||
): ReminderAlertStore
|
||||
abstract fun bindReminderInstanceSource(
|
||||
impl: ProviderReminderInstanceSource,
|
||||
): ReminderInstanceSource
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
|
||||
@@ -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]
|
||||
* alarm), so the receiver is not exported.
|
||||
*
|
||||
* - **Dismiss** just cancels the notification — the `CalendarAlerts` row is
|
||||
* already fired, so nothing re-posts it.
|
||||
* - **Dismiss** just cancels the notification — the scan's watermark has moved
|
||||
* past this reminder, so nothing re-posts it.
|
||||
* - **Snooze** cancels the notification and schedules an exact alarm to re-show
|
||||
* it after the user's snooze delay.
|
||||
* - **Show** (the alarm) re-posts the same notification, so the user can snooze
|
||||
* or dismiss it again — unless the calendar was switched off during the
|
||||
* snooze, which [ReminderNotifier.post] catches (this alarm is ours, so no
|
||||
* provider alert row stands between it and the notification).
|
||||
* snooze, which [ReminderNotifier.post] catches — this alarm is its own
|
||||
* trigger, outside the ordinary scan.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class ReminderActionReceiver : BroadcastReceiver() {
|
||||
@@ -75,7 +75,14 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
const val ACTION_DISMISS = "de.jeanlucmakiola.calendula.reminders.DISMISS"
|
||||
const val ACTION_SHOW = "de.jeanlucmakiola.calendula.reminders.SHOW"
|
||||
|
||||
private const val EXTRA_ALERT_ID = "alert_id"
|
||||
/**
|
||||
* Not handled here — the notification body opens the detail screen
|
||||
* directly. It only claims a slot in [requestCode] so that intent stays
|
||||
* distinct from the three this receiver does handle.
|
||||
*/
|
||||
const val ACTION_OPEN = "de.jeanlucmakiola.calendula.reminders.OPEN"
|
||||
|
||||
private const val EXTRA_ALERT_KEY = "alert_key"
|
||||
private const val EXTRA_EVENT_ID = "event_id"
|
||||
private const val EXTRA_CALENDAR_ID = "calendar_id"
|
||||
private const val EXTRA_BEGIN = "begin"
|
||||
@@ -88,7 +95,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
fun intent(context: Context, action: String, alert: ReminderAlert): Intent =
|
||||
Intent(context, ReminderActionReceiver::class.java).apply {
|
||||
this.action = action
|
||||
putExtra(EXTRA_ALERT_ID, alert.alertId)
|
||||
putExtra(EXTRA_ALERT_KEY, alert.key)
|
||||
putExtra(EXTRA_EVENT_ID, alert.eventId)
|
||||
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
|
||||
putExtra(EXTRA_BEGIN, alert.beginMillis)
|
||||
@@ -99,23 +106,29 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable request code per (alert, action) so the three PendingIntents
|
||||
* of one notification stay distinct and don't clobber each other.
|
||||
* A stable request code per (alert, action) so one notification's
|
||||
* PendingIntents stay distinct and don't clobber each other.
|
||||
*
|
||||
* The key is a hash now rather than a small row id, so the shift is what
|
||||
* keeps the action slot intact; the top three bits it drops only make two
|
||||
* *different* reminders collide, which the intent extras then separate
|
||||
* (PendingIntents compare their intents too, not just the request code).
|
||||
*/
|
||||
fun requestCode(alert: ReminderAlert, action: String): Int {
|
||||
val actionOffset = when (action) {
|
||||
ACTION_SNOOZE -> 1
|
||||
ACTION_DISMISS -> 2
|
||||
ACTION_SHOW -> 3
|
||||
ACTION_OPEN -> 4
|
||||
else -> 0
|
||||
}
|
||||
return alert.alertId.toInt() * 8 + actionOffset
|
||||
return (alert.key.toInt() shl 3) + actionOffset
|
||||
}
|
||||
|
||||
private fun alertFrom(intent: Intent): ReminderAlert? {
|
||||
if (!intent.hasExtra(EXTRA_ALERT_ID)) return null
|
||||
if (!intent.hasExtra(EXTRA_ALERT_KEY)) return null
|
||||
return ReminderAlert(
|
||||
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
|
||||
key = intent.getLongExtra(EXTRA_ALERT_KEY, 0L),
|
||||
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
|
||||
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
|
||||
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
* Posts one notification per due reminder alert on a dedicated channel.
|
||||
* Tapping opens the event's detail screen; the tag is the alert id, so a
|
||||
* re-broadcast of an alert we couldn't mark fired replaces its notification
|
||||
* silently ([NotificationCompat.Builder.setOnlyAlertOnce]) instead of
|
||||
* duplicating it.
|
||||
* Posts one notification per due reminder on a dedicated channel. Tapping opens
|
||||
* the event's detail screen.
|
||||
*
|
||||
* The tag is the reminder's stable key, so a scan that posts the same reminder
|
||||
* again — a catch-up pass overlapping the alarm that already fired — replaces
|
||||
* its notification silently ([NotificationCompat.Builder.setOnlyAlertOnce])
|
||||
* instead of stacking a second one.
|
||||
*/
|
||||
@Singleton
|
||||
class ReminderNotifier @Inject constructor(
|
||||
@@ -52,13 +54,12 @@ class ReminderNotifier @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* The single choke point for "this calendar is switched off". The provider
|
||||
* side needs no help — with `VISIBLE = 0` it creates no alert rows at all —
|
||||
* but two paths reach [post] without one: a snooze we re-show from our own
|
||||
* exact alarm, scheduled before the calendar was switched off, and a
|
||||
* read-only install whose switch lives app-side ([CalendarPrefs]) because it
|
||||
* may not write the flag. Both are covered here rather than in either
|
||||
* receiver.
|
||||
* The single choke point for "this calendar is switched off". The scan
|
||||
* already filters on `Calendars.VISIBLE`, but two paths reach [post] around
|
||||
* it: a snooze re-shown from its own alarm, armed before the calendar was
|
||||
* switched off, and a read-only install whose switch lives app-side
|
||||
* ([CalendarPrefs]) because it may not write the flag. Both are covered here
|
||||
* rather than in either receiver.
|
||||
*/
|
||||
private suspend fun isSilenced(calendarId: Long): Boolean =
|
||||
calendarId in calendarPrefs.pendingDisabledCalendarIds.first() ||
|
||||
@@ -66,8 +67,8 @@ class ReminderNotifier @Inject constructor(
|
||||
|
||||
/**
|
||||
* Post [alert], unless its calendar is switched off. Returns whether the
|
||||
* notification was put up: a silenced alert must stay unhandled so that
|
||||
* switching the calendar back on can still surface it (see [handledAlertIds]).
|
||||
* notification was put up, which the snooze re-show path uses to tell a
|
||||
* silenced reminder from a delivered one.
|
||||
*/
|
||||
suspend fun post(alert: ReminderAlert): Boolean {
|
||||
if (isSilenced(alert.calendarId)) return false
|
||||
@@ -117,7 +118,7 @@ class ReminderNotifier @Inject constructor(
|
||||
.build()
|
||||
try {
|
||||
NotificationManagerCompat.from(context)
|
||||
.notify(alert.alertId.toString(), NOTIFICATION_ID, notification)
|
||||
.notify(alert.key.toString(), NOTIFICATION_ID, notification)
|
||||
} catch (e: SecurityException) {
|
||||
// POST_NOTIFICATIONS was revoked between canPost() and here.
|
||||
Log.w(TAG, "Could not post reminder for event ${alert.eventId}", e)
|
||||
@@ -128,7 +129,7 @@ class ReminderNotifier @Inject constructor(
|
||||
|
||||
/** Remove a posted reminder (snooze re-shows it later; dismiss is final). */
|
||||
fun cancel(alert: ReminderAlert) {
|
||||
NotificationManagerCompat.from(context).cancel(alert.alertId.toString(), NOTIFICATION_ID)
|
||||
NotificationManagerCompat.from(context).cancel(alert.key.toString(), NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
private fun actionIntent(alert: ReminderAlert, action: String): PendingIntent =
|
||||
@@ -141,7 +142,11 @@ class ReminderNotifier @Inject constructor(
|
||||
|
||||
private fun detailIntent(alert: ReminderAlert): PendingIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
/* requestCode = */ alert.alertId.toInt(),
|
||||
// Shares the per-(alert, action) request-code scheme with the buttons, so
|
||||
// the key's wider value range can't collide one notification's intents.
|
||||
/* requestCode = */ ReminderActionReceiver.requestCode(
|
||||
alert, ReminderActionReceiver.ACTION_OPEN,
|
||||
),
|
||||
MainActivity.eventDetailIntent(context, alert.eventId, alert.beginMillis, alert.endMillis),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* The app otherwise relies entirely on the calendar provider's `EVENT_REMINDER`
|
||||
* broadcast (the Etar model), but a snoozed reminder has no provider backing —
|
||||
* its `CalendarAlerts` row is already fired — so we must re-fire it ourselves.
|
||||
* Separate from [ReminderAlarmScheduler]'s scan alarm, and deliberately so: a
|
||||
* snooze is pinned to one reminder at a time the user chose, while the scan
|
||||
* alarm is a single moving wake-up for whatever comes next. A re-show also has
|
||||
* to outlive the scan's watermark moving past that reminder, so it carries the
|
||||
* reminder in its own intent rather than re-deriving it.
|
||||
*
|
||||
* A snooze that lands late is a broken snooze, hence an *exact* alarm; we fall
|
||||
* back to an inexact allow-while-idle alarm only if the OS withholds the
|
||||
* exact-alarm capability (API 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
|
||||
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class CalendarSource(
|
||||
@@ -72,6 +76,35 @@ data class EventInstance(
|
||||
*/
|
||||
fun EventInstance.hasEnded(now: Instant): Boolean = end <= now
|
||||
|
||||
/**
|
||||
* The zone this event's calendar dates live in. Timed events are resolved in the
|
||||
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
||||
* resolving them anywhere else shifts the day boundaries — east of UTC the end
|
||||
* leaks onto the following day (#65), west of UTC the start pulls back onto the
|
||||
* previous one (#82). Every surface that has to name an all-day event's date
|
||||
* goes through here, so grid, agenda, detail and search cannot disagree.
|
||||
*/
|
||||
fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event actually occupies. An event ending exactly at
|
||||
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
||||
* into that boundary day, so resolve the instant just before [EventInstance.end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
data class EventDetail(
|
||||
val instance: EventInstance,
|
||||
val description: String?,
|
||||
|
||||
@@ -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,
|
||||
onClose = { createDateIso = null },
|
||||
onSaved = { createDateIso = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -434,6 +435,7 @@ fun CalendarHost(
|
||||
editKey = null
|
||||
detailKey = null
|
||||
},
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -450,18 +452,6 @@ fun CalendarHost(
|
||||
)
|
||||
}
|
||||
|
||||
// Calendar manager — slides over Settings.
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
onImport = { importUri = it; importForceMany = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Import flow for an opened/received .ics file. A single event routes
|
||||
// into the create form (prefilled, for review); many open the picker.
|
||||
importUri?.let { uri ->
|
||||
@@ -469,6 +459,7 @@ fun CalendarHost(
|
||||
uri = uri,
|
||||
forceMany = importForceMany,
|
||||
onClose = { importUri = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
onOpenSingle = { form ->
|
||||
importUri = null
|
||||
importFormSource = ImportSource.File
|
||||
@@ -483,6 +474,27 @@ fun CalendarHost(
|
||||
initialFormSource = importFormSource,
|
||||
onClose = { importForm = null },
|
||||
onSaved = { importForm = null },
|
||||
onManageCalendars = { showCalendars = true },
|
||||
)
|
||||
}
|
||||
|
||||
// Calendar manager — declared last so it covers every overlay that can
|
||||
// open it: Settings, both event forms, and the .ics import picker (#76).
|
||||
// Coming back from it leaves the caller exactly as it was, with the
|
||||
// calendar list already refreshed by the provider's notification.
|
||||
AnimatedVisibility(
|
||||
visible = showCalendars,
|
||||
enter = slideInHorizontally(slideSpec) { it } + fadeIn(),
|
||||
exit = slideOutHorizontally(slideSpec) { it } + fadeOut(),
|
||||
) {
|
||||
CalendarsScreen(
|
||||
onBack = { showCalendars = false },
|
||||
// The manager opens the import too (restore from backup), and
|
||||
// that way round it has to step aside: declared above the import
|
||||
// overlays, it would otherwise cover the screen it just asked
|
||||
// for. Closing it hands the user back to whatever opened the
|
||||
// manager once the import is done.
|
||||
onImport = { importUri = it; importForceMany = true; showCalendars = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,41 +2,14 @@ package de.jeanlucmakiola.calendula.ui.agenda
|
||||
|
||||
import de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.FailureReason
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.domain.spanLastDay
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.plus
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The zone the event's calendar dates live in. Timed events are resolved in the
|
||||
* device [zone]; all-day events live at UTC midnights with an exclusive end, so
|
||||
* resolving them anywhere but UTC shifts the boundaries — east of UTC that leaks
|
||||
* a one-day event onto its next day. Matches the Week view and detail card.
|
||||
*/
|
||||
private fun EventInstance.dateZone(zone: TimeZone): TimeZone =
|
||||
if (isAllDay) TimeZone.UTC else zone
|
||||
|
||||
/** The first calendar day this event occupies. */
|
||||
fun EventInstance.spanFirstDay(zone: TimeZone): LocalDate =
|
||||
start.toLocalDateTime(dateZone(zone)).date
|
||||
|
||||
/**
|
||||
* The last calendar day this event actually occupies. An event ending exactly at
|
||||
* midnight (all-day events end at the exclusive next-midnight) does not reach
|
||||
* into that boundary day, so resolve the instant just before [end].
|
||||
*/
|
||||
fun EventInstance.spanLastDay(zone: TimeZone): LocalDate {
|
||||
val lastInstant = if (end > start) end - 1.milliseconds else start
|
||||
return lastInstant.toLocalDateTime(dateZone(zone)).date
|
||||
}
|
||||
|
||||
/** Whether this event occupies more than one calendar day in [zone]. */
|
||||
fun EventInstance.spansMultipleDays(zone: TimeZone): Boolean =
|
||||
spanFirstDay(zone) != spanLastDay(zone)
|
||||
|
||||
/**
|
||||
* What an agenda row's time line should convey for an event on a given day —
|
||||
* the part of a multi-day span that [day] falls in. Pure and shared so the
|
||||
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.material.icons.filled.CalendarMonth
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.FileDownload
|
||||
import androidx.compose.material.icons.filled.FileUpload
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
@@ -91,6 +92,12 @@ import de.jeanlucmakiola.calendula.R
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarStateLabel
|
||||
import de.jeanlucmakiola.calendula.domain.hasVisibilitySwitch
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import de.jeanlucmakiola.calendula.domain.isNotSynced
|
||||
import de.jeanlucmakiola.calendula.domain.orderedForManager
|
||||
import de.jeanlucmakiola.calendula.domain.stateLabels
|
||||
import de.jeanlucmakiola.calendula.data.calendar.CalendarColorPalette
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarColorChip
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
@@ -139,6 +146,7 @@ fun CalendarsScreen(
|
||||
viewModel: CalendarsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
|
||||
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
val backupResult by viewModel.backupResult.collectAsStateWithLifecycle()
|
||||
val autoBackup by viewModel.autoBackup.collectAsStateWithLifecycle()
|
||||
@@ -157,6 +165,7 @@ fun CalendarsScreen(
|
||||
initialName = editing?.displayName.orEmpty(),
|
||||
initialColor = editing?.color ?: CalendarColorPalette.all.first(),
|
||||
initialDescription = editing?.description.orEmpty(),
|
||||
deleteLocked = editing != null && editing.id in deleteLockedIds,
|
||||
onSave = { name, color, description ->
|
||||
val id = editorId
|
||||
if (id == null || id == NEW_CALENDAR_ID) {
|
||||
@@ -305,7 +314,7 @@ private fun CalendarsList(
|
||||
val disabled = !calendar.isVisibleInSystem
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
summary = calendar.description,
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == local.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
@@ -327,9 +336,9 @@ private fun CalendarsList(
|
||||
// safety net. Offered only when there is something exportable: the user's
|
||||
// own local calendars (managed special-dates mirrors don't count).
|
||||
val exportable = local.filter { it.canModifyContents && !it.isManaged }
|
||||
// Restore/import can target any writable, non-managed calendar (local or
|
||||
// synced), so its availability is broader than export's.
|
||||
val canImport = (local + synced).any { it.canModifyContents && !it.isManaged }
|
||||
// Restore/import can target any calendar the import picker would offer
|
||||
// (local or synced), so its availability is broader than export's.
|
||||
val canImport = (local + synced).any { it.isEventTarget }
|
||||
if (exportable.isNotEmpty()) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
SectionHeader(stringResource(R.string.calendars_backup_header))
|
||||
@@ -412,7 +421,11 @@ private fun CalendarsList(
|
||||
.forEach { (account, cals) ->
|
||||
val expanded = account !in collapsedAccounts
|
||||
val accountType = cals.first().accountType
|
||||
val accountDisabled = cals.none { it.isVisibleInSystem }
|
||||
// A non-syncing calendar has no switch, so it neither counts
|
||||
// towards "the whole account is off" nor moves with toggle-all.
|
||||
val switchable = cals.filter { it.hasVisibilitySwitch }
|
||||
val accountDisabled = switchable.isNotEmpty() &&
|
||||
switchable.none { it.isVisibleInSystem }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
CalendarGroup(
|
||||
title = account,
|
||||
@@ -432,24 +445,36 @@ private fun CalendarsList(
|
||||
collapsedAccounts - account
|
||||
}
|
||||
},
|
||||
showToggleAll = true,
|
||||
allEnabled = cals.all { it.isVisibleInSystem },
|
||||
onToggleAll = { enabled -> onSetAccountVisible(cals.map { it.id }, enabled) },
|
||||
showToggleAll = switchable.isNotEmpty(),
|
||||
allEnabled = switchable.all { it.isVisibleInSystem },
|
||||
onToggleAll = { enabled ->
|
||||
onSetAccountVisible(switchable.map { it.id }, enabled)
|
||||
},
|
||||
) {
|
||||
cals.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem
|
||||
// Calendars you can act on first; the ones this device isn't
|
||||
// syncing sit at the bottom, dimmed and switchless.
|
||||
val ordered = cals.orderedForManager()
|
||||
ordered.forEachIndexed { index, calendar ->
|
||||
val disabled = !calendar.isVisibleInSystem || calendar.isNotSynced
|
||||
GroupedRow(
|
||||
title = calendar.displayName,
|
||||
position = if (index == cals.lastIndex) Position.Bottom else Position.Middle,
|
||||
summary = calendarRowSummary(calendar),
|
||||
position = if (index == ordered.lastIndex) Position.Bottom else Position.Middle,
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
dimmed = disabled,
|
||||
leading = { CalendarColorChip(calendar.color, dimIf(disabled)) },
|
||||
trailing = {
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = !disabled,
|
||||
onToggle = { enabled -> onSetVisible(calendar.id, enabled) },
|
||||
)
|
||||
trailing = if (calendar.hasVisibilitySwitch) {
|
||||
{
|
||||
EnableSwitch(
|
||||
calendarName = calendar.displayName,
|
||||
enabled = calendar.isVisibleInSystem,
|
||||
onToggle = { enabled ->
|
||||
onSetVisible(calendar.id, enabled)
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -557,6 +582,7 @@ private fun CalendarEditor(
|
||||
onSave: (name: String, color: Int, description: String?) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
deleteLocked: Boolean = false,
|
||||
) {
|
||||
var name by rememberSaveable(sessionKey) { mutableStateOf(initialName) }
|
||||
var color by rememberSaveable(sessionKey) { mutableStateOf(initialColor) }
|
||||
@@ -590,11 +616,23 @@ private fun CalendarEditor(
|
||||
},
|
||||
actions = {
|
||||
if (!isNew) {
|
||||
IconButton(onClick = { confirmDelete = true }) {
|
||||
// Kept in place while the special-dates sync owns this
|
||||
// calendar, rather than hidden: the button is where you
|
||||
// expect it, disabled, with the card below saying why —
|
||||
// and it comes back to life the moment the feature is
|
||||
// off, when the delete would actually stick.
|
||||
IconButton(
|
||||
onClick = { confirmDelete = true },
|
||||
enabled = !deleteLocked,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = stringResource(R.string.event_detail_delete),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
tint = if (deleteLocked) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -623,6 +661,19 @@ private fun CalendarEditor(
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (deleteLocked) {
|
||||
EditorCard(
|
||||
icon = Icons.Default.Info,
|
||||
iconTint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
iconAtTop = true,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.calendars_managed_delete_locked),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
EditorCard(icon = Icons.Default.CalendarMonth, iconTint = eventFill(color, dark, soften)) {
|
||||
InlineTextField(
|
||||
value = name,
|
||||
@@ -694,6 +745,27 @@ private fun CalendarEditor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's supporting line: the states that make this calendar behave unlike a
|
||||
* plain writable one (#76), then its own description. Text rather than badges —
|
||||
* a row can carry several of these at once next to a switch, which is exactly
|
||||
* what M3 supporting text composes and a row of static chips doesn't.
|
||||
*/
|
||||
@Composable
|
||||
private fun calendarRowSummary(calendar: CalendarSource): String? {
|
||||
val states = calendar.stateLabels().map { label ->
|
||||
stringResource(
|
||||
when (label) {
|
||||
CalendarStateLabel.MANAGED -> R.string.calendars_state_managed
|
||||
CalendarStateLabel.READ_ONLY -> R.string.calendars_state_read_only
|
||||
CalendarStateLabel.NOT_SYNCED -> R.string.calendars_state_not_synced
|
||||
},
|
||||
)
|
||||
}
|
||||
val parts = states + listOfNotNull(calendar.description?.takeIf { it.isNotBlank() })
|
||||
return parts.joinToString(" · ").ifEmpty { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-row on/off control, writing the system's `Calendars.VISIBLE`: checked
|
||||
* = the calendar is shown, unchecked = it drops out of every surface (events,
|
||||
|
||||
@@ -13,7 +13,6 @@ import de.jeanlucmakiola.calendula.data.di.IoDispatcher
|
||||
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
|
||||
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
|
||||
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
|
||||
import de.jeanlucmakiola.calendula.data.reminders.ReminderRecovery
|
||||
import de.jeanlucmakiola.calendula.domain.CalendarSource
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsWriter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -44,7 +43,6 @@ class CalendarsViewModel @Inject constructor(
|
||||
private val repository: CalendarRepository,
|
||||
private val icsExporter: IcsExporter,
|
||||
private val settingsPrefs: SettingsPrefs,
|
||||
private val reminderRecovery: ReminderRecovery,
|
||||
@IoDispatcher private val io: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -74,6 +72,33 @@ class CalendarsViewModel @Inject constructor(
|
||||
initialValue = AutoBackupUiState(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Managed special-dates calendars whose deletion would not stick. While the
|
||||
* feature is on, the sync owns every mirror: it recreates a missing one for
|
||||
* an enabled type on the next pass and deletes the leftover of a disabled
|
||||
* one (`SpecialDatesSyncEngine.reconcileCalendars`), so either way the
|
||||
* delete would appear to work and then undo itself. Turning special dates
|
||||
* off empties this set, and deleting a leftover mirror is a real delete from
|
||||
* then on.
|
||||
*
|
||||
* Read off each calendar's own durable marker ([CalendarSource.isManaged],
|
||||
* the `CAL_SYNC2` one the editor lock already trusts) rather than the stored
|
||||
* ids, which are only rewritten on the next sync pass — a preferences loss
|
||||
* would otherwise unlock a live mirror until then.
|
||||
*/
|
||||
val deleteLockedCalendarIds: StateFlow<Set<Long>> = combine(
|
||||
calendars,
|
||||
settingsPrefs.specialDatesEnabled,
|
||||
) { sources, enabled ->
|
||||
if (!enabled) emptySet() else sources.filter { it.isManaged }.map { it.id }.toSet()
|
||||
}
|
||||
.flowOn(io)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000L),
|
||||
initialValue = emptySet(),
|
||||
)
|
||||
|
||||
private val _error = MutableStateFlow(false)
|
||||
val error: StateFlow<Boolean> = _error.asStateFlow()
|
||||
|
||||
@@ -128,24 +153,21 @@ class CalendarsViewModel @Inject constructor(
|
||||
* reminders. Nothing is patched by hand — the provider notifies and the
|
||||
* observer re-queries.
|
||||
*
|
||||
* Switching one back on also re-posts the reminders it silenced while it was
|
||||
* off and that are still relevant — those the app kept app-side because it
|
||||
* may not write the flag ([ReminderRecovery]).
|
||||
* Nothing has to be re-posted on the way back on: reminder delivery plans
|
||||
* from `Instances` and `Reminders` on every scan (#75), and the provider
|
||||
* change this write makes triggers one.
|
||||
*/
|
||||
fun setCalendarVisible(id: Long, visible: Boolean) = write {
|
||||
repository.setCalendarsVisible(listOf(id), visible)
|
||||
if (visible) reminderRecovery.rePostFor(listOf(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch every calendar of one account on or off — the "toggle all"
|
||||
* affordance on an account header. Each row is written on its own (the
|
||||
* provider only re-arms reminder alarms for a single-id update), in one
|
||||
* affordance on an account header. Each row is written on its own, in one
|
||||
* coroutine so the writes can't race each other.
|
||||
*/
|
||||
fun setAccountVisible(ids: Collection<Long>, visible: Boolean) = write {
|
||||
repository.setCalendarsVisible(ids, visible)
|
||||
if (visible) reminderRecovery.rePostFor(ids)
|
||||
}
|
||||
|
||||
// --- Automatic backup (issue #8) ------------------------------------
|
||||
|
||||
@@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.PhoneAndroid
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -42,12 +44,19 @@ import de.jeanlucmakiola.floret.components.SelectedCheck
|
||||
* account — with the calendars beneath it as a connected card, a colour chip on
|
||||
* each and a check on the selected one. Emits into the caller's [ColumnScope]
|
||||
* (a scrolling column), so the caller owns the surrounding chrome.
|
||||
*
|
||||
* The list holds event *targets* only, so a calendar that is switched off,
|
||||
* read-only or managed is silently absent — which reads as a missing calendar
|
||||
* rather than an excluded one (#76). [onManageCalendars], when given, adds the
|
||||
* footer row that names the possible reasons and opens the calendar manager,
|
||||
* where each row then says which one applies.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.CalendarPickerGroups(
|
||||
calendars: List<CalendarSource>,
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
val local = remember(calendars) { calendars.filter { it.isLocal } }
|
||||
val syncedGroups = remember(calendars) {
|
||||
@@ -75,6 +84,23 @@ fun ColumnScope.CalendarPickerGroups(
|
||||
onSelect = onSelect,
|
||||
)
|
||||
}
|
||||
if (onManageCalendars != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.calendar_picker_missing_title),
|
||||
summary = stringResource(R.string.calendar_picker_missing_summary),
|
||||
position = Position.Alone,
|
||||
leading = { LeadingAvatar(Icons.Default.VisibilityOff) },
|
||||
trailing = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
onClick = onManageCalendars,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One account's category header (avatar + name) atop its selectable calendars. */
|
||||
|
||||
@@ -89,6 +89,8 @@ import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -193,6 +195,7 @@ fun EventEditScreen(
|
||||
initialStartMinutes: Int? = null,
|
||||
initialForm: EventForm? = null,
|
||||
initialFormSource: ImportSource = ImportSource.File,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
viewModel: EventEditViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(initialDateIso, editKey, initialForm) {
|
||||
@@ -309,6 +312,7 @@ fun EventEditScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
onManageCalendars = onManageCalendars,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -500,6 +504,7 @@ private fun EventEditContent(
|
||||
state: EventEditUiState,
|
||||
viewModel: EventEditViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
val form = state.form
|
||||
val locale = currentLocale()
|
||||
@@ -509,6 +514,10 @@ private fun EventEditContent(
|
||||
// they're locked here; everything else (reminders, location, notes) is the
|
||||
// user's to edit.
|
||||
val locked = state.isManaged
|
||||
// Read in the form's own window, not the picker's: the field holding focus
|
||||
// lives here, so this is the controller that can put its keyboard away.
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
var picker by remember { mutableStateOf<PickerTarget?>(null) }
|
||||
var showCalendarPicker by rememberSaveable { mutableStateOf(false) }
|
||||
var showReminderPicker by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -1114,6 +1123,17 @@ private fun EventEditContent(
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
// A full-screen picker over the form is a change of place, so the form's
|
||||
// keyboard has no business following it there — least of all onto the
|
||||
// calendar manager, which the picker can hand off to. The form's own field
|
||||
// keeps its text; only focus and the IME go.
|
||||
LaunchedEffect(showCalendarPicker) {
|
||||
if (showCalendarPicker) {
|
||||
focusManager.clearFocus(force = true)
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
if (showCalendarPicker) {
|
||||
CalendarPicker(
|
||||
calendars = state.calendars,
|
||||
@@ -1122,6 +1142,17 @@ private fun EventEditContent(
|
||||
viewModel.setCalendar(it)
|
||||
showCalendarPicker = false
|
||||
},
|
||||
// Close the picker on the way out. It is a Compose Dialog — its own
|
||||
// window, always above the activity's content — so the manager would
|
||||
// otherwise open behind it and the tap would look dead. The form
|
||||
// stays standing underneath, its calendar row one tap from a picker
|
||||
// that re-queries on open.
|
||||
onManageCalendars = onManageCalendars?.let { openManager ->
|
||||
{
|
||||
showCalendarPicker = false
|
||||
openManager()
|
||||
}
|
||||
},
|
||||
onDismiss = { showCalendarPicker = false },
|
||||
)
|
||||
}
|
||||
@@ -2277,6 +2308,7 @@ private fun CalendarPicker(
|
||||
selectedId: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
FullScreenPicker(
|
||||
title = stringResource(R.string.event_detail_calendar),
|
||||
@@ -2286,6 +2318,7 @@ private fun CalendarPicker(
|
||||
calendars = calendars,
|
||||
selectedId = selectedId,
|
||||
onSelect = onSelect,
|
||||
onManageCalendars = onManageCalendars,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import de.jeanlucmakiola.calendula.domain.EventColorOption
|
||||
import de.jeanlucmakiola.calendula.domain.EventForm
|
||||
import de.jeanlucmakiola.calendula.domain.EventFormField
|
||||
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import de.jeanlucmakiola.calendula.domain.populatedFields
|
||||
import de.jeanlucmakiola.calendula.domain.problems
|
||||
import de.jeanlucmakiola.calendula.domain.toEditSnapshot
|
||||
@@ -177,19 +178,16 @@ class EventEditViewModel @Inject constructor(
|
||||
repository.calendars().catch { emit(emptyList()) }
|
||||
|
||||
/**
|
||||
* Writable calendars — the only valid event targets. Calendars switched off
|
||||
* in Settings → Calendars are excluded, so you can't create into one you've
|
||||
* turned off; a last-used preselect landing on a now-off calendar falls back
|
||||
* to the first remaining writable one (handled by [resolvedCalendarId] and
|
||||
* [state]). Managed special-dates calendars are excluded too: their events
|
||||
* are owned by the contact sync, which would delete any user event created
|
||||
* there.
|
||||
* The calendars a new event can be saved to ([isEventTarget]): writable,
|
||||
* switched on, not a contact-filled mirror, not a non-syncing subscription.
|
||||
* A last-used preselect landing on an excluded calendar falls back to the
|
||||
* first remaining one (handled by [resolvedCalendarId] and [state]).
|
||||
*
|
||||
* This is the list of *targets*. An event already living in an excluded
|
||||
* calendar keeps it — [state] adds it back to the picker.
|
||||
*/
|
||||
private val writableCalendars: Flow<List<CalendarSource>> = allCalendars.map { calendars ->
|
||||
calendars.filter { it.canModifyContents && it.isVisibleInSystem && !it.isManaged }
|
||||
calendars.filter { it.isEventTarget }
|
||||
}
|
||||
|
||||
/** The target calendar id, resolved exactly as the form shows it. */
|
||||
|
||||
@@ -75,6 +75,7 @@ fun ImportScreen(
|
||||
onClose: () -> Unit,
|
||||
onOpenSingle: (EventForm) -> Unit,
|
||||
forceMany: Boolean = false,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
// Key the VM by the file uri. This screen has no nav backstack, so an
|
||||
// unkeyed hiltViewModel() resolves to the Activity's store and is retained
|
||||
// across imports — its one-shot `load` guard would then show the *previous*
|
||||
@@ -155,7 +156,12 @@ fun ImportScreen(
|
||||
|
||||
ImportUiState.Empty -> CenteredMessage(stringResource(R.string.import_empty), onClose)
|
||||
ImportUiState.Failed -> CenteredMessage(stringResource(R.string.import_failed), onClose)
|
||||
is ImportUiState.Many -> ManyContent(s, selected, onSelect = { selected = it })
|
||||
is ImportUiState.Many -> ManyContent(
|
||||
state = s,
|
||||
selected = selected,
|
||||
onSelect = { selected = it },
|
||||
onManageCalendars = onManageCalendars,
|
||||
)
|
||||
is ImportUiState.Done -> DoneContent(s, onClose)
|
||||
}
|
||||
}
|
||||
@@ -163,10 +169,24 @@ fun ImportScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (Long) -> Unit) {
|
||||
// No writable calendar to import into — tell the user honestly.
|
||||
private fun ManyContent(
|
||||
state: ImportUiState.Many,
|
||||
selected: Long?,
|
||||
onSelect: (Long) -> Unit,
|
||||
onManageCalendars: (() -> Unit)? = null,
|
||||
) {
|
||||
// No calendar to import into — tell the user honestly, and carry the same
|
||||
// way out the picker's footer offers below. This is the state that footer
|
||||
// exists for: every writable calendar being switched off, read-only or
|
||||
// contact-filled is exactly what empties this list (#76).
|
||||
if (state.calendars.isEmpty()) {
|
||||
CenteredMessage(stringResource(R.string.import_no_calendar), onClose = null)
|
||||
CenteredMessage(
|
||||
message = stringResource(R.string.import_no_calendar),
|
||||
onClose = null,
|
||||
actionLabel = stringResource(R.string.settings_manage_calendars)
|
||||
.takeIf { onManageCalendars != null },
|
||||
onAction = onManageCalendars,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -178,6 +198,7 @@ private fun ManyContent(state: ImportUiState.Many, selected: Long?, onSelect: (L
|
||||
calendars = state.calendars,
|
||||
selectedId = selected,
|
||||
onSelect = onSelect,
|
||||
onManageCalendars = onManageCalendars,
|
||||
)
|
||||
if (state.warnings.isNotEmpty()) {
|
||||
Column(
|
||||
@@ -333,7 +354,12 @@ private fun WarningText(warning: IcsParseWarning) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
||||
private fun CenteredMessage(
|
||||
message: String,
|
||||
onClose: (() -> Unit)?,
|
||||
actionLabel: String? = null,
|
||||
onAction: (() -> Unit)? = null,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
Modifier.padding(24.dp),
|
||||
@@ -344,6 +370,9 @@ private fun CenteredMessage(message: String, onClose: (() -> Unit)?) {
|
||||
if (onClose != null) {
|
||||
Button(onClick = onClose) { Text(stringResource(R.string.import_close)) }
|
||||
}
|
||||
if (actionLabel != null && onAction != null) {
|
||||
Button(onClick = onAction) { Text(actionLabel) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsParseWarning
|
||||
import de.jeanlucmakiola.calendula.domain.ics.IcsParser
|
||||
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
|
||||
import de.jeanlucmakiola.calendula.domain.ics.toEventForm
|
||||
import de.jeanlucmakiola.calendula.domain.isEventTarget
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -85,18 +86,14 @@ class ImportViewModel @Inject constructor(
|
||||
warnings = parsed.warnings,
|
||||
)
|
||||
else -> {
|
||||
// A calendar switched off in Settings → Calendars is off
|
||||
// everywhere, so it can't be an import target — exclude it
|
||||
// alongside the read-only ones. Managed special-dates
|
||||
// calendars are contact-derived and editor-locked, so
|
||||
// they're not a valid destination either.
|
||||
// The same targets the event form offers ([isEventTarget]):
|
||||
// an import is a bulk create, so a calendar that can't hold
|
||||
// one event can't hold thirty.
|
||||
ImportUiState.Many(
|
||||
events = parsed.events,
|
||||
warnings = parsed.warnings,
|
||||
calendars = repository.calendars().first()
|
||||
.filter {
|
||||
it.canModifyContents && !it.isManaged && it.isVisibleInSystem
|
||||
},
|
||||
.filter { it.isEventTarget },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,9 +57,12 @@ import de.jeanlucmakiola.floret.components.Position
|
||||
import de.jeanlucmakiola.floret.locale.currentLocale
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalSoftenColors
|
||||
import de.jeanlucmakiola.calendula.ui.common.eventFill
|
||||
import de.jeanlucmakiola.calendula.domain.spanFirstDay
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.common.timeOfDayFormatter
|
||||
import de.jeanlucmakiola.floret.components.positionOf
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toJavaLocalDate
|
||||
import java.time.Instant as JavaInstant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
@@ -220,9 +223,14 @@ private fun searchSummary(event: EventInstance): String {
|
||||
val start = remember(event.start, zone) {
|
||||
JavaInstant.ofEpochMilli(event.start.toEpochMilliseconds()).atZone(zone)
|
||||
}
|
||||
val dateText = remember(locale) {
|
||||
// The date comes from the shared span rule, not from [start]: an all-day
|
||||
// event sits at UTC midnight, so reading its date in the device zone names
|
||||
// the day before west of UTC (#82). The clock time below stays in the device
|
||||
// zone — it is only ever rendered for timed events.
|
||||
val dateText = remember(event.start, event.end, event.isAllDay, locale) {
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)
|
||||
}.format(start)
|
||||
.format(event.spanFirstDay(TimeZone.currentSystemDefault()).toJavaLocalDate())
|
||||
}
|
||||
val use24Hour = LocalUse24HourFormat.current
|
||||
val timeText = if (event.isAllDay) {
|
||||
stringResource(R.string.event_detail_all_day)
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.PrivacyTip
|
||||
import androidx.compose.material.icons.filled.Translate
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material.icons.filled.UploadFile
|
||||
@@ -408,7 +409,9 @@ private fun LanguageRow(position: Position) {
|
||||
@Composable
|
||||
private fun AboutCard() {
|
||||
// The card layout lives in floret-kit (components.AboutCard); Calendula
|
||||
// supplies its own logo, author and the source / licence / support links.
|
||||
// supplies its own logo, author and the source / licence / privacy / support
|
||||
// links. The privacy policy has to be reachable from inside the app, not just
|
||||
// from the store listing, because Calendula touches calendar and contact data.
|
||||
AboutCard(
|
||||
logo = { AppLogo() },
|
||||
appName = stringResource(R.string.app_name),
|
||||
@@ -424,6 +427,11 @@ private fun AboutCard() {
|
||||
label = stringResource(R.string.settings_license),
|
||||
url = stringResource(R.string.about_license_url),
|
||||
),
|
||||
AboutLink(
|
||||
icon = Icons.Default.PrivacyTip,
|
||||
label = stringResource(R.string.settings_about_privacy),
|
||||
url = stringResource(R.string.about_privacy_url),
|
||||
),
|
||||
),
|
||||
highlightLink = AboutLink(
|
||||
icon = Icons.Default.Favorite,
|
||||
|
||||
@@ -53,11 +53,11 @@
|
||||
<string name="event_share_failed">تعذّر مشاركة هذا الحدث.</string>
|
||||
<string name="event_delete_title">حذف الحدث؟</string>
|
||||
<string name="event_delete_body">الحدث أُزيل من تقويمك ومن كل جهاز تتم مزامنته معه.</string>
|
||||
<string name="event_delete_recurring_title">حذف الحدث المتكرر</string>
|
||||
<string name="event_delete_recurring_title">حذف الحدث المتكرّر</string>
|
||||
<string name="event_delete_option_occurrence">فقط هذا الحدث</string>
|
||||
<string name="event_delete_option_following">هذا وجميع الأحداث اللاحقة</string>
|
||||
<string name="event_delete_option_series">جميع الأحداث في السلسلة</string>
|
||||
<string name="event_edit_recurring_title">تعديل الحدث المتكرر</string>
|
||||
<string name="event_edit_recurring_title">تعديل الحدث المتكرّر</string>
|
||||
<string name="event_delete_failed">تعذّر حذف الحدث</string>
|
||||
<string name="event_delete_write_denied">Calendula يحتاج إلى صلاحية للكتابة لحذف الأحداث</string>
|
||||
<string name="dialog_cancel">إلغاء</string>
|
||||
@@ -108,7 +108,7 @@
|
||||
<string name="event_edit_conflict_discard_hint">الحدث يبقى كما هو الآن</string>
|
||||
<string name="import_reminder_prompt_title">هل تريد تطبيق تذكيرك الافتراضي؟</string>
|
||||
<string name="event_edit_gone_body">هذا الحدث حُذف في هذه الأثناء، على سبيل المثال على جهاز آخر. لم يعد من الممكن حفظ تغييراتك.</string>
|
||||
<string name="event_edit_more_fields">المزيد من الخيارات</string>
|
||||
<string name="event_edit_more_fields">المزيد من الخانات</string>
|
||||
<string name="event_access_public">علني</string>
|
||||
<string name="event_access_default">الافتراضي</string>
|
||||
<string name="event_availability_busy">مشغول</string>
|
||||
@@ -153,7 +153,7 @@
|
||||
<string name="recurrence_with_until">%1$s حتى %2$s</string>
|
||||
<string name="recurrence_with_count">%1$s، %2$d مرّات</string>
|
||||
<string name="import_reminder_prompt_apply">طَبِّق الافتراضي</string>
|
||||
<string name="event_detail_recurring">حدث مُتكرر</string>
|
||||
<string name="event_detail_recurring">حدث متكرّر</string>
|
||||
<string name="recurrence_on_days">%1$s في %2$s</string>
|
||||
<string name="event_attendee_unknown">—</string>
|
||||
<string name="event_attendee_accepted">تم القُبول</string>
|
||||
@@ -166,7 +166,7 @@
|
||||
<string name="event_status_cancelled">ملغي</string>
|
||||
<string name="event_availability_free">متاح</string>
|
||||
<string name="event_access_private">خاص</string>
|
||||
<string name="event_access_confidential">سري</string>
|
||||
<string name="event_access_confidential">سرّي</string>
|
||||
<string name="event_attendee_organizer">المُنظِّم</string>
|
||||
<string name="event_attendee_optional">اختياري</string>
|
||||
<string name="reminder_default">التذكير الافتراضي</string>
|
||||
@@ -180,7 +180,7 @@
|
||||
<string name="reminder_onboarding_body">Android لا يعرض تذكيرات الأحداث من نفسه — بل يجب أن يقوم تطبيق تقويم بذلك. دع Calendula يتولى هذه المهمة.</string>
|
||||
<string name="reminder_benefit_delivery_body">كل تذكير لأحداثك يصِل كإشعار، في الوقت المحدد تمامًا.</string>
|
||||
<string name="reminder_benefit_duplicates_title">هل تستخدم تطبيق تقويم ثانٍ؟</string>
|
||||
<string name="reminder_benefit_duplicates_body">إذا كان تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
|
||||
<string name="reminder_benefit_duplicates_body">إذا تطبيق آخر أيضًا ينشر تذكيرات، فستراهم مرتين — قم بإيقاف تشغيلهم هناك أو هنا.</string>
|
||||
<string name="reminder_benefit_reversible_title">يمكنك تغييره في أي وقت</string>
|
||||
<string name="reminder_onboarding_enable_button">شَغِّل التذكيرات</string>
|
||||
<string name="reminder_onboarding_skip_button">ليس الآن</string>
|
||||
@@ -200,7 +200,7 @@
|
||||
<string name="search_hint">البحث عن الأحداث</string>
|
||||
<string name="search_clear">مسح</string>
|
||||
<string name="agenda_no_more_today">لا مزيد من الأحداث اليوم</string>
|
||||
<string name="search_empty">لا توجد أحداث مطابقة ”%1$s“.</string>
|
||||
<string name="search_empty">لا أحداث مطابقة ”%1$s“.</string>
|
||||
<string name="search_idle_hint">ابحث عن أحداثك بالعنوان، الموقع أو الملاحظات.</string>
|
||||
<string name="search_back">الرجوع</string>
|
||||
<string name="search_action">البحث</string>
|
||||
@@ -217,7 +217,7 @@
|
||||
<string name="back">رجوع</string>
|
||||
<string name="settings_section_appearance">المظهر</string>
|
||||
<string name="settings_dynamic_color">اللون الديناميكي</string>
|
||||
<string name="settings_dynamic_color_unavailable">يتطلب Android 12 أو أحدث</string>
|
||||
<string name="settings_dynamic_color_unavailable">يتطلب أندرويد ١٢ أو أحدث</string>
|
||||
<string name="settings_default_view">طريقة العرض الافتراضية</string>
|
||||
<string name="settings_soften_colors">ألوان تقويم ناعمة</string>
|
||||
<string name="settings_soften_colors_summary">خفف ألوان التقويم والأحداث لتتناسب مع الثيم. قم بإيقافه لإظهار الألوان الخام من مصدر التقويم.</string>
|
||||
@@ -227,7 +227,7 @@
|
||||
<string name="settings_font_choose_file">اختر ملفًا…</string>
|
||||
<string name="settings_font_custom_selected">خط مخصص</string>
|
||||
<string name="settings_font_import_failed">تعذّر قراءة هذا الملف كخط</string>
|
||||
<string name="settings_week_start">يبدأ الأسبوع في</string>
|
||||
<string name="settings_week_start">الأسبوع يبدأ في</string>
|
||||
<string name="settings_week_start_auto">تلقائي</string>
|
||||
<string name="settings_time_format_auto">تلقائي</string>
|
||||
<string name="settings_time_format">تنسيق الوقت</string>
|
||||
@@ -243,4 +243,58 @@
|
||||
<string name="settings_past_events_show">إظهار</string>
|
||||
<string name="settings_past_events_hide">إخفاء</string>
|
||||
<string name="settings_agenda_header">جدول</string>
|
||||
<string name="event_edit_timezone_device">المنطقة الزمنية للجهاز</string>
|
||||
<string name="event_edit_timezone_search">البحث عن المناطق الزمنية</string>
|
||||
<string name="event_edit_timezone_all">جميع المناطق الزمنية</string>
|
||||
<string name="event_edit_timezone_none">لا منطقة زمنية تطابق \"%1$s\"</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s توقيتك</string>
|
||||
<string name="event_edit_timezone_recent">الأحدث</string>
|
||||
<string name="event_edit_recurrence_incomplete">أدخل رقمًا من ١ إلى ٩٩٩</string>
|
||||
<plurals name="duration_minutes">
|
||||
<item quantity="zero">(%d) لا دقائق</item>
|
||||
<item quantity="one">(%d) دقيقة واحده</item>
|
||||
<item quantity="two">%d دقيقتان</item>
|
||||
<item quantity="few">%d دقائق</item>
|
||||
<item quantity="many">%d دقيقة</item>
|
||||
<item quantity="other">%d دقيقة</item>
|
||||
</plurals>
|
||||
<plurals name="duration_hours">
|
||||
<item quantity="zero">(%d) لا ساعات</item>
|
||||
<item quantity="one">(%d) ساعة واحده</item>
|
||||
<item quantity="two">%d ساعتان</item>
|
||||
<item quantity="few">%d ساعات</item>
|
||||
<item quantity="many">%d ساعة</item>
|
||||
<item quantity="other">%d ساعة</item>
|
||||
</plurals>
|
||||
<plurals name="duration_days">
|
||||
<item quantity="zero">(%d) لا أيام</item>
|
||||
<item quantity="one">(%d) يوم واحد</item>
|
||||
<item quantity="two">%d يومان</item>
|
||||
<item quantity="few">%d أيام</item>
|
||||
<item quantity="many">%d يوم</item>
|
||||
<item quantity="other">%d يوم</item>
|
||||
</plurals>
|
||||
<plurals name="duration_weeks">
|
||||
<item quantity="zero">(%d) لا أسابيع</item>
|
||||
<item quantity="one">(%d) أسبوع واحد</item>
|
||||
<item quantity="two">%d أسبوعان</item>
|
||||
<item quantity="few">%d أسابيع</item>
|
||||
<item quantity="many">%d أسبوع</item>
|
||||
<item quantity="other">%d أسبوع</item>
|
||||
</plurals>
|
||||
<string name="agenda_span_starts">يبدأ %1$s</string>
|
||||
<string name="agenda_span_ends">ينتهي %1$s</string>
|
||||
<string name="today_jump_action">الانتقال إلى اليوم</string>
|
||||
<string name="widget_refresh">تحديث</string>
|
||||
<string name="settings_app_name">اسم التطبيق</string>
|
||||
<string name="settings_dim_completed">تعتيم الأحداث المكتملة</string>
|
||||
<string name="settings_dim_completed_summary">تعتيم الأحداث التي انتهت بالفعل في عرض الشهر والأسبوع</string>
|
||||
<string name="settings_today_toolbar">زر اليوم في شريط الأدوات</string>
|
||||
<string name="settings_today_toolbar_summary">إظهار زر الانتقال إلى اليوم في شريط الأدوات بدلاً من زر عائم</string>
|
||||
<string name="settings_app_name_summary">اعرض Calendula كـ \"Calendar\" في مشغّل التطبيقات الخاص بك. فقط الاسم في المشغّل يتغير؛ وقد ينتقل الرمز إلى مكان جديد بعد التبديل.</string>
|
||||
<string name="settings_past_events_dim">تعتيم</string>
|
||||
<string name="settings_past_events">الأحداث السابقة</string>
|
||||
<string name="settings_agenda_range">مدى الجدول</string>
|
||||
<string name="agenda_range_custom">مخصص…</string>
|
||||
<string name="agenda_range_custom_hint">أيام</string>
|
||||
</resources>
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
<string name="settings_translate">Pomóż w tłumaczeniu</string>
|
||||
<string name="settings_translate_hint">Dodaj lub ulepsz tłumaczenie w Weblate</string>
|
||||
<string name="settings_appearance_subtitle">Motyw, domyślny widok, pierwszy dzień tygodnia</string>
|
||||
<string name="settings_views_subtitle">Kolejność przycisku szybkiego przełączania oraz menu</string>
|
||||
<string name="settings_views_subtitle">Układ widoku miesiąca, przycisk szybkiego przełączania, kolejność menu</string>
|
||||
<string name="settings_event_form_subtitle">Domyślne pola dla nowych wydarzeń</string>
|
||||
<string name="settings_notifications_subtitle">Przypomnienia o wydarzeniach</string>
|
||||
<string name="settings_special_dates_subtitle">Urodziny i rocznice kontaktów</string>
|
||||
@@ -515,4 +515,32 @@
|
||||
<string name="settings_agenda_show_today_hint">Zachowaj dzisiejszy dzień na górze agendy i widżetu, nawet gdy nie ma już na dziś żadnych zadań.</string>
|
||||
<string name="special_dates_calendar_birthday">Urodziny</string>
|
||||
<string name="special_dates_calendar_anniversary">Rocznice</string>
|
||||
<string name="event_edit_timezone_device">Strefa czasowa urządzenia</string>
|
||||
<string name="event_edit_timezone_device_summary">Podąża za Twoją lokalizacją</string>
|
||||
<string name="event_edit_timezone_search">Znajdź strefę czasową</string>
|
||||
<string name="event_edit_timezone_recent">Ostatnie</string>
|
||||
<string name="event_edit_timezone_all">Wszystkie strefy czasowe</string>
|
||||
<string name="event_edit_timezone_none">Żadna strefa czasowa nie pasuje do „%1$s”</string>
|
||||
<string name="event_edit_timezone_local_time">%1$s Twojego czasu</string>
|
||||
<string name="event_edit_recurrence_incomplete">Wpisz liczbę od 1 do 999</string>
|
||||
<string name="agenda_span_starts">Początek o %1$s</string>
|
||||
<string name="agenda_span_ends">Koniec o %1$s</string>
|
||||
<string name="today_jump_action">Dzisiaj</string>
|
||||
<string name="settings_today_toolbar">Przycisk „Dzisiaj” na pasku narzędzi</string>
|
||||
<string name="settings_today_toolbar_summary">Pokaż przycisk skoku do dzisiaj na pasku narzędzi zamiast przycisku pływającego</string>
|
||||
<string name="settings_app_name">Nazwa aplikacji</string>
|
||||
<string name="settings_app_name_summary">Wyświetlaj Calendula jako „Kalendarz” w menu aplikacji. Zmieni się tylko nazwa w menu; po przełączeniu ikona może pojawić się w innym miejscu.</string>
|
||||
<string name="settings_month_header">Widok miesiąca</string>
|
||||
<string name="settings_month_view_style">Styl widoku miesiąca</string>
|
||||
<string name="month_style_paged">Strony</string>
|
||||
<string name="month_style_paged_summary">Jeden miesiąc wypełnia ekran. Przesuń w lewo lub w prawo, aby zmienić miesiąc.</string>
|
||||
<string name="month_style_continuous">Przewijanie miesięcy</string>
|
||||
<string name="month_style_continuous_summary">Każdy miesiąc znajduje się pod własnym nagłówkiem, oddzielony od następnego niewielkim odstępem.</string>
|
||||
<string name="month_style_dense">Tygodnie bez przerw</string>
|
||||
<string name="month_style_dense_summary">Tygodnie są ułożone jeden po drugim, każdy miesiąc płynnie łączy się z następnym, bez odstępu pomiędzy.</string>
|
||||
<string name="month_style_split">Podzielony</string>
|
||||
<string name="month_style_split_summary">Zwięzła siatka wyróżnia dni z wydarzeniami a lista dla dnia wybranego dotknięciem wyświetla się poniżej.</string>
|
||||
<string name="month_split_no_events">Brak planów</string>
|
||||
<string name="month_split_expand">Pokaż cały miesiąc</string>
|
||||
<string name="month_split_collapse">Pokaż wydarzenia dnia</string>
|
||||
</resources>
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
<string name="settings_license_value">MIT</string>
|
||||
<string name="settings_about_author">by Jean-Luc Makiola</string>
|
||||
<string name="settings_about_source">Source</string>
|
||||
<string name="settings_about_privacy">Privacy policy</string>
|
||||
<string name="settings_about_support">Support development</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_about_logo_desc">Calendula app icon</string>
|
||||
@@ -477,6 +478,13 @@
|
||||
<string name="calendars_visibility_a11y">Show \"%1$s\"</string>
|
||||
<string name="calendars_visibility_notice_title">Some calendars are switched off</string>
|
||||
<string name="calendars_visibility_notice_message">Calendula now shows the calendars that are switched on for this device, so what you see and what reminds you can no longer disagree. Some of yours are currently off — they were switched off here or in another calendar app. Turn any of them back on in Settings → Calendars.</string>
|
||||
<!-- Footer row under the event-form and .ics import calendar pickers. -->
|
||||
<string name="calendar_picker_missing_title">Missing a calendar?</string>
|
||||
<string name="calendar_picker_missing_summary">It may be switched off, read-only, or filled from your contacts — manage your calendars here.</string>
|
||||
<string name="calendars_state_read_only">Read-only</string>
|
||||
<string name="calendars_state_not_synced">Not synced to this device</string>
|
||||
<string name="calendars_state_managed">Filled from your contacts</string>
|
||||
<string name="calendars_managed_delete_locked">This calendar is filled from your contacts, so Calendula would create it again on the next sync. Turn special dates off under Settings → Special dates to delete it.</string>
|
||||
<string name="calendars_synced_header">Synced calendars</string>
|
||||
<string name="calendars_synced_hint">These come from accounts on your device. Create and edit them in their own app.</string>
|
||||
<string name="calendars_manage_in_app">Manage in app</string>
|
||||
@@ -572,8 +580,9 @@
|
||||
<string name="settings_qs_tile">Add Quick Settings tile</string>
|
||||
<string name="settings_qs_tile_hint">Add a “New event” tile to the Quick Settings panel.</string>
|
||||
|
||||
<string name="about_source_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula</string>
|
||||
<string name="about_license_url" translatable="false">https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/LICENSE</string>
|
||||
<string name="about_source_url" translatable="false">https://codeberg.org/jlmakiola/calendula</string>
|
||||
<string name="about_license_url" translatable="false">https://codeberg.org/jlmakiola/calendula/src/branch/main/LICENSE</string>
|
||||
<string name="about_privacy_url" translatable="false">https://jeanlucmakiola.de/calendula/privacy</string>
|
||||
<string name="about_support_url" translatable="false">https://ko-fi.com/jeanlucmakiola</string>
|
||||
<string name="about_translate_url" translatable="false">https://weblate.dev.jeanlucmakiola.de/engage/calendula/</string>
|
||||
|
||||
|
||||
@@ -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 de.jeanlucmakiola.calendula.domain.EventInstance
|
||||
import de.jeanlucmakiola.calendula.domain.spansMultipleDays
|
||||
import kotlinx.datetime.LocalDate
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
|
||||
@@ -45,9 +45,14 @@ class EventEditViewModelTest {
|
||||
private val beginMillis = 1_781_164_800_000L
|
||||
private val endMillis = beginMillis + 3_600_000L
|
||||
|
||||
private fun cal(id: Long, visible: Boolean = true): CalendarSource = CalendarSource(
|
||||
private fun cal(
|
||||
id: Long,
|
||||
visible: Boolean = true,
|
||||
syncsEvents: Boolean = true,
|
||||
): CalendarSource = CalendarSource(
|
||||
id = id, displayName = "Cal $id", accountName = "acc@local", accountType = "LOCAL",
|
||||
color = 0xFF112233.toInt(), isVisibleInSystem = visible, canModifyContents = true,
|
||||
syncsEvents = syncsEvents,
|
||||
)
|
||||
|
||||
private fun detail(calendarId: Long, rrule: String? = null): EventDetail = EventDetail(
|
||||
@@ -108,6 +113,27 @@ class EventEditViewModelTest {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a calendar whose account is not synced to this device is not a target`(
|
||||
@TempDir tempDir: Path,
|
||||
) = runTest(dispatcher) {
|
||||
// Writable and switched on, but the account keeps its events off the
|
||||
// device: nothing saved here ever reaches it, and the provider drops the
|
||||
// rows when the subscription comes back (#76).
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
calendarsResult = listOf(cal(1L), cal(2L, syncsEvents = false))
|
||||
eventDetailResult = { detail(calendarId = 1L) }
|
||||
}
|
||||
val vm = viewModel(tempDir, fake)
|
||||
val job = activate(vm)
|
||||
|
||||
vm.openForEdit(eventId = 42L, beginMillis = beginMillis, endMillis = endMillis)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(vm.state.value?.calendars?.map { it.id }).containsExactly(1L)
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `editing an event in a switched-off calendar keeps it in the picker`(
|
||||
@TempDir tempDir: Path,
|
||||
|
||||
@@ -33,7 +33,7 @@ flowchart TD
|
||||
Repo["CalendarRepository\n(interface + impl, Flow-based, io-dispatched)"]
|
||||
DS["CalendarDataSource\n(interface + AndroidCalendarDataSource)"]
|
||||
Prefs["SettingsPrefs / CalendarPrefs\n(DataStore)"]
|
||||
Rem["reminders/\nReminderAlertStore + ReminderNotifier"]
|
||||
Rem["reminders/\nReminderScanner + ReminderNotifier"]
|
||||
end
|
||||
Provider[("CalendarContract\n(system calendar provider)")]
|
||||
|
||||
@@ -137,29 +137,56 @@ can't fake a conflict.
|
||||
|
||||
## Reminder delivery
|
||||
|
||||
The provider schedules reminder alarms (for `METHOD_ALERT` rows only) and
|
||||
broadcasts `EVENT_REMINDER` — but posts no notification; a calendar app
|
||||
must (the Etar model):
|
||||
Calendula plans and fires its own reminders. It reads the offsets in
|
||||
`Reminders` as data, works out when each occurrence's reminder is due, and holds
|
||||
**one** exact alarm for the earliest one still ahead:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as CalendarProvider
|
||||
participant R as EventReminderReceiver
|
||||
participant S as ReminderAlertStore
|
||||
participant T as Trigger (alarm / boot / time change / edit / launch / daily worker)
|
||||
participant Sc as ReminderScanner
|
||||
participant Src as ReminderInstanceSource
|
||||
participant P as ReminderPlan (pure)
|
||||
participant N as ReminderNotifier
|
||||
P->>R: EVENT_REMINDER broadcast (manifest receiver, exported)
|
||||
R->>S: dueAlerts(now) — CalendarAlerts: SCHEDULED, alarmTime ≤ now
|
||||
S-->>R: due alerts
|
||||
R->>N: post(alert) — one notification per alert, tag = alert id
|
||||
R->>S: markFired(ids) — best effort, needs WRITE_CALENDAR
|
||||
participant A as ReminderAlarmScheduler
|
||||
T->>Sc: scan()
|
||||
Sc->>Src: occurrences(window) + reminderMinutes(ids)
|
||||
Src-->>Sc: Instances ⋈ Reminders
|
||||
Sc->>P: planReminders / scheduleReminders(watermark, now)
|
||||
P-->>Sc: due + next alarm
|
||||
Sc->>N: post(alert) — tag = reminder key
|
||||
Sc->>A: scheduleScan(next)
|
||||
```
|
||||
|
||||
Posting happens before marking: a crash in between re-posts silently (same
|
||||
tag + `setOnlyAlertOnce`) rather than losing a reminder. Swiped
|
||||
notifications never return because `FIRED` rows are never re-queried.
|
||||
**Why not the provider's broadcast.** It used to schedule the alarms, write the
|
||||
`CalendarAlerts` rows and broadcast `EVENT_REMINDER`, and the app only reacted.
|
||||
That chain holds on stock Android and demonstrably not everywhere: AOSP's own
|
||||
unbundled calendar carries three separate workarounds for OEM providers that
|
||||
retarget the broadcast or only write the alert row at alert time. A reacting app
|
||||
cannot tell "nothing was due" from "the broadcast never came" (#75) — and the
|
||||
reporter's silent events were in a calendar Calendula created itself, so
|
||||
`VISIBLE` was never the cause there.
|
||||
|
||||
**One visibility model.** The provider only schedules alarms for calendars with
|
||||
`Calendars.VISIBLE = 1`, so that flag *is* the app's on/off switch: Settings →
|
||||
**The watermark replaces `CalendarAlerts.STATE`.** A scan posts the reminders
|
||||
whose moment falls in `(lastScan, now]`, then moves the mark
|
||||
(`ReminderStatePrefs`). Half-open, so a scan that runs twice cannot post twice,
|
||||
while a scan that runs *late* still posts what the missed alarm owed — a reboot,
|
||||
an app update or a doze window costs nothing. A first-ever scan claims the
|
||||
present rather than the epoch, and a watermark left in the future by a clock
|
||||
change is clamped. Every trigger runs the same idempotent `scan()`, so there is
|
||||
no ordering between them to get wrong; `BOOT_COMPLETED` and `MY_PACKAGE_REPLACED`
|
||||
matter because both wipe pending alarms.
|
||||
|
||||
**All-day reminders fire at the hour the setting names.** The stored offset is
|
||||
not a plain lead time — `AllDayReminderEncoding` folds a wall-clock hour into it,
|
||||
sampled against one date's UTC offset — so taking it at face value drifts by the
|
||||
offset delta across a DST boundary, and rows from other apps carry no hour at
|
||||
all. The offset is therefore read only for *which day* it means; the hour comes
|
||||
from the global all-day reminder setting, recomposed against each occurrence's
|
||||
own date. Timed reminders need none of this: `begin` is an absolute instant.
|
||||
|
||||
**One visibility model.** The scan only plans occurrences of calendars with
|
||||
`Calendars.VISIBLE = 1`, and that flag *is* the app's on/off switch: Settings →
|
||||
Calendars writes it (one calendar per update — `CalendarProvider2` skips its own
|
||||
`checkNextAlarm()` reschedule for any selection that isn't `_id=`), and every
|
||||
display predicate reads `CalendarSource.isVisibleInSystem`. The reconciliation
|
||||
@@ -171,16 +198,14 @@ the app has not been allowed to write yet (read-only permission grant, or a
|
||||
pre-permission launch); `CalendarVisibilityReconciler` drains it entry by entry,
|
||||
and until it does, the repository and `ReminderNotifier.post` honour it. That
|
||||
gate also covers a snooze re-shown from our own alarm after its calendar was
|
||||
switched off. Silencing is not handling: an alert the gate drops keeps its
|
||||
`SCHEDULED` state while its event is still ahead (`handledAlertIds`), so
|
||||
switching the calendar back on re-posts it (`ReminderRecovery`) instead of
|
||||
losing it — the provider's own table is the stash. The drawer's filter sheet
|
||||
(`CalendarPrefs.hiddenCalendarIds`) is a separate in-app declutter that never
|
||||
touches reminders.
|
||||
switched off. The drawer's filter sheet (`CalendarPrefs.hiddenCalendarIds`) is a
|
||||
separate in-app declutter that never touches reminders.
|
||||
|
||||
Deliberately absent until real devices prove it necessary: own alarm
|
||||
scheduling, `BOOT_COMPLETED`, snooze/dismiss actions, battery-exemption
|
||||
prompts.
|
||||
Deliberately absent: a fallback to the provider's `EVENT_REMINDER` broadcast.
|
||||
Keeping both would double-post wherever the provider works, and Etar's way out —
|
||||
a latch that disables its own scheduling once a real broadcast arrives — cannot
|
||||
be copied, because our failure mode includes a broadcast that arrives with no
|
||||
alert row behind it.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -73,7 +73,8 @@ Published version codes so far: `v0.1.0`→100 … `v1.0.0`→10000 … `v2.0.0`
|
||||
CI and release are split so a change is built once on its PR and only does
|
||||
release work when a merge actually cuts a release:
|
||||
|
||||
- **`ci.yaml`** (on `pull_request`) — lint + unit tests + a debug assemble (and
|
||||
- **`ci.yaml`** (`.forgejo/workflows/`, on `pull_request`, **Codeberg**) — lint +
|
||||
unit tests + a debug assemble (and
|
||||
a Trivy scan), once per PR. Docs/metadata-only PRs skip the Android build but
|
||||
still report a green `CI` check.
|
||||
- **`release.yaml`** (on push to `main`, plus `workflow_dispatch`) — a cheap
|
||||
@@ -91,9 +92,9 @@ release work when a merge actually cuts a release:
|
||||
|
||||
Alongside F-Droid, each release is mirrored to the Codeberg repo
|
||||
(`jlmakiola/calendula`) as a plain download for users who don't want F-Droid.
|
||||
Gitea already **push-mirrors** branches and tags to Codeberg, but releases
|
||||
aren't git objects and don't sync, so the pipeline creates the release over the
|
||||
Codeberg API and attaches `calendula_v<version>.apk` + its `.sha256`. It's the
|
||||
Codeberg **push-mirrors** branches and tags to Gitea, but releases aren't git
|
||||
objects and don't sync in either direction, so the pipeline creates the release
|
||||
over the Codeberg API and attaches `calendula_v<version>.apk` + its `.sha256`. It's the
|
||||
same APK the F-Droid repo serves (same **app key**), so it adds no trust
|
||||
surface. The step is best-effort: a Codeberg outage never fails an
|
||||
already-published F-Droid release, and it skips cleanly if `CODEBERG_RELEASE_TOKEN` is
|
||||
@@ -109,6 +110,39 @@ build, the version bump, and tag/release creation, and just re-signs the
|
||||
existing F-Droid index with the configured repo key and re-uploads. Use this
|
||||
for key rotation or repo recovery without publishing a new app version.
|
||||
|
||||
## Two forges, one repo
|
||||
|
||||
**Codeberg (`jlmakiola/calendula`) is canonical** — git, issues, PRs, tags and
|
||||
releases. The self-hosted Gitea instance is build infrastructure: it holds the
|
||||
signing key, publishes the F-Droid repo, and runs the release pipeline. Codeberg
|
||||
push-mirrors `main` and tags to Gitea, and a bumped `versionName` arriving there
|
||||
triggers `release.yaml` exactly as before.
|
||||
|
||||
Workflows are separated by **directory**, not by conditionals. Forgejo looks in
|
||||
`.forgejo/workflows` → `.gitea/workflows` → `.github/workflows` and stops at the
|
||||
first that exists; Gitea doesn't know `.forgejo/` at all:
|
||||
|
||||
| Directory | Runs on | Contains | Secrets |
|
||||
| --- | --- | --- | --- |
|
||||
| `.forgejo/workflows/` | Codeberg | `ci.yaml`, `translations.yaml` | **none** |
|
||||
| `.gitea/workflows/` | Gitea | `release.yaml`, `renovate.yml` | signing key, F-Droid, Play, bot tokens |
|
||||
|
||||
The line is drawn at **secrets, not at CI-vs-release**. That's what makes fork
|
||||
PRs safe: everything a contributor can trigger lives in `.forgejo/` and can
|
||||
reference no secret. Renovate stays on the Gitea runner *even though it opens
|
||||
PRs on Codeberg* — it talks to Codeberg's API rather than moving its token onto
|
||||
the contributor-facing runner.
|
||||
|
||||
Two consequences worth remembering:
|
||||
|
||||
- **`detect` reads tags from Codeberg**, not from the Gitea instance it runs on.
|
||||
Push mirroring is `git push --mirror`, so a tag minted on Gitea is deleted by
|
||||
the next sync until the Codeberg tag push propagates back. Asking Gitea inside
|
||||
that window would re-cut a shipped release.
|
||||
- **Any ref that exists only on Gitea gets deleted** by the mirror. That's
|
||||
correct under Codeberg-canonical, but don't debug a "vanished" branch without
|
||||
remembering it.
|
||||
|
||||
## Secrets (Gitea → repo Settings → Actions → Secrets)
|
||||
|
||||
| Secret | Purpose |
|
||||
|
||||
@@ -18,9 +18,9 @@ Categories:
|
||||
- Calendar & Agenda
|
||||
License: MIT
|
||||
AuthorName: Jean-Luc Makiola
|
||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
||||
Changelog: https://gitea.jeanlucmakiola.de/makiolaj/calendula/src/branch/main/CHANGELOG.md
|
||||
SourceCode: https://codeberg.org/jlmakiola/calendula
|
||||
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
||||
Changelog: https://codeberg.org/jlmakiola/calendula/src/branch/main/CHANGELOG.md
|
||||
Donate: https://ko-fi.com/jeanlucmakiola
|
||||
|
||||
AutoName: Calendula
|
||||
|
||||
@@ -6,6 +6,6 @@ Summary: A modern Material 3 Expressive calendar for Android.
|
||||
Categories:
|
||||
- Time
|
||||
|
||||
SourceCode: https://gitea.jeanlucmakiola.de/makiolaj/calendula
|
||||
IssueTracker: https://gitea.jeanlucmakiola.de/makiolaj/calendula/issues
|
||||
SourceCode: https://codeberg.org/jlmakiola/calendula
|
||||
IssueTracker: https://codeberg.org/jlmakiola/calendula/issues
|
||||
Donate: https://ko-fi.com/jeanlucmakiola
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
":semanticCommits",
|
||||
],
|
||||
|
||||
// `config:recommended` brings in mergeConfidence:age-confidence-badges, whose
|
||||
// Age column is a Mend badge. Mend's Merge Confidence index only covers Maven
|
||||
// Central: org.jetbrains.kotlin, junit, truth, turbine et al resolve, but
|
||||
// every androidx/compose artifact lives on Google's Maven repo and comes back
|
||||
// as a grey UNKNOWN — i.e. most of this project. Renovate already knows the
|
||||
// real answer, since it derives release timestamps itself for the
|
||||
// minimumReleaseAge rules below (Google Maven serves `last-modified` on its
|
||||
// POMs), so take the age from there and leave Mend to the Confidence column,
|
||||
// which still carries signal for the Maven Central half.
|
||||
prBodyDefinitions: {
|
||||
Age: "{{#if releaseTimestamp}}{{{newVersionAgeInDays}}} d{{else}}unknown{{/if}}",
|
||||
},
|
||||
// Default heading links to the Merge Confidence docs; this column is ours now.
|
||||
prBodyHeadingDefinitions: {
|
||||
Age: "Age",
|
||||
},
|
||||
|
||||
// No automerge: a dependency bump goes through the same review (and, for
|
||||
// anything touching the build, the same on-device check) as a feature
|
||||
// before it can ride a release — see docs/RELEASING.md and the
|
||||
@@ -15,6 +32,22 @@
|
||||
|
||||
// One reviewable surface; the dashboard issue lists everything pending.
|
||||
dependencyDashboard: true,
|
||||
|
||||
// The cooling-off periods below are advisory, not a gate. "flexible" still
|
||||
// prefers a version that has cleared its window, but when every candidate is
|
||||
// too young it opens the PR at the newest one anyway, so merging early stays
|
||||
// a judgement call. (The default, "strict", would suppress the PR entirely
|
||||
// until a release aged in.) A still-young branch carries a yellow
|
||||
// `renovate/stability-days` check so it's visible which side of the line
|
||||
// it's on; with automerge off, nothing acts on that check by itself.
|
||||
//
|
||||
// NOT "none": that short-circuits the candidate loop in filter-checks.ts, and
|
||||
// that loop is what calls postprocessRelease — the only thing that fetches a
|
||||
// Maven artifact's Last-Modified header. Skipping it leaves releaseTimestamp
|
||||
// unset, which empties the Age column and quietly makes minimumReleaseAge and
|
||||
// the stability check no-ops, since both need that timestamp to compare.
|
||||
internalChecksFilter: "flexible",
|
||||
|
||||
labels: ["dependencies"],
|
||||
prConcurrentLimit: 5,
|
||||
prHourlyLimit: 0,
|
||||
@@ -25,11 +58,28 @@
|
||||
|
||||
// Gitea Actions workflows live under .gitea/workflows, not .github — extend
|
||||
// the github-actions manager (same syntax) to watch them too.
|
||||
// `fileMatch` is deprecated; the replacement takes the regex delimited, and
|
||||
// Renovate's config migration was already rewriting this on every run.
|
||||
"github-actions": {
|
||||
fileMatch: ["^\\.gitea/workflows/[^/]+\\.ya?ml$"],
|
||||
managerFilePatterns: ["/^\\.gitea/workflows/[^/]+\\.ya?ml$/"],
|
||||
},
|
||||
|
||||
packageRules: [
|
||||
// Cooling-off period, scaled by blast radius: how long a release should
|
||||
// have been out (and un-yanked, un-hotfixed) before it's considered
|
||||
// settled. Advisory only — see `internalChecksFilter` above.
|
||||
{
|
||||
matchUpdateTypes: ["major"],
|
||||
minimumReleaseAge: "30 days",
|
||||
},
|
||||
{
|
||||
matchUpdateTypes: ["minor"],
|
||||
minimumReleaseAge: "20 days",
|
||||
},
|
||||
{
|
||||
matchUpdateTypes: ["patch", "digest", "pin", "rollback"],
|
||||
minimumReleaseAge: "10 days",
|
||||
},
|
||||
// material3 is deliberately pinned to the 1.5 *alpha* line for the
|
||||
// Expressive APIs (see gradle/libs.versions.toml). Follow the alpha train
|
||||
// but keep it in its own PR, reviewed in isolation; revisit the pin when
|
||||
@@ -52,5 +102,18 @@
|
||||
],
|
||||
groupName: "test dependencies",
|
||||
},
|
||||
// Last word on the PR table. The merge-confidence preset sets prBodyColumns
|
||||
// from inside a packageRule of its own, and only for the datasources Mend
|
||||
// supports — so a plain top-level prBodyColumns would lose to it for maven
|
||||
// deps, and the Gradle wrapper / Actions / container bumps would keep the
|
||||
// default columns and show no age at all. A rule declared after it wins,
|
||||
// and gives every PR the same table.
|
||||
// "Pending" earns its place under a flexible filter: when the bump lands on
|
||||
// a version that has cleared its window but a newer one hasn't, that newer
|
||||
// version is named here rather than silently withheld.
|
||||
{
|
||||
matchPackageNames: ["*"],
|
||||
prBodyColumns: ["Package", "Type", "Change", "Age", "Pending", "Confidence"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user