Compare commits

..

3 Commits

Author SHA1 Message Date
225f4c3491 test(reminders): cover disabled-calendar alert filtering
Extract the disabled-calendar filtering from EventReminderReceiver into a
pure postableAlerts() function and unit-test it, since the receiver flow
itself is not reachable from a plain JUnit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:46:20 +02:00
5d887524b1 fix(reminders): suppress notifications for disabled calendars
Reminders fired for every due CalendarAlerts row regardless of whether
the event's calendar had been disabled in-app, even though disabled
calendars are hidden from every other surface. Carry the calendar id
through the alert and filter disabled calendars before posting, while
still marking all due alerts fired so the provider stops re-broadcasting
the suppressed ones.

Closes #17

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 00:43:56 +02:00
048f407ba1 docs(changelog): record v2.13.0 unreleased changes
Add an [Unreleased] section covering the two user-facing changes in
release/v2.13.0: multiple default reminders per calendar (Codeberg #14,
@moonj) and the fix for editing a single occurrence of a recurring event
(Codeberg #16). The debug-build ribbon is dev-only and intentionally omitted.
Define the [#14] and [#16] links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 00:40:46 +02:00
5 changed files with 109 additions and 6 deletions

View File

@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Set more than one default reminder per calendar. A calendar's default
reminders — and the global defaults under Settings → Notifications — can now
hold several lead times instead of just one, so new events can start with, say,
a reminder a week before *and* one on the day. The reminder pickers are now
multi-select; per-calendar overrides can still inherit the global default or
turn reminders off entirely. Thanks to @moonj for the suggestion ([#14]).
### Fixed
- Disabled calendars no longer notify. Reminders for events in a calendar you
have disabled (Settings → Calendars) are now suppressed instead of still
popping up — matching how a disabled calendar's events already stay hidden
everywhere else in the app ([#17]).
- Editing a single occurrence of a recurring event works again. Choosing **Only
this event** and saving a change to one event in a repeating series silently
did nothing — the change was rejected and the edit form simply reappeared with
nothing applied. The edited occurrence is now stored correctly, so the change
lands on just that one event and leaves the rest of the series untouched
([#16]).
## [2.12.0] — 2026-06-28
### Added
@@ -729,3 +751,6 @@ automatically, with zero telemetry and no internet permission.
[#10]: https://codeberg.org/jlmakiola/calendula/issues/10
[#12]: https://codeberg.org/jlmakiola/calendula/issues/12
[#13]: https://codeberg.org/jlmakiola/calendula/issues/13
[#14]: https://codeberg.org/jlmakiola/calendula/issues/14
[#16]: https://codeberg.org/jlmakiola/calendula/issues/16
[#17]: https://codeberg.org/jlmakiola/calendula/issues/17

View File

@@ -8,6 +8,7 @@ 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.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -16,6 +17,17 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The due alerts that should actually surface as notifications: everything
* except alerts whose calendar the user has disabled in-app (mirroring the
* event filtering in CalendarRepositoryImpl). The caller still marks the full
* due set fired, so suppressed alerts are not re-broadcast by the provider.
*/
internal fun postableAlerts(
due: List<ReminderAlert>,
disabledCalendarIds: Set<Long>,
): List<ReminderAlert> = due.filterNot { it.calendarId in disabledCalendarIds }
/**
* Becomes the app that turns the calendar provider's reminder alarms into
* visible notifications (the Etar model — the provider broadcasts
@@ -32,6 +44,7 @@ class EventReminderReceiver : BroadcastReceiver() {
@Inject lateinit var alertStore: ReminderAlertStore
@Inject lateinit var notifier: ReminderNotifier
@Inject lateinit var settingsPrefs: SettingsPrefs
@Inject lateinit var calendarPrefs: CalendarPrefs
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != CalendarContract.ACTION_EVENT_REMINDER) return
@@ -46,7 +59,11 @@ class EventReminderReceiver : BroadcastReceiver() {
if (settingsPrefs.remindersEnabled.first()) {
val now = System.currentTimeMillis()
val due = alertStore.dueAlerts(now)
due.forEach { notifier.post(it) }
val disabled = calendarPrefs.disabledCalendarIds.first()
// Suppress reminders for disabled calendars, but still mark
// every due alert fired so the provider stops re-broadcasting
// the suppressed ones.
postableAlerts(due, disabled).forEach { notifier.post(it) }
alertStore.markFired(due.map { it.alertId }, now)
}
} finally {

View File

@@ -75,6 +75,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
private const val EXTRA_ALERT_ID = "alert_id"
private const val EXTRA_EVENT_ID = "event_id"
private const val EXTRA_CALENDAR_ID = "calendar_id"
private const val EXTRA_BEGIN = "begin"
private const val EXTRA_END = "end"
private const val EXTRA_TITLE = "title"
@@ -87,6 +88,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
this.action = action
putExtra(EXTRA_ALERT_ID, alert.alertId)
putExtra(EXTRA_EVENT_ID, alert.eventId)
putExtra(EXTRA_CALENDAR_ID, alert.calendarId)
putExtra(EXTRA_BEGIN, alert.beginMillis)
putExtra(EXTRA_END, alert.endMillis)
putExtra(EXTRA_TITLE, alert.title)
@@ -113,6 +115,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
return ReminderAlert(
alertId = intent.getLongExtra(EXTRA_ALERT_ID, 0L),
eventId = intent.getLongExtra(EXTRA_EVENT_ID, 0L),
calendarId = intent.getLongExtra(EXTRA_CALENDAR_ID, 0L),
beginMillis = intent.getLongExtra(EXTRA_BEGIN, 0L),
endMillis = intent.getLongExtra(EXTRA_END, 0L),
title = intent.getStringExtra(EXTRA_TITLE).orEmpty(),

View File

@@ -16,6 +16,7 @@ import javax.inject.Singleton
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)". */
@@ -66,11 +67,12 @@ class AndroidReminderAlertStore @Inject constructor(
ReminderAlert(
alertId = c.getLong(0),
eventId = c.getLong(1),
beginMillis = c.getLong(2),
endMillis = c.getLong(3),
title = c.getString(4).orEmpty(),
location = c.getString(5)?.takeIf { it.isNotBlank() },
isAllDay = c.getInt(6) == 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,
),
)
}
@@ -102,6 +104,7 @@ class AndroidReminderAlertStore @Inject constructor(
val PROJECTION = arrayOf(
CalendarContract.CalendarAlerts._ID,
CalendarContract.CalendarAlerts.EVENT_ID,
CalendarContract.CalendarAlerts.CALENDAR_ID,
CalendarContract.CalendarAlerts.BEGIN,
CalendarContract.CalendarAlerts.END,
CalendarContract.CalendarAlerts.TITLE,

View File

@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class PostableAlertsTest {
private fun alert(alertId: Long, calendarId: Long) = ReminderAlert(
alertId = alertId,
eventId = alertId * 10,
calendarId = calendarId,
beginMillis = 0L,
endMillis = 0L,
title = "Event $alertId",
location = null,
isAllDay = false,
)
@Test
fun `keeps alerts when no calendar is disabled`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 200))
val postable = postableAlerts(due, disabledCalendarIds = emptySet())
assertThat(postable).isEqualTo(due)
}
@Test
fun `drops alerts for a disabled calendar`() {
val keep = alert(1, calendarId = 100)
val drop = alert(2, calendarId = 200)
val postable = postableAlerts(listOf(keep, drop), disabledCalendarIds = setOf(200))
assertThat(postable).containsExactly(keep)
}
@Test
fun `drops every alert when all their calendars are disabled`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
val postable = postableAlerts(due, disabledCalendarIds = setOf(100))
assertThat(postable).isEmpty()
}
@Test
fun `keeps multiple alerts from the same enabled calendar`() {
val due = listOf(alert(1, calendarId = 100), alert(2, calendarId = 100))
val postable = postableAlerts(due, disabledCalendarIds = setOf(999))
assertThat(postable).isEqualTo(due)
}
}