Skip to content

Commit 269df3f

Browse files
authored
feat: Room Migration p2 DismissedEventsStorage (#138)
* test: maybe fix slowness in full concert test run * fix: narrow exception catch * feat: copy old db pattern for DismissedEventsStorage also docs * test: DismissedEventsStorageMigrationTest * test: hopefully fix concert perf * ci: hopefully more stable gradle deps cache * fix: bug bot close * fix: another bug bot catch Room database not closed when migration fails In getInstance, if copyFromLegacyIfNeeded throws an exception, the Room database db that was built is never closed. The database connection is opened when dao.getAll() is called inside copyFromLegacyIfNeeded. When an exception propagates, INSTANCE is never set and the db reference is lost, but the underlying database connection remains open. This leaks a database connection that will persist until process termination, potentially locking the database file and consuming * fix: another bug bot Migration validation cannot detect partial legacy data reads The migration count validation compares the Room count to the number of alerts that were successfully READ from legacy (legacyAlerts.size), not the actual total count in the legacy database. The underlying MonitorStorageImplV1.getAlerts() method catches broad Exception and returns partial results if an error occurs mid-read. If a cursor error occurs after reading some rows, the migration would insert partial data, pass validation (since Room count equals read count), and permanently orphan the remaining legacy data. On subsequent launches, migration is skipped because Room already has data. * feat: room integration p1 DismissedEventsStorage wip
1 parent 75878e4 commit 269df3f

15 files changed

Lines changed: 1447 additions & 200 deletions

File tree

.github/actions/cache-update/action.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,17 @@ runs:
5252
android/app/build/intermediates/test_apk_androidTest
5353
key: ${{ runner.os }}-build-outputs-${{ inputs.arch }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}-${{ github.sha }}
5454

55+
# Gradle dependencies cache - Save only (more stable than build cache)
56+
# Prevents intermittent 403s from Maven Central rate limiting in CI
57+
- name: Save Gradle Dependencies Cache
58+
if: always()
59+
uses: actions/cache/save@v4
60+
with:
61+
path: |
62+
~/.gradle/caches/modules-2
63+
~/.gradle/caches/jars-*
64+
key: ${{ runner.os }}-gradle-deps-v1-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
65+
5566
# Gradle cache - Save only with architecture in key
5667
- name: Save Gradle Cache
5768
if: always()

.github/actions/common-setup/action.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,21 @@ runs:
198198
java-version: '21'
199199
distribution: 'temurin'
200200

201+
# Cache Gradle downloaded dependencies (separate from build cache for stability)
202+
# This uses a stable key so dependencies persist across gradle file changes
203+
# Prevents intermittent 403s from Maven Central rate limiting in CI
204+
- name: Restore Gradle Dependencies Cache
205+
uses: actions/cache/restore@v4
206+
id: gradle-deps-cache-restore
207+
with:
208+
path: |
209+
~/.gradle/caches/modules-2
210+
~/.gradle/caches/jars-*
211+
key: ${{ runner.os }}-gradle-deps-v1-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}
212+
restore-keys: |
213+
${{ runner.os }}-gradle-deps-v1-
214+
${{ runner.os }}-gradle-deps-
215+
201216
# Cache Gradle files
202217
# Note: Cache key includes gradle-wrapper.properties hash to invalidate on Gradle version changes
203218
- name: Restore Cache Gradle files

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

