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).
This commit is contained in:
@@ -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<CalendarSource>
|
||||
fun instances(beginMillis: Long, endMillis: Long): List<EventInstance>
|
||||
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<CalendarSource> = 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<EventInstance> {
|
||||
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<Attendee> = 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 <T> Cursor.mapAll(mapper: (Cursor) -> T): List<T> = buildList {
|
||||
while (moveToNext()) add(mapper(this@mapAll))
|
||||
}
|
||||
|
||||
/** Iterate every row and map; drops nulls. */
|
||||
private inline fun <T : Any> Cursor.mapAllNotNull(mapper: (Cursor) -> T?): List<T> = buildList {
|
||||
while (moveToNext()) mapper(this@mapAllNotNull)?.let(::add)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user