From d7b8bba75db14de7a19523efa73259c71cc9e504 Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 21 Sep 2026 01:15:37 +0000 Subject: [PATCH 1/8] feat: add portable event identity storage (Phase 0a) First code for portable event identity (#273). Adds a dedicated Room database holding durable, provider-independent identity for each stored event, so a database restored onto a new phone can re-resolve itself to the right calendar and event rows. Separate database rather than eventsV9's reserved s2 column: the reserved columns are a scarce one-shot resource better spent on data that must live in the event row, and keeping identity out of eventsV9 keeps account emails out of the Supabase sync payload by construction rather than by remembering to filter them. No legacy predecessor and no migration -- it starts at version 1 and begins empty. _SYNC_ID is the primary identifier, per the probe measurement: UID_2445 was null for all 4761 events on the target device while _SYNC_ID was populated and unique for 100%. UID_2445 is still captured opportunistically, since non-Google providers may populate it. originalCalendarId/originalEventId duplicate the event row on purpose -- they are the staleness check that distinguishes "never resolved" from "already resolved" across retry passes. Capture is best-effort: writes swallow SQLException and report false rather than propagating, since failing to record identity must never fail the event write it accompanies. Catches SQLException specifically, never broad Exception. 16 Robolectric tests against a fake DAO. Real SQLite cannot run under Robolectric here (cr-sqlite is native), so DAO-level query coverage will come from an instrumentation test. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --- .../identitystorage/EventIdentityDao.kt | 113 +++++++ .../identitystorage/EventIdentityDatabase.kt | 96 ++++++ .../identitystorage/EventIdentityEntity.kt | 167 ++++++++++ .../identitystorage/EventIdentityStorage.kt | 140 ++++++++ .../EventIdentityStorageRobolectricTest.kt | 315 ++++++++++++++++++ 5 files changed, 831 insertions(+) create mode 100644 android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityDao.kt create mode 100644 android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityDatabase.kt create mode 100644 android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityEntity.kt create mode 100644 android/app/src/main/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorage.kt create mode 100644 android/app/src/test/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorageRobolectricTest.kt 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 00000000..1f8aad0c --- /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 00000000..520e3e88 --- /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 00000000..6da2cfa1 --- /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 00000000..973d6c60 --- /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 00000000..7cec365e --- /dev/null +++ b/android/app/src/test/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorageRobolectricTest.kt @@ -0,0 +1,315 @@ +// +// 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()) + } + + // --- 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) + } +} From c19c9ce56933e17561e680dcf8fd9b305083086f Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 21 Sep 2026 01:22:33 +0000 Subject: [PATCH 2/8] docs: record branching and PR workflow in AGENTS.md Branches are long-lived and named after the plan they implement, not per-commit. Work accumulates; the PR merges at a coherent stopping point and a new branch starts the next stage. Sizing signal is reviewability in the GitHub UI, not a line or commit count. Also documents never pushing to master, with the specific trap that caused it here: `git checkout -b origin/master` sets the new branch's upstream to master, so `git push -u origin ` resolves to the tracked ref and lands on master. Branch protection reported failing checks but the push still went through. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --- AGENTS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 97427028..e542b609 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 From 169db9d6229531ae904db7fe403a2aac2a2a803d Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 21 Sep 2026 01:32:16 +0000 Subject: [PATCH 3/8] feat: read _SYNC_ID from the provider, add lookup by sync id (Phase 0b) Adds the provider half of portable event identity (#273). CalendarProvider.getEvent() now reads Events._SYNC_ID and Events.UID_2445, exposed as nullable fields on EventRecord. They live on EventRecord rather than CalendarEventDetails because they are provider identity, not user-visible event content. Adds findEventIdBySyncId(), which maps a stored sync id back to this device's local event id -- the event half of restore re-association, and the query the resolver will be built on. Three deliberate choices: - scoped to a single calendar, since the same event can appear in another calendar the user subscribes to - _SYNC_ID tried first, UID_2445 second, matching what was measured (UID_2445 null for all 4761 events, _SYNC_ID unique for 100%) - an ambiguous match returns -1 rather than picking one, because re-keying onto the wrong event is worse than staying unresolved Adds the instrumentation test for the DAO. The Robolectric test fakes the DAO out of necessity (cr-sqlite is native and will not load under Robolectric), which left the actual Room queries unverified -- this covers the schema, the composite primary key, and the hand-written reKey/recordResolutionAttempt UPDATEs. It compiles but has NOT been executed: no device attached. The 16 Robolectric tests pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --- .../EventIdentityStorageTest.kt | 253 ++++++++++++++++++ .../calnotify/testutils/UITestFixture.kt | 1 + .../calnotify/calendar/CalendarProvider.kt | 85 +++++- .../calendar/CalendarProviderInterface.kt | 19 ++ .../quarck/calnotify/calendar/EventRecord.kt | 22 +- 5 files changed, 377 insertions(+), 3 deletions(-) create mode 100644 android/app/src/androidTest/java/com/github/quarck/calnotify/identitystorage/EventIdentityStorageTest.kt 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 00000000..0d555965 --- /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 b6230ec2..563bbaef 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 8ec01349..d0a90160 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 ba9bd612..1fa860b4 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 71b19453..d5df1f07 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 From bf7d82f54337d9edd5e05a3781c4dc7b9c3cfee0 Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 21 Sep 2026 02:15:00 +0000 Subject: [PATCH 4/8] ci: drop the removed 'tools' SDK package from emulator setup All four instrumentation shards and the connected-test verification job were failing in Common Setup, before any test ran: Warning: Failed to find package 'tools' Error: sdkmanager failed with exit code 1 Google removed the legacy 'tools' package from the SDK repository (superseded by cmdline-tools), so sdkmanager exits non-zero and takes the whole setup step with it. Downstream that surfaces as "No test result XML files found! Tests may have crashed", which points at the tests rather than at the setup that never finished -- the shards were failing in ~20s, far too fast to have run anything. Nothing here 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. Added a comment so the package does not get added back. Pre-existing breakage, not caused by the identity storage work -- it was invisible on the last two PRs because they were docs and standalone scripts, so the emulator jobs had nothing to run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --- .github/actions/common-setup/action.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/actions/common-setup/action.yml b/.github/actions/common-setup/action.yml index 4068281e..2b79d93d 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 From 2127ad33b11acb00c8561c68c3cdd9af85513304 Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 21 Sep 2026 03:03:27 +0000 Subject: [PATCH 5/8] ci: upgrade allure-report-action to v1.15 for findutils The Merge Integration Test Coverage job was failing at "Generate Allure HTML Report" with: xargs is not available cp: cannot stat './android/app/build/outputs/allure-report/.': No such file v1.13's image installs only tar, wget and gzip, so xargs is missing and the action's own script cannot build the report. v1.15 exists solely to fix this -- its single change is "add findutils to dockerfile" (simple-elf/allure-report-action#78). Allure results themselves were produced correctly by all four shards, and the JaCoCo coverage merge in the same job succeeded; only the HTML report generation failed. Pre-existing, like the 'tools' package problem -- this job has failed on every recent run including the docs-only probe branch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --- .github/workflows/actions.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml index a51f8c90..7ef3b294 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 From b935fb82fb0ff5cb78e3bf5e45aab95a55e3d8a5 Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 21 Sep 2026 03:28:13 +0000 Subject: [PATCH 6/8] test: cover putAll and getAll paths missed by coverage Coverage from the last good CI run showed EventIdentityStorage line 57 completely unexecuted by unit tests (ci=0 mi=13): putAll's only Robolectric test passed an empty list, which short-circuits at the early return before ever reaching the DAO. getAll was likewise only exercised through its failure path. Adds putAllWritesEveryRow, putAllReportsFailure and getAllReturnsEveryStoredRow. 19 tests pass, up from 16. Note the inline runCatchingWrite/runCatchingRead helpers still read as uncovered in JaCoCo: it attributes inlined bodies to the call sites, which are covered including branches (cb=2 mb=0). Same for the entity's constructor default-value initializers, which only execute when a parameter is omitted -- create() always passes them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --- .../EventIdentityStorageRobolectricTest.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 index 7cec365e..9b0258db 100644 --- 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 @@ -188,6 +188,30 @@ class EventIdentityStorageRobolectricTest { 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 From 90df8aead92d79e00b5d202b1a0e8f39c8341207 Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 21 Sep 2026 04:33:41 +0000 Subject: [PATCH 7/8] docs: add ways to see progress during long test runs Gradle's Test task logs nothing per-test by default, so a working build is indistinguishable from a hung one -- --console=plain prints the task name and then goes silent for minutes. Both of us misread a healthy run as stalled. Three options, documented in the WSL/build doc: - scripts/watch_test_progress.sh reads the JUnit XMLs as each class finishes. Needs no config change and works on a build that is ALREADY running, which is the case where you most want it. - --info at launch prints each test, at the cost of logging everything else Gradle does. - Summing java process CPU twice ~20s apart answers "hung or just slow" without any log at all. Also notes that configuring testLogging on the Test task would make the first option unnecessary, but that is not set up today. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --- docs/build/wsl_unison_environment.md | 39 +++++++++++++++++++++++ scripts/watch_test_progress.sh | 47 ++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 scripts/watch_test_progress.sh diff --git a/docs/build/wsl_unison_environment.md b/docs/build/wsl_unison_environment.md index b8ae4d1d..d89c7318 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 00000000..79b74688 --- /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 From a215f2a71310455d75dc04b837eaf2d8e4bf5854 Mon Sep 17 00:00:00 2001 From: William Harris Date: Mon, 21 Sep 2026 05:03:13 +0000 Subject: [PATCH 8/8] build: log test progress and full failure stack traces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gradle's Test task prints nothing per-test by default, so a 15-minute unit run looks identical to a hung one -- just the task name and then silence. Android Studio hides this behind its own test UI; plain Gradle and CI do not, and we both misread a healthy run as stalled. Adds to the existing unitTests.all block: - testLogging with exceptionFormat 'full' -- a CI failure now prints its whole stack trace instead of a single "AssertionError at Foo.kt:327" line that requires downloading artifacts to diagnose. - afterSuite printing one line per test CLASS plus a run summary. Only failures are logged per-test: at ~930 tests, logging passes too would bury the failures worth reading. Output goes from silence to: SUCCESS CalendarIntentsRobolectricTest (9 tests) SUCCESS EventIdentityStorageRobolectricTest (19 tests) Test result: SUCCESS — 28 tests, 28 passed, 0 failed, 0 skipped Note the afterSuite class check keys on desc.className rather than desc.parent.parent == null: the Gradle worker adds a nesting level, so the latter silently matches nothing (which is what the first attempt did). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N34fouw76cNkoFVg6V3j4Z --- android/app/build.gradle | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/android/app/build.gradle b/android/app/build.gradle index 8c32244a..0192b514 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 {