From ba03804ef065afd4f82791704eec30d61178d092 Mon Sep 17 00:00:00 2001 From: Rob Pilling Date: Wed, 4 Jun 2025 20:13:18 +0100 Subject: [PATCH 01/19] feat: show next alert time Closes #89 --- .../com/github/quarck/calnotify/Settings.kt | 4 ++++ .../quarck/calnotify/calendar/EventRecord.kt | 10 +++++++++- .../calnotify/textutils/EventFormatter.kt | 19 +++++++++++++++++++ android/app/src/main/res/values/strings.xml | 1 + 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt b/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt index f10645504..436d9df12 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt @@ -137,6 +137,9 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter val snoozePresetsRaw: String get() = getString(SNOOZE_PRESET_KEY, DEFAULT_SNOOZE_PRESET) + val displayNextAlertTime: Boolean + get() = getBoolean(DISPLAY_NEXT_ALERT_TIME, false) + val snoozePresets: LongArray get() { var ret = PreferenceUtils.parseSnoozePresets(snoozePresetsRaw) @@ -443,6 +446,7 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter private const val CALENDAR_IS_HANDLED_KEY_PREFIX = "calendar_handled_" private const val SNOOZE_PRESET_KEY = "pref_snooze_presets" //"15m, 1h, 4h, 1d" + private const val DISPLAY_NEXT_ALERT_TIME = "pref_display_next_alert_time" //false private const val VIEW_AFTER_EDIT_KEY = "show_event_after_reschedule" // true private const val ENABLE_REMINDERS_KEY = "enable_reminding_key" // false private const val REMINDER_INTERVAL_PATTERN_KEY = "remind_interval_key_pattern" // "10m" diff --git a/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventRecord.kt b/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventRecord.kt index 7e6c9461c..61f106d38 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventRecord.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/calendar/EventRecord.kt @@ -167,4 +167,12 @@ fun EventRecord.nextAlarmTime(currentTime: Long): Long { } return ret -} \ No newline at end of file +} + +fun EventRecord.getNextAlertTimeAfter(anchor: Long): Long? { + val futureReminders = this + .reminders + .map { this.startTime - it.millisecondsBefore } + .filter { it > anchor } + return futureReminders.maxOrNull() +} diff --git a/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt b/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt index 15e7460eb..7518b3e86 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt @@ -22,10 +22,13 @@ package com.github.quarck.calnotify.textutils import android.content.Context import android.text.format.DateUtils import com.github.quarck.calnotify.Consts +import com.github.quarck.calnotify.Settings import com.github.quarck.calnotify.R import com.github.quarck.calnotify.calendar.EventAlertRecord import com.github.quarck.calnotify.calendar.displayedEndTime import com.github.quarck.calnotify.calendar.displayedStartTime +import com.github.quarck.calnotify.calendar.CalendarProviderInterface +import com.github.quarck.calnotify.calendar.CalendarProvider import com.github.quarck.calnotify.utils.DateTimeUtils import com.github.quarck.calnotify.utils.CNPlusClockInterface import com.github.quarck.calnotify.utils.CNPlusSystemClock @@ -63,6 +66,7 @@ class EventFormatter( ) : EventFormatterInterface { private val defaultLocale by lazy { Locale.getDefault() } + private val calendarProvider: CalendarProviderInterface = CalendarProvider private fun formatDateRangeUTC(startMillis: Long, endMillis: Long, flags: Int): String { @@ -79,6 +83,21 @@ class EventFormatter( sb.append(formatDateTimeOneLine(event, false)) + if (Settings(ctx).displayNextAlertTime) { + val eventRecord = calendarProvider.getEvent(ctx, event.eventId) + val nextAlertTime = eventRecord.getNextAlertTimeAfter(event.displayedStartTime) + if (nextAlertTime != null) { + val duration = nextAlertTime - clock.currentTimeMillis() + if (duration > 0) { + sb.append(" (") + sb.append(ctx.getString(R.string.event_next_alert_in)) + sb.append(" ") + sb.append(formatTimeDuration(duration, 60)) + sb.append(")") + } + } + } + if (event.location != "") { sb.append("\n") sb.append(ctx.resources.getString(R.string.location)); diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9beaf90a9..c83567714 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -195,6 +195,7 @@ Dismissed via notification %s Moved, new time: %s Confirmed Rescheduled on %s + Next alert in %s Restore notification Swipe to delete from history Remove all From 1b2e0d9d9f27069a4e705ff45cc9ff53361e11e8 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 25 Dec 2025 19:48:11 +0000 Subject: [PATCH 02/19] test: wip --- .../github/quarck/calnotify/SettingsTest.kt | 9 + .../calnotify/textutils/EventFormatterTest.kt | 141 +++++++++++++ .../calnotify/textutils/EventFormatter.kt | 3 +- .../calnotify/SettingsRobolectricTest.kt | 8 + .../calendar/EventRecordExtensionsTest.kt | 197 ++++++++++++++++++ .../EventFormatterRobolectricTest.kt | 173 +++++++++++++++ 6 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 android/app/src/test/java/com/github/quarck/calnotify/calendar/EventRecordExtensionsTest.kt diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/SettingsTest.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/SettingsTest.kt index f37887c5e..cec719aa0 100644 --- a/android/app/src/androidTest/java/com/github/quarck/calnotify/SettingsTest.kt +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/SettingsTest.kt @@ -170,5 +170,14 @@ class SettingsTest { DevLog.info(LOG_TAG, "Running testFormatSnoozePresetSeconds") assertEquals("45s", PreferenceUtils.formatSnoozePreset(45 * 1000L)) } + + // === Display Next Alert Time Tests === + + @Test + fun testDisplayNextAlertTimeDefaultValue() { + DevLog.info(LOG_TAG, "Running testDisplayNextAlertTimeDefaultValue") + // The default value should be false + assertFalse("displayNextAlertTime should default to false", settings.displayNextAlertTime) + } } diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt index cc8b2503c..ef38e742a 100644 --- a/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt @@ -1,16 +1,25 @@ package com.github.quarck.calnotify.textutils import android.content.Context +import android.preference.PreferenceManager import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.github.quarck.calnotify.Consts import com.github.quarck.calnotify.calendar.AttendanceStatus +import com.github.quarck.calnotify.calendar.CalendarEventDetails +import com.github.quarck.calnotify.calendar.CalendarProvider import com.github.quarck.calnotify.calendar.EventAlertRecord import com.github.quarck.calnotify.calendar.EventDisplayStatus import com.github.quarck.calnotify.calendar.EventOrigin +import com.github.quarck.calnotify.calendar.EventRecord +import com.github.quarck.calnotify.calendar.EventReminderRecord import com.github.quarck.calnotify.calendar.EventStatus import com.github.quarck.calnotify.logs.DevLog import com.github.quarck.calnotify.utils.CNPlusTestClock +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -39,6 +48,22 @@ class EventFormatterTest { DevLog.info(LOG_TAG, "Setup complete with baseTime=$baseTime") } + @After + fun cleanup() { + DevLog.info(LOG_TAG, "Cleaning up after test") + // Reset the displayNextAlertTime setting to default (false) + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .remove("pref_display_next_alert_time") + .commit() + // Unmock CalendarProvider if it was mocked + try { + unmockkObject(CalendarProvider) + } catch (e: Exception) { + // Ignore if not mocked + } + } + private fun createTestEvent( eventId: Long = 1L, title: String = "Test Event", @@ -265,5 +290,121 @@ class EventFormatterTest { assertNotEquals("Different times should produce different timestamps", result1, result2) } + + // === Next Alert Time feature tests === + + private fun setDisplayNextAlertTimeSetting(enabled: Boolean) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean("pref_display_next_alert_time", enabled) + .commit() + } + + private fun createMockEventRecord( + eventId: Long, + startTime: Long, + reminders: List + ): EventRecord { + return EventRecord( + calendarId = 1L, + eventId = eventId, + details = CalendarEventDetails( + title = "Test Event", + desc = "Test Description", + location = "", + timezone = "UTC", + startTime = startTime, + endTime = startTime + Consts.HOUR_IN_MILLISECONDS, + isAllDay = false, + reminders = reminders, + repeatingRule = "", + repeatingRDate = "", + repeatingExRule = "", + repeatingExRDate = "", + color = 0 + ), + eventStatus = EventStatus.Confirmed, + attendanceStatus = AttendanceStatus.None + ) + } + + @Test + fun testFormatNotificationSecondaryTextNextAlertTimeDisabled() { + DevLog.info(LOG_TAG, "Running testFormatNotificationSecondaryTextNextAlertTimeDisabled") + + // Ensure the setting is disabled (default) + setDisplayNextAlertTimeSetting(false) + + // Create a new formatter to pick up the setting + val testFormatter = EventFormatter(context, testClock) + + // Event starts 2 hours from now with a reminder 30 minutes before + val eventStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS + val event = createTestEvent( + eventId = 100L, + startTime = eventStartTime, + endTime = eventStartTime + Consts.HOUR_IN_MILLISECONDS + ) + + val result = testFormatter.formatNotificationSecondaryText(event) + + DevLog.info(LOG_TAG, "Result with setting disabled: $result") + + // Should NOT contain "Next alert" when setting is disabled + assertFalse( + "Should NOT contain 'Next alert' when setting is disabled", + result.contains("Next alert", ignoreCase = true) + ) + } + + @Test + fun testFormatNotificationSecondaryTextNextAlertTimeEnabled() { + DevLog.info(LOG_TAG, "Running testFormatNotificationSecondaryTextNextAlertTimeEnabled") + + // Enable the setting + setDisplayNextAlertTimeSetting(true) + + // Event starts 2 hours from now + val eventStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS + val eventId = 101L + + // Create reminders: one that already fired (60min before = baseTime + 1h) + // and one in the future (30min before = baseTime + 1.5h) + val reminders = listOf( + EventReminderRecord.minutes(60), // fires at baseTime + 1h (future from baseTime) + EventReminderRecord.minutes(30) // fires at baseTime + 1.5h (future from baseTime) + ) + + // Mock CalendarProvider to return our event with reminders + mockkObject(CalendarProvider) + every { CalendarProvider.getEvent(any(), eq(eventId)) } returns createMockEventRecord( + eventId = eventId, + startTime = eventStartTime, + reminders = reminders + ) + + // Create the test event (EventAlertRecord) + val event = createTestEvent( + eventId = eventId, + startTime = eventStartTime, + endTime = eventStartTime + Consts.HOUR_IN_MILLISECONDS + ) + + // Create a new formatter to pick up the setting + val testFormatter = EventFormatter(context, testClock) + + val result = testFormatter.formatNotificationSecondaryText(event) + + DevLog.info(LOG_TAG, "Result with setting enabled: $result") + DevLog.info(LOG_TAG, "Event start time: $eventStartTime, Base time: $baseTime") + DevLog.info(LOG_TAG, "Reminder 1 fires at: ${eventStartTime - 60 * Consts.MINUTE_IN_MILLISECONDS}") + DevLog.info(LOG_TAG, "Reminder 2 fires at: ${eventStartTime - 30 * Consts.MINUTE_IN_MILLISECONDS}") + + // Should contain "Next alert" when setting is enabled and there are future reminders + assertTrue( + "Should contain 'Next alert' when setting is enabled and future reminders exist. Result: $result", + result.contains("Next alert", ignoreCase = true) + ) + } } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt b/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt index 7518b3e86..c5a324740 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt @@ -29,6 +29,7 @@ import com.github.quarck.calnotify.calendar.displayedEndTime import com.github.quarck.calnotify.calendar.displayedStartTime import com.github.quarck.calnotify.calendar.CalendarProviderInterface import com.github.quarck.calnotify.calendar.CalendarProvider +import com.github.quarck.calnotify.calendar.getNextAlertTimeAfter import com.github.quarck.calnotify.utils.DateTimeUtils import com.github.quarck.calnotify.utils.CNPlusClockInterface import com.github.quarck.calnotify.utils.CNPlusSystemClock @@ -85,7 +86,7 @@ class EventFormatter( if (Settings(ctx).displayNextAlertTime) { val eventRecord = calendarProvider.getEvent(ctx, event.eventId) - val nextAlertTime = eventRecord.getNextAlertTimeAfter(event.displayedStartTime) + val nextAlertTime = eventRecord?.getNextAlertTimeAfter(event.displayedStartTime) if (nextAlertTime != null) { val duration = nextAlertTime - clock.currentTimeMillis() if (duration > 0) { diff --git a/android/app/src/test/java/com/github/quarck/calnotify/SettingsRobolectricTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/SettingsRobolectricTest.kt index d53867e0f..d90c9ca2d 100644 --- a/android/app/src/test/java/com/github/quarck/calnotify/SettingsRobolectricTest.kt +++ b/android/app/src/test/java/com/github/quarck/calnotify/SettingsRobolectricTest.kt @@ -164,5 +164,13 @@ class SettingsRobolectricTest { fun testFormatSnoozePresetSeconds() { assertEquals("45s", PreferenceUtils.formatSnoozePreset(45 * 1000L)) } + + // === Display Next Alert Time Tests === + + @Test + fun testDisplayNextAlertTimeDefaultValue() { + // The default value should be false + assertFalse("displayNextAlertTime should default to false", settings.displayNextAlertTime) + } } diff --git a/android/app/src/test/java/com/github/quarck/calnotify/calendar/EventRecordExtensionsTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/calendar/EventRecordExtensionsTest.kt new file mode 100644 index 000000000..f52d2f188 --- /dev/null +++ b/android/app/src/test/java/com/github/quarck/calnotify/calendar/EventRecordExtensionsTest.kt @@ -0,0 +1,197 @@ +// +// Calendar Notifications Plus +// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com) +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation; either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program; if not, write to the Free Software Foundation, +// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +// + +package com.github.quarck.calnotify.calendar + +import com.github.quarck.calnotify.Consts +import org.junit.Assert.* +import org.junit.Test + +/** + * Unit tests for EventRecord extension functions. + * Tests getNextAlertTimeAfter which calculates the next reminder time after a given anchor. + */ +class EventRecordExtensionsTest { + + // Base time for tests: 2021-11-01 12:00:00 UTC + private val baseTime = 1635768000000L + + private fun createTestEvent( + startTime: Long = baseTime + Consts.HOUR_IN_MILLISECONDS, + reminders: List = listOf(EventReminderRecord.minutes(15)) + ): EventRecord { + return EventRecord( + calendarId = 1L, + eventId = 1L, + details = CalendarEventDetails( + title = "Test Event", + desc = "Test Description", + location = "", + timezone = "UTC", + startTime = startTime, + endTime = startTime + Consts.HOUR_IN_MILLISECONDS, + isAllDay = false, + reminders = reminders, + repeatingRule = "", + repeatingRDate = "", + repeatingExRule = "", + repeatingExRDate = "", + color = 0 + ), + eventStatus = EventStatus.Confirmed, + attendanceStatus = AttendanceStatus.None + ) + } + + @Test + fun `getNextAlertTimeAfter returns null when no reminders`() { + val event = createTestEvent(reminders = emptyList()) + val anchor = baseTime + + val result = event.getNextAlertTimeAfter(anchor) + + assertNull("Should return null when event has no reminders", result) + } + + @Test + fun `getNextAlertTimeAfter returns null when single reminder is before anchor`() { + // Event starts 1 hour from baseTime, reminder is 15 minutes before + val eventStartTime = baseTime + Consts.HOUR_IN_MILLISECONDS + val event = createTestEvent( + startTime = eventStartTime, + reminders = listOf(EventReminderRecord.minutes(15)) + ) + // Reminder fires at: eventStartTime - 15min = baseTime + 45min + // Anchor is after the reminder time + val anchor = eventStartTime // anchor at event start, reminder already fired + + val result = event.getNextAlertTimeAfter(anchor) + + assertNull("Should return null when reminder is before anchor", result) + } + + @Test + fun `getNextAlertTimeAfter returns reminder time when single reminder is after anchor`() { + // Event starts 1 hour from baseTime, reminder is 15 minutes before + val eventStartTime = baseTime + Consts.HOUR_IN_MILLISECONDS + val event = createTestEvent( + startTime = eventStartTime, + reminders = listOf(EventReminderRecord.minutes(15)) + ) + // Reminder fires at: eventStartTime - 15min = baseTime + 45min + val expectedReminderTime = eventStartTime - (15 * Consts.MINUTE_IN_MILLISECONDS) + // Anchor is before the reminder time + val anchor = baseTime + + val result = event.getNextAlertTimeAfter(anchor) + + assertNotNull("Should return reminder time when reminder is after anchor", result) + assertEquals("Should return correct reminder time", expectedReminderTime, result) + } + + @Test + fun `getNextAlertTimeAfter returns max of future reminders when multiple reminders exist`() { + // Event starts 2 hours from baseTime + val eventStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS + // Reminders at: 60min before (baseTime + 1h), 30min before (baseTime + 1.5h), 15min before (baseTime + 1h45min) + val event = createTestEvent( + startTime = eventStartTime, + reminders = listOf( + EventReminderRecord.minutes(60), // fires at baseTime + 1h + EventReminderRecord.minutes(30), // fires at baseTime + 1.5h + EventReminderRecord.minutes(15) // fires at baseTime + 1h45min + ) + ) + // Anchor is at baseTime + 1h (after the 60min reminder) + val anchor = baseTime + Consts.HOUR_IN_MILLISECONDS + + // Expected: max of future reminders = 1h45min (15min before event) + val expectedMaxFutureReminder = eventStartTime - (15 * Consts.MINUTE_IN_MILLISECONDS) + + val result = event.getNextAlertTimeAfter(anchor) + + assertNotNull("Should return max of future reminders", result) + assertEquals("Should return the latest future reminder", expectedMaxFutureReminder, result) + } + + @Test + fun `getNextAlertTimeAfter returns null when all reminders are before anchor`() { + // Event starts 1 hour from baseTime + val eventStartTime = baseTime + Consts.HOUR_IN_MILLISECONDS + val event = createTestEvent( + startTime = eventStartTime, + reminders = listOf( + EventReminderRecord.minutes(60), // fires at baseTime + EventReminderRecord.minutes(30), // fires at baseTime + 30min + EventReminderRecord.minutes(15) // fires at baseTime + 45min + ) + ) + // Anchor is after all reminders (at event start time) + val anchor = eventStartTime + + val result = event.getNextAlertTimeAfter(anchor) + + assertNull("Should return null when all reminders are before anchor", result) + } + + @Test + fun `getNextAlertTimeAfter handles anchor exactly at reminder time`() { + // Event starts 1 hour from baseTime, reminder is 15 minutes before + val eventStartTime = baseTime + Consts.HOUR_IN_MILLISECONDS + val event = createTestEvent( + startTime = eventStartTime, + reminders = listOf(EventReminderRecord.minutes(15)) + ) + // Reminder fires at exactly: eventStartTime - 15min + val reminderTime = eventStartTime - (15 * Consts.MINUTE_IN_MILLISECONDS) + // Anchor is exactly at reminder time - should NOT include this reminder (filter is > not >=) + val anchor = reminderTime + + val result = event.getNextAlertTimeAfter(anchor) + + assertNull("Should return null when anchor is exactly at reminder time", result) + } + + @Test + fun `getNextAlertTimeAfter with multiple reminders returns only future ones`() { + // Event starts 2 hours from baseTime + val eventStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS + val event = createTestEvent( + startTime = eventStartTime, + reminders = listOf( + EventReminderRecord.minutes(90), // fires at baseTime + 30min (PAST) + EventReminderRecord.minutes(60), // fires at baseTime + 1h (EXACTLY AT ANCHOR - excluded) + EventReminderRecord.minutes(30), // fires at baseTime + 1.5h (FUTURE) + EventReminderRecord.minutes(15) // fires at baseTime + 1h45min (FUTURE) + ) + ) + // Anchor is at baseTime + 1h + val anchor = baseTime + Consts.HOUR_IN_MILLISECONDS + + // Future reminders are: 30min before and 15min before + // Max of those = 15min before = baseTime + 1h45min + val expectedMaxFutureReminder = eventStartTime - (15 * Consts.MINUTE_IN_MILLISECONDS) + + val result = event.getNextAlertTimeAfter(anchor) + + assertNotNull("Should return a future reminder", result) + assertEquals("Should return max of future reminders only", expectedMaxFutureReminder, result) + } +} + diff --git a/android/app/src/test/java/com/github/quarck/calnotify/textutils/EventFormatterRobolectricTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/textutils/EventFormatterRobolectricTest.kt index 9b821d93f..5e58ad22f 100644 --- a/android/app/src/test/java/com/github/quarck/calnotify/textutils/EventFormatterRobolectricTest.kt +++ b/android/app/src/test/java/com/github/quarck/calnotify/textutils/EventFormatterRobolectricTest.kt @@ -1,14 +1,23 @@ package com.github.quarck.calnotify.textutils import android.content.Context +import android.preference.PreferenceManager import androidx.test.core.app.ApplicationProvider import com.github.quarck.calnotify.Consts import com.github.quarck.calnotify.calendar.AttendanceStatus +import com.github.quarck.calnotify.calendar.CalendarEventDetails +import com.github.quarck.calnotify.calendar.CalendarProvider import com.github.quarck.calnotify.calendar.EventAlertRecord import com.github.quarck.calnotify.calendar.EventDisplayStatus import com.github.quarck.calnotify.calendar.EventOrigin +import com.github.quarck.calnotify.calendar.EventRecord +import com.github.quarck.calnotify.calendar.EventReminderRecord import com.github.quarck.calnotify.calendar.EventStatus import com.github.quarck.calnotify.utils.CNPlusUnitTestClock +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import org.junit.After import org.junit.Assert.* import org.junit.Before import org.junit.Test @@ -37,6 +46,21 @@ class EventFormatterRobolectricTest { formatter = EventFormatter(context, testClock) } + @After + fun cleanup() { + // Reset the displayNextAlertTime setting to default (false) + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .remove("pref_display_next_alert_time") + .commit() + // Unmock CalendarProvider if it was mocked + try { + unmockkObject(CalendarProvider) + } catch (e: Exception) { + // Ignore if not mocked + } + } + private fun createTestEvent( eventId: Long = 1L, title: String = "Test Event", @@ -305,5 +329,154 @@ class EventFormatterRobolectricTest { assertNotNull(result) assertTrue("Encoded timestamp with modulo should not be empty", result.isNotEmpty()) } + + // === Next Alert Time feature tests === + + private fun setDisplayNextAlertTimeSetting(enabled: Boolean) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean("pref_display_next_alert_time", enabled) + .commit() + } + + private fun createMockEventRecord( + eventId: Long, + startTime: Long, + reminders: List + ): EventRecord { + return EventRecord( + calendarId = 1L, + eventId = eventId, + details = CalendarEventDetails( + title = "Test Event", + desc = "Test Description", + location = "", + timezone = "UTC", + startTime = startTime, + endTime = startTime + Consts.HOUR_IN_MILLISECONDS, + isAllDay = false, + reminders = reminders, + repeatingRule = "", + repeatingRDate = "", + repeatingExRule = "", + repeatingExRDate = "", + color = 0 + ), + eventStatus = EventStatus.Confirmed, + attendanceStatus = AttendanceStatus.None + ) + } + + @Test + fun testFormatNotificationSecondaryTextNextAlertTimeDisabled() { + // Ensure the setting is disabled (default) + setDisplayNextAlertTimeSetting(false) + + // Create a new formatter to pick up the setting + val testFormatter = EventFormatter(context, testClock) + + // Event starts 2 hours from now with a reminder 30 minutes before + val eventStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS + val event = createTestEvent( + eventId = 100L, + startTime = eventStartTime, + endTime = eventStartTime + Consts.HOUR_IN_MILLISECONDS + ) + + val result = testFormatter.formatNotificationSecondaryText(event) + + // Should NOT contain "Next alert" when setting is disabled + assertFalse( + "Should NOT contain 'Next alert' when setting is disabled", + result.contains("Next alert", ignoreCase = true) + ) + } + + @Test + fun testFormatNotificationSecondaryTextNextAlertTimeEnabled() { + // Enable the setting + setDisplayNextAlertTimeSetting(true) + + // Event starts 2 hours from now + val eventStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS + val eventId = 101L + + // Create reminders: one in the future (30min before = baseTime + 1.5h) + val reminders = listOf( + EventReminderRecord.minutes(60), // fires at baseTime + 1h (future from baseTime) + EventReminderRecord.minutes(30) // fires at baseTime + 1.5h (future from baseTime) + ) + + // Mock CalendarProvider to return our event with reminders + mockkObject(CalendarProvider) + every { CalendarProvider.getEvent(any(), eq(eventId)) } returns createMockEventRecord( + eventId = eventId, + startTime = eventStartTime, + reminders = reminders + ) + + // Create the test event (EventAlertRecord) + val event = createTestEvent( + eventId = eventId, + startTime = eventStartTime, + endTime = eventStartTime + Consts.HOUR_IN_MILLISECONDS + ) + + // Create a new formatter to pick up the setting + val testFormatter = EventFormatter(context, testClock) + + val result = testFormatter.formatNotificationSecondaryText(event) + + // Should contain "Next alert" when setting is enabled and there are future reminders + assertTrue( + "Should contain 'Next alert' when setting is enabled and future reminders exist. Result: $result", + result.contains("Next alert", ignoreCase = true) + ) + } + + @Test + fun testFormatNotificationSecondaryTextNextAlertNoFutureReminders() { + // Enable the setting + setDisplayNextAlertTimeSetting(true) + + // Event starts in 30 minutes, reminder was 15 minutes before (so already fired) + val eventStartTime = baseTime + 30 * Consts.MINUTE_IN_MILLISECONDS + val eventId = 102L + + // Reminder at 15 min before = baseTime + 15min (which is in the future from baseTime) + // But we need to use displayedStartTime as anchor, which is eventStartTime + // So reminder at eventStartTime - 15min = baseTime + 15min + // This is BEFORE the anchor (eventStartTime), so should NOT be included + val reminders = listOf( + EventReminderRecord.minutes(15) // fires at eventStartTime - 15min = baseTime + 15min + ) + + // Mock CalendarProvider to return our event with reminders + mockkObject(CalendarProvider) + every { CalendarProvider.getEvent(any(), eq(eventId)) } returns createMockEventRecord( + eventId = eventId, + startTime = eventStartTime, + reminders = reminders + ) + + // Create the test event (EventAlertRecord) + val event = createTestEvent( + eventId = eventId, + startTime = eventStartTime, + endTime = eventStartTime + Consts.HOUR_IN_MILLISECONDS + ) + + // Create a new formatter to pick up the setting + val testFormatter = EventFormatter(context, testClock) + + val result = testFormatter.formatNotificationSecondaryText(event) + + // Should NOT contain "Next alert" because all reminders are before the event start time + // (The feature looks for reminders AFTER displayedStartTime) + assertFalse( + "Should NOT contain 'Next alert' when no reminders are after event start. Result: $result", + result.contains("Next alert", ignoreCase = true) + ) + } } From 3d017ff4c5bc85042d22fbb0c7de50492531361c Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 25 Dec 2025 20:17:29 +0000 Subject: [PATCH 03/19] fix: display string and roboelectric tests --- .../calnotify/textutils/EventFormatterTest.kt | 12 ++++---- .../calnotify/textutils/EventFormatter.kt | 8 ++--- .../app/src/main/res/values-pl/strings.xml | 2 -- android/app/src/main/res/values/strings.xml | 3 +- .../EventFormatterRobolectricTest.kt | 30 ++++++++----------- 5 files changed, 22 insertions(+), 33 deletions(-) diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt index ef38e742a..8d44ab715 100644 --- a/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt @@ -350,10 +350,10 @@ class EventFormatterTest { DevLog.info(LOG_TAG, "Result with setting disabled: $result") - // Should NOT contain "Next alert" when setting is disabled + // Should NOT contain "reminder in" when setting is disabled assertFalse( - "Should NOT contain 'Next alert' when setting is disabled", - result.contains("Next alert", ignoreCase = true) + "Should NOT contain 'reminder in' when setting is disabled", + result.contains("reminder in", ignoreCase = true) ) } @@ -400,10 +400,10 @@ class EventFormatterTest { DevLog.info(LOG_TAG, "Reminder 1 fires at: ${eventStartTime - 60 * Consts.MINUTE_IN_MILLISECONDS}") DevLog.info(LOG_TAG, "Reminder 2 fires at: ${eventStartTime - 30 * Consts.MINUTE_IN_MILLISECONDS}") - // Should contain "Next alert" when setting is enabled and there are future reminders + // Should contain "reminder in" when setting is enabled and there are future reminders assertTrue( - "Should contain 'Next alert' when setting is enabled and future reminders exist. Result: $result", - result.contains("Next alert", ignoreCase = true) + "Should contain 'reminder in' when setting is enabled and future reminders exist. Result: $result", + result.contains("reminder in", ignoreCase = true) ) } } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt b/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt index c5a324740..4b0612f71 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt @@ -86,15 +86,11 @@ class EventFormatter( if (Settings(ctx).displayNextAlertTime) { val eventRecord = calendarProvider.getEvent(ctx, event.eventId) - val nextAlertTime = eventRecord?.getNextAlertTimeAfter(event.displayedStartTime) + val nextAlertTime = eventRecord?.getNextAlertTimeAfter(clock.currentTimeMillis()) if (nextAlertTime != null) { val duration = nextAlertTime - clock.currentTimeMillis() if (duration > 0) { - sb.append(" (") - sb.append(ctx.getString(R.string.event_next_alert_in)) - sb.append(" ") - sb.append(formatTimeDuration(duration, 60)) - sb.append(")") + sb.append(ctx.getString(R.string.reminder_in, formatTimeDuration(duration, 60))) } } } diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 887400d9b..950acd0ec 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -155,8 +155,6 @@ %d wydarzeΕ„ kalendarza - (przypomnienie o %s) - Odrzucone powiadomienia Kosz Inne dziaΕ‚ania diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c83567714..6693a4bc4 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -185,7 +185,6 @@ %d calendar events - (reminder at %s) Dismissed events Bin @@ -195,7 +194,7 @@ Dismissed via notification %s Moved, new time: %s Confirmed Rescheduled on %s - Next alert in %s + (reminder in %s) Restore notification Swipe to delete from history Remove all diff --git a/android/app/src/test/java/com/github/quarck/calnotify/textutils/EventFormatterRobolectricTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/textutils/EventFormatterRobolectricTest.kt index 5e58ad22f..453a34689 100644 --- a/android/app/src/test/java/com/github/quarck/calnotify/textutils/EventFormatterRobolectricTest.kt +++ b/android/app/src/test/java/com/github/quarck/calnotify/textutils/EventFormatterRobolectricTest.kt @@ -385,10 +385,10 @@ class EventFormatterRobolectricTest { val result = testFormatter.formatNotificationSecondaryText(event) - // Should NOT contain "Next alert" when setting is disabled + // Should NOT contain "reminder in" when setting is disabled assertFalse( - "Should NOT contain 'Next alert' when setting is disabled", - result.contains("Next alert", ignoreCase = true) + "Should NOT contain 'reminder in' when setting is disabled", + result.contains("reminder in", ignoreCase = true) ) } @@ -427,10 +427,10 @@ class EventFormatterRobolectricTest { val result = testFormatter.formatNotificationSecondaryText(event) - // Should contain "Next alert" when setting is enabled and there are future reminders + // Should contain "reminder in" when setting is enabled and there are future reminders assertTrue( - "Should contain 'Next alert' when setting is enabled and future reminders exist. Result: $result", - result.contains("Next alert", ignoreCase = true) + "Should contain 'reminder in' when setting is enabled and future reminders exist. Result: $result", + result.contains("reminder in", ignoreCase = true) ) } @@ -439,16 +439,13 @@ class EventFormatterRobolectricTest { // Enable the setting setDisplayNextAlertTimeSetting(true) - // Event starts in 30 minutes, reminder was 15 minutes before (so already fired) - val eventStartTime = baseTime + 30 * Consts.MINUTE_IN_MILLISECONDS + // Event started 30 minutes ago, reminder was 15 minutes before that (so already fired) + val eventStartTime = baseTime - 30 * Consts.MINUTE_IN_MILLISECONDS val eventId = 102L - // Reminder at 15 min before = baseTime + 15min (which is in the future from baseTime) - // But we need to use displayedStartTime as anchor, which is eventStartTime - // So reminder at eventStartTime - 15min = baseTime + 15min - // This is BEFORE the anchor (eventStartTime), so should NOT be included + // Reminder at 15 min before event = baseTime - 45min (which is in the PAST from baseTime) val reminders = listOf( - EventReminderRecord.minutes(15) // fires at eventStartTime - 15min = baseTime + 15min + EventReminderRecord.minutes(15) // fires at eventStartTime - 15min = baseTime - 45min ) // Mock CalendarProvider to return our event with reminders @@ -471,11 +468,10 @@ class EventFormatterRobolectricTest { val result = testFormatter.formatNotificationSecondaryText(event) - // Should NOT contain "Next alert" because all reminders are before the event start time - // (The feature looks for reminders AFTER displayedStartTime) + // Should NOT contain "reminder in" because the reminder already fired (is in the past) assertFalse( - "Should NOT contain 'Next alert' when no reminders are after event start. Result: $result", - result.contains("Next alert", ignoreCase = true) + "Should NOT contain 'reminder in' when all reminders are in the past. Result: $result", + result.contains("reminder in", ignoreCase = true) ) } } From 3ada41b481c903a5961074d40fb9731d123bc777 Mon Sep 17 00:00:00 2001 From: William Harris Date: Thu, 25 Dec 2025 21:08:02 +0000 Subject: [PATCH 04/19] fix: feature works plus test activity --- .../com/github/quarck/calnotify/Settings.kt | 5 +- .../calnotify/textutils/EventFormatter.kt | 1 + .../quarck/calnotify/ui/TestActivity.kt | 85 ++++++++++++++++++- .../app/src/main/res/layout/activity_test.xml | 9 ++ android/app/src/main/res/values/strings.xml | 2 +- 5 files changed, 98 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt b/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt index 436d9df12..8839b6aa9 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/Settings.kt @@ -137,8 +137,9 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter val snoozePresetsRaw: String get() = getString(SNOOZE_PRESET_KEY, DEFAULT_SNOOZE_PRESET) - val displayNextAlertTime: Boolean - get() = getBoolean(DISPLAY_NEXT_ALERT_TIME, false) + var displayNextAlertTime: Boolean + get() = getBoolean(DISPLAY_NEXT_ALERT_TIME, false) + set(value) = setBoolean(DISPLAY_NEXT_ALERT_TIME, value) val snoozePresets: LongArray get() { diff --git a/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt b/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt index 4b0612f71..5d68cbc97 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/textutils/EventFormatter.kt @@ -90,6 +90,7 @@ class EventFormatter( if (nextAlertTime != null) { val duration = nextAlertTime - clock.currentTimeMillis() if (duration > 0) { + sb.append(" ") sb.append(ctx.getString(R.string.reminder_in, formatTimeDuration(duration, 60))) } } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/ui/TestActivity.kt b/android/app/src/main/java/com/github/quarck/calnotify/ui/TestActivity.kt index 3c3b81cbd..ef8c5cf2c 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/ui/TestActivity.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/ui/TestActivity.kt @@ -32,8 +32,12 @@ import com.github.quarck.calnotify.Consts import com.github.quarck.calnotify.R import com.github.quarck.calnotify.Settings import com.github.quarck.calnotify.app.ApplicationController +import com.github.quarck.calnotify.calendar.CalendarEventDetails +import com.github.quarck.calnotify.calendar.CalendarProvider import com.github.quarck.calnotify.calendar.EventAlertRecord import com.github.quarck.calnotify.calendar.EventDisplayStatus +import com.github.quarck.calnotify.calendar.EventReminderRecord +import com.github.quarck.calnotify.logs.DevLog import com.github.quarck.calnotify.utils.CNPlusClockInterface import com.github.quarck.calnotify.utils.CNPlusSystemClock import com.github.quarck.calnotify.utils.findOrThrow @@ -224,6 +228,85 @@ class TestActivity : Activity() { ApplicationController.afterCalendarEventFired(this) } + @Suppress("unused", "UNUSED_PARAMETER") + fun OnButtonAddReminderInEventClick(v: View) { + val LOG_TAG = "TestActivity" + + // Enable the displayNextAlertTime setting + settings.displayNextAlertTime = true + DevLog.info(LOG_TAG, "Enabled displayNextAlertTime setting") + + // Find a calendar to use + val calendars = CalendarProvider.getCalendars(this) + val calendar = calendars.firstOrNull() + + if (calendar == null) { + DevLog.error(LOG_TAG, "No calendars available - cannot create test event") + return + } + + val currentTime = clock.currentTimeMillis() + // Event starts 2 hours from now + val eventStart = currentTime + 2 * Consts.HOUR_IN_MILLISECONDS + val eventEnd = eventStart + Consts.HOUR_IN_MILLISECONDS + + // Create reminders at 15min, 30min, and 60min before event + // These will be at 1h45m, 1h30m, and 1h from now respectively + val reminders = listOf( + EventReminderRecord.minutes(15), // fires 1h45m from now + EventReminderRecord.minutes(30), // fires 1h30m from now + EventReminderRecord.minutes(60) // fires 1h from now + ) + + val details = CalendarEventDetails( + title = "Test Reminder In Feature - ${System.currentTimeMillis() % 10000}", + desc = "This event tests the 'reminder in X' notification feature", + location = "", + timezone = java.util.TimeZone.getDefault().id, + startTime = eventStart, + endTime = eventEnd, + isAllDay = false, + reminders = reminders, + color = 0xff00aa00.toInt() + ) + + val eventId = CalendarProvider.createEvent(this, calendar.calendarId, calendar.owner, details) + + if (eventId == -1L) { + DevLog.error(LOG_TAG, "Failed to create calendar event") + return + } + + DevLog.info(LOG_TAG, "Created calendar event $eventId with ${reminders.size} reminders") + + // Create and post a notification for this event + val event = EventAlertRecord( + calendarId = calendar.calendarId, + eventId = eventId, + isAllDay = false, + isRepeating = false, + alertTime = currentTime, + notificationId = 0, + title = details.title, + desc = details.desc, + startTime = eventStart, + endTime = eventEnd, + instanceStartTime = eventStart, + instanceEndTime = eventEnd, + location = details.location, + lastStatusChangeTime = currentTime, + snoozedUntil = 0L, + displayStatus = EventDisplayStatus.Hidden, + color = details.color + ) + + ApplicationController.registerNewEvent(this, event) + ApplicationController.postEventNotifications(this, listOf(event)) + ApplicationController.afterCalendarEventFired(this) + + DevLog.info(LOG_TAG, "Posted notification - look for '(reminder in X)' in the notification text!") + } + @Suppress("unused", "UNUSED_PARAMETER") fun OnButtonAddProvierEventClick(v: View) { @@ -241,7 +324,7 @@ class TestActivity : Activity() { // startActivity( // Intent(Intent.ACTION_VIEW).setData( -// ContentUris.withAppendedId( +// )v_dfadsfadfadsf ContentUris.withAppendedId( // CalendarContract.Events.CONTENT_URI, // id))) diff --git a/android/app/src/main/res/layout/activity_test.xml b/android/app/src/main/res/layout/activity_test.xml index 87186fad1..07c408182 100644 --- a/android/app/src/main/res/layout/activity_test.xml +++ b/android/app/src/main/res/layout/activity_test.xml @@ -83,6 +83,15 @@ WARNING!\n\nNo usable hidden functionality can be found here\nThis Activity is p android:padding="14dp" /> +