Skip to content

Commit fd19f72

Browse files
authored
feat: long lived dismissed events and config (#158)
Introduces configurable retention for dismissed events and improves storage consistency and listing behavior. * docs: plan * docs: plan update * docs: test plan * docs: move sort to db * docs: confirm dismiss and search * feat: phase 1 wip * test: fix units * fix: bug bot ListPreference value read as Int instead of String High Severity The keepHistoryDays property uses getInt() to read the preference, but ListPreference in Android stores its values as Strings in SharedPreferences. This causes getInt() to always return the default value (3), ignoring the user's selection. Other similar preferences in this file like firstDayOfWeek correctly use getString() and convert with toIntOrNull(). * feat: default is 14 days now * fix: narrow exception * fix: not quite so narrow * test: fix an keep naming convention * test: remove useless add useful * fix: legacy storage * fix: test the real thing
1 parent c1de75c commit fd19f72

14 files changed

Lines changed: 1473 additions & 41 deletions

File tree

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,5 +186,31 @@ class SettingsTest {
186186
// The default value should be false
187187
assertFalse("displayNextAppAlert should default to false", settings.displayNextAppAlert)
188188
}
189+
190+
// === Keep History Settings Tests ===
191+
192+
@Test
193+
fun testKeepHistoryDaysDefaultValue() {
194+
DevLog.info(LOG_TAG, "Running testKeepHistoryDaysDefaultValue")
195+
// Default should be 14 days
196+
assertEquals(14, settings.keepHistoryDays)
197+
}
198+
199+
@Test
200+
fun testKeepHistoryMillisDefaultValue() {
201+
DevLog.info(LOG_TAG, "Running testKeepHistoryMillisDefaultValue")
202+
// 14 days in milliseconds
203+
val expected = 14L * Consts.DAY_IN_MILLISECONDS
204+
assertEquals(expected, settings.keepHistoryMillis)
205+
}
206+
207+
@Test
208+
fun testKeepHistoryMillisCalculation() {
209+
DevLog.info(LOG_TAG, "Running testKeepHistoryMillisCalculation")
210+
// With default of 14 days, should be 14 * DAY_IN_MILLISECONDS
211+
val days = settings.keepHistoryDays
212+
val expectedMillis = days.toLong() * Consts.DAY_IN_MILLISECONDS
213+
assertEquals(expectedMillis, settings.keepHistoryMillis)
214+
}
189215
}
190216

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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
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.dismissedeventsstorage.DismissedEventsStorage
32+
import com.github.quarck.calnotify.dismissedeventsstorage.EventDismissType
33+
import com.github.quarck.calnotify.eventsstorage.EventsStorage
34+
import com.github.quarck.calnotify.logs.DevLog
35+
import com.github.quarck.calnotify.ui.MainActivity
36+
import org.junit.After
37+
import org.junit.Assert.*
38+
import org.junit.Before
39+
import org.junit.Test
40+
import org.junit.runner.RunWith
41+
42+
/**
43+
* Instrumented tests for storage consistency between EventsStorage and DismissedEventsStorage.
44+
*
45+
* These tests verify that the cleanupOrphanedEvents logic (removing events that exist in both
46+
* storages due to failed deletions) works correctly with real Room databases.
47+
*/
48+
@RunWith(AndroidJUnit4::class)
49+
class StorageConsistencyTest {
50+
51+
companion object {
52+
private const val LOG_TAG = "StorageConsistencyTest"
53+
}
54+
55+
private lateinit var context: Context
56+
private lateinit var eventsStorage: EventsStorage
57+
private lateinit var dismissedStorage: DismissedEventsStorage
58+
59+
private val baseTime = 1635724800000L // 2021-11-01 00:00:00 UTC
60+
61+
@Before
62+
fun setup() {
63+
context = InstrumentationRegistry.getInstrumentation().targetContext
64+
eventsStorage = EventsStorage(context)
65+
dismissedStorage = DismissedEventsStorage(context)
66+
67+
// Clear storages
68+
eventsStorage.classCustomUse { it.deleteAllEvents() }
69+
dismissedStorage.classCustomUse { it.clearHistory() }
70+
71+
DevLog.info(LOG_TAG, "Test setup complete")
72+
}
73+
74+
@After
75+
fun cleanup() {
76+
eventsStorage.classCustomUse { it.deleteAllEvents() }
77+
dismissedStorage.classCustomUse { it.clearHistory() }
78+
DevLog.info(LOG_TAG, "Test cleanup complete")
79+
}
80+
81+
private fun createTestEvent(
82+
eventId: Long,
83+
instanceStartTime: Long = baseTime
84+
): EventAlertRecord {
85+
return EventAlertRecord(
86+
calendarId = 1L,
87+
eventId = eventId,
88+
isAllDay = false,
89+
isRepeating = false,
90+
alertTime = instanceStartTime - 900000,
91+
notificationId = eventId.toInt(),
92+
title = "Test Event $eventId",
93+
desc = "Description",
94+
startTime = instanceStartTime,
95+
endTime = instanceStartTime + 3600000,
96+
instanceStartTime = instanceStartTime,
97+
instanceEndTime = instanceStartTime + 3600000,
98+
location = "Test Location",
99+
snoozedUntil = 0L,
100+
lastStatusChangeTime = 0L,
101+
displayStatus = EventDisplayStatus.Hidden,
102+
color = 0,
103+
origin = EventOrigin.ProviderBroadcast,
104+
timeFirstSeen = 0L,
105+
eventStatus = EventStatus.Confirmed,
106+
attendanceStatus = AttendanceStatus.None,
107+
flags = 0
108+
)
109+
}
110+
111+
/**
112+
* Calls the real cleanupOrphanedEvents from MainActivity.
113+
*/
114+
private fun cleanupOrphanedEvents() {
115+
MainActivity.cleanupOrphanedEvents(context)
116+
}
117+
118+
@Test
119+
fun testNoOrphanedEvents() {
120+
DevLog.info(LOG_TAG, "Running testNoOrphanedEvents")
121+
122+
// Add events only to active storage
123+
eventsStorage.classCustomUse { it.addEvent(createTestEvent(1, baseTime)) }
124+
eventsStorage.classCustomUse { it.addEvent(createTestEvent(2, baseTime + 3600000)) }
125+
126+
// Add different event to dismissed storage
127+
dismissedStorage.classCustomUse {
128+
it.addEvent(EventDismissType.ManuallyDismissedFromNotification, baseTime, createTestEvent(3, baseTime + 7200000))
129+
}
130+
131+
val activeCountBefore = eventsStorage.classCustomUse { it.events.size }
132+
assertEquals("Active storage should have 2 events", 2, activeCountBefore)
133+
134+
cleanupOrphanedEvents()
135+
136+
val activeCountAfter = eventsStorage.classCustomUse { it.events.size }
137+
assertEquals("Active storage should still have 2 events", 2, activeCountAfter)
138+
}
139+
140+
@Test
141+
fun testSingleOrphanedEvent() {
142+
DevLog.info(LOG_TAG, "Running testSingleOrphanedEvent")
143+
144+
val orphanedEvent = createTestEvent(1, baseTime)
145+
146+
// Add event to both storages (simulating failed deletion)
147+
eventsStorage.classCustomUse { it.addEvent(orphanedEvent) }
148+
dismissedStorage.classCustomUse {
149+
it.addEvent(EventDismissType.ManuallyDismissedFromNotification, baseTime, orphanedEvent)
150+
}
151+
152+
// Add normal event only to active storage
153+
eventsStorage.classCustomUse { it.addEvent(createTestEvent(2, baseTime + 3600000)) }
154+
155+
val activeCountBefore = eventsStorage.classCustomUse { it.events.size }
156+
assertEquals("Active storage should have 2 events", 2, activeCountBefore)
157+
158+
cleanupOrphanedEvents()
159+
160+
val activeCountAfter = eventsStorage.classCustomUse { it.events.size }
161+
assertEquals("Active storage should have 1 event after cleanup", 1, activeCountAfter)
162+
163+
val remainingEvent = eventsStorage.classCustomUse { it.events[0] }
164+
assertEquals("Remaining event should be event 2", 2L, remainingEvent.eventId)
165+
}
166+
167+
@Test
168+
fun testOrphanedEventWithDifferentInstanceTime() {
169+
DevLog.info(LOG_TAG, "Running testOrphanedEventWithDifferentInstanceTime")
170+
171+
// Same eventId but different instanceStartTime should NOT be considered orphaned
172+
val activeEvent = createTestEvent(1, baseTime)
173+
val dismissedEvent = createTestEvent(1, baseTime + Consts.DAY_IN_MILLISECONDS)
174+
175+
eventsStorage.classCustomUse { it.addEvent(activeEvent) }
176+
dismissedStorage.classCustomUse {
177+
it.addEvent(EventDismissType.ManuallyDismissedFromNotification, baseTime, dismissedEvent)
178+
}
179+
180+
val activeCountBefore = eventsStorage.classCustomUse { it.events.size }
181+
assertEquals("Active storage should have 1 event", 1, activeCountBefore)
182+
183+
cleanupOrphanedEvents()
184+
185+
// Active event should NOT be removed because instanceStartTime differs
186+
val activeCountAfter = eventsStorage.classCustomUse { it.events.size }
187+
assertEquals("Active storage should still have 1 event", 1, activeCountAfter)
188+
}
189+
190+
@Test
191+
fun testOrphanedEventWithSameInstanceTime() {
192+
DevLog.info(LOG_TAG, "Running testOrphanedEventWithSameInstanceTime")
193+
194+
// Same eventId AND same instanceStartTime IS considered orphaned
195+
val event = createTestEvent(1, baseTime)
196+
197+
eventsStorage.classCustomUse { it.addEvent(event) }
198+
dismissedStorage.classCustomUse {
199+
it.addEvent(EventDismissType.ManuallyDismissedFromNotification, baseTime, event)
200+
}
201+
202+
val activeCountBefore = eventsStorage.classCustomUse { it.events.size }
203+
assertEquals("Active storage should have 1 event", 1, activeCountBefore)
204+
205+
cleanupOrphanedEvents()
206+
207+
// Active event SHOULD be removed because both eventId and instanceStartTime match
208+
val activeCountAfter = eventsStorage.classCustomUse { it.events.size }
209+
assertEquals("Active storage should be empty after cleanup", 0, activeCountAfter)
210+
}
211+
212+
@Test
213+
fun testEmptyStorages() {
214+
DevLog.info(LOG_TAG, "Running testEmptyStorages")
215+
216+
// Both storages are already empty from setup
217+
val activeCount = eventsStorage.classCustomUse { it.events.size }
218+
val dismissedCount = dismissedStorage.classCustomUse { it.events.size }
219+
220+
assertEquals("Active storage should be empty", 0, activeCount)
221+
assertEquals("Dismissed storage should be empty", 0, dismissedCount)
222+
223+
// Should not throw on empty storages
224+
cleanupOrphanedEvents()
225+
226+
val activeCountAfter = eventsStorage.classCustomUse { it.events.size }
227+
assertEquals("Active storage should still be empty", 0, activeCountAfter)
228+
}
229+
}
230+

0 commit comments

Comments
 (0)