diff --git a/.github/actions/common-setup/action.yml b/.github/actions/common-setup/action.yml index 4068281e5..2b79d93df 100644 --- a/.github/actions/common-setup/action.yml +++ b/.github/actions/common-setup/action.yml @@ -360,11 +360,19 @@ runs: ${{ runner.os }}-android-avd-${{ inputs.android_api_level }}- # Setup Android SDK for emulator if needed and not cached + # + # NOTE: do not add the legacy 'tools' package here. Google removed it from + # the SDK repository (superseded by cmdline-tools), so sdkmanager exits 1 + # with "Failed to find package 'tools'" and the whole setup step dies -- + # which shows up downstream as "No test result XML files found" because the + # emulator never starts. Nothing in this repo uses the old tools/ binaries; + # avdmanager and emulator come from cmdline-tools and the emulator package, + # and reactivecircus/android-emulator-runner installs what else it needs. - name: Setup Android SDK if: inputs.run_emulator_setup == 'true' && steps.android-cache.outputs.cache-hit != 'true' uses: android-actions/setup-android@v3 with: - packages: 'tools platform-tools system-images;android-${{ inputs.android_api_level }};${{ inputs.android_target }};${{ inputs.arch }}' + packages: 'platform-tools system-images;android-${{ inputs.android_api_level }};${{ inputs.android_target }};${{ inputs.arch }}' accept-android-sdk-licenses: true log-accepted-android-sdk-licenses: false diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index a51f8c90e..7ef3b2944 100644 --- a/.github/workflows/actions.yml +++ b/.github/workflows/actions.yml @@ -966,8 +966,11 @@ jobs: android/app/build/test-results/ retention-days: 90 + # v1.15+ is required: earlier images lack findutils, so the action's own + # script dies with "xargs is not available" and produces no report. + # See https://github.com/simple-elf/allure-report-action/pull/78 - name: Generate Allure HTML Report - uses: simple-elf/allure-report-action@v1.13 + uses: simple-elf/allure-report-action@v1.15 if: always() with: allure_results: android/app/build/outputs/allure-results diff --git a/AGENTS.md b/AGENTS.md index 974270282..e542b6091 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,28 @@ Use the `plan-making` skill in `.skills/plan-making/` when a task warrants a wri **Do not use an editor's built-in Plan Mode** or create `.cursor/plans/*.plan.md` files — that directory is deprecated and retained for historical reference only. +# Branching and PRs + +**The default branch is `master`.** There is no `main`. + +Branches are **long-lived and named after the plan** they implement, not per-commit or per-task. Work accumulates on the branch; when it reaches a coherent stopping point the PR gets merged and a new branch starts for the next stage. A small plan lands on one branch; a large one spans several sequential branches. + +**How to size a PR:** if it is getting onerous to review in the GitHub UI, it is too large. That is the signal — there is no commit or line-count rule. Suggest merging when a branch reaches a natural stopping point, rather than letting it grow until review is painful. + +**Never merge a PR yourself.** Say when something is ready and let the maintainer decide. + +## Never push to `master` + +Always push with an explicit refspec: + +```bash +git push origin HEAD:refs/heads/ +``` + +This is not hypothetical. `git checkout -b origin/master` sets the new branch's upstream to **`master`**, so a subsequent `git push -u origin ` resolves to the tracked ref and pushes to `master` instead of creating the branch. Branch protection reported failing checks but the push still landed. + +After branching from `origin/master`, run `git branch --unset-upstream` before pushing, and verify afterwards with `git ls-remote origin master`. + # Don't try to boil the ocean. Dont try to make big sweeping changes when more focused ones will do Always think of the minimum viable solution to a problem or change to make. make sure that works and then build on top of it. break things down into small testable pieces first. That said diff --git a/android/app/build.gradle b/android/app/build.gradle index 8c32244aa..0192b514c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -234,6 +234,34 @@ android { includeNoLocationClasses = true excludes = ['jdk.internal.*'] } + + // Gradle's Test task prints nothing per-test by default, which makes a + // long run indistinguishable from a hung one -- you get the task name and + // then silence for ~15 minutes. Android Studio papers over this with its + // own test UI; plain Gradle and CI do not. + testLogging { + events 'failed', 'skipped' + exceptionFormat 'full' // full stack traces, not just the first line + showCauses true + showStackTraces true + // Only failures are logged per-test: at ~930 tests, logging passes too + // would bury the failures we actually want to read in CI. + } + + // Per-class progress, so it is obvious the build is alive. One line per + // test class rather than per test keeps this readable. + afterSuite { desc, result -> + if (desc.parent == null) { // root suite: the run-wide summary + println "\nTest result: ${result.resultType} — " + + "${result.testCount} tests, ${result.successfulTestCount} passed, " + + "${result.failedTestCount} failed, ${result.skippedTestCount} skipped" + } else if (desc.className != null) { + // A class-level suite. Note the worker process adds its own nesting + // level, so checking parent.parent == null silently matches nothing. + def name = desc.className.tokenize('.').last() + println " ${result.resultType} ${name} (${result.testCount} tests)" + } + } } // Add Robolectric configuration unitTests { diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorageTest.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorageTest.kt new file mode 100644 index 000000000..0d5559659 --- /dev/null +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorageTest.kt @@ -0,0 +1,253 @@ +// +// 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.identitystorage + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.github.quarck.calnotify.calendar.CalendarBackupInfo +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Exercises [EventIdentityDao] against real SQLite. + * + * The Robolectric test fakes the DAO, because cr-sqlite is a native library + * that cannot load under Robolectric (see + * docs/dev_completed/sqlite-mocking-robolectric.md). That leaves the actual + * Room queries unverified, which is what this covers: the schema builds, the + * primary key behaves, and the hand-written UPDATE statements do what their + * names claim. + * + * Uses a throwaway database name so it never touches the real one. + */ +@RunWith(AndroidJUnit4::class) +class EventIdentityStorageTest { + + private lateinit var context: Context + private lateinit var database: EventIdentityDatabase + private lateinit var storage: EventIdentityStorage + + private val backupInfo = CalendarBackupInfo( + calendarId = 6L, + accountName = "user@example.com", + accountType = "com.google", + ownerAccount = "user@example.com", + displayName = "Work", + name = "user@example.com" + ) + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + context.deleteDatabase(TEST_DATABASE_NAME) + + database = EventIdentityDatabase.buildDatabase(context, TEST_DATABASE_NAME) + storage = EventIdentityStorage(database.eventIdentityDao()) + } + + @After + fun teardown() { + database.close() + context.deleteDatabase(TEST_DATABASE_NAME) + } + + private fun identity( + eventId: Long = 100L, + instanceStartTime: Long = INSTANCE_START, + syncId: String? = "sync-abc", + uid: String? = null + ) = EventIdentityEntity.create( + eventId = eventId, + instanceStartTime = instanceStartTime, + calendarId = 6L, + backupInfo = backupInfo, + eventSyncId = syncId, + eventUid = uid, + capturedAtTime = CAPTURED_AT + ) + + @Test + fun schemaBuildsAndRoundTripsEveryColumn() { + storage.put(identity(uid = "uid-xyz")) + + val loaded = storage.get(100L, INSTANCE_START) + + assertNotNull(loaded) + assertEquals(100L, loaded!!.eventId) + assertEquals(INSTANCE_START, loaded.instanceStartTime) + assertEquals("user@example.com", loaded.calendarAccountName) + assertEquals("com.google", loaded.calendarAccountType) + assertEquals("user@example.com", loaded.calendarOwnerAccount) + assertEquals("Work", loaded.calendarDisplayName) + assertEquals("user@example.com", loaded.calendarName) + assertEquals("sync-abc", loaded.eventSyncId) + assertEquals("uid-xyz", loaded.eventUid) + assertEquals(6L, loaded.originalCalendarId) + assertEquals(100L, loaded.originalEventId) + assertEquals(CAPTURED_AT, loaded.capturedAtTime) + assertEquals(0, loaded.resolutionAttemptCount) + } + + @Test + fun nullIdentifiersPersistAsNull() { + storage.put(identity(syncId = null, uid = null)) + + val loaded = storage.get(100L, INSTANCE_START) + + assertNull(loaded?.eventSyncId) + assertNull(loaded?.eventUid) + assertFalse( + "a never-synced event has nothing to resolve against", + loaded!!.hasUsableIdentifier() + ) + } + + @Test + fun primaryKeyIsEventIdPlusInstanceStart() { + // Same event, two occurrences: both rows must coexist. + storage.put(identity(instanceStartTime = INSTANCE_START)) + storage.put(identity(instanceStartTime = INSTANCE_START + 86_400_000L)) + + assertEquals(2, storage.count()) + } + + @Test + fun putReplacesRatherThanFailingOnConflict() { + storage.put(identity(syncId = "first")) + storage.put(identity(syncId = "second")) + + assertEquals(1, storage.count()) + assertEquals("second", storage.get(100L, INSTANCE_START)?.eventSyncId) + } + + @Test + fun putAllInsertsEveryRow() { + storage.putAll((1L..5L).map { identity(eventId = it) }) + + assertEquals(5, storage.count()) + } + + // --- The hand-written UPDATE statements --- + + @Test + fun reKeyMovesTheRowAndLeavesOriginalEventIdAlone() { + storage.put(identity(eventId = 100L)) + + assertTrue(storage.reKey(100L, INSTANCE_START, 555L)) + + assertNull(storage.get(100L, INSTANCE_START)) + + val moved = storage.get(555L, INSTANCE_START) + assertNotNull("row should exist under the new id", moved) + assertEquals(555L, moved!!.eventId) + assertEquals( + "originalEventId is the staleness marker and must not move", + 100L, moved.originalEventId + ) + assertEquals("sync-abc", moved.eventSyncId) + } + + @Test + fun recordResolutionAttemptIncrements() { + storage.put(identity()) + + storage.recordResolutionAttempt(100L, INSTANCE_START, 999L) + storage.recordResolutionAttempt(100L, INSTANCE_START, 1000L) + + val row = storage.get(100L, INSTANCE_START) + assertEquals(2, row?.resolutionAttemptCount) + assertEquals(1000L, row?.lastResolutionAttemptTime) + } + + // --- getUnresolved drives the retry pass, so its WHERE clause matters --- + + @Test + fun unresolvedIncludesFreshlyCapturedRows() { + storage.putAll((1L..3L).map { identity(eventId = it) }) + + assertEquals(3, storage.getUnresolved().size) + } + + @Test + fun unresolvedExcludesReKeyedRows() { + storage.put(identity(eventId = 100L)) + storage.put(identity(eventId = 200L)) + storage.reKey(100L, INSTANCE_START, 555L) + + val unresolved = storage.getUnresolved() + + assertEquals(1, unresolved.size) + assertEquals(200L, unresolved.first().eventId) + } + + @Test + fun unresolvedExcludesRowsAtTheAttemptCap() { + storage.put(identity()) + repeat(EventIdentityStorage.DEFAULT_MAX_RESOLUTION_ATTEMPTS) { + storage.recordResolutionAttempt(100L, INSTANCE_START, it.toLong()) + } + + assertEquals( + "a permanently unmatchable event must stop being retried", + 0, storage.getUnresolved().size + ) + } + + @Test + fun unresolvedStillIncludesRowsBelowTheCap() { + storage.put(identity()) + storage.recordResolutionAttempt(100L, INSTANCE_START, 1L) + + assertEquals(1, storage.getUnresolved().size) + } + + @Test + fun deleteRemovesOnlyTheTargetedRow() { + storage.put(identity(eventId = 100L)) + storage.put(identity(eventId = 200L)) + + assertTrue(storage.delete(100L, INSTANCE_START)) + + assertEquals(1, storage.count()) + assertNotNull(storage.get(200L, INSTANCE_START)) + } + + @Test + fun emptyDatabaseReadsCleanly() { + assertEquals(0, storage.count()) + assertNull(storage.get(1L, 1L)) + assertTrue(storage.getAll().isEmpty()) + assertTrue(storage.getUnresolved().isEmpty()) + } + + companion object { + private const val TEST_DATABASE_NAME = "RoomEventIdentity_Test" + private const val INSTANCE_START = 1_700_000_000_000L + private const val CAPTURED_AT = 1_700_000_500_000L + } +} diff --git a/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/UITestFixture.kt b/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/UITestFixture.kt index b6230ec24..563bbaef4 100644 --- a/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/UITestFixture.kt +++ b/android/app/src/androidTest/java/com/github/quarck/calnotify/testutils/UITestFixture.kt @@ -943,6 +943,7 @@ class UITestFixture { override fun deleteEvent(context: Context, eventId: Long) = false override fun getCalendarBackupInfo(context: Context, calendarId: Long) = null override fun findMatchingCalendarId(context: Context, backupInfo: com.github.quarck.calnotify.calendar.CalendarBackupInfo) = -1L + override fun findEventIdBySyncId(context: Context, calendarId: Long, syncId: String?, uid2445: String?) = -1L override fun getUpcomingEventCountsByCalendar(context: Context, daysAhead: Int) = mapOf() } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/calendar/CalendarProvider.kt b/android/app/src/main/java/com/github/quarck/calnotify/calendar/CalendarProvider.kt index 8ec013492..d0a90160c 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/calendar/CalendarProvider.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/calendar/CalendarProvider.kt @@ -25,6 +25,7 @@ import android.content.ContentUris import android.content.ContentValues import android.content.Context import android.database.Cursor +import android.database.SQLException import android.os.Build import android.provider.CalendarContract import com.github.quarck.calnotify.Consts @@ -435,7 +436,11 @@ object CalendarProvider : CalendarProviderInterface { CalendarContract.Events.DISPLAY_COLOR, CalendarContract.Events.STATUS, CalendarContract.Events.SELF_ATTENDEE_STATUS, - CalendarContract.Events.LAST_SYNCED + CalendarContract.Events.LAST_SYNCED, + // Portable identity: server-assigned ids that survive a + // restore onto a new device, unlike the local row id. + CalendarContract.Events._SYNC_ID, + CalendarContract.Events.UID_2445 ) val cursor: Cursor? = @@ -466,6 +471,9 @@ object CalendarProvider : CalendarProviderInterface { val color: Int? = cursor.getInt(12) val status: Int? = cursor.getInt(13) val attendance: Int? = cursor.getInt(14) + // index 15 is LAST_SYNCED, not read here + val syncId: String? = cursor.getString(16) + val uid2445: String? = cursor.getString(17) if (title != null && start != null) { @@ -497,7 +505,9 @@ object CalendarProvider : CalendarProviderInterface { color = color ?: Consts.DEFAULT_CALENDAR_EVENT_COLOR, title = title // stub for now ), eventStatus = EventStatus.fromInt(status), - attendanceStatus = AttendanceStatus.fromInt(attendance) + attendanceStatus = AttendanceStatus.fromInt(attendance), + syncId = syncId, + uid2445 = uid2445 ) } } @@ -1850,6 +1860,77 @@ object CalendarProvider : CalendarProviderInterface { return -1L } + override fun findEventIdBySyncId( + context: Context, + calendarId: Long, + syncId: String?, + uid2445: String? + ): Long { + if (!PermissionsManager.hasReadCalendar(context)) { + DevLog.error(LOG_TAG, "findEventIdBySyncId: no permissions") + return -1L + } + + // _SYNC_ID first: measured populated and unique for 100% of events on a + // real Google-synced device, where UID_2445 was null for all of them. + val bySyncId = queryEventIdByColumn( + context, calendarId, CalendarContract.Events._SYNC_ID, syncId) + if (bySyncId != -1L) + return bySyncId + + return queryEventIdByColumn( + context, calendarId, CalendarContract.Events.UID_2445, uid2445) + } + + /** + * Look up a single event id by an identifier column, scoped to one calendar. + * + * Returns -1 unless exactly one row matches. An ambiguous match is treated + * as no match: re-keying an event onto the wrong row would be worse than + * leaving it unresolved for the next retry. + */ + private fun queryEventIdByColumn( + context: Context, + calendarId: Long, + column: String, + value: String? + ): Long { + if (value.isNullOrBlank()) + return -1L + + val selection = + "$column = ? AND ${CalendarContract.Events.CALENDAR_ID} = ? AND " + + "(${CalendarContract.Events.DELETED} IS NULL OR ${CalendarContract.Events.DELETED} = 0)" + + try { + context.contentResolver.query( + CalendarContract.Events.CONTENT_URI, + arrayOf(CalendarContract.Events._ID), + selection, + arrayOf(value, calendarId.toString()), + null + )?.use { cursor -> + if (!cursor.moveToFirst()) + return -1L + + val eventId = cursor.getLong(0) + + if (cursor.moveToNext()) { + DevLog.warn(LOG_TAG, + "findEventIdBySyncId: $column '$value' matches multiple events " + + "in calendar $calendarId - treating as no match") + return -1L + } + return eventId + } + } + catch (ex: SQLException) { + DevLog.error(LOG_TAG, "findEventIdBySyncId query failed: ${ex.message}") + } + + return -1L + } + private fun checkPermissions(context: Context): Boolean { return PermissionsManager.hasAllCalendarPermissionsNoCache(context) } diff --git a/android/app/src/main/java/com/github/quarck/calnotify/calendar/CalendarProviderInterface.kt b/android/app/src/main/java/com/github/quarck/calnotify/calendar/CalendarProviderInterface.kt index ba9bd612a..1fa860b4a 100644 --- a/android/app/src/main/java/com/github/quarck/calnotify/calendar/CalendarProviderInterface.kt +++ b/android/app/src/main/java/com/github/quarck/calnotify/calendar/CalendarProviderInterface.kt @@ -84,5 +84,24 @@ interface CalendarProviderInterface { fun findMatchingCalendarId(context: Context, backupInfo: CalendarBackupInfo): Long + /** + * Find an event by its server-assigned id within a specific calendar. + * + * The event half of restore re-association: after the calendar has been + * matched, this maps a stored `_SYNC_ID` (or `UID_2445`) back to the local + * event id this device assigned. + * + * Scoped to [calendarId] deliberately — searching provider-wide could match + * the same event in a different calendar the user also subscribes to. + * + * @return the local event id, or -1 if no unambiguous match exists. + */ + fun findEventIdBySyncId( + context: Context, + calendarId: Long, + syncId: String?, + uid2445: String? = null + ): Long + fun getUpcomingEventCountsByCalendar(context: Context, daysAhead: Int = 7): Map } \ No newline at end of file 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 71b19453f..d5df1f076 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 @@ -108,7 +108,27 @@ data class EventRecord( val eventId: Long, val details: CalendarEventDetails, var eventStatus: EventStatus = EventStatus.Confirmed, - var attendanceStatus: AttendanceStatus = AttendanceStatus.None + var attendanceStatus: AttendanceStatus = AttendanceStatus.None, + /** + * `Events._SYNC_ID` — the server-assigned event id, stable across devices. + * + * The primary identifier for portable event identity: unlike [eventId], + * which is a row number local to this device's provider, this value is + * the same on every device that syncs the event. Null when the event has + * never synced to an account (local-only calendars). + * + * See docs/dev_todo/portable_event_identity.md. + */ + val syncId: String? = null, + /** + * `Events.UID_2445` — the iCalendar UID. + * + * Read opportunistically. Measured null for all 4761 events on a real + * Google-synced device (https://issuetracker.google.com/issues/37053160), + * but other providers may populate it, so it is captured rather than + * depended upon. Prefer [syncId]. + */ + val uid2445: String? = null ) { val title: String get() = details.title val desc: String get() = details.desc diff --git a/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityDao.kt b/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityDao.kt new file mode 100644 index 000000000..1f8aad0ce --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityDao.kt @@ -0,0 +1,113 @@ +// +// 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.identitystorage + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +/** + * Room DAO for [EventIdentityEntity]. + * + * Note: Room @Query annotations require string literals, so the table and + * column names are interpolated from the entity's constants. They are + * validated at compile time against the @Entity definition. + */ +@Dao +interface EventIdentityDao { + + @Query("SELECT * FROM ${EventIdentityEntity.TABLE_NAME}") + fun getAll(): List + + @Query("SELECT COUNT(*) FROM ${EventIdentityEntity.TABLE_NAME}") + fun count(): Int + + @Query( + "SELECT * FROM ${EventIdentityEntity.TABLE_NAME} " + + "WHERE ${EventIdentityEntity.COL_EVENT_ID} = :eventId " + + "AND ${EventIdentityEntity.COL_INSTANCE_START_TIME} = :instanceStartTime" + ) + fun getByKey(eventId: Long, instanceStartTime: Long): EventIdentityEntity? + + @Query( + "SELECT * FROM ${EventIdentityEntity.TABLE_NAME} " + + "WHERE ${EventIdentityEntity.COL_EVENT_ID} = :eventId" + ) + fun getByEventId(eventId: Long): List + + /** + * Rows whose identity has not been re-resolved yet on this device. + * + * "Not yet resolved" means the event id still matches what was captured. + * Once resolution rewrites the key, the two diverge and the row drops out. + * Capped by attempt count so a permanently unmatchable event stops being + * retried forever. + */ + @Query( + "SELECT * FROM ${EventIdentityEntity.TABLE_NAME} " + + "WHERE ${EventIdentityEntity.COL_EVENT_ID} = ${EventIdentityEntity.COL_ORIGINAL_EVENT_ID} " + + "AND ${EventIdentityEntity.COL_RESOLUTION_ATTEMPT_COUNT} < :maxAttempts" + ) + fun getUnresolved(maxAttempts: Int): List + + /** REPLACE: re-capturing an event's identity should overwrite, not fail. */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun put(entity: EventIdentityEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun putAll(entities: List) + + @Query( + "DELETE FROM ${EventIdentityEntity.TABLE_NAME} " + + "WHERE ${EventIdentityEntity.COL_EVENT_ID} = :eventId " + + "AND ${EventIdentityEntity.COL_INSTANCE_START_TIME} = :instanceStartTime" + ) + fun deleteByKey(eventId: Long, instanceStartTime: Long): Int + + @Query("DELETE FROM ${EventIdentityEntity.TABLE_NAME}") + fun deleteAllRows() + + /** + * Move an identity row onto a new event id, keeping the same occurrence. + * + * Used by the re-key step: the event's row in `eventsV9` is deleted and + * re-inserted under the resolved id, and its identity row has to follow or + * the next retry pass would not find it. + */ + @Query( + "UPDATE ${EventIdentityEntity.TABLE_NAME} " + + "SET ${EventIdentityEntity.COL_EVENT_ID} = :newEventId " + + "WHERE ${EventIdentityEntity.COL_EVENT_ID} = :oldEventId " + + "AND ${EventIdentityEntity.COL_INSTANCE_START_TIME} = :instanceStartTime" + ) + fun reKey(oldEventId: Long, instanceStartTime: Long, newEventId: Long): Int + + /** Record a resolution attempt, so the retry cap and backoff can apply. */ + @Query( + "UPDATE ${EventIdentityEntity.TABLE_NAME} " + + "SET ${EventIdentityEntity.COL_RESOLUTION_ATTEMPT_COUNT} = " + + "${EventIdentityEntity.COL_RESOLUTION_ATTEMPT_COUNT} + 1, " + + "${EventIdentityEntity.COL_LAST_RESOLUTION_ATTEMPT_TIME} = :attemptTime " + + "WHERE ${EventIdentityEntity.COL_EVENT_ID} = :eventId " + + "AND ${EventIdentityEntity.COL_INSTANCE_START_TIME} = :instanceStartTime" + ) + fun recordResolutionAttempt(eventId: Long, instanceStartTime: Long, attemptTime: Long): Int +} diff --git a/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityDatabase.kt b/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityDatabase.kt new file mode 100644 index 000000000..520e3e885 --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityDatabase.kt @@ -0,0 +1,96 @@ +// +// 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.identitystorage + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.github.quarck.calnotify.database.CrSqliteRoomFactory + +/** + * Room database holding portable identity for stored events. + * + * Unlike the other three databases in this app, this one has **no legacy + * predecessor and no migration path** — it is new, starts at version 1, and + * begins empty. Nothing to copy, nothing to fall back to. + * + * It is deliberately separate from `RoomEvents` rather than extra columns on + * `eventsV9`: + * + * - The reserved `s2` column is a scarce one-shot resource, better spent on + * something that must live in the event row. Identity is read only during a + * restore, and joins by `(eventId, instanceStartTime)` when needed. + * - The sync layer targets the `eventsV9` table by name, so keeping identity + * out of that table keeps account emails out of the Supabase payload by + * construction rather than by remembering to filter them. + * + * Backed up automatically: `res/xml/backup_rules.xml` includes + * `domain="database"`, which is essential — this database is useless unless it + * restores alongside the events it describes. + * + * See docs/dev_todo/portable_event_identity.md. + */ +@Database( + entities = [EventIdentityEntity::class], + version = 1, + exportSchema = false +) +abstract class EventIdentityDatabase : RoomDatabase() { + + abstract fun eventIdentityDao(): EventIdentityDao + + companion object { + internal const val DATABASE_NAME = "RoomEventIdentity" + + @Volatile + private var INSTANCE: EventIdentityDatabase? = null + + fun getInstance(context: Context): EventIdentityDatabase { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: buildDatabase(context, DATABASE_NAME).also { INSTANCE = it } + } + } + + /** + * Build against an explicit database name. Tests use this to work on a + * throwaway file instead of the real one. + */ + fun buildDatabase(context: Context, databaseName: String): EventIdentityDatabase = + Room.databaseBuilder( + context.applicationContext, + EventIdentityDatabase::class.java, + databaseName + ) + .openHelperFactory(CrSqliteRoomFactory()) + // Matches the other storages: callers are already on background + // threads and use synchronous queries throughout. + .allowMainThreadQueries() + .build() + + /** Drop the cached singleton. Tests only. */ + internal fun resetInstanceForTesting() { + synchronized(this) { + INSTANCE?.close() + INSTANCE = null + } + } + } +} diff --git a/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityEntity.kt b/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityEntity.kt new file mode 100644 index 000000000..6da2cfa1f --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityEntity.kt @@ -0,0 +1,167 @@ +// +// 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.identitystorage + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import com.github.quarck.calnotify.calendar.CalendarBackupInfo + +/** + * Durable, provider-independent identity for one stored event. + * + * `eventsV9` stores `cid` and `id`, which are row numbers assigned by *this* + * device's Calendar Provider. Restore the database onto a new phone and both + * point at nothing. This table holds the information needed to find the same + * event again from scratch, using only values that mean something on any + * device: the calendar's account tuple, and the event's server-assigned id. + * + * See docs/dev_todo/portable_event_identity.md. + * + * **Column names are spelled out deliberately.** The abbreviations in + * `eventsV9` (`cid`, `istart`, `attsts`) are a 2016 inheritance that is now + * costly to change; this table is new and carries no such constraint. The + * calendar columns map one-to-one onto the `CalendarContract.Calendars` + * columns they are read from. + */ +@Entity( + tableName = EventIdentityEntity.TABLE_NAME, + primaryKeys = [ + EventIdentityEntity.COL_EVENT_ID, + EventIdentityEntity.COL_INSTANCE_START_TIME + ], + indices = [ + // Resolution looks rows up by sync id, not by primary key. + Index(value = [EventIdentityEntity.COL_EVENT_SYNC_ID], + name = EventIdentityEntity.INDEX_SYNC_ID) + ] +) +data class EventIdentityEntity( + /** Joins to `eventsV9.id`. Changes when an event is re-keyed after a restore. */ + @ColumnInfo(name = COL_EVENT_ID) val eventId: Long, + + /** Joins to `eventsV9.istart`. Stable across devices for the same occurrence. */ + @ColumnInfo(name = COL_INSTANCE_START_TIME) val instanceStartTime: Long, + + @ColumnInfo(name = COL_CALENDAR_ACCOUNT_NAME) val calendarAccountName: String = "", + @ColumnInfo(name = COL_CALENDAR_ACCOUNT_TYPE) val calendarAccountType: String = "", + @ColumnInfo(name = COL_CALENDAR_OWNER_ACCOUNT) val calendarOwnerAccount: String = "", + @ColumnInfo(name = COL_CALENDAR_DISPLAY_NAME) val calendarDisplayName: String = "", + @ColumnInfo(name = COL_CALENDAR_NAME) val calendarName: String = "", + + /** + * `Events._SYNC_ID` — the primary event identifier. + * + * Measured on a real device: populated and unique for 100% of 4761 events, + * while [eventUid] was null for every one of them. Null here means the + * event never synced to an account, which is the unresolvable case. + */ + @ColumnInfo(name = COL_EVENT_SYNC_ID) val eventSyncId: String? = null, + + /** + * `Events.UID_2445` — the iCalendar UID, read opportunistically. + * + * Null on Google Calendar (see https://issuetracker.google.com/issues/37053160), + * but other providers may populate it, so it is captured rather than + * depended upon. + */ + @ColumnInfo(name = COL_EVENT_UID) val eventUid: String? = null, + + /** + * The `cid`/`id` in force when this row was captured. + * + * These duplicate the event row on purpose: they are the staleness check. + * If [originalEventId] still equals the event's current `id`, resolution + * has not run; if they differ, it already has. Without them there is no way + * to tell "never resolved" from "already resolved", which matters because + * the retry pass re-runs on every launch. + */ + @ColumnInfo(name = COL_ORIGINAL_CALENDAR_ID) val originalCalendarId: Long = -1L, + @ColumnInfo(name = COL_ORIGINAL_EVENT_ID) val originalEventId: Long = -1L, + + /** Set via CNPlusClockInterface, never System.currentTimeMillis(). */ + @ColumnInfo(name = COL_CAPTURED_AT_TIME) val capturedAtTime: Long = 0L, + + /** Backs the retry cap, so a permanently unmatchable event stops re-querying. */ + @ColumnInfo(name = COL_RESOLUTION_ATTEMPT_COUNT) val resolutionAttemptCount: Int = 0, + @ColumnInfo(name = COL_LAST_RESOLUTION_ATTEMPT_TIME) val lastResolutionAttemptTime: Long = 0L +) { + /** True when there is any server-assigned id to resolve against. */ + fun hasUsableIdentifier(): Boolean = + !eventSyncId.isNullOrBlank() || !eventUid.isNullOrBlank() + + /** The calendar half of the identity, in the shape the existing matcher takes. */ + fun toCalendarBackupInfo(): CalendarBackupInfo = + CalendarBackupInfo( + calendarId = originalCalendarId, + accountName = calendarAccountName, + accountType = calendarAccountType, + ownerAccount = calendarOwnerAccount, + displayName = calendarDisplayName, + name = calendarName + ) + + companion object { + const val TABLE_NAME = "eventIdentityV1" + const val INDEX_SYNC_ID = "eventIdentityIdxSyncIdV1" + + const val COL_EVENT_ID = "eventId" + const val COL_INSTANCE_START_TIME = "instanceStartTime" + const val COL_CALENDAR_ACCOUNT_NAME = "calendarAccountName" + const val COL_CALENDAR_ACCOUNT_TYPE = "calendarAccountType" + const val COL_CALENDAR_OWNER_ACCOUNT = "calendarOwnerAccount" + const val COL_CALENDAR_DISPLAY_NAME = "calendarDisplayName" + const val COL_CALENDAR_NAME = "calendarName" + const val COL_EVENT_SYNC_ID = "eventSyncId" + const val COL_EVENT_UID = "eventUid" + const val COL_ORIGINAL_CALENDAR_ID = "originalCalendarId" + const val COL_ORIGINAL_EVENT_ID = "originalEventId" + const val COL_CAPTURED_AT_TIME = "capturedAtTime" + const val COL_RESOLUTION_ATTEMPT_COUNT = "resolutionAttemptCount" + const val COL_LAST_RESOLUTION_ATTEMPT_TIME = "lastResolutionAttemptTime" + + /** + * Build an identity row from a calendar's backup info plus the event's + * server-assigned identifiers. + */ + fun create( + eventId: Long, + instanceStartTime: Long, + calendarId: Long, + backupInfo: CalendarBackupInfo?, + eventSyncId: String?, + eventUid: String?, + capturedAtTime: Long + ) = EventIdentityEntity( + eventId = eventId, + instanceStartTime = instanceStartTime, + calendarAccountName = backupInfo?.accountName ?: "", + calendarAccountType = backupInfo?.accountType ?: "", + calendarOwnerAccount = backupInfo?.ownerAccount ?: "", + calendarDisplayName = backupInfo?.displayName ?: "", + calendarName = backupInfo?.name ?: "", + eventSyncId = eventSyncId, + eventUid = eventUid, + originalCalendarId = calendarId, + originalEventId = eventId, + capturedAtTime = capturedAtTime + ) + } +} diff --git a/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorage.kt b/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorage.kt new file mode 100644 index 000000000..973d6c600 --- /dev/null +++ b/android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorage.kt @@ -0,0 +1,140 @@ +// +// 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.identitystorage + +import android.content.Context +import android.database.SQLException +import com.github.quarck.calnotify.logs.DevLog + +/** + * Read/write access to portable event identity. + * + * Capture is **best-effort**: a failure to record identity must never fail the + * event write it accompanies. Identity is a recovery aid, so losing a row + * costs a future restore some accuracy, while propagating the exception would + * cost the user an actual notification. Write paths therefore swallow + * [SQLException] and log; read paths return empty rather than throw. + * + * Only the resolver reads this during a restore — nothing on the app's hot + * path touches it. + * + * See docs/dev_todo/portable_event_identity.md. + */ +class EventIdentityStorage( + private val dao: EventIdentityDao +) { + constructor(context: Context) : this( + EventIdentityDatabase.getInstance(context).eventIdentityDao() + ) + + /** Record identity for one event. Overwrites any existing row for the key. */ + fun put(identity: EventIdentityEntity): Boolean = + runCatchingWrite("put(${identity.eventId}/${identity.instanceStartTime})") { + dao.put(identity) + } + + fun putAll(identities: List): Boolean { + if (identities.isEmpty()) + return true + + return runCatchingWrite("putAll(${identities.size})") { dao.putAll(identities) } + } + + fun get(eventId: Long, instanceStartTime: Long): EventIdentityEntity? = + runCatchingRead("get($eventId/$instanceStartTime)", null) { + dao.getByKey(eventId, instanceStartTime) + } + + fun getAll(): List = + runCatchingRead("getAll", emptyList()) { dao.getAll() } + + /** Rows still awaiting resolution, below the retry cap. */ + fun getUnresolved(maxAttempts: Int = DEFAULT_MAX_RESOLUTION_ATTEMPTS): List = + runCatchingRead("getUnresolved", emptyList()) { dao.getUnresolved(maxAttempts) } + + fun count(): Int = runCatchingRead("count", 0) { dao.count() } + + fun delete(eventId: Long, instanceStartTime: Long): Boolean = + runCatchingWrite("delete($eventId/$instanceStartTime)") { + dao.deleteByKey(eventId, instanceStartTime) + } + + /** + * Move an identity row onto a resolved event id. + * + * The caller is mid re-key: the `eventsV9` row has been deleted and + * re-inserted under [newEventId], and this row has to follow or the next + * retry pass will not find it. + * + * @return true if a row was moved. + */ + fun reKey(oldEventId: Long, instanceStartTime: Long, newEventId: Long): Boolean { + if (oldEventId == newEventId) + return true // already current; nothing to do + + return runCatchingRead("reKey($oldEventId->$newEventId)", false) { + dao.reKey(oldEventId, instanceStartTime, newEventId) > 0 + } + } + + /** Note that resolution was attempted, feeding the retry cap and backoff. */ + fun recordResolutionAttempt( + eventId: Long, + instanceStartTime: Long, + attemptTime: Long + ): Boolean = + runCatchingWrite("recordResolutionAttempt($eventId/$instanceStartTime)") { + dao.recordResolutionAttempt(eventId, instanceStartTime, attemptTime) + } + + /** + * Catch only SQLException, never the broad Exception -- a programming error + * in a query should surface loudly rather than be silently logged. + */ + private inline fun runCatchingWrite(what: String, body: () -> Unit): Boolean = + try { + body() + true + } catch (ex: SQLException) { + DevLog.error(LOG_TAG, "$what failed: ${ex.message}") + false + } + + private inline fun runCatchingRead(what: String, fallback: T, body: () -> T): T = + try { + body() + } catch (ex: SQLException) { + DevLog.error(LOG_TAG, "$what failed: ${ex.message}") + fallback + } + + companion object { + private const val LOG_TAG = "EventIdentityStorage" + + /** + * Stop retrying a row after this many failed resolution attempts. + * + * Events outside the provider's ~12 month sync window can never + * resolve, so an uncapped retry would re-query the provider on every + * launch forever. + */ + const val DEFAULT_MAX_RESOLUTION_ATTEMPTS = 10 + } +} diff --git a/android/app/src/test/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorageRobolectricTest.kt b/android/app/src/test/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorageRobolectricTest.kt new file mode 100644 index 000000000..9b0258db1 --- /dev/null +++ b/android/app/src/test/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorageRobolectricTest.kt @@ -0,0 +1,339 @@ +// +// 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.identitystorage + +import android.database.SQLException +import com.github.quarck.calnotify.calendar.CalendarBackupInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests [EventIdentityStorage]'s behaviour against a fake DAO. + * + * Real SQLite cannot run under Robolectric here -- cr-sqlite is a native + * library (see docs/dev_completed/sqlite-mocking-robolectric.md) -- so the DAO + * is faked and the actual queries are covered by the instrumentation test + * `EventIdentityStorageTest`. What is worth testing at this level is the + * facade's own contract: that capture failures are swallowed rather than + * propagated, and that reads degrade to empty instead of throwing. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = "AndroidManifest.xml", sdk = [24]) +class EventIdentityStorageRobolectricTest { + + /** In-memory stand-in for the Room DAO, with an injectable failure mode. */ + private class FakeEventIdentityDao : EventIdentityDao { + val rows = mutableMapOf, EventIdentityEntity>() + var failWith: SQLException? = null + + private fun checkFailure() { + failWith?.let { throw it } + } + + override fun getAll(): List { + checkFailure() + return rows.values.toList() + } + + override fun count(): Int { + checkFailure() + return rows.size + } + + override fun getByKey(eventId: Long, instanceStartTime: Long): EventIdentityEntity? { + checkFailure() + return rows[eventId to instanceStartTime] + } + + override fun getByEventId(eventId: Long): List { + checkFailure() + return rows.values.filter { it.eventId == eventId } + } + + override fun getUnresolved(maxAttempts: Int): List { + checkFailure() + return rows.values.filter { + it.eventId == it.originalEventId && it.resolutionAttemptCount < maxAttempts + } + } + + override fun put(entity: EventIdentityEntity) { + checkFailure() + rows[entity.eventId to entity.instanceStartTime] = entity + } + + override fun putAll(entities: List) { + checkFailure() + entities.forEach { rows[it.eventId to it.instanceStartTime] = it } + } + + override fun deleteByKey(eventId: Long, instanceStartTime: Long): Int { + checkFailure() + return if (rows.remove(eventId to instanceStartTime) != null) 1 else 0 + } + + override fun deleteAllRows() { + checkFailure() + rows.clear() + } + + override fun reKey(oldEventId: Long, instanceStartTime: Long, newEventId: Long): Int { + checkFailure() + val existing = rows.remove(oldEventId to instanceStartTime) ?: return 0 + rows[newEventId to instanceStartTime] = existing.copy(eventId = newEventId) + return 1 + } + + override fun recordResolutionAttempt( + eventId: Long, + instanceStartTime: Long, + attemptTime: Long + ): Int { + checkFailure() + val existing = rows[eventId to instanceStartTime] ?: return 0 + rows[eventId to instanceStartTime] = existing.copy( + resolutionAttemptCount = existing.resolutionAttemptCount + 1, + lastResolutionAttemptTime = attemptTime + ) + return 1 + } + } + + private lateinit var dao: FakeEventIdentityDao + private lateinit var storage: EventIdentityStorage + + private val backupInfo = CalendarBackupInfo( + calendarId = 6L, + accountName = "user@example.com", + accountType = "com.google", + ownerAccount = "user@example.com", + displayName = "Work", + name = "user@example.com" + ) + + @Before + fun setup() { + dao = FakeEventIdentityDao() + storage = EventIdentityStorage(dao) + } + + private fun identity( + eventId: Long = 100L, + instanceStartTime: Long = 1_700_000_000_000L, + syncId: String? = "sync-abc", + uid: String? = null + ) = EventIdentityEntity.create( + eventId = eventId, + instanceStartTime = instanceStartTime, + calendarId = 6L, + backupInfo = backupInfo, + eventSyncId = syncId, + eventUid = uid, + capturedAtTime = 1_700_000_000_000L + ) + + @Test + fun putThenGetRoundTrips() { + assertTrue(storage.put(identity())) + + val loaded = storage.get(100L, 1_700_000_000_000L) + + assertEquals("sync-abc", loaded?.eventSyncId) + assertEquals("user@example.com", loaded?.calendarAccountName) + assertEquals(6L, loaded?.originalCalendarId) + assertEquals(100L, loaded?.originalEventId) + } + + @Test + fun getMissingRowReturnsNull() { + assertNull(storage.get(999L, 1L)) + } + + @Test + fun putOverwritesExistingRowForSameKey() { + storage.put(identity(syncId = "first")) + storage.put(identity(syncId = "second")) + + assertEquals(1, storage.count()) + assertEquals("second", storage.get(100L, 1_700_000_000_000L)?.eventSyncId) + } + + @Test + fun putAllOfEmptyListIsANoOp() { + assertTrue(storage.putAll(emptyList())) + assertEquals(0, storage.count()) + } + + @Test + fun putAllWritesEveryRow() { + // Distinct from the empty-list case above, which short-circuits before + // reaching the DAO at all. + storage.putAll((1L..3L).map { identity(eventId = it) }) + + assertEquals(3, storage.count()) + assertEquals("sync-abc", storage.get(2L, 1_700_000_000_000L)?.eventSyncId) + } + + @Test + fun putAllReportsFailure() { + dao.failWith = SQLException("disk full") + + assertFalse(storage.putAll(listOf(identity()))) + } + + @Test + fun getAllReturnsEveryStoredRow() { + storage.putAll((1L..3L).map { identity(eventId = it) }) + + assertEquals(3, storage.getAll().size) + } + + // --- The point of the facade: capture must never break the event write --- + + @Test + fun writeFailureIsSwallowedAndReported() { + dao.failWith = SQLException("disk full") + + assertFalse("write should report failure, not throw", storage.put(identity())) + } + + @Test + fun readFailureDegradesToEmptyRatherThanThrowing() { + dao.failWith = SQLException("corrupt") + + assertNull(storage.get(100L, 1L)) + assertEquals(emptyList(), storage.getAll()) + assertEquals(emptyList(), storage.getUnresolved()) + assertEquals(0, storage.count()) + } + + // --- Re-key: identity has to follow the event onto its new id --- + + @Test + fun reKeyMovesRowToNewEventId() { + storage.put(identity(eventId = 100L)) + + assertTrue(storage.reKey(100L, 1_700_000_000_000L, 555L)) + + assertNull("old key should be gone", storage.get(100L, 1_700_000_000_000L)) + val moved = storage.get(555L, 1_700_000_000_000L) + assertEquals(555L, moved?.eventId) + assertEquals( + "originalEventId must NOT move - it is the staleness marker", + 100L, moved?.originalEventId + ) + } + + @Test + fun reKeyToSameIdIsANoOpAndSucceeds() { + storage.put(identity(eventId = 100L)) + + assertTrue(storage.reKey(100L, 1_700_000_000_000L, 100L)) + assertEquals(100L, storage.get(100L, 1_700_000_000_000L)?.eventId) + } + + @Test + fun reKeyOfMissingRowReportsFailure() { + assertFalse(storage.reKey(1L, 2L, 3L)) + } + + // --- Unresolved set drives the retry pass --- + + @Test + fun freshlyCapturedRowCountsAsUnresolved() { + storage.put(identity()) + + assertEquals(1, storage.getUnresolved().size) + } + + @Test + fun reKeyedRowDropsOutOfUnresolved() { + storage.put(identity(eventId = 100L)) + storage.reKey(100L, 1_700_000_000_000L, 555L) + + assertEquals( + "eventId now differs from originalEventId, so it is resolved", + 0, storage.getUnresolved().size + ) + } + + @Test + fun rowAtAttemptCapDropsOutOfUnresolved() { + storage.put(identity()) + repeat(EventIdentityStorage.DEFAULT_MAX_RESOLUTION_ATTEMPTS) { + storage.recordResolutionAttempt(100L, 1_700_000_000_000L, 1L) + } + + assertEquals( + "capped rows must stop being retried", + 0, storage.getUnresolved().size + ) + } + + @Test + fun recordResolutionAttemptIncrementsCountAndTime() { + storage.put(identity()) + + storage.recordResolutionAttempt(100L, 1_700_000_000_000L, 42L) + + val row = storage.get(100L, 1_700_000_000_000L) + assertEquals(1, row?.resolutionAttemptCount) + assertEquals(42L, row?.lastResolutionAttemptTime) + } + + @Test + fun deleteRemovesRow() { + storage.put(identity()) + + assertTrue(storage.delete(100L, 1_700_000_000_000L)) + assertNull(storage.get(100L, 1_700_000_000_000L)) + } + + // --- Entity helpers --- + + @Test + fun hasUsableIdentifierReflectsWhatWasCaptured() { + assertTrue(identity(syncId = "s", uid = null).hasUsableIdentifier()) + assertTrue(identity(syncId = null, uid = "u").hasUsableIdentifier()) + + assertFalse( + "never-synced local events have neither, and cannot be resolved", + identity(syncId = null, uid = null).hasUsableIdentifier() + ) + assertFalse(identity(syncId = "", uid = "").hasUsableIdentifier()) + } + + @Test + fun toCalendarBackupInfoFeedsTheExistingMatcher() { + val info = identity().toCalendarBackupInfo() + + assertEquals("user@example.com", info.accountName) + assertEquals("com.google", info.accountType) + assertEquals("user@example.com", info.ownerAccount) + assertEquals("Work", info.displayName) + } +} diff --git a/docs/build/wsl_unison_environment.md b/docs/build/wsl_unison_environment.md index b8ae4d1d0..d89c73181 100644 --- a/docs/build/wsl_unison_environment.md +++ b/docs/build/wsl_unison_environment.md @@ -121,6 +121,45 @@ powershell.exe -Command 'cd C:\dev\CN\android; $env:JAVA_HOME = "C:\Program File powershell.exe -Command 'cd C:\dev\CN\android; $env:JAVA_HOME = "C:\Program Files\Android\Android_Studio\jbr"; .\gradlew.bat :app:connectedX8664DebugAndroidTest' ``` +### Seeing progress during a long test run + +Gradle's `Test` task prints **nothing per-test by default**, so a run that is +working looks identical to one that is hung: `--console=plain` shows +`> Task :app:testX8664DebugUnitTest` and then silence for many minutes. Three +ways to get visibility, in order of how much they cost: + +**1. Watch the result XMLs (no config change, works on a build already running)** + +```bash +./scripts/watch_test_progress.sh +``` + +Each test class writes its JUnit XML as it finishes, so this reports +`classes=N tests=N` plus the last classes completed. Useful when a build is +already in flight and you just want to know whether it is moving. + +**2. Add `--info` at launch** + +```bash +powershell.exe -Command 'cd C:\dev\CN\android; $env:JAVA_HOME = "C:\Program Files\Android\Android_Studio\jbr"; .\gradlew.bat :app:testX8664DebugUnitTest --console=plain --info' +``` + +Prints each test as it runs. Also very noisy — everything else Gradle does gets +logged too, so prefer piping to a file rather than watching the terminal. + +**3. Confirm it is alive without any log at all** + +```bash +powershell.exe -Command "(Get-Process java -EA SilentlyContinue | Measure-Object CPU -Sum).Sum" +``` + +Run twice ~20s apart. A rising total means the JVMs are burning CPU, i.e. the +build is working. This settles "hung or slow?" in seconds. + +For permanently nicer output, `testLogging` can be configured on the `Test` +task in `android/app/build.gradle` (`events "passed", "failed", "skipped"`), +which would make option 1 unnecessary — not currently set up. + ### Important Notes 1. **Wait for sync**: Unison syncs every 10 seconds (no inotify on Windows mounts). Wait 15s after file changes before running tests. diff --git a/scripts/watch_test_progress.sh b/scripts/watch_test_progress.sh new file mode 100644 index 000000000..79b74688c --- /dev/null +++ b/scripts/watch_test_progress.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# +# Live progress for a running Gradle unit-test build. +# +# Gradle's Test task prints nothing per-test by default, so a long run looks +# identical to a hung one -- `--console=plain` shows "> Task :app:testX..." +# and then silence for minutes. This reads the JUnit XML files as they land +# instead, which works on a build that is ALREADY RUNNING (no config change, +# no restart). +# +# Usage: ./scripts/watch_test_progress.sh [results_dir] [interval_seconds] + +set -uo pipefail + +RESULTS="${1:-/mnt/c/dev/CN/android/app/build/test-results/testX8664DebugUnitTest}" +INTERVAL="${2:-15}" + +[ -d "$RESULTS" ] || { echo "No results dir yet: $RESULTS" >&2; exit 1; } + +while true; do + python3 - "$RESULTS" <<'PY' +import glob, os, sys, xml.etree.ElementTree as ET +from datetime import datetime + +results = sys.argv[1] +files = sorted(glob.glob(os.path.join(results, "*.xml")), key=os.path.getmtime) + +classes = tests = failures = 0 +latest = [] +for path in files: + try: + root = ET.parse(path).getroot() + except ET.ParseError: + continue # still being written + classes += 1 + tests += int(root.get("tests") or 0) + failures += int(root.get("failures") or 0) + int(root.get("errors") or 0) + latest.append((root.get("name", "?").split(".")[-1], root.get("tests"), root.get("time"))) + +stamp = datetime.now().strftime("%H:%M:%S") +flag = f" FAILURES={failures}" if failures else "" +print(f"[{stamp}] classes={classes} tests={tests}{flag}") +for name, n, secs in latest[-2:]: + print(f" {name}: {n} tests, {secs}s") +PY + sleep "$INTERVAL" +done