Compare commits

...

1 Commits

Author SHA1 Message Date
c63dfddb88 feat(intent): handle ACTION_EDIT and broaden .ics MIME types
Round out the calendar-intent surface toward AOSP/Etar parity — the app
already handled VIEW (date + event), INSERT, and .ics open/share, but was
missing the edit action and the alternate .ics MIME labels.

- ACTION_EDIT on content://com.android.calendar/events/<id> now opens the
  event in the edit form (previously only VIEW → read-only detail existed).
  An assistant, task app, or widget can hand an event to Calendula to edit.
  A bare EDIT URI with no occurrence extras falls back to the event row's
  own DTSTART/DTEND, mirroring the #48 view-event fallback.
- ACTION_EDIT with no event id (AOSP's "edit a new event") maps to the same
  prefilled create form as ACTION_INSERT.
- The .ics VIEW/SEND filters now also accept text/x-vcalendar (vCalendar
  1.0 / .vcs) and application/ics — the alternate labels the same calendar
  data arrives under from some file/mail apps (matches Etar's ImportActivity).

Deliberately excluded: webcal:// / http(s) remote-calendar subscription
(needs INTERNET, which the app doesn't have) and the Google-web-link handler
(Google-specific + network).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:22:06 +02:00
5 changed files with 109 additions and 14 deletions

View File

@@ -97,32 +97,58 @@
<data android:mimeType="time/epoch" /> <data android:mimeType="time/epoch" />
</intent-filter> </intent-filter>
<!-- Open a .ics file (file manager / email attachment / browser). --> <!-- Open a .ics/.vcs file (file manager / email attachment / browser).
The three MIME types cover the common labels the same calendar
data arrives under: iCalendar 2.0 (text/calendar), the older
vCalendar 1.0 / .vcs (text/x-vcalendar), and application/ics some
mail apps emit — Android cross-products the scheme and mimeType
tags, so each MIME is accepted on both schemes (matches Etar). -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="content" android:mimeType="text/calendar" /> <data android:scheme="content" />
<data android:scheme="file" android:mimeType="text/calendar" /> <data android:scheme="file" />
<data android:mimeType="text/calendar" />
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</intent-filter> </intent-filter>
<!-- Receive a .ics shared from another app. --> <!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.SEND" /> <action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/calendar" /> <data android:mimeType="text/calendar" />
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</intent-filter> </intent-filter>
<!-- Let another app or widget (e.g. the Todo Agenda widget) launch us <!-- Let another app or widget (e.g. the Todo Agenda widget) launch us
to create a new event, the way the AOSP calendar accepts it: to create a new event, the way the AOSP calendar accepts it:
ACTION_INSERT on the events dir mime type, carrying the new ACTION_INSERT on the events dir mime type, carrying the new
event's fields as CalendarContract extras event's fields as CalendarContract extras
(MainActivity.insertFormOrNull, issue #30). --> (MainActivity.insertFormOrNull, issue #30). ACTION_EDIT on the
dir mime is AOSP's "edit a new event" — i.e. create — so it maps
to the same prefilled create form. -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.INSERT" /> <action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.dir/event" /> <data android:mimeType="vnd.android.cursor.dir/event" />
</intent-filter> </intent-filter>
<!-- Edit an existing event another app/assistant/widget points at:
ACTION_EDIT on content://com.android.calendar/events/<id>, the way
AOSP fires it. Opens the occurrence in the edit form (not the
read-only detail — that's the VIEW filter above). Matched by the
provider's item MIME type, like the VIEW filter. The occurrence's
times ride as EXTRA_EVENT_BEGIN_TIME / EXTRA_EVENT_END_TIME when
supplied (MainActivity.editEventKeyOrNull). -->
<intent-filter>
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter>
<!-- Open an existing event another app/widget points at (e.g. tapping <!-- Open an existing event another app/widget points at (e.g. tapping
an event in the Todo Agenda widget): ACTION_VIEW on an event in the Todo Agenda widget): ACTION_VIEW on
content://com.android.calendar/events/<id>, the way AOSP fires it. content://com.android.calendar/events/<id>, the way AOSP fires it.

View File

@@ -70,6 +70,11 @@ class MainActivity : AppCompatActivity() {
// CalendarHost, which opens it in the create form for review. // CalendarHost, which opens it in the create form for review.
private var requestedInsertForm by mutableStateOf<EventForm?>(null) private var requestedInsertForm by mutableStateOf<EventForm?>(null)
// An external "edit this event" (ACTION_EDIT on content://.../events/<id>):
// opens the occurrence in the edit form. Same occurrence-key shape as the
// detail channel; consumed once by CalendarHost.
private var requestedEditKey by mutableStateOf<LongArray?>(null)
// A captured crash report awaiting the user's decision, surfaced as a dialog // A captured crash report awaiting the user's decision, surfaced as a dialog
// over the calendar on the next launch (the single-crash path). A startup // over the calendar on the next launch (the single-crash path). A startup
// crash-loop is handled out of band, before setContent — see below. // crash-loop is handled out of band, before setContent — see below.
@@ -95,6 +100,7 @@ class MainActivity : AppCompatActivity() {
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull() requestedInsertForm = intent.insertFormOrNull()
requestedEditKey = intent.editEventKeyOrNull()
if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this)
setContent { setContent {
// One activity-scoped SettingsViewModel drives both the theme here // One activity-scoped SettingsViewModel drives both the theme here
@@ -145,6 +151,8 @@ class MainActivity : AppCompatActivity() {
onImportConsumed = { requestedImportUri = null }, onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm, requestedInsertForm = requestedInsertForm,
onInsertConsumed = { requestedInsertForm = null }, onInsertConsumed = { requestedInsertForm = null },
requestedEditKey = requestedEditKey,
onEditKeyConsumed = { requestedEditKey = null },
) )
} }
// A persistent corner marker so a debug build is never // A persistent corner marker so a debug build is never
@@ -183,6 +191,7 @@ class MainActivity : AppCompatActivity() {
intent.navRequestOrNull()?.let { requestedNav = it } intent.navRequestOrNull()?.let { requestedNav = it }
intent.importUriOrNull()?.let { requestedImportUri = it } intent.importUriOrNull()?.let { requestedImportUri = it }
intent.insertFormOrNull()?.let { requestedInsertForm = it } intent.insertFormOrNull()?.let { requestedInsertForm = it }
intent.editEventKeyOrNull()?.let { requestedEditKey = it }
} }
/** /**
@@ -203,13 +212,19 @@ class MainActivity : AppCompatActivity() {
} }
/** /**
* A prefilled new-event form from an external `ACTION_INSERT` launch — another * A prefilled new-event form from an external launch asking us to create an
* app or widget (e.g. Todo Agenda) asking us to create an event (issue #30). * event — another app or widget (e.g. Todo Agenda) firing `ACTION_INSERT`
* The new event's fields ride as CalendarContract extras; anything omitted * (issue #30), or `ACTION_EDIT` with no concrete event id (AOSP's "edit a new
* falls back to the in-app "new event" defaults in [buildInsertEventForm]. * event", i.e. create). The new event's fields ride as CalendarContract
* extras; anything omitted falls back to the in-app "new event" defaults in
* [buildInsertEventForm].
*/ */
private fun Intent.insertFormOrNull(): EventForm? { private fun Intent.insertFormOrNull(): EventForm? {
if (action != Intent.ACTION_INSERT) return null // ACTION_EDIT on an existing event routes to the edit form instead
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
val isCreate = action == Intent.ACTION_INSERT ||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
if (!isCreate) return null
return buildInsertEventForm( return buildInsertEventForm(
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME), beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME), endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME),
@@ -318,6 +333,31 @@ class MainActivity : AppCompatActivity() {
) )
} }
/**
* The occurrence key for an external "edit this event" — `ACTION_EDIT` on
* `content://com.android.calendar/events/<id>`, the way AOSP fires it (e.g.
* an assistant, task app, or widget that wants to open the event for editing
* rather than viewing). Opens it in the edit form. Reuses the same
* occurrence-key channel as reminder/view taps; the caller passes the
* occurrence's times as `EXTRA_EVENT_BEGIN_TIME` / `EXTRA_EVENT_END_TIME`
* when it has them, otherwise we carry [NO_OCCURRENCE_TIME] and
* [EventEditViewModel.openForEdit] falls back to the event row's own
* DTSTART/DTEND. An id-less `ACTION_EDIT` is a create instead ([insertFormOrNull]).
*/
private fun Intent.editEventKeyOrNull(): LongArray? {
if (action != Intent.ACTION_EDIT) 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 { companion object {
// The calendar provider's authority/host. A date tap arrives as // The calendar provider's authority/host. A date tap arrives as
// ACTION_VIEW on content://com.android.calendar/time/<epochMillis>. // ACTION_VIEW on content://com.android.calendar/time/<epochMillis>.

View File

@@ -75,6 +75,8 @@ fun CalendarHost(
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null, requestedInsertForm: EventForm? = null,
onInsertConsumed: () -> Unit = {}, onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
viewModel: CalendarHostViewModel = hiltViewModel(), viewModel: CalendarHostViewModel = hiltViewModel(),
) { ) {
// Wait for the persisted default view before seeding the stack, so the app // Wait for the persisted default view before seeding the stack, so the app
@@ -212,6 +214,20 @@ fun CalendarHost(
importForm = null importForm = null
} }
// An external "edit this event" (ACTION_EDIT, e.g. an assistant/task app or
// widget) opens the occurrence straight in the edit form. Drop any covering
// overlay first — the edit overlay sits below Settings/import in the Box, so
// without this it would open hidden underneath them. Same held-key pattern as
// a detail-screen "Edit" tap; a saved edit just returns to the calendar.
LaunchedEffect(requestedEditKey) {
if (requestedEditKey != null) {
dismissCoveringOverlays()
heldEditKey = requestedEditKey
editKey = requestedEditKey
onEditKeyConsumed()
}
}
// A home-screen widget launch asks to open a date (→ day view), open an // A home-screen widget launch asks to open a date (→ day view), open an
// event's detail, or start a create. Handled once and cleared, mirroring // event's detail, or start a create. Handled once and cleared, mirroring
// [requestedDetailKey]. Date/event opens root the stack in the widget's own // [requestedDetailKey]. Date/event opens root the stack in the widget's own

View File

@@ -37,6 +37,8 @@ fun RootScreen(
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null, requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
onInsertConsumed: () -> Unit = {}, onInsertConsumed: () -> Unit = {},
requestedEditKey: LongArray? = null,
onEditKeyConsumed: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
var hasPermission by remember { var hasPermission by remember {
@@ -88,6 +90,8 @@ fun RootScreen(
onImportConsumed = onImportConsumed, onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm, requestedInsertForm = requestedInsertForm,
onInsertConsumed = onInsertConsumed, onInsertConsumed = onInsertConsumed,
requestedEditKey = requestedEditKey,
onEditKeyConsumed = onEditKeyConsumed,
) )
false -> ReminderOnboardingScreen( false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish, onFinished = reminderOnboarding::finish,

View File

@@ -21,6 +21,7 @@ import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.calendula.domain.populatedFields import de.jeanlucmakiola.calendula.domain.populatedFields
import de.jeanlucmakiola.calendula.domain.problems import de.jeanlucmakiola.calendula.domain.problems
import de.jeanlucmakiola.calendula.domain.toEditSnapshot import de.jeanlucmakiola.calendula.domain.toEditSnapshot
import de.jeanlucmakiola.calendula.ui.detail.EventDetailViewModel.Companion.NO_OCCURRENCE_TIME
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -396,8 +397,12 @@ class EventEditViewModel @Inject constructor(
/** /**
* Load an existing event into the form. [beginMillis]/[endMillis] are the * Load an existing event into the form. [beginMillis]/[endMillis] are the
* tapped occurrence's own times, like on the detail screen. No-op while a * tapped occurrence's own times, like on the detail screen. An external
* form is open, so user edits survive configuration changes. * "edit this event" (`ACTION_EDIT`) that names no occurrence passes
* [NO_OCCURRENCE_TIME]; the row's own DTSTART/DTEND is used then, so the
* form loads the event's real times instead of the epoch (mirrors the
* detail screen's #48 fallback). No-op while a form is open, so user edits
* survive configuration changes.
*/ */
fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) { fun openForEdit(eventId: Long, beginMillis: Long, endMillis: Long) {
if (_form.value != null || _editTarget.value != null) return if (_form.value != null || _editTarget.value != null) return
@@ -411,8 +416,12 @@ class EventEditViewModel @Inject constructor(
return@launch return@launch
} }
val zone = TimeZone.currentSystemDefault() val zone = TimeZone.currentSystemDefault()
val snapshot = detail.toEditSnapshot(beginMillis, endMillis, zone) val begin = beginMillis.takeUnless { it == NO_OCCURRENCE_TIME }
_editTarget.value = EditTarget(eventId, snapshot, beginMillis, endMillis, zone) ?: detail.instance.start.toEpochMilliseconds()
val end = endMillis.takeUnless { it == NO_OCCURRENCE_TIME }
?: detail.instance.end.toEpochMilliseconds()
val snapshot = detail.toEditSnapshot(begin, end, zone)
_editTarget.value = EditTarget(eventId, snapshot, begin, end, zone)
// Sections holding data must show even when not in the defaults. // Sections holding data must show even when not in the defaults.
_revealed.value = snapshot.form.populatedFields() _revealed.value = snapshot.form.populatedFields()
_form.value = snapshot.form _form.value = snapshot.form