Compare commits
1 Commits
fix/recurr
...
renovate/c
| Author | SHA1 | Date | |
|---|---|---|---|
| 035ce710ae |
@@ -23,29 +23,26 @@ internal fun ColumnReader.toEventDetailCore(
|
|||||||
attendees: List<Attendee>,
|
attendees: List<Attendee>,
|
||||||
reminders: List<Reminder>,
|
reminders: List<Reminder>,
|
||||||
): EventDetail? {
|
): EventDetail? {
|
||||||
// DTSTART is epoch millis in UTC, so a series anchored before 1970 (common
|
val begin = getLong(EventDetailProjection.IDX_DTSTART)
|
||||||
// for yearly birthdays/anniversaries synced over CalDAV) is legitimately
|
|
||||||
// negative — only an *absent* DTSTART marks a malformed row worth dropping.
|
if (begin < 0L) {
|
||||||
// Dropping negatives made every occurrence of such a series un-openable
|
Log.w(TAG, "Dropping event with negative dtstart=$begin")
|
||||||
// (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. A present-but-backwards DTEND is malformed,
|
// CalendarContract.Instances. Only 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 {
|
||||||
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
|
// Kept raw (no untitled fallback): the detail screen substitutes its own
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ 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))
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ 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
|
||||||
@@ -22,13 +27,10 @@ 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 = LocalUse24HourFormat.current,
|
is24Hour = deviceUses24HourClock(LocalContext.current),
|
||||||
)
|
)
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@@ -43,3 +45,24 @@ 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,31 +120,9 @@ class EventDetailMapperTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `dtend before dtstart is clamped to a zero-length event, not dropped`() {
|
fun `dtend before dtstart drops detail`() {
|
||||||
// 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).isNotNull()
|
assertThat(detail).isNull()
|
||||||
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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ coreKtx = "1.19.0"
|
|||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
lifecycleRuntime = "2.10.0"
|
lifecycleRuntime = "2.10.0"
|
||||||
activityCompose = "1.13.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.
|
# 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).
|
# Pin explicitly to override the BOM (which ships stable 1.4.0).
|
||||||
# Re-evaluate when 1.5.0 stable lands.
|
# Re-evaluate when 1.5.0 stable lands.
|
||||||
|
|||||||
Reference in New Issue
Block a user