From 7abb2e6ab4d4c6724bd583e793e6abdf4b114e86 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 8 Jun 2026 17:44:19 +0200 Subject: [PATCH] data: add CalendarDataSource seam returning domain lists (not Cursors) Deviation from Plan 02: changing from Cursor-returning interface to domain-returning interface so the repository unit tests can use a simple fake without constructing ContentObserver/Handler/Looper on the JVM (which would either crash or no-op via the mockable.jar stubs). --- .../data/calendar/CalendarDataSource.kt | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt new file mode 100644 index 0000000..5da24dd --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -0,0 +1,112 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.database.ContentObserver +import android.database.Cursor +import android.os.Handler +import android.os.Looper +import android.provider.CalendarContract +import dagger.hilt.android.qualifiers.ApplicationContext +import de.jeanlucmakiola.calendula.domain.Attendee +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventInstance +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Domain-shaped seam over Android's ContentResolver. Returns parsed lists so + * the repository can be unit-tested without constructing Cursors or + * ContentObservers on the JVM. + * + * Cursor handling and the ContentObserver-to-listener bridge live entirely + * in AndroidCalendarDataSource. + */ +interface CalendarDataSource { + fun calendars(): List + fun instances(beginMillis: Long, endMillis: Long): List + fun eventDetail(eventId: Long): EventDetail? + fun registerChangeListener(listener: () -> Unit) + fun unregisterChangeListener(listener: () -> Unit) +} + +@Singleton +class AndroidCalendarDataSource @Inject constructor( + @ApplicationContext private val context: Context, +) : CalendarDataSource { + + private val resolver: ContentResolver get() = context.contentResolver + private val observers = mutableMapOf<() -> Unit, ContentObserver>() + + override fun calendars(): List = resolver.query( + CalendarContract.Calendars.CONTENT_URI, + CalendarProjection.COLUMNS, + null, null, + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME + " ASC", + )?.use { it.mapAll(::toCalendarSource) } ?: emptyList() + + override fun instances(beginMillis: Long, endMillis: Long): List { + val uri = CalendarContract.Instances.CONTENT_URI.buildUpon().apply { + ContentUris.appendId(this, beginMillis) + ContentUris.appendId(this, endMillis) + }.build() + return resolver.query( + uri, + InstanceProjection.COLUMNS, + null, null, + CalendarContract.Instances.BEGIN + " ASC", + )?.use { c -> c.mapAllNotNull { CursorColumnReader(c).toEventInstance() } } ?: emptyList() + } + + override fun eventDetail(eventId: Long): EventDetail? { + val attendees = queryAttendees(eventId) + return resolver.query( + ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId), + EventDetailProjection.COLUMNS, + null, null, null, + )?.use { c -> + if (!c.moveToFirst()) null + else CursorColumnReader(c).toEventDetailCore(attendees) + } + } + + override fun registerChangeListener(listener: () -> Unit) { + val obs = object : ContentObserver(Handler(Looper.getMainLooper())) { + override fun onChange(selfChange: Boolean) { + listener() + } + } + observers[listener] = obs + resolver.registerContentObserver( + CalendarContract.CONTENT_URI, + /* notifyForDescendants = */ true, + obs, + ) + } + + override fun unregisterChangeListener(listener: () -> Unit) { + observers.remove(listener)?.let { resolver.unregisterContentObserver(it) } + } + + private fun queryAttendees(eventId: Long): List = resolver.query( + CalendarContract.Attendees.CONTENT_URI, + AttendeeProjection.COLUMNS, + CalendarContract.Attendees.EVENT_ID + " = ?", + arrayOf(eventId.toString()), + null, + )?.use { c -> c.mapAll { CursorColumnReader(c).toAttendee() } } ?: emptyList() + + private fun toCalendarSource(c: Cursor): CalendarSource = CursorColumnReader(c).toCalendarSource() + + /** Iterate every row and map; skips nothing. */ + private inline fun Cursor.mapAll(mapper: (Cursor) -> T): List = buildList { + while (moveToNext()) add(mapper(this@mapAll)) + } + + /** Iterate every row and map; drops nulls. */ + private inline fun Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List = buildList { + while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add) + } +}