Compare commits
4 Commits
release/v2
...
9fb4502ed0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9fb4502ed0 | |||
| d398c72005 | |||
| 9d718e0f51 | |||
| 2218c11d3f |
@@ -123,6 +123,22 @@
|
||||
<data android:mimeType="vnd.android.cursor.dir/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Open an existing event another app/widget points at (e.g. tapping
|
||||
an event in the Todo Agenda widget): ACTION_VIEW on
|
||||
content://com.android.calendar/events/<id>, the way AOSP fires it.
|
||||
Matched by the provider's item MIME type, not the path — a
|
||||
content: VIEW intent carries the resolved type
|
||||
(vnd.android.cursor.item/event) and a path-only filter wouldn't
|
||||
match it. The occurrence's times ride as EXTRA_EVENT_BEGIN_TIME /
|
||||
EXTRA_EVENT_END_TIME when the launcher supplies them
|
||||
(MainActivity.viewEventKeyOrNull, issue #48). -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:mimeType="vnd.android.cursor.item/event" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Launcher long-press shortcuts (e.g. "New event"). -->
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
|
||||
@@ -32,6 +32,7 @@ import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
|
||||
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
|
||||
import de.jeanlucmakiola.calendula.ui.WidgetNavRequest
|
||||
import de.jeanlucmakiola.calendula.ui.common.CalendarView
|
||||
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
|
||||
import de.jeanlucmakiola.floret.components.DebugRibbon
|
||||
import de.jeanlucmakiola.calendula.ui.crash.CrashReportActivity
|
||||
import de.jeanlucmakiola.calendula.domain.FontRole
|
||||
@@ -90,7 +91,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
requestedDetailKey = intent.detailKeyOrNull()
|
||||
requestedDetailKey = intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull()
|
||||
requestedNav = intent.navRequestOrNull()
|
||||
requestedImportUri = intent.importUriOrNull()
|
||||
requestedInsertForm = intent.insertFormOrNull()
|
||||
@@ -178,7 +179,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
intent.detailKeyOrNull()?.let { requestedDetailKey = it }
|
||||
(intent.detailKeyOrNull() ?: intent.viewEventKeyOrNull())?.let { requestedDetailKey = it }
|
||||
intent.navRequestOrNull()?.let { requestedNav = it }
|
||||
intent.importUriOrNull()?.let { requestedImportUri = it }
|
||||
intent.insertFormOrNull()?.let { requestedInsertForm = it }
|
||||
@@ -293,6 +294,30 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail key for an external "open this event" — ACTION_VIEW on
|
||||
* `content://com.android.calendar/events/<id>`, 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/<epochMillis>.
|
||||
|
||||
@@ -492,7 +492,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return resolver.query(
|
||||
uri,
|
||||
InstanceProjection.COLUMNS,
|
||||
null, null,
|
||||
// Hide cancelled occurrences: "delete only this event" writes a
|
||||
// cancelled exception for the one instance (#47). A NULL status is a
|
||||
// normal, un-cancelled event, so it must survive the filter — a bare
|
||||
// `!= CANCELED` would drop it (NULL != 2 is NULL, not true).
|
||||
"${CalendarContract.Instances.STATUS} IS NULL OR " +
|
||||
"${CalendarContract.Instances.STATUS} != ${CalendarContract.Events.STATUS_CANCELED}",
|
||||
null,
|
||||
CalendarContract.Instances.BEGIN + " ASC",
|
||||
)?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList()
|
||||
}
|
||||
@@ -1183,15 +1189,25 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
|
||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||
// A cancelled exception row hides exactly this occurrence; the sync
|
||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream.
|
||||
val values = ContentValues().apply {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, beginMillis)
|
||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||
}
|
||||
// adapter turns it into an EXDATE/cancelled VEVENT upstream. It has to
|
||||
// carry the full time set (DTSTART + DURATION + zone), not just STATUS:
|
||||
// the provider only demotes the cloned exception to a single instance —
|
||||
// clearing the inherited RRULE — when it can derive that instance from
|
||||
// those columns. A STATUS-only cancel left the RRULE standing and
|
||||
// cancelled the whole series, wiping every other occurrence (#47), the
|
||||
// same trap the edit path documents (Codeberg #16).
|
||||
val row = querySeriesRow(eventId)
|
||||
val values = buildOccurrenceCancelValues(
|
||||
originalInstanceMillis = beginMillis,
|
||||
dtStartMillis = beginMillis,
|
||||
duration = row.duration,
|
||||
timezone = row.timezone,
|
||||
allDay = row.allDay,
|
||||
)
|
||||
val uri = ContentUris.withAppendedId(
|
||||
CalendarContract.Events.CONTENT_EXCEPTION_URI, eventId,
|
||||
)
|
||||
resolver.insert(uri, values)
|
||||
resolver.insert(uri, values.toContentValues())
|
||||
?: throw WriteFailedException("cancel occurrence event id=$eventId begin=$beginMillis")
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,33 @@ internal fun buildOccurrenceExceptionValues(
|
||||
putAll(eventColorColumns(form.colorKey, form.color))
|
||||
}
|
||||
|
||||
/**
|
||||
* Column values for a *cancelled*-occurrence exception row ("delete only this
|
||||
* event"): inserting them at `Events.CONTENT_EXCEPTION_URI/<id>` makes the
|
||||
* provider clone the series row and cancel exactly this one instance.
|
||||
*
|
||||
* As with [buildOccurrenceExceptionValues], the occurrence must be anchored with
|
||||
* DTSTART + DURATION so the provider derives a single instance and clears the
|
||||
* inherited RRULE. A STATUS-only cancel skips that: the clone keeps the RRULE, so
|
||||
* the *whole series* is cancelled and every other occurrence disappears
|
||||
* (Codeberg #47). The occurrence's length/zone come straight from the series row
|
||||
* — cancelling never changes them.
|
||||
*/
|
||||
internal fun buildOccurrenceCancelValues(
|
||||
originalInstanceMillis: Long,
|
||||
dtStartMillis: Long,
|
||||
duration: String?,
|
||||
timezone: String?,
|
||||
allDay: Int,
|
||||
): Map<String, Any?> = buildMap {
|
||||
put(CalendarContract.Events.ORIGINAL_INSTANCE_TIME, originalInstanceMillis)
|
||||
put(CalendarContract.Events.DTSTART, dtStartMillis)
|
||||
put(CalendarContract.Events.DURATION, duration)
|
||||
put(CalendarContract.Events.EVENT_TIMEZONE, timezone)
|
||||
put(CalendarContract.Events.ALL_DAY, allDay)
|
||||
put(CalendarContract.Events.STATUS, CalendarContract.Events.STATUS_CANCELED)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `EVENT_COLOR` / `EVENT_COLOR_KEY` columns for a colour selection. A
|
||||
* [colorKey] writes the key alone (the provider derives `EVENT_COLOR` from the
|
||||
|
||||
@@ -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/<id> 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/<id>` 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). */
|
||||
|
||||
@@ -220,6 +220,46 @@ class EventWriteMapperTest {
|
||||
assertThat(values).containsEntry(CalendarContract.Events.DESCRIPTION, null)
|
||||
}
|
||||
|
||||
// --- buildOccurrenceCancelValues ("delete only this event") ---
|
||||
|
||||
@Test
|
||||
fun `occurrence cancel anchors a single instance and cancels only it`() {
|
||||
val values = buildOccurrenceCancelValues(
|
||||
originalInstanceMillis = 1_700_000_000_000L,
|
||||
dtStartMillis = 1_700_000_000_000L,
|
||||
duration = "P3600S",
|
||||
timezone = "Europe/Berlin",
|
||||
allDay = 0,
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.ORIGINAL_INSTANCE_TIME])
|
||||
.isEqualTo(1_700_000_000_000L)
|
||||
// DTSTART + DURATION make the provider derive a single instance and drop
|
||||
// the inherited RRULE, so only this occurrence is cancelled — not the
|
||||
// whole series (#47). DTEND is never sent (the provider rejects it).
|
||||
assertThat(values[CalendarContract.Events.DTSTART]).isEqualTo(1_700_000_000_000L)
|
||||
assertThat(values[CalendarContract.Events.DURATION]).isEqualTo("P3600S")
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("Europe/Berlin")
|
||||
assertThat(values[CalendarContract.Events.STATUS])
|
||||
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.DTEND)
|
||||
assertThat(values).doesNotContainKey(CalendarContract.Events.RRULE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all-day occurrence cancel keeps the all-day flag and utc zone`() {
|
||||
val values = buildOccurrenceCancelValues(
|
||||
originalInstanceMillis = 1_700_000_000_000L,
|
||||
dtStartMillis = 1_700_000_000_000L,
|
||||
duration = "P1D",
|
||||
timezone = "UTC",
|
||||
allDay = 1,
|
||||
)
|
||||
assertThat(values[CalendarContract.Events.ALL_DAY]).isEqualTo(1)
|
||||
assertThat(values[CalendarContract.Events.EVENT_TIMEZONE]).isEqualTo("UTC")
|
||||
assertThat(values[CalendarContract.Events.STATUS])
|
||||
.isEqualTo(CalendarContract.Events.STATUS_CANCELED)
|
||||
}
|
||||
|
||||
// --- per-event colour ---
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user