Skip to content

Commit 5e6cb41

Browse files
authored
feat: upcoming events aka events view look ahead 92 11 (#173)
* docs: inital plan * docs: plan update * docs: pre snooze and such * docs: plan update * docs: time picker * docs: plan update * docs: plan update * docs: plan update * docs: bugs fixs * docs: implementation order * docs: bug bot * feat: milestone 1 * feat: new ui on by default easy way back to old one * fix: build break * fix: bug bot ListPreference values read with wrong type causes crash High Severity The upcomingEventsCutoffHour and upcomingEventsFixedHours settings use getInt() to read values, but the corresponding ListPreference elements in misc_preferences.xml store values as strings. When a user changes these settings via the UI, SharedPreferences.getInt() will throw a ClassCastException. The existing codebase pattern for ListPreference values (e.g., keepHistoryDays) correctly uses getString(...).toIntOrNull() ?: default instead * fix: floating action button layout * feat: upcoming test activity * fix: bug bot MonitorStorage resource leak in UpcomingEventsFragment Medium Severity The MonitorStorage instance created in loadEvents() is passed to UpcomingEventsProvider but is never closed. MonitorStorage implements Closeable and holds database resources. Each call to loadEvents() (triggered by swipe refresh or resume) leaks a storage instance. The codebase pattern uses .use { } for proper resource cleanup with closeable storages. Undo callback crashes if fragment is detached Medium Severity The requireContext() call inside the UndoState Runnable is evaluated when the Runnable executes (on Undo tap), not when it's created. Since UndoManager stores this Runnable globally, if the user dismisses an event, switches tabs (detaching the fragment), and then taps "Undo" on the still-visible Snackbar, requireContext() will throw IllegalStateException. The existing MainActivity pattern safely uses this (the Activity context). The context should be captured before creating the Runnable. Fragments missing data update broadcast registration causes stale UI Medium Severity In new navigation mode, MainActivity.onDataUpdated() returns early with comment "fragments handle their own data updates" but the fragments (ActiveEventsFragment, UpcomingEventsFragment, DismissedEventsFragment) don't register any BroadcastReceiver for DATA_UPDATED_BROADCAST. When events are modified externally (e.g., dismissed via notification action), the fragment UI shows stale data until the user manually pulls to refresh or switches tabs. The fragments only load data in onResume() and pull-to-refresh, missing live updates. * fix: linter error * fix: maybe TestActivity * fix: a classic bug * fix: crash on list tap * fix: disable swipe on milestone 1 and fix legacy view tests * fix: bug bot Lazy settings access in background block may crash Medium Severity The settings property uses lazy { Settings(requireContext()) } but is only accessed inside the background block in loadEvents(). If the user quickly switches tabs after onResume() schedules background work, the fragment may detach before the background thread runs. When the lazy initializer executes requireContext() on the background thread, it throws IllegalStateException because the fragment is no longer attached. Unlike ActiveEventsFragment which initializes settings synchronously in onViewCreated(), this fragment has no synchronous access path to initialize the lazy property while attached. * fix: bug bot Fragment callbacks use unsafe requireContext() call Medium Severity The onItemRemoved and onItemRestored callbacks use requireContext() which throws IllegalStateException if the fragment is detached. These callbacks are triggered by ItemTouchHelper.onSwiped() after swipe animations complete, which can occur after the user navigates away. Other methods in the same fragments correctly use the safe pattern val ctx = context ?: return (e.g., onItemDismiss at line 215), but these callbacks don't follow the same pattern. This inconsistency could cause crashes during rapid navigation. Popup menu callback uses unsafe requireContext() call Low Severity The onItemClick method creates a PopupMenu and registers a setOnMenuItemClickListener lambda that calls requireContext() at line 139. This lambda executes asynchronously when the user selects a menu item. If the fragment becomes detached between showing the popup and the user clicking "Restore" (e.g., during a configuration change), requireContext() will throw IllegalStateException. The safe pattern context ?: return with an early return in the lambda would prevent this crash. * docs: dependency injection patterns * docs: fragment tweak * fix: di for testing * refactor: sorting stuff * fix: make sure we aren't missing old stuff also new tests * test: UpcomingEventsFragmentTest * fix: build * fix: build some tests pass * test: fix * fix: instrument test build * fix: build * fix: bug bot Missing context null check causes crash on fragment detachment Medium Severity onItemClick and onItemSnooze use requireContext() which throws IllegalStateException if the fragment is detached. Other callbacks in this same file (onItemDismiss, onItemRemoved, onItemRestored) correctly use val ctx = context ?: return. The sister fragments UpcomingEventsFragment and DismissedEventsFragment also use the safe pattern consistently. If a configuration change occurs while a user tap is being processed, the app will crash. * fix: maybe UpcomingEventsFragment with roboeletric * test: maybe fix * test: seperate test * fix: UITestFixture * feat: search and different page titles * test: search
1 parent 9f094cb commit 5e6cb41

57 files changed

Lines changed: 6673 additions & 78 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

android/app/build.gradle

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,10 @@ dependencies {
274274
// AndroidX Preference (for modern settings UI with DayNight support)
275275
implementation "androidx.preference:preference-ktx:1.2.1"
276276

277+
// Navigation Component (for tabbed UI with fragments)
278+
implementation "androidx.navigation:navigation-fragment-ktx:2.7.7"
279+
implementation "androidx.navigation:navigation-ui-ktx:2.7.7"
280+
277281
// React Native
278282
implementation "com.facebook.react:react-android"
279283
implementation "com.facebook.react:hermes-android"
@@ -309,6 +313,8 @@ dependencies {
309313
testImplementation 'androidx.test:core-ktx:1.5.0'
310314
testImplementation 'androidx.test.ext:junit:1.1.5'
311315
testImplementation 'androidx.test.ext:junit-ktx:1.1.5'
316+
// Fragment testing for Robolectric (must be debugImplementation so EmptyFragmentActivity is available)
317+
debugImplementation "androidx.fragment:fragment-testing:$androidx_lib_version"
312318

313319
// Test dependencies - use same versions as main app to avoid conflicts
314320
androidTestImplementation "androidx.core:core:$android_core_version"
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
//
2+
// Calendar Notifications Plus
3+
// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com)
4+
//
5+
// This program is free software; you can redistribute it and/or modify
6+
// it under the terms of the GNU General Public License as published by
7+
// the Free Software Foundation; either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// This program is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU General Public License
16+
// along with this program; if not, write to the Free Software Foundation,
17+
// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
18+
//
19+
20+
package com.github.quarck.calnotify.dismissedeventsstorage
21+
22+
import android.content.Context
23+
import androidx.test.ext.junit.runners.AndroidJUnit4
24+
import androidx.test.platform.app.InstrumentationRegistry
25+
import com.github.quarck.calnotify.calendar.AttendanceStatus
26+
import com.github.quarck.calnotify.calendar.EventAlertRecord
27+
import com.github.quarck.calnotify.calendar.EventDisplayStatus
28+
import com.github.quarck.calnotify.calendar.EventOrigin
29+
import com.github.quarck.calnotify.calendar.EventStatus
30+
import com.github.quarck.calnotify.database.SQLiteDatabaseExtensions.classCustomUse
31+
import com.github.quarck.calnotify.logs.DevLog
32+
import org.junit.After
33+
import org.junit.Assert.*
34+
import org.junit.Before
35+
import org.junit.Test
36+
import org.junit.runner.RunWith
37+
38+
/**
39+
* Instrumented tests for DismissedEventsStorage functionality.
40+
*/
41+
@RunWith(AndroidJUnit4::class)
42+
class DismissedEventsStorageTest {
43+
44+
companion object {
45+
private const val LOG_TAG = "DismissedEventsStorageTest"
46+
}
47+
48+
private lateinit var context: Context
49+
private lateinit var storage: DismissedEventsStorage
50+
51+
private val baseTime = 1635724800000L
52+
53+
@Before
54+
fun setup() {
55+
context = InstrumentationRegistry.getInstrumentation().targetContext
56+
storage = DismissedEventsStorage(context)
57+
storage.classCustomUse { it.clearHistory() }
58+
DevLog.info(LOG_TAG, "Test setup complete, isUsingRoom=${storage.isUsingRoom}")
59+
}
60+
61+
@After
62+
fun cleanup() {
63+
storage.classCustomUse { it.clearHistory() }
64+
DevLog.info(LOG_TAG, "Test cleanup complete")
65+
}
66+
67+
private fun createTestEvent(eventId: Long): EventAlertRecord = EventAlertRecord(
68+
calendarId = 1L,
69+
eventId = eventId,
70+
isAllDay = false,
71+
isRepeating = false,
72+
alertTime = baseTime - 900000,
73+
notificationId = eventId.toInt(),
74+
title = "Test Event $eventId",
75+
desc = "",
76+
startTime = baseTime,
77+
endTime = baseTime + 3600000,
78+
instanceStartTime = baseTime + eventId, // Unique per event
79+
instanceEndTime = baseTime + 3600000,
80+
location = "",
81+
snoozedUntil = 0,
82+
lastStatusChangeTime = baseTime,
83+
displayStatus = EventDisplayStatus.Hidden,
84+
color = 0,
85+
origin = EventOrigin.ProviderBroadcast,
86+
timeFirstSeen = baseTime,
87+
eventStatus = EventStatus.Confirmed,
88+
attendanceStatus = AttendanceStatus.None,
89+
flags = 0
90+
)
91+
92+
// ============ eventsForDisplay tests ============
93+
94+
@Test
95+
fun test_eventsForDisplay_sorts_by_dismissTime_descending() {
96+
DevLog.info(LOG_TAG, "=== test_eventsForDisplay_sorts_by_dismissTime_descending ===")
97+
98+
storage.classCustomUse { db ->
99+
db.addEvent(EventDismissType.ManuallyDismissedFromNotification, 1000, createTestEvent(1))
100+
db.addEvent(EventDismissType.ManuallyDismissedFromNotification, 3000, createTestEvent(2))
101+
db.addEvent(EventDismissType.ManuallyDismissedFromNotification, 2000, createTestEvent(3))
102+
}
103+
104+
val sorted = storage.classCustomUse { it.eventsForDisplay }
105+
106+
assertEquals(3, sorted.size)
107+
assertEquals("Most recent dismiss first", 2L, sorted[0].event.eventId)
108+
assertEquals("Middle dismiss second", 3L, sorted[1].event.eventId)
109+
assertEquals("Oldest dismiss third", 1L, sorted[2].event.eventId)
110+
}
111+
112+
@Test
113+
fun test_eventsForDisplay_empty_returns_emptyList() {
114+
val sorted = storage.classCustomUse { it.eventsForDisplay }
115+
assertTrue("Empty storage should return empty list", sorted.isEmpty())
116+
}
117+
118+
@Test
119+
fun test_eventsForDisplay_single_event() {
120+
storage.classCustomUse { db ->
121+
db.addEvent(EventDismissType.ManuallyDismissedFromNotification, 1000, createTestEvent(1))
122+
}
123+
124+
val sorted = storage.classCustomUse { it.eventsForDisplay }
125+
assertEquals(1, sorted.size)
126+
assertEquals(1L, sorted[0].event.eventId)
127+
}
128+
129+
@Test
130+
fun test_eventsForDisplay_same_dismissTime_is_stable() {
131+
DevLog.info(LOG_TAG, "=== test_eventsForDisplay_same_dismissTime_is_stable ===")
132+
133+
storage.classCustomUse { db ->
134+
db.addEvent(EventDismissType.ManuallyDismissedFromNotification, 1000, createTestEvent(1))
135+
db.addEvent(EventDismissType.ManuallyDismissedFromNotification, 1000, createTestEvent(2))
136+
db.addEvent(EventDismissType.ManuallyDismissedFromNotification, 1000, createTestEvent(3))
137+
}
138+
139+
val sorted = storage.classCustomUse { it.eventsForDisplay }
140+
141+
assertEquals(3, sorted.size)
142+
// All have same dismissTime - just verify all are present
143+
val eventIds = sorted.map { it.event.eventId }.toSet()
144+
assertEquals(setOf(1L, 2L, 3L), eventIds)
145+
}
146+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
//
2+
// Calendar Notifications Plus
3+
// Copyright (C) 2025 William Harris (wharris+cnplus@upscalews.com)
4+
//
5+
// This program is free software; you can redistribute it and/or modify
6+
// it under the terms of the GNU General Public License as published by
7+
// the Free Software Foundation; either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// This program is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU General Public License
16+
// along with this program; if not, write to the Free Software Foundation,
17+
// Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
18+
//
19+
20+
package com.github.quarck.calnotify.eventsstorage
21+
22+
import android.content.Context
23+
import androidx.test.ext.junit.runners.AndroidJUnit4
24+
import androidx.test.platform.app.InstrumentationRegistry
25+
import com.github.quarck.calnotify.calendar.AttendanceStatus
26+
import com.github.quarck.calnotify.calendar.EventAlertRecord
27+
import com.github.quarck.calnotify.calendar.EventDisplayStatus
28+
import com.github.quarck.calnotify.calendar.EventOrigin
29+
import com.github.quarck.calnotify.calendar.EventStatus
30+
import com.github.quarck.calnotify.database.SQLiteDatabaseExtensions.classCustomUse
31+
import com.github.quarck.calnotify.logs.DevLog
32+
import org.junit.After
33+
import org.junit.Assert.*
34+
import org.junit.Before
35+
import org.junit.Test
36+
import org.junit.runner.RunWith
37+
38+
/**
39+
* Instrumented tests for EventsStorage functionality.
40+
*/
41+
@RunWith(AndroidJUnit4::class)
42+
class EventsStorageTest {
43+
44+
companion object {
45+
private const val LOG_TAG = "EventsStorageTest"
46+
}
47+
48+
private lateinit var context: Context
49+
private lateinit var storage: EventsStorage
50+
51+
private val baseTime = 1635724800000L
52+
53+
@Before
54+
fun setup() {
55+
context = InstrumentationRegistry.getInstrumentation().targetContext
56+
storage = EventsStorage(context)
57+
storage.classCustomUse { it.deleteAllEvents() }
58+
DevLog.info(LOG_TAG, "Test setup complete, isUsingRoom=${storage.isUsingRoom}")
59+
}
60+
61+
@After
62+
fun cleanup() {
63+
storage.classCustomUse { it.deleteAllEvents() }
64+
DevLog.info(LOG_TAG, "Test cleanup complete")
65+
}
66+
67+
private fun createTestEvent(
68+
eventId: Long,
69+
snoozedUntil: Long = 0,
70+
lastStatusChangeTime: Long = baseTime
71+
): EventAlertRecord = EventAlertRecord(
72+
calendarId = 1L,
73+
eventId = eventId,
74+
isAllDay = false,
75+
isRepeating = false,
76+
alertTime = baseTime - 900000,
77+
notificationId = 0,
78+
title = "Test Event $eventId",
79+
desc = "",
80+
startTime = baseTime,
81+
endTime = baseTime + 3600000,
82+
instanceStartTime = baseTime + eventId, // Unique per event
83+
instanceEndTime = baseTime + 3600000,
84+
location = "",
85+
snoozedUntil = snoozedUntil,
86+
lastStatusChangeTime = lastStatusChangeTime,
87+
displayStatus = EventDisplayStatus.Hidden,
88+
color = 0,
89+
origin = EventOrigin.ProviderBroadcast,
90+
timeFirstSeen = baseTime,
91+
eventStatus = EventStatus.Confirmed,
92+
attendanceStatus = AttendanceStatus.None,
93+
flags = 0
94+
)
95+
96+
// ============ eventsForDisplay tests ============
97+
98+
@Test
99+
fun test_eventsForDisplay_sorts_nonSnoozed_before_snoozed() {
100+
DevLog.info(LOG_TAG, "=== test_eventsForDisplay_sorts_nonSnoozed_before_snoozed ===")
101+
102+
storage.classCustomUse { db ->
103+
db.addEvent(createTestEvent(1, snoozedUntil = 5000, lastStatusChangeTime = 100))
104+
db.addEvent(createTestEvent(2, snoozedUntil = 0, lastStatusChangeTime = 100))
105+
}
106+
107+
val sorted = storage.classCustomUse { it.eventsForDisplay }
108+
109+
assertEquals(2, sorted.size)
110+
assertEquals("Non-snoozed should be first", 2L, sorted[0].eventId)
111+
assertEquals("Snoozed should be second", 1L, sorted[1].eventId)
112+
}
113+
114+
@Test
115+
fun test_eventsForDisplay_sorts_snoozed_by_snoozeTime_ascending() {
116+
DevLog.info(LOG_TAG, "=== test_eventsForDisplay_sorts_snoozed_by_snoozeTime_ascending ===")
117+
118+
storage.classCustomUse { db ->
119+
db.addEvent(createTestEvent(1, snoozedUntil = 3000, lastStatusChangeTime = 100))
120+
db.addEvent(createTestEvent(2, snoozedUntil = 1000, lastStatusChangeTime = 100))
121+
db.addEvent(createTestEvent(3, snoozedUntil = 2000, lastStatusChangeTime = 100))
122+
}
123+
124+
val sorted = storage.classCustomUse { it.eventsForDisplay }
125+
126+
assertEquals(3, sorted.size)
127+
assertEquals("Earliest snooze first", 2L, sorted[0].eventId)
128+
assertEquals("Middle snooze second", 3L, sorted[1].eventId)
129+
assertEquals("Latest snooze third", 1L, sorted[2].eventId)
130+
}
131+
132+
@Test
133+
fun test_eventsForDisplay_sorts_same_snooze_by_lastStatusChangeTime_descending() {
134+
DevLog.info(LOG_TAG, "=== test_eventsForDisplay_sorts_same_snooze_by_lastStatusChangeTime_descending ===")
135+
136+
storage.classCustomUse { db ->
137+
db.addEvent(createTestEvent(1, snoozedUntil = 0, lastStatusChangeTime = 100))
138+
db.addEvent(createTestEvent(2, snoozedUntil = 0, lastStatusChangeTime = 300))
139+
db.addEvent(createTestEvent(3, snoozedUntil = 0, lastStatusChangeTime = 200))
140+
}
141+
142+
val sorted = storage.classCustomUse { it.eventsForDisplay }
143+
144+
assertEquals(3, sorted.size)
145+
assertEquals("Most recent change first", 2L, sorted[0].eventId)
146+
assertEquals("Middle change second", 3L, sorted[1].eventId)
147+
assertEquals("Oldest change third", 1L, sorted[2].eventId)
148+
}
149+
150+
@Test
151+
fun test_eventsForDisplay_complex_sort() {
152+
DevLog.info(LOG_TAG, "=== test_eventsForDisplay_complex_sort ===")
153+
154+
storage.classCustomUse { db ->
155+
// Non-snoozed events with different lastStatusChangeTime
156+
db.addEvent(createTestEvent(1, snoozedUntil = 0, lastStatusChangeTime = 300))
157+
db.addEvent(createTestEvent(2, snoozedUntil = 0, lastStatusChangeTime = 500))
158+
db.addEvent(createTestEvent(3, snoozedUntil = 0, lastStatusChangeTime = 400))
159+
// Snoozed events
160+
db.addEvent(createTestEvent(4, snoozedUntil = 2000, lastStatusChangeTime = 100))
161+
db.addEvent(createTestEvent(5, snoozedUntil = 1000, lastStatusChangeTime = 200))
162+
}
163+
164+
val sorted = storage.classCustomUse { it.eventsForDisplay }
165+
166+
assertEquals(5, sorted.size)
167+
// Non-snoozed first, sorted by lastStatusChangeTime desc
168+
assertEquals(2L, sorted[0].eventId) // lastStatusChangeTime=500
169+
assertEquals(3L, sorted[1].eventId) // lastStatusChangeTime=400
170+
assertEquals(1L, sorted[2].eventId) // lastStatusChangeTime=300
171+
// Snoozed last, sorted by snoozedUntil asc
172+
assertEquals(5L, sorted[3].eventId) // snoozedUntil=1000
173+
assertEquals(4L, sorted[4].eventId) // snoozedUntil=2000
174+
}
175+
176+
@Test
177+
fun test_eventsForDisplay_empty_returns_emptyList() {
178+
val sorted = storage.classCustomUse { it.eventsForDisplay }
179+
assertTrue("Empty storage should return empty list", sorted.isEmpty())
180+
}
181+
182+
@Test
183+
fun test_eventsForDisplay_single_event() {
184+
storage.classCustomUse { db ->
185+
db.addEvent(createTestEvent(1, snoozedUntil = 0, lastStatusChangeTime = 100))
186+
}
187+
188+
val sorted = storage.classCustomUse { it.eventsForDisplay }
189+
assertEquals(1, sorted.size)
190+
assertEquals(1L, sorted[0].eventId)
191+
}
192+
}

0 commit comments

Comments
 (0)