Lines changed: 470 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,22 @@ class AsyncTaskIdlingResource(
4646
* Get current count (for debugging).
4747
*/
4848
fun getCount(): Int = counter.get()
49+
50+
/**
51+
* Resets the counter to zero.
52+
* Call this when clearing IdlingResources between tests to prevent stale
53+
* counter values from causing subsequent tests to hang.
54+
*/
55+
fun reset() {
56+
val previousCount = counter.getAndSet(0)
57+
if (previousCount > 0) {
58+
com.github.quarck.calnotify.logs.DevLog.info(
59+
"AsyncTaskIdlingResource",
60+
"Reset counter from $previousCount to 0 (clearing stale async task count)"
61+
)
62+
}
63+
// Notify callback that we're now idle (counter is 0)
64+
callback?.onTransitionToIdle()
65+
}
4966
}
5067

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

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,6 @@ class UITestFixture {
4747
private var eventIdCounter = 100000L
4848
private val seededEvents = mutableListOf<EventAlertRecord>()
4949

50-
// IdlingResource for AsyncTask tracking
51-
private val asyncTaskIdlingResource = AsyncTaskIdlingResource()
52-
5350
// Track if calendar reload prevention is active
5451
private var calendarReloadPrevented = false
5552

@@ -77,6 +74,10 @@ class UITestFixture {
7774
) {
7875
DevLog.info(LOG_TAG, "Setting up UITestFixture (waitForAsyncTasks=$waitForAsyncTasks, preventCalendarReload=$preventCalendarReload, grantPermissions=$grantPermissions, suppressBatteryDialog=$suppressBatteryDialog)")
7976

77+
// CRITICAL: Clear any leftover IdlingResources from previous tests FIRST
78+
// This prevents accumulation when running the full test suite
79+
clearIdlingResources()
80+
8081
// Reset dialog flag so each test can handle dialogs if they appear
8182
startupDialogsDismissed = false
8283
dialogsSuppressed = false
@@ -109,8 +110,8 @@ class UITestFixture {
109110

110111
// Only register IdlingResource if we need to wait for async operations
111112
if (waitForAsyncTasks) {
112-
IdlingRegistry.getInstance().register(asyncTaskIdlingResource)
113-
globalAsyncTaskCallback = asyncTaskIdlingResource
113+
IdlingRegistry.getInstance().register(sharedAsyncTaskIdlingResource)
114+
globalAsyncTaskCallback = sharedAsyncTaskIdlingResource
114115
DevLog.info(LOG_TAG, "Registered AsyncTask IdlingResource")
115116
} else {
116117
DevLog.info(LOG_TAG, "Skipping AsyncTask IdlingResource for faster UI tests")
@@ -332,13 +333,8 @@ class UITestFixture {
332333
DevLog.error(LOG_TAG, "Failed to reset battery dialog setting: ${e.message}")
333334
}
334335

335-
// Unregister IdlingResource if it was registered
336-
try {
337-
IdlingRegistry.getInstance().unregister(asyncTaskIdlingResource)
338-
globalAsyncTaskCallback = null
339-
} catch (e: IllegalArgumentException) {
340-
// Expected if IdlingResource wasn't registered - safe to ignore
341-
}
336+
// Clear IdlingResources to ensure clean state for next test
337+
clearIdlingResources()
342338

343339
unmockkAll()
344340
}
@@ -642,6 +638,34 @@ class UITestFixture {
642638
@Volatile
643639
private var startupDialogsDismissed = false
644640

641+
/**
642+
* Shared IdlingResource instance to prevent accumulation across tests.
643+
* Using a singleton ensures we don't register multiple IdlingResources
644+
* when running the full test suite.
645+
*/
646+
private val sharedAsyncTaskIdlingResource = AsyncTaskIdlingResource()
647+
648+
/**
649+
* Clears any leftover IdlingResources from previous test runs.
650+
* Call at the START of setup to ensure clean state.
651+
*/
652+
fun clearIdlingResources() {
653+
// Clear the global callback first
654+
globalAsyncTaskCallback = null
655+
656+
// Reset the counter to clear any stale async task counts from previous tests
657+
// This prevents tests from hanging if a previous test terminated with tasks in-flight
658+
sharedAsyncTaskIdlingResource.reset()
659+
660+
// Unregister our shared IdlingResource if it's registered
661+
try {
662+
IdlingRegistry.getInstance().unregister(sharedAsyncTaskIdlingResource)
663+
DevLog.info(LOG_TAG, "Cleared previously registered IdlingResource")
664+
} catch (e: IllegalArgumentException) {
665+
// Not registered, that's fine
666+
}
667+
}
668+
645669
/**
646670
* Creates a fixture instance. Typically used with JUnit rules.
647671
*/

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
package com.github.quarck.calnotify.database
2121

22+
import android.database.SQLException
2223
import androidx.sqlite.db.SupportSQLiteOpenHelper
2324
import com.github.quarck.calnotify.logs.DevLog
2425
import io.requery.android.database.sqlite.RequerySQLiteOpenHelperFactory
@@ -71,10 +72,13 @@ class CrSqliteFinalizeWrapper(
7172
override fun close() {
7273
try {
7374
writableDatabase.query("SELECT crsql_finalize()").use { it.moveToFirst() }
74-
} catch (e: Exception) {
75+
} catch (e: SQLException) {
7576
DevLog.error("CrSqliteFinalizeWrapper", "Error calling crsql_finalize: ${e.message}")
77+
} catch (e: IllegalStateException) {
78+
DevLog.error("CrSqliteFinalizeWrapper", "Error calling crsql_finalize: ${e.message}")
79+
} finally {
80+
delegate.close()
7681
}
77-
delegate.close()
7882
}
7983
}
8084

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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 androidx.room.Dao
23+
import androidx.room.Delete
24+
import androidx.room.Insert
25+
import androidx.room.OnConflictStrategy
26+
import androidx.room.Query
27+
28+
/**
29+
* Room DAO for DismissedEventEntity.
30+
*
31+
* Provides all database operations needed by DismissedEventsStorageInterface.
32+
*/
33+
@Dao
34+
interface DismissedEventDao {
35+
36+
@Query("SELECT * FROM ${DismissedEventEntity.TABLE_NAME}")
37+
fun getAll(): List<DismissedEventEntity>
38+
39+
@Insert(onConflict = OnConflictStrategy.REPLACE)
40+
fun insert(entity: DismissedEventEntity)
41+
42+
@Insert(onConflict = OnConflictStrategy.REPLACE)
43+
fun insertAll(entities: List<DismissedEventEntity>)
44+
45+
@Delete
46+
fun delete(entity: DismissedEventEntity)
47+
48+
@Query("DELETE FROM ${DismissedEventEntity.TABLE_NAME} WHERE ${DismissedEventEntity.COL_EVENT_ID} = :eventId AND ${DismissedEventEntity.COL_INSTANCE_START} = :instanceStart")
49+
fun deleteByKey(eventId: Long, instanceStart: Long)
50+
51+
@Query("DELETE FROM ${DismissedEventEntity.TABLE_NAME}")
52+
fun deleteAll()
53+
54+
@Query("DELETE FROM ${DismissedEventEntity.TABLE_NAME} WHERE ${DismissedEventEntity.COL_DISMISS_TIME} < :cutoffTime")
55+
fun deleteOlderThan(cutoffTime: Long)
56+
}
57+

0 commit comments

Comments
 (0)