Merge pull request 'release: v2.13.1 — recurring-event open fix, 24h time picker, INSERT intent' (!60) from release/v2.13.1 into main
Some checks failed
Release — F-Droid repo + Gitea release / detect (push) Successful in 4s
Release — F-Droid repo + Gitea release / release (push) Has been cancelled

Reviewed-on: #60
This commit was merged in pull request #60.
This commit is contained in:
2026-07-06 15:36:42 +00:00
14 changed files with 395 additions and 45 deletions

View File

@@ -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/), 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). 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 ## [2.13.0] — 2026-07-03
### Added ### Added
@@ -809,4 +834,7 @@ automatically, with zero telemetry and no internet permission.
[#19]: https://codeberg.org/jlmakiola/calendula/issues/19 [#19]: https://codeberg.org/jlmakiola/calendula/issues/19
[#20]: https://codeberg.org/jlmakiola/calendula/issues/20 [#20]: https://codeberg.org/jlmakiola/calendula/issues/20
[#24]: https://codeberg.org/jlmakiola/calendula/issues/24 [#24]: https://codeberg.org/jlmakiola/calendula/issues/24
[#27]: https://codeberg.org/jlmakiola/calendula/issues/27
[#29]: https://codeberg.org/jlmakiola/calendula/issues/29 [#29]: https://codeberg.org/jlmakiola/calendula/issues/29
[#30]: https://codeberg.org/jlmakiola/calendula/issues/30
[#34]: https://codeberg.org/jlmakiola/calendula/issues/34

View File

@@ -28,8 +28,8 @@ android {
// which builds this version and then creates the matching vX.Y.Z tag + // which builds this version and then creates the matching vX.Y.Z tag +
// release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 + // release itself (versionCode is pinned to MAJOR*10000 + MINOR*100 +
// PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md. // PATCH from versionName, e.g. 2.7.2 -> 20702). See docs/RELEASING.md.
versionCode = 21300 versionCode = 21301
versionName = "2.13.0" versionName = "2.13.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -112,6 +112,17 @@
<data android:mimeType="text/calendar" /> <data android:mimeType="text/calendar" />
</intent-filter> </intent-filter>
<!-- 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
event's fields as CalendarContract extras
(MainActivity.insertFormOrNull, issue #30). -->
<intent-filter>
<action android:name="android.intent.action.INSERT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.dir/event" />
</intent-filter>
<!-- Launcher long-press shortcuts (e.g. "New event"). --> <!-- Launcher long-press shortcuts (e.g. "New event"). -->
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.CalendarContract
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@@ -25,6 +26,8 @@ import dagger.hilt.android.AndroidEntryPoint
import de.jeanlucmakiola.calendula.data.crash.CrashReporter import de.jeanlucmakiola.calendula.data.crash.CrashReporter
import de.jeanlucmakiola.calendula.data.prefs.ThemeMode import de.jeanlucmakiola.calendula.data.prefs.ThemeMode
import de.jeanlucmakiola.calendula.data.prefs.is24Hour import de.jeanlucmakiola.calendula.data.prefs.is24Hour
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.buildInsertEventForm
import de.jeanlucmakiola.calendula.ui.RootScreen import de.jeanlucmakiola.calendula.ui.RootScreen
import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines import de.jeanlucmakiola.calendula.ui.common.LocalShowHourLines
import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat import de.jeanlucmakiola.calendula.ui.common.LocalUse24HourFormat
@@ -42,6 +45,7 @@ import de.jeanlucmakiola.calendula.ui.theme.resolveFontFamily
import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
@AndroidEntryPoint @AndroidEntryPoint
@@ -60,6 +64,11 @@ class MainActivity : AppCompatActivity() {
// by CalendarHost's import flow. // by CalendarHost's import flow.
private var requestedImportUri by mutableStateOf<Uri?>(null) private var requestedImportUri by mutableStateOf<Uri?>(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<EventForm?>(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.
@@ -84,6 +93,7 @@ class MainActivity : AppCompatActivity() {
requestedDetailKey = intent.detailKeyOrNull() requestedDetailKey = intent.detailKeyOrNull()
requestedNav = intent.navRequestOrNull() requestedNav = intent.navRequestOrNull()
requestedImportUri = intent.importUriOrNull() requestedImportUri = intent.importUriOrNull()
requestedInsertForm = intent.insertFormOrNull()
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
@@ -132,6 +142,8 @@ class MainActivity : AppCompatActivity() {
onWidgetNavConsumed = { requestedNav = null }, onWidgetNavConsumed = { requestedNav = null },
requestedImportUri = requestedImportUri, requestedImportUri = requestedImportUri,
onImportConsumed = { requestedImportUri = null }, onImportConsumed = { requestedImportUri = null },
requestedInsertForm = requestedInsertForm,
onInsertConsumed = { requestedInsertForm = null },
) )
} }
// A persistent corner marker so a debug build is never // A persistent corner marker so a debug build is never
@@ -169,6 +181,7 @@ class MainActivity : AppCompatActivity() {
intent.detailKeyOrNull()?.let { requestedDetailKey = it } intent.detailKeyOrNull()?.let { requestedDetailKey = it }
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 }
} }
/** /**
@@ -188,6 +201,31 @@ class MainActivity : AppCompatActivity() {
return uri.takeIf { it.scheme == "content" || it.scheme == "file" } 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 * 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/ * "view time" intent: ACTION_VIEW on `content://com.android.calendar/time/

View File

@@ -23,26 +23,29 @@ internal fun ColumnReader.toEventDetailCore(
attendees: List<Attendee>, attendees: List<Attendee>,
reminders: List<Reminder>, reminders: List<Reminder>,
): EventDetail? { ): EventDetail? {
val begin = getLong(EventDetailProjection.IDX_DTSTART) // DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
if (begin < 0L) { // negative — only an *absent* DTSTART marks a malformed row worth dropping.
Log.w(TAG, "Dropping event with negative dtstart=$begin") // 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 return null
} }
val begin = getLong(EventDetailProjection.IDX_DTSTART)
// Recurring events store DURATION instead of DTEND, so the series row's // Recurring events store DURATION instead of DTEND, so the series row's
// DTEND is null. Keep the event (end == begin); callers that opened a // DTEND is null. Keep the event (end == begin); callers that opened a
// specific occurrence supply the real per-occurrence times from // 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)) { val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin begin
} else { } else {
val rawEnd = getLong(EventDetailProjection.IDX_DTEND) getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
if (rawEnd < begin) {
Log.w(TAG, "Dropping event with dtend=$rawEnd < dtstart=$begin")
return null
}
rawEnd
} }
// Kept raw (no untitled fallback): the detail screen substitutes its own // Kept raw (no untitled fallback): the detail screen substitutes its own

View File

@@ -10,8 +10,11 @@ import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
* of DTEND — reconstruct the end the same way the `.ics` export does. * of DTEND — reconstruct the end the same way the `.ics` export does.
*/ */
internal fun ColumnReader.toSearchResult(): EventInstance? { 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) val dtStart = getLong(SearchProjection.IDX_DTSTART)
if (dtStart < 0L) return null
val end = when { val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND) !isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION)) else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))

View File

@@ -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)
}

View File

@@ -72,6 +72,8 @@ fun CalendarHost(
onWidgetNavConsumed: () -> Unit = {}, onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null, requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: EventForm? = null,
onInsertConsumed: () -> 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
@@ -176,6 +178,16 @@ fun CalendarHost(
onImportConsumed() 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 // Close every overlay that can sit over the calendar, so an externally
// requested destination (a widget/shortcut/QS-tile launch) is revealed on // requested destination (a widget/shortcut/QS-tile launch) is revealed on

View File

@@ -35,6 +35,8 @@ fun RootScreen(
onWidgetNavConsumed: () -> Unit = {}, onWidgetNavConsumed: () -> Unit = {},
requestedImportUri: android.net.Uri? = null, requestedImportUri: android.net.Uri? = null,
onImportConsumed: () -> Unit = {}, onImportConsumed: () -> Unit = {},
requestedInsertForm: de.jeanlucmakiola.calendula.domain.EventForm? = null,
onInsertConsumed: () -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
var hasPermission by remember { var hasPermission by remember {
@@ -84,6 +86,8 @@ fun RootScreen(
onWidgetNavConsumed = onWidgetNavConsumed, onWidgetNavConsumed = onWidgetNavConsumed,
requestedImportUri = requestedImportUri, requestedImportUri = requestedImportUri,
onImportConsumed = onImportConsumed, onImportConsumed = onImportConsumed,
requestedInsertForm = requestedInsertForm,
onInsertConsumed = onInsertConsumed,
) )
false -> ReminderOnboardingScreen( false -> ReminderOnboardingScreen(
onFinished = reminderOnboarding::finish, onFinished = reminderOnboarding::finish,

View File

@@ -4,14 +4,9 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton 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.TimePicker
import androidx.compose.material3.rememberTimePickerState import androidx.compose.material3.rememberTimePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.R
import kotlinx.datetime.LocalTime import kotlinx.datetime.LocalTime
@@ -27,10 +22,13 @@ fun TimePickerAlert(
onConfirm: (LocalTime) -> Unit, onConfirm: (LocalTime) -> Unit,
onDismiss: () -> 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( val state = rememberTimePickerState(
initialHour = initial.hour, initialHour = initial.hour,
initialMinute = initial.minute, initialMinute = initial.minute,
is24Hour = deviceUses24HourClock(LocalContext.current), is24Hour = LocalUse24HourFormat.current,
) )
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -45,24 +43,3 @@ fun TimePickerAlert(
text = { TimePicker(state = state) }, 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')
}
}

View File

@@ -22,8 +22,8 @@ class EventDetailMapperTest {
eventColor: Any? = null, eventColor: Any? = null,
eventColorKey: String? = null, eventColorKey: String? = null,
calendarColor: Int = 0xFFAABBCC.toInt(), calendarColor: Int = 0xFFAABBCC.toInt(),
dtstart: Long = 1_000_000_000L, dtstart: Long? = 1_000_000_000L,
dtend: Long = 1_000_003_600L, dtend: Long? = 1_000_003_600L,
allDay: Int = 0, allDay: Int = 0,
location: String? = "Berlin", location: String? = "Berlin",
calendarId: Long = 7L, calendarId: Long = 7L,
@@ -120,9 +120,31 @@ class EventDetailMapperTest {
} }
@Test @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() 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
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 @Test

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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]).