From 9d718e0f512ede198cf10711550a76ae17aee694 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 12 Jul 2026 11:01:53 +0200 Subject: [PATCH] fix: open existing events from external VIEW intents (#48) Follow-up to #30. v2.14.0 handles ACTION_INSERT (the widget "+"), but tapping an existing event in a third-party widget (e.g. Todo Agenda) never offered Calendula, because nothing handled ACTION_VIEW on content://com.android.calendar/events/. - Manifest: add a VIEW intent-filter matched by the provider's item MIME type (vnd.android.cursor.item/event), mirroring AOSP Calendar and the sibling INSERT dir/event filter. A content: VIEW intent carries the resolved type, so a path-only filter wouldn't match it. - MainActivity.viewEventKeyOrNull: parse the events URI into the existing occurrence detail-key channel (the one reminder taps use). Occurrence times ride as EXTRA_EVENT_BEGIN_TIME/END_TIME when the launcher supplies them; a bare URI omits them. - EventDetailViewModel: a NO_OCCURRENCE_TIME sentinel makes loadDetail keep the event row's own DTSTART/DTEND for a bare URI instead of overriding to the epoch (would otherwise render at 1970). Needs on-device verification (intent-filter matching + the widget's actual extras). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/main/AndroidManifest.xml | 16 ++++++++++ .../jeanlucmakiola/calendula/MainActivity.kt | 29 +++++++++++++++-- .../ui/detail/EventDetailViewModel.kt | 31 ++++++++++++++----- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1139b8b..47866af 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -123,6 +123,22 @@ + + + + + + + + `, the way AOSP fires it (e.g. + * tapping an existing event in the Todo Agenda widget, issue #48). Reuses the + * same occurrence-key channel as reminder taps. The launcher passes the + * occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME` when + * it has them; a bare URI omits them, so we carry [NO_OCCURRENCE_TIME] and + * [EventDetailViewModel] falls back to the event row's own DTSTART/DTEND + * rather than rendering at the epoch. + */ + private fun Intent.viewEventKeyOrNull(): LongArray? { + if (action != Intent.ACTION_VIEW) return null + val uri = data ?: return null + if (uri.host != CALENDAR_PROVIDER_HOST) return null + val segments = uri.pathSegments + if (segments.firstOrNull() != "events") return null + val eventId = segments.getOrNull(1)?.toLongOrNull() ?: return null + return longArrayOf( + eventId, + longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME) ?: NO_OCCURRENCE_TIME, + longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME) ?: NO_OCCURRENCE_TIME, + ) + } + companion object { // The calendar provider's authority/host. A date tap arrives as // ACTION_VIEW on content://com.android.calendar/time/. diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt index 8374c21..4a4aebd 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/detail/EventDetailViewModel.kt @@ -141,13 +141,20 @@ class EventDetailViewModel @Inject constructor( private suspend fun loadDetail(target: Target): EventDetailUiState = try { val detail = repository.eventDetail(target.eventId) // The Events row holds the series start; replace it with this - // occurrence's time so recurring events render correctly. - val corrected = detail.copy( - instance = detail.instance.copy( - start = Instant.fromEpochMilliseconds(target.beginMillis), - end = Instant.fromEpochMilliseconds(target.endMillis), - ), - ) + // occurrence's time so recurring events render correctly. An external + // "open event" that names no occurrence ([NO_OCCURRENCE_TIME] — e.g. a + // bare content://.../events/ VIEW intent, issue #48) keeps the row's + // own DTSTART/DTEND instead of overriding it to the epoch. + val corrected = if (target.beginMillis == NO_OCCURRENCE_TIME) { + detail + } else { + detail.copy( + instance = detail.instance.copy( + start = Instant.fromEpochMilliseconds(target.beginMillis), + end = Instant.fromEpochMilliseconds(target.endMillis), + ), + ) + } val calendar = repository.calendars().first() .firstOrNull { it.id == corrected.instance.calendarId } EventDetailUiState.Success( @@ -168,6 +175,16 @@ class EventDetailViewModel @Inject constructor( /** A tapped occurrence: the series [eventId] plus this occurrence's own times. */ private data class Target(val eventId: Long, val beginMillis: Long, val endMillis: Long) + + companion object { + /** + * Sentinel begin/end for an "open this event" that names no occurrence — + * a bare `content://com.android.calendar/events/` VIEW intent with no + * `EXTRA_EVENT_BEGIN_TIME` (issue #48). [loadDetail] then keeps the event + * row's own DTSTART/DTEND instead of overriding it to the epoch. + */ + const val NO_OCCURRENCE_TIME: Long = Long.MIN_VALUE + } } /** A filesystem-safe `.ics` file name from an event title (or a fallback). */ -- 2.49.1