Share row-reading helpers across the calendar mappers

This commit is contained in:
2026-10-16 13:35:34 +02:00
parent 770d73b529
commit a91ac3a4e5
8 changed files with 62 additions and 68 deletions
@@ -1,6 +1,11 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.database.Cursor
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
/**
* Read-only view over a single row's columns by index. Lets the mappers work
@@ -20,3 +25,33 @@ internal class CursorColumnReader(private val cursor: Cursor) : ColumnReader {
override fun getInt(index: Int): Int = cursor.getInt(index)
override fun isNull(index: Int): Boolean = cursor.isNull(index)
}
internal fun ColumnReader.getIntOrNull(index: Int): Int? = if (isNull(index)) null else getInt(index)
internal fun ColumnReader.getLongOrNull(index: Int): Long? = if (isNull(index)) null else getLong(index)
/**
* The row's end: DTEND, or DTSTART + DURATION for a recurring row, which stores
* no DTEND. Unclamped; a missing/blank both means a zero-length event.
*/
internal fun ColumnReader.endMillis(dtStartMillis: Long, dtEndIndex: Int, durationIndex: Int): Long =
getLongOrNull(dtEndIndex) ?: (dtStartMillis + parseRfc2445DurationMillis(getString(durationIndex)))
/** STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed. */
internal fun ColumnReader.eventStatus(index: Int): EventStatus =
getIntOrNull(index)?.let(::mapEventStatus) ?: EventStatus.Confirmed
/** Map every row, reading each through one [CursorColumnReader]. */
internal inline fun <T> Cursor.mapRows(map: ColumnReader.() -> T): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) add(reader.map()) }
}
/** [mapRows], dropping the rows [map] returns null for. */
internal inline fun <T : Any> Cursor.mapRowsNotNull(map: ColumnReader.() -> T?): List<T> {
val reader = CursorColumnReader(this)
return buildList { while (moveToNext()) reader.map()?.let(::add) }
}
/** The UTC calendar day of [millis] — an all-day row's date, since it sits at UTC midnight. */
internal fun utcDate(millis: Long): LocalDate = Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate()
@@ -15,9 +15,7 @@ import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.Reminder
import de.jeanlucmakiola.calendula.domain.ReminderMethod
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
private const val TAG = "EventDetailMapper"
@@ -48,11 +46,8 @@ internal fun ColumnReader.toEventDetailCore(
// — 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).
val end = if (isNull(EventDetailProjection.IDX_DTEND)) {
begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION))
} else {
getLong(EventDetailProjection.IDX_DTEND).coerceAtLeast(begin)
}
val end = getLongOrNull(EventDetailProjection.IDX_DTEND)?.coerceAtLeast(begin)
?: (begin + parseRfc2445DurationMillis(getString(EventDetailProjection.IDX_DURATION)))
// Kept raw (no untitled fallback): the detail screen substitutes its own
// localized placeholder, and the edit form must prefill the true value.
@@ -61,11 +56,7 @@ internal fun ColumnReader.toEventDetailCore(
// The event's own colour (null = inherits the calendar's) is kept apart
// from the resolved display colour: the edit form needs to tell the two
// cases apart, while the instance carries the calendar fallback for display.
val eventColor = if (isNull(EventDetailProjection.IDX_EVENT_COLOR)) {
null
} else {
getInt(EventDetailProjection.IDX_EVENT_COLOR)
}
val eventColor = getIntOrNull(EventDetailProjection.IDX_EVENT_COLOR)
val eventColorKey = getString(EventDetailProjection.IDX_EVENT_COLOR_KEY)
val color = eventColor ?: getInt(EventDetailProjection.IDX_CALENDAR_COLOR)
@@ -88,7 +79,7 @@ internal fun ColumnReader.toEventDetailCore(
// and detail screen speak. DTSTART is UTC midnight for all-day events, so the
// event's date is its UTC date.
val displayReminders = if (isAllDay) {
val startDate = Instant.ofEpochMilli(begin).atZone(ZoneOffset.UTC).toLocalDate()
val startDate = utcDate(begin)
val zone = ZoneId.systemDefault()
reminders.map {
it.copy(
@@ -101,14 +92,6 @@ internal fun ColumnReader.toEventDetailCore(
reminders
}
// STATUS shares its 0 value with STATUS_TENTATIVE, so a missing column must
// be distinguished from a present 0 — an absent status means "just confirmed".
val status = if (isNull(EventDetailProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventDetailProjection.IDX_STATUS))
}
return EventDetail(
instance = instance,
description = getString(EventDetailProjection.IDX_DESCRIPTION),
@@ -116,7 +99,7 @@ internal fun ColumnReader.toEventDetailCore(
attendees = attendees,
rrule = getString(EventDetailProjection.IDX_RRULE),
reminders = displayReminders,
status = status,
status = eventStatus(EventDetailProjection.IDX_STATUS),
// BUSY and DEFAULT are both 0, so a null column folds into the same
// default these mappers already return — no isNull guard needed.
availability = mapAvailability(getInt(EventDetailProjection.IDX_AVAILABILITY)),
@@ -609,7 +609,7 @@ internal fun exdateAfter(
* optionally behind the parameter prefix AOSP's `RecurrenceSet` puts in front of
* a zoned one ([exdateListZone] reads that half).
*/
private fun exdateStamps(exdate: String?): List<String> = exdate
internal fun exdateStamps(exdate: String?): List<String> = exdate
?.withoutExdateParameters()
?.split(',')
?.map { it.trim() }
@@ -879,7 +879,7 @@ internal fun buildImportedEventValues(
*/
private fun importDuration(startMillis: Long, endMillis: Long, isAllDay: Boolean): String {
val span = (endMillis - startMillis).coerceAtLeast(0)
return if (isAllDay) "P${(span / 86_400_000L).coerceAtLeast(1)}D" else "P${span / 1_000L}S"
return rfc2445Duration(if (isAllDay) span.coerceAtLeast(MILLIS_PER_DAY) else span, isAllDay)
}
private fun EventStatus.toProviderStatus(): Int = when (this) {
@@ -1,13 +1,9 @@
package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventStatus
import de.jeanlucmakiola.calendula.domain.ics.IcsEvent
import de.jeanlucmakiola.calendula.domain.ics.deriveIcsUid
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
/**
* Map one Events row (read through [EventExportProjection]) into an [IcsEvent]
@@ -33,19 +29,8 @@ internal fun ColumnReader.toIcsEvent(
val rrule = getString(EventExportProjection.IDX_RRULE)?.takeIf { it.isNotBlank() }
val isAllDay = getInt(EventExportProjection.IDX_ALL_DAY) != 0
// Recurring rows store DURATION instead of DTEND; reconstruct the end from it
// so the writer can render DTEND. A missing/blank both means a zero-length event.
val end = when {
!isNull(EventExportProjection.IDX_DTEND) -> getLong(EventExportProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(EventExportProjection.IDX_DURATION))
}
// STATUS shares value 0 with TENTATIVE, so an absent column must read as Confirmed.
val status = if (isNull(EventExportProjection.IDX_STATUS)) {
EventStatus.Confirmed
} else {
mapEventStatus(getInt(EventExportProjection.IDX_STATUS))
}
// Unclamped: the writer renders DTEND as stored.
val end = endMillis(dtStart, EventExportProjection.IDX_DTEND, EventExportProjection.IDX_DURATION)
return IcsEvent(
uid = deriveIcsUid(getString(EventExportProjection.IDX_UID), eventId, dtStart),
@@ -68,14 +53,14 @@ internal fun ColumnReader.toIcsEvent(
location = getString(EventExportProjection.IDX_LOCATION),
description = getString(EventExportProjection.IDX_DESCRIPTION),
reminderMinutes = if (isAllDay) {
val startDate = Instant.ofEpochMilli(dtStart).atZone(ZoneOffset.UTC).toLocalDate()
val startDate = utcDate(dtStart)
reminderMinutes.map {
fromProviderAllDayMinutes(it, startDate, zone, allDayReminderTimeMinutes)
}
} else {
reminderMinutes
},
status = status,
status = eventStatus(EventExportProjection.IDX_STATUS),
availability = mapAvailability(getInt(EventExportProjection.IDX_AVAILABILITY)),
calendarName = calendarName,
)
@@ -112,11 +97,7 @@ internal fun exportExDates(
): List<String> {
if (exdate.isNullOrBlank()) return emptyList()
val zone = exdateListZone(exdate, eventTimezone)
return exdate
.withoutExdateParameters()
.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
return exdateStamps(exdate)
.map { stamp ->
if (isAllDay) {
stamp.substringBefore('T')
@@ -25,11 +25,7 @@ internal fun ColumnReader.toEventInstance(): EventInstance? {
// string, so it belongs to the UI layer where it can be localised (#321).
val title = getString(InstanceProjection.IDX_TITLE).orEmpty()
val color = if (isNull(InstanceProjection.IDX_EVENT_COLOR)) {
getInt(InstanceProjection.IDX_CALENDAR_COLOR)
} else {
getInt(InstanceProjection.IDX_EVENT_COLOR)
}
val color = getIntOrNull(InstanceProjection.IDX_EVENT_COLOR) ?: getInt(InstanceProjection.IDX_CALENDAR_COLOR)
return EventInstance(
instanceId = getLong(InstanceProjection.IDX_INSTANCE_ID),
@@ -1,7 +1,16 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.content.ContentUris
import android.net.Uri
import android.provider.CalendarContract
/** The `Instances` table over the window [beginMillis]..[endMillis]. */
internal fun instancesUri(beginMillis: Long, endMillis: Long): Uri =
CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, beginMillis)
ContentUris.appendId(this, endMillis)
}.build()
internal object CalendarProjection {
val COLUMNS: Array<String> = arrayOf(
CalendarContract.Calendars._ID,
@@ -2,7 +2,6 @@ package de.jeanlucmakiola.calendula.data.calendar
import de.jeanlucmakiola.floret.time.toKotlinInstantFromEpochMillis
import de.jeanlucmakiola.calendula.domain.EventInstance
import de.jeanlucmakiola.calendula.domain.ics.parseRfc2445DurationMillis
/**
* Map an Events-table row (a search hit) to an [EventInstance]. Unlike the
@@ -16,17 +15,11 @@ internal fun ColumnReader.toSearchResult(): EventInstance? {
// long-running birthdays/anniversaries still surface in search.
if (isNull(SearchProjection.IDX_DTSTART)) return null
val dtStart = getLong(SearchProjection.IDX_DTSTART)
val end = when {
!isNull(SearchProjection.IDX_DTEND) -> getLong(SearchProjection.IDX_DTEND)
else -> dtStart + parseRfc2445DurationMillis(getString(SearchProjection.IDX_DURATION))
}.coerceAtLeast(dtStart)
val end = endMillis(dtStart, SearchProjection.IDX_DTEND, SearchProjection.IDX_DURATION)
.coerceAtLeast(dtStart)
val title = getString(SearchProjection.IDX_TITLE).orEmpty()
val color = if (isNull(SearchProjection.IDX_EVENT_COLOR)) {
getInt(SearchProjection.IDX_CALENDAR_COLOR)
} else {
getInt(SearchProjection.IDX_EVENT_COLOR)
}
val color = getIntOrNull(SearchProjection.IDX_EVENT_COLOR) ?: getInt(SearchProjection.IDX_CALENDAR_COLOR)
val eventId = getLong(SearchProjection.IDX_ID)
return EventInstance(
@@ -1,9 +1,9 @@
package de.jeanlucmakiola.calendula.data.reminders
import android.content.Context
import android.content.ContentUris
import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.calendar.instancesUri
import de.jeanlucmakiola.calendula.domain.reminders.ReminderEventInstance
import javax.inject.Inject
import javax.inject.Singleton
@@ -37,10 +37,7 @@ class ProviderReminderInstanceSource @Inject constructor(
) : ReminderInstanceSource {
override fun occurrences(fromMillis: Long, toMillis: Long): List<ReminderEventInstance> {
val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply {
ContentUris.appendId(this, fromMillis)
ContentUris.appendId(this, toMillis)
}.build()
val uri = instancesUri(fromMillis, toMillis)
// `visible` is the flag the app's one visibility model writes (#75).
// The status clause mirrors CalendarDataSource.instances: NULL means
// "normal", so a bare `!= CANCELED` would drop every ordinary event.