Skip to content

Commit 1132b58

Browse files
feat: show next alert time (#132)
* feat: show next alert time Closes #89 * test: wip * fix: display string and roboelectric tests * fix: feature works plus test activity * docs: fix typo * feat: add next notification indicator with GCal/app alert support - Add displayNextGCalReminder (default ON) and displayNextAppAlert (default OFF) settings - Show 📅 for calendar reminders, 🔔 for app alerts, 🔇 prefix for muted - Collapsed notifications show soonest next alert in title - GCal wins ties when both are same time - Add TestActivity buttons for individual, collapsed, and muted scenarios - Add Robolectric tests for calculation logic * docs: docs * fix: at least unit tests pass * fix: EventFormatterTest * fix: MUCH better and safer way of setting up test activity in dev * ci: make sure dev page not visible * fix: test activity and notficiation display * fix: cleaner with parens * fix: better time display * fix: settings and test * fix: dev page check * feat: next app alert test button * fix: layout and display intent doing emoji X instead of emoji in X makes it more clear this is static showing the next from the original display of the notfication. not an active countdown * fix: muted next app --------- Co-authored-by: Rob Pilling <robpilling@gmail.com>
1 parent 018de5d commit 1132b58

21 files changed

Lines changed: 1796 additions & 13 deletions

File tree

.github/workflows/actions.yml

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,25 @@ jobs:
5454
outputs:
5555
build_datetime: ${{ steps.set_date.outputs.build_datetime }}
5656

57+
safety_checks:
58+
name: Safety Checks
59+
runs-on: ubuntu-latest
60+
steps:
61+
- uses: actions/checkout@v3
62+
with:
63+
fetch-depth: 1
64+
sparse-checkout: |
65+
android/app/src/main/res/menu/main.xml
66+
scripts/check_dev_page_hidden.sh
67+
68+
- name: Ensure dev page is not exposed
69+
run: |
70+
chmod +x ./scripts/check_dev_page_hidden.sh
71+
./scripts/check_dev_page_hidden.sh
72+
5773
build:
5874
name: Build Android App
59-
needs: set_build_datetime
75+
needs: [set_build_datetime, safety_checks]
6076
runs-on: ubuntu-latest
6177
strategy:
6278
matrix:

.github/workflows/release.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ jobs:
2121
ssh-key: ${{ secrets.VERSION_BUMP_DEPLOY_PRIVATE_KEY }}
2222
fetch-depth: 0
2323

24+
- name: Ensure dev page is not exposed
25+
run: |
26+
chmod +x ./scripts/check_dev_page_hidden.sh
27+
./scripts/check_dev_page_hidden.sh
28+
2429
- name: Setup Node.js
2530
uses: actions/setup-node@v4
2631
with:

android/app/build.gradle

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ def isGitHubActions = System.getenv("GITHUB_ACTIONS") != null && System.getenv("
3333
// so now we just do it ci
3434
def isCIBuild = isCiBuild || isGitHubActions
3535

36+
// Read dev page flag from local.properties (gitignored = can't leak to releases)
37+
def localProperties = new Properties()
38+
def localPropertiesFile = rootProject.file('local.properties')
39+
if (localPropertiesFile.exists()) {
40+
localProperties.load(new FileInputStream(localPropertiesFile))
41+
}
42+
def devPageEnabled = localProperties.getProperty('devPage.enabled', 'false').toBoolean()
43+
3644
android {
3745
compileSdkVersion rootProject.ext.compileSdkVersion
3846

@@ -94,6 +102,7 @@ android {
94102
// React Native New Architecture flags
95103
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", project.hasProperty("newArchEnabled") ? project.property("newArchEnabled") : "false"
96104
buildConfigField "boolean", "IS_HERMES_ENABLED", project.hasProperty("hermesEnabled") ? project.property("hermesEnabled") : "true"
105+
buildConfigField "boolean", "DEV_PAGE_ENABLED", devPageEnabled.toString()
97106

98107
// Enhanced test instrumentation with JaCoCo and Ultron/Allure
99108
testInstrumentationRunner "com.atiurin.ultron.allure.UltronAllureTestRunner"

android/app/src/androidTest/java/com/github/quarck/calnotify/SettingsTest.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,5 +170,21 @@ class SettingsTest {
170170
DevLog.info(LOG_TAG, "Running testFormatSnoozePresetSeconds")
171171
assertEquals("45s", PreferenceUtils.formatSnoozePreset(45 * 1000L))
172172
}
173+
174+
// === Display Next Alert Settings Tests ===
175+
176+
@Test
177+
fun testDisplayNextGCalReminderDefaultValue() {
178+
DevLog.info(LOG_TAG, "Running testDisplayNextGCalReminderDefaultValue")
179+
// The default value should be true
180+
assertTrue("displayNextGCalReminder should default to true", settings.displayNextGCalReminder)
181+
}
182+
183+
@Test
184+
fun testDisplayNextAppAlertDefaultValue() {
185+
DevLog.info(LOG_TAG, "Running testDisplayNextAppAlertDefaultValue")
186+
// The default value should be false
187+
assertFalse("displayNextAppAlert should default to false", settings.displayNextAppAlert)
188+
}
173189
}
174190

android/app/src/androidTest/java/com/github/quarck/calnotify/textutils/EventFormatterTest.kt

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
package com.github.quarck.calnotify.textutils
22

33
import android.content.Context
4+
import android.preference.PreferenceManager
45
import androidx.test.ext.junit.runners.AndroidJUnit4
56
import androidx.test.platform.app.InstrumentationRegistry
67
import com.github.quarck.calnotify.Consts
78
import com.github.quarck.calnotify.calendar.AttendanceStatus
9+
import com.github.quarck.calnotify.calendar.CalendarEventDetails
10+
import com.github.quarck.calnotify.calendar.CalendarProvider
811
import com.github.quarck.calnotify.calendar.EventAlertRecord
912
import com.github.quarck.calnotify.calendar.EventDisplayStatus
1013
import com.github.quarck.calnotify.calendar.EventOrigin
14+
import com.github.quarck.calnotify.calendar.EventRecord
15+
import com.github.quarck.calnotify.calendar.EventReminderRecord
1116
import com.github.quarck.calnotify.calendar.EventStatus
1217
import com.github.quarck.calnotify.logs.DevLog
1318
import com.github.quarck.calnotify.utils.CNPlusTestClock
19+
import io.mockk.every
20+
import io.mockk.mockkObject
21+
import io.mockk.unmockkObject
22+
import org.junit.After
1423
import org.junit.Assert.*
1524
import org.junit.Before
1625
import org.junit.Test
@@ -39,6 +48,23 @@ class EventFormatterTest {
3948
DevLog.info(LOG_TAG, "Setup complete with baseTime=$baseTime")
4049
}
4150

51+
@After
52+
fun cleanup() {
53+
DevLog.info(LOG_TAG, "Cleaning up after test")
54+
// Reset the next alert settings to defaults
55+
PreferenceManager.getDefaultSharedPreferences(context)
56+
.edit()
57+
.remove("pref_display_next_gcal_reminder")
58+
.remove("pref_display_next_app_alert")
59+
.commit()
60+
// Unmock CalendarProvider if it was mocked
61+
try {
62+
unmockkObject(CalendarProvider)
63+
} catch (e: Exception) {
64+
// Ignore if not mocked
65+
}
66+
}
67+
4268
private fun createTestEvent(
4369
eventId: Long = 1L,
4470
title: String = "Test Event",
@@ -265,5 +291,128 @@ class EventFormatterTest {
265291

266292
assertNotEquals("Different times should produce different timestamps", result1, result2)
267293
}
294+
295+
// === Next Alert Indicator feature tests ===
296+
297+
private fun setNextGCalReminderSetting(enabled: Boolean) {
298+
PreferenceManager.getDefaultSharedPreferences(context)
299+
.edit()
300+
.putBoolean("pref_display_next_gcal_reminder", enabled)
301+
.commit()
302+
}
303+
304+
private fun setNextAppAlertSetting(enabled: Boolean) {
305+
PreferenceManager.getDefaultSharedPreferences(context)
306+
.edit()
307+
.putBoolean("pref_display_next_app_alert", enabled)
308+
.commit()
309+
}
310+
311+
private fun createMockEventRecord(
312+
eventId: Long,
313+
startTime: Long,
314+
reminders: List<EventReminderRecord>
315+
): EventRecord {
316+
return EventRecord(
317+
calendarId = 1L,
318+
eventId = eventId,
319+
details = CalendarEventDetails(
320+
title = "Test Event",
321+
desc = "Test Description",
322+
location = "",
323+
timezone = "UTC",
324+
startTime = startTime,
325+
endTime = startTime + Consts.HOUR_IN_MILLISECONDS,
326+
isAllDay = false,
327+
reminders = reminders,
328+
repeatingRule = "",
329+
repeatingRDate = "",
330+
repeatingExRule = "",
331+
repeatingExRDate = "",
332+
color = 0
333+
),
334+
eventStatus = EventStatus.Confirmed,
335+
attendanceStatus = AttendanceStatus.None
336+
)
337+
}
338+
339+
@Test
340+
fun testFormatNotificationSecondaryTextNextAlertTimeDisabled() {
341+
DevLog.info(LOG_TAG, "Running testFormatNotificationSecondaryTextNextAlertTimeDisabled")
342+
343+
// Ensure the setting is disabled (default)
344+
setNextGCalReminderSetting(false)
345+
346+
// Create a new formatter to pick up the setting
347+
val testFormatter = EventFormatter(context, testClock)
348+
349+
// Event starts 2 hours from now with a reminder 30 minutes before
350+
val eventStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS
351+
val event = createTestEvent(
352+
eventId = 100L,
353+
startTime = eventStartTime,
354+
endTime = eventStartTime + Consts.HOUR_IN_MILLISECONDS
355+
)
356+
357+
val result = testFormatter.formatNotificationSecondaryText(event)
358+
359+
DevLog.info(LOG_TAG, "Result with setting disabled: $result")
360+
361+
// Should NOT contain "reminder in" when setting is disabled
362+
assertFalse(
363+
"Should NOT contain 'reminder in' when setting is disabled",
364+
result.contains("reminder in", ignoreCase = true)
365+
)
366+
}
367+
368+
@Test
369+
fun testFormatNotificationSecondaryTextNextGCalEnabled() {
370+
DevLog.info(LOG_TAG, "Running testFormatNotificationSecondaryTextNextGCalEnabled")
371+
372+
// Enable GCal setting, disable app alert
373+
setNextGCalReminderSetting(true)
374+
setNextAppAlertSetting(false)
375+
376+
// Event starts 2 hours from now
377+
val eventStartTime = baseTime + 2 * Consts.HOUR_IN_MILLISECONDS
378+
val eventId = 101L
379+
380+
// Create reminders: one that fires in the future (60min before = baseTime + 1h)
381+
val reminders = listOf(
382+
EventReminderRecord.minutes(60), // fires at baseTime + 1h (future from baseTime)
383+
EventReminderRecord.minutes(30) // fires at baseTime + 1.5h (future from baseTime)
384+
)
385+
386+
// Mock CalendarProvider to return our event with reminders
387+
mockkObject(CalendarProvider)
388+
every { CalendarProvider.getEvent(any(), eq(eventId)) } returns createMockEventRecord(
389+
eventId = eventId,
390+
startTime = eventStartTime,
391+
reminders = reminders
392+
)
393+
394+
// Create the test event (EventAlertRecord)
395+
val event = createTestEvent(
396+
eventId = eventId,
397+
startTime = eventStartTime,
398+
endTime = eventStartTime + Consts.HOUR_IN_MILLISECONDS
399+
)
400+
401+
// Create a new formatter to pick up the setting
402+
val testFormatter = EventFormatter(context, testClock)
403+
404+
val result = testFormatter.formatNotificationSecondaryText(event)
405+
406+
DevLog.info(LOG_TAG, "Result with setting enabled: $result")
407+
DevLog.info(LOG_TAG, "Event start time: $eventStartTime, Base time: $baseTime")
408+
DevLog.info(LOG_TAG, "Reminder 1 fires at: ${eventStartTime - 60 * Consts.MINUTE_IN_MILLISECONDS}")
409+
DevLog.info(LOG_TAG, "Reminder 2 fires at: ${eventStartTime - 30 * Consts.MINUTE_IN_MILLISECONDS}")
410+
411+
// Should contain 📅 when GCal setting is enabled and there are future reminders
412+
assertTrue(
413+
"Should contain 📅 when setting is enabled and future GCal reminders exist. Result: $result",
414+
result.contains("📅")
415+
)
416+
}
268417
}
269418

android/app/src/main/java/com/github/quarck/calnotify/Settings.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter
137137
val snoozePresetsRaw: String
138138
get() = getString(SNOOZE_PRESET_KEY, DEFAULT_SNOOZE_PRESET)
139139

140+
var displayNextGCalReminder: Boolean
141+
get() = getBoolean(DISPLAY_NEXT_GCAL_REMINDER, true)
142+
set(value) = setBoolean(DISPLAY_NEXT_GCAL_REMINDER, value)
143+
144+
var displayNextAppAlert: Boolean
145+
get() = getBoolean(DISPLAY_NEXT_APP_ALERT, false)
146+
set(value) = setBoolean(DISPLAY_NEXT_APP_ALERT, value)
147+
140148
val snoozePresets: LongArray
141149
get() {
142150
var ret = PreferenceUtils.parseSnoozePresets(snoozePresetsRaw)
@@ -443,6 +451,8 @@ class Settings(context: Context) : PersistentStorageBase(context), SettingsInter
443451
private const val CALENDAR_IS_HANDLED_KEY_PREFIX = "calendar_handled_"
444452

445453
private const val SNOOZE_PRESET_KEY = "pref_snooze_presets" //"15m, 1h, 4h, 1d"
454+
private const val DISPLAY_NEXT_GCAL_REMINDER = "pref_display_next_gcal_reminder" // true
455+
private const val DISPLAY_NEXT_APP_ALERT = "pref_display_next_app_alert" // false
446456
private const val VIEW_AFTER_EDIT_KEY = "show_event_after_reschedule" // true
447457
private const val ENABLE_REMINDERS_KEY = "enable_reminding_key" // false
448458
private const val REMINDER_INTERVAL_PATTERN_KEY = "remind_interval_key_pattern" // "10m"

android/app/src/main/java/com/github/quarck/calnotify/calendar/EventRecord.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,4 +167,12 @@ fun EventRecord.nextAlarmTime(currentTime: Long): Long {
167167
}
168168

169169
return ret
170-
}
170+
}
171+
172+
fun EventRecord.getNextAlertTimeAfter(anchor: Long): Long? {
173+
val futureReminders = this
174+
.reminders
175+
.map { this.startTime - it.millisecondsBefore }
176+
.filter { it > anchor }
177+
return futureReminders.maxOrNull()
178+
}

android/app/src/main/java/com/github/quarck/calnotify/notification/EventNotificationManager.kt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,9 +507,19 @@ open class EventNotificationManager : EventNotificationManagerInterface {
507507

508508
val numEvents = events.size
509509

510-
val title = java.lang.String.format(
510+
val baseTitle = java.lang.String.format(
511511
context.getString(R.string.multiple_events_single_notification),
512512
numEvents)
513+
514+
// Add next notification indicator to title if enabled
515+
val formatter = EventFormatter(context, clock)
516+
val nextIndicator = formatter.formatNextNotificationIndicatorForCollapsed(
517+
events = events,
518+
displayNextGCalReminder = settings.displayNextGCalReminder,
519+
displayNextAppAlert = settings.displayNextAppAlert,
520+
remindersEnabled = settings.remindersEnabled
521+
)
522+
val title = if (nextIndicator != null) "$baseTitle $nextIndicator" else baseTitle
513523

514524
val text = context.getString(R.string.multiple_events_details_2)
515525

@@ -1471,7 +1481,17 @@ open class EventNotificationManager : EventNotificationManagerInterface {
14711481

14721482
val numEvents = events.size
14731483

1474-
val title = java.lang.String.format(context.getString(R.string.multiple_events), numEvents)
1484+
val baseTitle = java.lang.String.format(context.getString(R.string.multiple_events), numEvents)
1485+
1486+
// Add next notification indicator to title if enabled
1487+
val formatter = EventFormatter(context, clock)
1488+
val nextIndicator = formatter.formatNextNotificationIndicatorForCollapsed(
1489+
events = events,
1490+
displayNextGCalReminder = settings.displayNextGCalReminder,
1491+
displayNextAppAlert = settings.displayNextAppAlert,
1492+
remindersEnabled = settings.remindersEnabled
1493+
)
1494+
val title = if (nextIndicator != null) "$baseTitle $nextIndicator" else baseTitle
14751495

14761496
val text = context.getString(com.github.quarck.calnotify.R.string.multiple_events_details_2)
14771497

0 commit comments

Comments
 (0)