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