Merge pull request 'fix(intents): show Calendula under "Add to calendar" from other apps (#74)' (!93) from fix/insert-event-intent-filters into release/v2.16.0

Reviewed-on: #93
This commit was merged in pull request #93.
This commit is contained in:
2026-07-24 15:46:13 +00:00
3 changed files with 69 additions and 14 deletions

View File

@@ -32,6 +32,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Now a bigger widget gets bigger, more readable type and roomier rows, while the
default size looks exactly as before — no new setting; it follows the size you
already chose ([#51]).
- Calendula now appears under other apps' "Add to calendar" / "Save to calendar"
actions. Some apps (e.g. DB Navigator) fire the widely-used "insert event"
intent with the singular `vnd.android.cursor.item/event` type, which Calendula
didn't advertise — so it was left out of the chooser, and if it was your only
calendar app the save silently did nothing. It now accepts that form, plus the
`INSERT_OR_EDIT` action, and opens the new event prefilled for review ([#74]).
- Opening a `.ics`/`.vcs` file now works even when another app hands it over
mislabelled as a generic download (`application/octet-stream`), as some mail
clients, browsers and file managers do — Calendula recognises it by its file
extension instead of relying on the declared type ([#74]).
## [2.16.0] — 2026-07-17
@@ -1079,3 +1089,4 @@ automatically, with zero telemetry and no internet permission.
[#53]: https://codeberg.org/jlmakiola/calendula/issues/53
[#60]: https://codeberg.org/jlmakiola/calendula/issues/60
[#65]: https://codeberg.org/jlmakiola/calendula/issues/65
[#74]: https://codeberg.org/jlmakiola/calendula/issues/74

View File

@@ -112,6 +112,28 @@
<data android:mimeType="text/x-vcalendar" />
<data android:mimeType="application/ics" />
</intent-filter>
<!-- Same .ics/.vcs data arriving mislabelled as a generic download —
application/octet-stream — the way many mail clients, browsers and
file managers hand off attachments. Matched by file extension, so
this stays a separate filter: a pathPattern here must not narrow
the MIME-typed VIEW filter above (that one has no path and must
keep matching regardless of name). The import handler ignores the
MIME type, so a let-through octet-stream .ics imports normally.
Best-effort: pathPattern is reliable for file:// (and content://
whose path carries the filename); content:// URIs that expose no
name still fall back to the MIME-typed filter above. -->
<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:scheme="content" />
<data android:scheme="file" />
<data android:host="*" />
<data android:mimeType="application/octet-stream" />
<data android:pathPattern=".*\\.ics" />
<data android:pathPattern=".*\\.vcs" />
</intent-filter>
<!-- Receive a .ics/.vcs shared from another app (same MIME set). -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
@@ -123,11 +145,13 @@
<!-- 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:
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
(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. -->
to the same prefilled create form. (The far more common *item*-
typed INSERT — the form the Android docs' example and apps like
DB Navigator use — is the item filter below, issue #74.) -->
<intent-filter>
<action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.EDIT" />
@@ -135,14 +159,31 @@
<data android:mimeType="vnd.android.cursor.dir/event" />
</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). -->
<!-- Create or edit an event another app/assistant/widget points at,
addressed by the provider's *item* MIME type. Three actions share
this filter, told apart at runtime by the intent's data:
• ACTION_INSERT — create. This is the form the Android docs'
"insert an event" example and many apps use
(setType("vnd.android.cursor.item/event")), e.g. DB Navigator's
"Save to calendar". The dir-typed filter above alone missed it,
so Calendula never showed in the chooser — and, when it was the
only calendar app, the intent resolved to nothing (issue #74).
• ACTION_INSERT_OR_EDIT — the third "add to calendar" action AOSP
and Google Calendar register; a create, or an edit when it
carries an event id.
• ACTION_EDIT — edit the existing event at
content://com.android.calendar/events/<id> (an id-less EDIT is a
create). Opens the occurrence in the edit form, not the
read-only detail — that's the VIEW filter above.
Create fields ride as CalendarContract extras; an edit's
occurrence times ride as EXTRA_EVENT_BEGIN_TIME /
EXTRA_EVENT_END_TIME when supplied (MainActivity.insertFormOrNull
/ editEventKeyOrNull). -->
<intent-filter>
<action android:name="android.intent.action.INSERT" />
<action android:name="android.intent.action.INSERT_OR_EDIT" />
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/event" />

View File

@@ -222,10 +222,12 @@ class MainActivity : AppCompatActivity() {
* [buildInsertEventForm].
*/
private fun Intent.insertFormOrNull(): EventForm? {
// ACTION_EDIT on an existing event routes to the edit form instead
// ([editEventKeyOrNull]); only an id-less EDIT is a create.
// ACTION_EDIT / ACTION_INSERT_OR_EDIT on an existing event route to the
// edit form instead ([editEventKeyOrNull]); an id-less one is a create,
// as is any plain ACTION_INSERT.
val isCreate = action == Intent.ACTION_INSERT ||
(action == Intent.ACTION_EDIT && editEventKeyOrNull() == null)
((action == Intent.ACTION_EDIT || action == Intent.ACTION_INSERT_OR_EDIT) &&
editEventKeyOrNull() == null)
if (!isCreate) return null
return buildInsertEventForm(
beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME),
@@ -344,10 +346,11 @@ class MainActivity : AppCompatActivity() {
* 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]).
* DTSTART/DTEND. An id-less `ACTION_EDIT` — or `ACTION_INSERT_OR_EDIT`, which
* some apps fire — is a create instead ([insertFormOrNull]).
*/
private fun Intent.editEventKeyOrNull(): LongArray? {
if (action != Intent.ACTION_EDIT) return null
if (action != Intent.ACTION_EDIT && action != Intent.ACTION_INSERT_OR_EDIT) return null
val uri = data ?: return null
if (uri.host != CALENDAR_PROVIDER_HOST) return null
val segments = uri.pathSegments