From 974d65f619d1239a4e73d3f775e935f291a1f778 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 16:05:33 +0200 Subject: [PATCH 1/5] fix(detail): open events whose series starts before 1970 (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DTSTART is stored as UTC epoch millis, so a recurring series anchored before 1970-01-01 (common for yearly birthdays/anniversaries synced over CalDAV) has a legitimately negative DTSTART. The detail and search mappers dropped any row with dtstart < 0, and since the detail query reads the series-master DTSTART (the ancient anchor), every occurrence of such a series became un-openable — surfacing as the generic "Something went wrong." error screen — and the events vanished from search too. Relax the guard to reject only an *absent* DTSTART (isNull), which is the malformed case it was meant to catch; negative epoch millis flow through correctly (Instant/formatting and the all-day reminder decode are all Long-based). Add regression tests for both mappers. Co-Authored-By: Claude Opus 4.8 --- .../data/calendar/EventDetailMapper.kt | 12 +++-- .../calendula/data/calendar/SearchMapper.kt | 5 ++- .../data/calendar/EventDetailMapperTest.kt | 21 ++++++++- .../data/calendar/SearchMapperTest.kt | 45 +++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt index 4a59358..d441fd8 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -23,12 +23,16 @@ internal fun ColumnReader.toEventDetailCore( attendees: List, reminders: List, ): EventDetail? { - val begin = getLong(EventDetailProjection.IDX_DTSTART) - - if (begin < 0L) { - Log.w(TAG, "Dropping event with negative dtstart=$begin") + // DTSTART is epoch millis in UTC, so a series anchored before 1970 (common + // for yearly birthdays/anniversaries synced over CalDAV) is legitimately + // negative — only an *absent* DTSTART marks a malformed row worth dropping. + // Dropping negatives made every occurrence of such a series un-openable + // (the detail loads the ancient series-master DTSTART), see issue #34. + if (isNull(EventDetailProjection.IDX_DTSTART)) { + Log.w(TAG, "Dropping event with missing dtstart") return null } + val begin = getLong(EventDetailProjection.IDX_DTSTART) // Recurring events store DURATION instead of DTEND, so the series row's // DTEND is null. Keep the event (end == begin); callers that opened a diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt index 4c09781..f83733d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapper.kt @@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis * of DTEND — reconstruct the end the same way the `.ics` export does. */ internal fun ColumnReader.toSearchResult(): EventInstance? { + // A pre-1970 series anchor is a legitimately negative epoch-millis DTSTART + // (see EventDetailMapper / issue #34); drop only a genuinely absent one, so + // long-running birthdays/anniversaries still surface in search. + if (isNull(SearchProjection.IDX_DTSTART)) return null val dtStart = getLong(SearchProjection.IDX_DTSTART) - if (dtStart < 0L) return null val end = when { !isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND) else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION)) diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt index 6ef9528..9870cde 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt @@ -22,8 +22,8 @@ class EventDetailMapperTest { eventColor: Any? = null, eventColorKey: String? = null, calendarColor: Int = 0xFFAABBCC.toInt(), - dtstart: Long = 1_000_000_000L, - dtend: Long = 1_000_003_600L, + dtstart: Long? = 1_000_000_000L, + dtend: Long? = 1_000_003_600L, allDay: Int = 0, location: String? = "Berlin", calendarId: Long = 7L, @@ -125,6 +125,23 @@ class EventDetailMapperTest { assertThat(detail).isNull() } + @Test + fun `pre-1970 negative dtstart is kept, not dropped (issue #34)`() { + // A yearly birthday/anniversary anchored before the epoch has a + // legitimately negative UTC epoch-millis DTSTART; recurring rows carry + // no DTEND (they use DURATION), so it stays end == begin. + val begin = -157_766_400_000L // 1965-01-01T00:00:00Z + val detail = detailReader(dtstart = begin, dtend = null).toDetail() + assertThat(detail).isNotNull() + assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(begin) + assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(begin) + } + + @Test + fun `absent dtstart drops detail`() { + assertThat(detailReader(dtstart = null).toDetail()).isNull() + } + @Test fun `rrule passes through when present`() { val detail = detailReader(rrule = "FREQ=WEEKLY;BYDAY=MO").toDetail() diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt new file mode 100644 index 0000000..9b1bb30 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SearchMapperTest.kt @@ -0,0 +1,45 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +class SearchMapperTest { + + private fun searchReader( + id: Long = 1L, + calendarId: Long = 7L, + title: String? = "Birthday", + dtstart: Long? = 1_000_000_000L, + dtend: Long? = null, + duration: String? = "P1D", + allDay: Int = 1, + eventColor: Any? = null, + calendarColor: Int = 0xFFAABBCC.toInt(), + location: String? = null, + ): MapColumnReader = MapColumnReader( + SearchProjection.IDX_ID to id, + SearchProjection.IDX_CALENDAR_ID to calendarId, + SearchProjection.IDX_TITLE to title, + SearchProjection.IDX_DTSTART to dtstart, + SearchProjection.IDX_DTEND to dtend, + SearchProjection.IDX_DURATION to duration, + SearchProjection.IDX_ALL_DAY to allDay, + SearchProjection.IDX_EVENT_COLOR to eventColor, + SearchProjection.IDX_CALENDAR_COLOR to calendarColor, + SearchProjection.IDX_LOCATION to location, + ) + + @Test + fun `pre-1970 negative dtstart still surfaces in search (issue #34)`() { + val begin = -157_766_400_000L // 1965-01-01T00:00:00Z + val result = searchReader(dtstart = begin, dtend = null, duration = "P1D").toSearchResult() + assertThat(result).isNotNull() + assertThat(result!!.start.toEpochMilliseconds()).isEqualTo(begin) + assertThat(result.title).isEqualTo("Birthday") + } + + @Test + fun `absent dtstart drops the search hit`() { + assertThat(searchReader(dtstart = null).toSearchResult()).isNull() + } +} From 1a3e4f501f4082ef1b69b42dc0e55e77cc736032 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 16:05:43 +0200 Subject: [PATCH 2/5] fix(edit): time picker dial honours the app 24h/12h setting (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event-form (and Settings) time picker seeded is24Hour from the system TIME_12_24 override / device locale, ignoring the app's own TimeFormatPref. So with the app set to 24h under an English locale the dial still showed AM/PM, while every time label (which reads LocalUse24HourFormat) showed 24h. Seed the picker from LocalUse24HourFormat — the app-wide clock convention already resolved once at the root from TimeFormatPref — so the dial matches the labels. Drops the now-unused deviceUses24HourClock helper. Co-Authored-By: Claude Opus 4.8 --- .../calendula/ui/common/TimePickerAlert.kt | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimePickerAlert.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimePickerAlert.kt index d161728..3426433 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimePickerAlert.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/TimePickerAlert.kt @@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import android.content.Context -import android.content.res.Resources -import android.provider.Settings -import android.text.format.DateFormat import androidx.compose.material3.TimePicker import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import de.jeanlucmakiola.calendula.R import kotlinx.datetime.LocalTime @@ -27,10 +22,13 @@ fun TimePickerAlert( onConfirm: (LocalTime) -> Unit, onDismiss: () -> Unit, ) { + // Honour the app's own time-format preference (the value every time label + // reads), not the system/locale clock — otherwise an explicit 24h setting + // still showed an AM/PM dial (issue #27). val state = rememberTimePickerState( initialHour = initial.hour, initialMinute = initial.minute, - is24Hour = deviceUses24HourClock(LocalContext.current), + is24Hour = LocalUse24HourFormat.current, ) AlertDialog( onDismissRequest = onDismiss, @@ -45,24 +43,3 @@ fun TimePickerAlert( text = { TimePicker(state = state) }, ) } - -/** - * Whether the clock should read 24-hour, matching the rest of the device. - * - * [DateFormat.is24HourFormat] resolves a "locale default" system setting against - * the *app's* context locale — and this app applies a per-app language - * (AppCompatDelegate), so an English UI on a German-region phone would wrongly - * read 12-hour while the system clock shows 24-hour. So we honour an explicit - * system 12/24 override, and otherwise fall back to the **device** locale - * (Resources.getSystem), not the app's. - */ -private fun deviceUses24HourClock(context: Context): Boolean = - when (Settings.System.getString(context.contentResolver, Settings.System.TIME_12_24)) { - "24" -> true - "12" -> false - // 'a' is the AM/PM marker; a best-fit pattern without it is 24-hour. - else -> { - val deviceLocale = Resources.getSystem().configuration.locales[0] - !DateFormat.getBestDateTimePattern(deviceLocale, "jm").contains('a') - } - } From 65bd6c425431afcd9ab5e22fb9f8ac00a0498530 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 16:12:56 +0200 Subject: [PATCH 3/5] feat(intent): create events from external ACTION_INSERT launches (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register an intent-filter for ACTION_INSERT on the events dir mime type (vnd.android.cursor.dir/event), the way the AOSP calendar accepts one, so other apps and widgets (e.g. the Todo Agenda widget) can launch Calendula to create a new event. MainActivity.insertFormOrNull parses the standard CalendarContract extras (EXTRA_EVENT_BEGIN_TIME/END_TIME/ALL_DAY, Events.TITLE/DESCRIPTION/ EVENT_LOCATION/RRULE) into a prefilled EventForm via the pure, unit-tested buildInsertEventForm — omitted fields fall back to the same defaults the in-app "new event" uses (next full hour, +1h). The form is routed through the existing single-event prefill channel (RootScreen → CalendarHost → the create form for review), with calendarId left null so it resolves to the last-used / first-writable calendar. No new permission is needed (WRITE_CALENDAR is already held), and the user still explicitly saves. Co-Authored-By: Claude Opus 4.8 --- app/src/main/AndroidManifest.xml | 11 ++ .../jeanlucmakiola/calendula/MainActivity.kt | 38 ++++++ .../calendula/domain/InsertEventForm.kt | 76 ++++++++++++ .../calendula/ui/CalendarHost.kt | 12 ++ .../jeanlucmakiola/calendula/ui/RootScreen.kt | 4 + .../calendula/domain/InsertEventFormTest.kt | 115 ++++++++++++++++++ 6 files changed, 256 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ad7750..1139b8b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -112,6 +112,17 @@ + + + + + + + (null) + // A prefilled new-event form from an external ACTION_INSERT launch (another + // app/widget asking us to create an event, issue #30). Consumed once by + // CalendarHost, which opens it in the create form for review. + private var requestedInsertForm by mutableStateOf(null) + // 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 // crash-loop is handled out of band, before setContent — see below. @@ -84,6 +93,7 @@ class MainActivity : AppCompatActivity() { requestedDetailKey = intent.detailKeyOrNull() requestedNav = intent.navRequestOrNull() requestedImportUri = intent.importUriOrNull() + requestedInsertForm = intent.insertFormOrNull() if (CrashReporter.shouldPrompt(this)) pendingCrashReport = CrashReporter.pendingReport(this) setContent { // One activity-scoped SettingsViewModel drives both the theme here @@ -132,6 +142,8 @@ class MainActivity : AppCompatActivity() { onWidgetNavConsumed = { requestedNav = null }, requestedImportUri = requestedImportUri, onImportConsumed = { requestedImportUri = null }, + requestedInsertForm = requestedInsertForm, + onInsertConsumed = { requestedInsertForm = null }, ) } // A persistent corner marker so a debug build is never @@ -169,6 +181,7 @@ class MainActivity : AppCompatActivity() { intent.detailKeyOrNull()?.let { requestedDetailKey = it } intent.navRequestOrNull()?.let { requestedNav = it } intent.importUriOrNull()?.let { requestedImportUri = it } + intent.insertFormOrNull()?.let { requestedInsertForm = it } } /** @@ -188,6 +201,31 @@ class MainActivity : AppCompatActivity() { return uri.takeIf { it.scheme == "content" || it.scheme == "file" } } + /** + * A prefilled new-event form from an external `ACTION_INSERT` launch — another + * app or widget (e.g. Todo Agenda) asking us to create an event (issue #30). + * 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? { + if (action != Intent.ACTION_INSERT) return null + return buildInsertEventForm( + beginMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_BEGIN_TIME), + endMillis = longExtraOrNull(CalendarContract.EXTRA_EVENT_END_TIME), + isAllDay = getBooleanExtra(CalendarContract.EXTRA_EVENT_ALL_DAY, false), + title = getStringExtra(CalendarContract.Events.TITLE), + description = getStringExtra(CalendarContract.Events.DESCRIPTION), + location = getStringExtra(CalendarContract.Events.EVENT_LOCATION), + rrule = getStringExtra(CalendarContract.Events.RRULE), + zone = TimeZone.currentSystemDefault(), + now = Clock.System.now(), + ) + } + + /** A Long extra's value, or null when the extra is absent. */ + private fun Intent.longExtraOrNull(key: String): Long? = + if (hasExtra(key)) getLongExtra(key, 0L) else null + /** * The date a launcher/clock date tap points at, parsed from the AOSP calendar * "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/ diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt new file mode 100644 index 0000000..059f301 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/domain/InsertEventForm.kt @@ -0,0 +1,76 @@ +package de.jeanlucmakiola.calendula.domain + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Duration.Companion.hours +import kotlin.time.Instant + +/** + * Build a prefilled [EventForm] from an `ACTION_INSERT` intent's extras (issue + * #30). External apps and widgets (e.g. the Todo Agenda widget) launch the + * calendar this way to create a new event, passing the fields as + * [android.provider.CalendarContract] extras. Any field the intent omits falls + * back to the same defaults the in-app "new event" uses — a timed start at the + * next full hour and a one-hour duration. [EventForm.calendarId] is left null so + * it resolves to the last-used / first-writable calendar, exactly like the + * `.ics` single-event and plain new-event paths. + * + * Pure (no Android types) so it is unit-testable; the intent parsing that reads + * the extras lives in `MainActivity.insertFormOrNull`. + */ +fun buildInsertEventForm( + beginMillis: Long?, + endMillis: Long?, + isAllDay: Boolean, + title: String?, + description: String?, + location: String?, + rrule: String?, + zone: TimeZone, + now: Instant, +): EventForm { + val (start, end) = if (isAllDay) { + // All-day provider times are UTC midnights with an exclusive end; show + // the last covered day and keep placeholder wall-clock times in case the + // user switches the event to timed (mirrors EventDetail.toEditForm). + val startDate = beginMillis + ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } + ?: now.toLocalDateTime(zone).date + val endDate = endMillis + ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(TimeZone.UTC).date } + ?.let { exclusive -> maxOf(startDate, LocalDate.fromEpochDays(exclusive.toEpochDays() - 1)) } + ?: startDate + LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0)) + } else { + val startTime = beginMillis + ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) } + ?: nextFullHour(now, zone) + val endTime = endMillis + ?.let { Instant.fromEpochMilliseconds(it).toLocalDateTime(zone) } + ?.takeIf { it >= startTime } + ?: (startTime.toInstant(zone) + 1.hours).toLocalDateTime(zone) + startTime to endTime + } + return EventForm( + calendarId = null, + title = title.orEmpty(), + isAllDay = isAllDay, + start = start, + end = end, + location = location.orEmpty(), + description = description.orEmpty(), + // Bare RRULE value (Events.RRULE convention); tolerate a leading "RRULE:" + // some callers include. + rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() }, + ) +} + +private fun nextFullHour(now: Instant, zone: TimeZone): LocalDateTime { + val hourMillis = 3_600_000L + val rounded = (now.toEpochMilliseconds() / hourMillis + 1) * hourMillis + return Instant.fromEpochMilliseconds(rounded).toLocalDateTime(zone) +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index 612840e..e0ce981 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -72,6 +72,8 @@ fun CalendarHost( onWidgetNavConsumed: () -> Unit = {}, requestedImportUri: android.net.Uri? = null, onImportConsumed: () -> Unit = {}, + requestedInsertForm: EventForm? = null, + onInsertConsumed: () -> Unit = {}, viewModel: CalendarHostViewModel = hiltViewModel(), ) { // Wait for the persisted default view before seeding the stack, so the app @@ -176,6 +178,16 @@ fun CalendarHost( onImportConsumed() } } + // An external ACTION_INSERT launch (another app/widget creating an event, + // issue #30) arrives already prefilled — open it in the same create form the + // single-event .ics path uses. [importForm] is the topmost overlay, so it + // reveals on top of whatever was open without extra dismissal. + LaunchedEffect(requestedInsertForm) { + if (requestedInsertForm != null) { + importForm = requestedInsertForm + onInsertConsumed() + } + } // Close every overlay that can sit over the calendar, so an externally // requested destination (a widget/shortcut/QS-tile launch) is revealed on diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index 3e7a2f3..284953a 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -35,6 +35,8 @@ fun RootScreen( onWidgetNavConsumed: () -> Unit = {}, requestedImportUri: android.net.Uri? = null, onImportConsumed: () -> Unit = {}, + requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null, + onInsertConsumed: () -> Unit = {}, ) { val context = LocalContext.current var hasPermission by remember { @@ -84,6 +86,8 @@ fun RootScreen( onWidgetNavConsumed = onWidgetNavConsumed, requestedImportUri = requestedImportUri, onImportConsumed = onImportConsumed, + requestedInsertForm = requestedInsertForm, + onInsertConsumed = onInsertConsumed, ) false -> ReminderOnboardingScreen( onFinished = reminderOnboarding::finish, diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt new file mode 100644 index 0000000..aaf9b60 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/domain/InsertEventFormTest.kt @@ -0,0 +1,115 @@ +package de.jeanlucmakiola.calendula.domain + +import com.google.common.truth.Truth.assertThat +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toInstant +import org.junit.jupiter.api.Test +import kotlin.time.Instant + +class InsertEventFormTest { + + private val zone = TimeZone.UTC + + // A fixed "now" at 2026-01-15T10:20:00Z; the next full hour is 11:00. + private val now = LocalDateTime(2026, 1, 15, 10, 20).toInstant(zone) + + private fun build( + beginMillis: Long? = null, + endMillis: Long? = null, + isAllDay: Boolean = false, + title: String? = null, + description: String? = null, + location: String? = null, + rrule: String? = null, + ): EventForm = buildInsertEventForm( + beginMillis = beginMillis, + endMillis = endMillis, + isAllDay = isAllDay, + title = title, + description = description, + location = location, + rrule = rrule, + zone = zone, + now = now, + ) + + private fun millis(dateTime: LocalDateTime): Long = dateTime.toInstant(zone).toEpochMilliseconds() + + @Test + fun `timed event maps all provided fields`() { + val start = LocalDateTime(2026, 3, 1, 14, 30) + val end = LocalDateTime(2026, 3, 1, 15, 45) + val form = build( + beginMillis = millis(start), + endMillis = millis(end), + title = "Standup", + description = "Daily", + location = "Room 1", + rrule = "FREQ=DAILY", + ) + assertThat(form.calendarId).isNull() // resolves to last-used / first-writable + assertThat(form.isAllDay).isFalse() + assertThat(form.start).isEqualTo(start) + assertThat(form.end).isEqualTo(end) + assertThat(form.title).isEqualTo("Standup") + assertThat(form.description).isEqualTo("Daily") + assertThat(form.location).isEqualTo("Room 1") + assertThat(form.rrule).isEqualTo("FREQ=DAILY") + } + + @Test + fun `missing times default to the next full hour and a one-hour duration`() { + val form = build(title = "Quick note") + assertThat(form.start).isEqualTo(LocalDateTime(2026, 1, 15, 11, 0)) + assertThat(form.end).isEqualTo(LocalDateTime(2026, 1, 15, 12, 0)) + assertThat(form.title).isEqualTo("Quick note") + } + + @Test + fun `timed event with only a start gets a one-hour end`() { + val start = LocalDateTime(2026, 5, 2, 9, 0) + val form = build(beginMillis = millis(start)) + assertThat(form.start).isEqualTo(start) + assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0)) + } + + @Test + fun `an end before the start is ignored and falls back to plus one hour`() { + val start = LocalDateTime(2026, 5, 2, 9, 0) + val badEnd = LocalDateTime(2026, 5, 2, 8, 0) + val form = build(beginMillis = millis(start), endMillis = millis(badEnd)) + assertThat(form.end).isEqualTo(LocalDateTime(2026, 5, 2, 10, 0)) + } + + @Test + fun `all-day event uses UTC dates and decrements the exclusive end`() { + val startMillis = LocalDate(2026, 1, 1).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + val endExclusive = LocalDate(2026, 1, 3).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + val form = build(beginMillis = startMillis, endMillis = endExclusive, isAllDay = true, title = "Trip") + assertThat(form.isAllDay).isTrue() + assertThat(form.start.date).isEqualTo(LocalDate(2026, 1, 1)) + assertThat(form.end.date).isEqualTo(LocalDate(2026, 1, 2)) + // Placeholder wall-clock times survive a switch back to a timed event. + assertThat(form.start.time).isEqualTo(LocalTime(9, 0)) + assertThat(form.end.time).isEqualTo(LocalTime(10, 0)) + } + + @Test + fun `all-day event without an end is a single day`() { + val startMillis = LocalDate(2026, 6, 10).atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + val form = build(beginMillis = startMillis, isAllDay = true) + assertThat(form.start.date).isEqualTo(LocalDate(2026, 6, 10)) + assertThat(form.end.date).isEqualTo(LocalDate(2026, 6, 10)) + } + + @Test + fun `a leading RRULE prefix is stripped and a blank rule becomes null`() { + assertThat(build(rrule = "RRULE:FREQ=WEEKLY").rrule).isEqualTo("FREQ=WEEKLY") + assertThat(build(rrule = " ").rrule).isNull() + assertThat(build(rrule = null).rrule).isNull() + } +} From 76846420a2b80ab7f0eaedf9bdb1cfc9856b3c19 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 17:11:51 +0200 Subject: [PATCH 4/5] fix(detail): clamp a backwards DTEND instead of dropping the event (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toEventDetailCore returned null when a present DTEND preceded DTSTART, the only remaining false-drop that surfaces as the generic "Something went wrong." error screen — the same un-openable trap as the pre-1970 DTSTART bug, and worse because the user can't even open the malformed event to fix it. Clamp the end to DTSTART (a zero-length event) instead, matching how SearchMapper already coerces its end. After this the detail mapper drops a row only when DTSTART is genuinely absent (unrenderable). Co-Authored-By: Claude Opus 4.8 --- .../calendula/data/calendar/EventDetailMapper.kt | 13 ++++++------- .../data/calendar/EventDetailMapperTest.kt | 9 +++++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt index d441fd8..e6de991 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapper.kt @@ -37,16 +37,15 @@ internal fun ColumnReader.toEventDetailCore( // Recurring events store DURATION instead of DTEND, so the series row's // DTEND is null. Keep the event (end == begin); callers that opened a // specific occurrence supply the real per-occurrence times from - // CalendarContract.Instances. Only a present-but-backwards DTEND is malformed. + // CalendarContract.Instances. A present-but-backwards DTEND is malformed, + // but dropping the row would make the event un-openable — the same trap as + // the pre-1970 DTSTART bug above (issue #34): it would surface as the + // generic error screen with no way to open the event and fix it. Clamp to a + // zero-length event instead (matching SearchMapper's coerceAtLeast). val end = if (isNull(EventDetailProjection.IDX_DTEND)) { begin } else { - val rawEnd = getLong(EventDetailProjection.IDX_DTEND) - if (rawEnd < begin) { - Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin") - return null - } - rawEnd + getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin) } // Kept raw (no untitled fallback): the detail screen substitutes its own diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt index 9870cde..299d2fe 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/EventDetailMapperTest.kt @@ -120,9 +120,14 @@ class EventDetailMapperTest { } @Test - fun `dtend before dtstart drops detail`() { + fun `dtend before dtstart is clamped to a zero-length event, not dropped`() { + // A backwards DTEND is malformed, but dropping it would make the event + // un-openable (the "Something went wrong" trap, same as issue #34); keep + // it as a zero-length event so the user can still open and fix it. val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail() - assertThat(detail).isNull() + assertThat(detail).isNotNull() + assertThat(detail!!.instance.start.toEpochMilliseconds()).isEqualTo(2000L) + assertThat(detail.instance.end.toEpochMilliseconds()).isEqualTo(2000L) } @Test From cf3b897305afa47de1a99a0ce8d1ecd8452e7f54 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Jul 2026 17:25:17 +0200 Subject: [PATCH 5/5] =?UTF-8?q?release:=20v2.13.1=20=E2=80=94=20recurring-?= =?UTF-8?q?event=20open=20fix,=2024h=20time=20picker,=20INSERT=20intent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Patch release bundling the fixes for #34 (pre-1970 recurring events could not be opened) and #27 (time-picker dial ignored the 24h setting), plus #30 (create events from external ACTION_INSERT launches). Bumps versionName to 2.13.1 (versionCode 21301) and cuts the changelog. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 28 +++++++++++++++++++ app/build.gradle.kts | 4 +-- .../android/en-US/changelogs/21301.txt | 16 +++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/21301.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index eff4ed3..3526c95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.13.1] — 2026-07-06 + +### Added +- Create events from other apps and widgets. Calendula now registers the + standard "insert event" intent (`ACTION_INSERT` on the calendar events type), + so other apps and home-screen widgets — such as the Todo Agenda widget — can + hand off to Calendula to create a new event. It opens the new-event form + prefilled with whatever they passed (title, start/end time, all-day, location, + description, recurrence), and picks your last-used or first writable calendar. + Thanks to @dschuermann for the suggestion ([#30]). + +### Fixed +- Some recurring events could not be opened. Events in a series that started + before 1970 — for example yearly birthdays or anniversaries synced over CalDAV + — showed "Something went wrong" instead of opening, because their stored start + time is a negative value that was wrongly treated as invalid. They now open + normally and appear in search again. A related case (an event whose stored end + precedes its start) is now kept and openable instead of failing the same way. + Thanks to @dschuermann for the report ([#34]). +- The time picker now follows your 24-hour setting. With Calendula set to + 24-hour time, the clock dial for choosing an event's start and end time still + showed AM/PM instead of a 24-hour dial; it now matches your setting (and the + same fix applies to the all-day reminder time in Settings). Thanks to + @abrossimow for the report ([#27]). + ## [2.13.0] — 2026-07-03 ### Added @@ -809,4 +834,7 @@ automatically, with zero telemetry and no internet permission. [#19]: https://codeberg.org/jlmakiola/calendula/issues/19 [#20]: https://codeberg.org/jlmakiola/calendula/issues/20 [#24]: https://codeberg.org/jlmakiola/calendula/issues/24 +[#27]: https://codeberg.org/jlmakiola/calendula/issues/27 [#29]: https://codeberg.org/jlmakiola/calendula/issues/29 +[#30]: https://codeberg.org/jlmakiola/calendula/issues/30 +[#34]: https://codeberg.org/jlmakiola/calendula/issues/34 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6ff2520..38a3422 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,8 +28,8 @@ android { // which builds this version and then creates the matching vX.Y.Z tag + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. - versionCode = 21300 - versionName = "2.13.0" + versionCode = 21301 + versionName = "2.13.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/fastlane/metadata/android/en-US/changelogs/21301.txt b/fastlane/metadata/android/en-US/changelogs/21301.txt new file mode 100644 index 0000000..b9dbb4e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21301.txt @@ -0,0 +1,16 @@ +### Added +- Create events from other apps and widgets. Calendula now registers the + standard "insert event" intent, so other apps and home-screen widgets (such as + the Todo Agenda widget) can hand off to Calendula to create a new event — it + opens the new-event form prefilled with whatever they passed (title, time, + location, description). Thanks to @dschuermann for the suggestion ([#30]). + +### Fixed +- Some recurring events could not be opened. Events in a series that started + before 1970 — for example yearly birthdays or anniversaries synced over CalDAV + — showed "Something went wrong" instead of opening. They now open normally, and + show up in search again. Thanks to @dschuermann for the report ([#34]). +- The time picker now follows your 24-hour setting. With Calendula set to + 24-hour time, the dial for choosing an event's start and end time still showed + AM/PM; it now matches your setting. Thanks to @abrossimow for the report + ([#27]).