Compare commits

..

1 Commits

Author SHA1 Message Date
035ce710ae fix(deps): update composebom to v2026.06.01
All checks were successful
CI / ci (pull_request) Successful in 10m36s
2026-07-06 05:01:56 +00:00
6 changed files with 44 additions and 94 deletions

View File

@@ -23,29 +23,26 @@ internal fun ColumnReader.toEventDetailCore(
attendees: List<Attendee>,
reminders: List<Reminder>,
): EventDetail? {
// 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")
val begin = getLong(EventDetailProjection.IDX_DTSTART)
if (begin < 0L) {
Log.w(TAG, "Dropping event with negative dtstart=$begin")
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
// specific occurrence supply the real per-occurrence times from
// 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).
// CalendarContract.Instances. Only a present-but-backwards DTEND is malformed.
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
val rawEnd = getLong(EventDetailProjection.IDX_DTEND)
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

View File

@@ -10,11 +10,8 @@ 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))

View File

@@ -4,9 +4,14 @@ 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
@@ -22,13 +27,10 @@ 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 = LocalUse24HourFormat.current,
is24Hour = deviceUses24HourClock(LocalContext.current),
)
AlertDialog(
onDismissRequest = onDismiss,
@@ -43,3 +45,24 @@ 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')
}
}

View File

@@ -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,
@@ -120,31 +120,9 @@ class EventDetailMapperTest {
}
@Test
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.
fun `dtend before dtstart drops detail`() {
val detail = detailReader(dtstart = 2000L, dtend = 1000L).toDetail()
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()
assertThat(detail).isNull()
}
@Test

View File

@@ -1,45 +0,0 @@
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

@@ -7,7 +7,7 @@ coreKtx = "1.19.0"
appcompat = "1.7.1"
lifecycleRuntime = "2.10.0"
activityCompose = "1.13.0"
composeBom = "2026.05.01"
composeBom = "2026.06.01"
# Material 3 Expressive APIs currently live only in the 1.5 alpha line.
# Pin explicitly to override the BOM (which ships stable 1.4.0).
# Re-evaluate when 1.5.0 stable lands.