diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt index 12286e3..3804f8b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/ColumnReader.kt @@ -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 Cursor.mapRows(map: ColumnReader.() -> T): List { + val reader = CursorColumnReader(this) + return buildList { while (moveToNext()) add(reader.map()) } +} + +/** [mapRows], dropping the rows [map] returns null for. */ +internal inline fun Cursor.mapRowsNotNull(map: ColumnReader.() -> T?): List { + 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() 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 df22fa9..51b8ea8 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 @@ -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)), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt index fe5903f..98dbe3c 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/EventWriteMapper.kt @@ -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 = exdate +internal fun exdateStamps(exdate: String?): List = 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) { diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt index d20dad8..1657e5b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/IcsExportMapper.kt @@ -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 { 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') diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt index a1a9a18..e8b49c6 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/InstanceMapper.kt @@ -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), diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt index 47caa39..e9dae51 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/Projections.kt @@ -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 = arrayOf( CalendarContract.Calendars._ID, 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 441f8c1..1b7dcf2 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 @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt index 6b13564..29b140d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/reminders/ReminderInstanceSource.kt @@ -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 { - 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.