diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt new file mode 100644 index 0000000..967bea2 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -0,0 +1,16 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.coroutines.flow.Flow +import kotlin.time.Instant + +interface CalendarRepository { + fun calendars(): Flow> + fun instances(range: ClosedRange): Flow> + suspend fun eventDetail(eventId: Long): EventDetail +} + +class NoSuchEventException(eventId: Long) : + NoSuchElementException("No event with id=$eventId") diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt new file mode 100644 index 0000000..182703d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -0,0 +1,62 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import de.jeanlucmakiola.calendula.data.di.IoDispatcher +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext +import kotlin.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +/** + * One ContentResolver-backed observer for the lifetime of the App process. + * Each public flow re-queries on subscribe and after every tick from the + * data source. + */ +@Singleton +class CalendarRepositoryImpl @Inject constructor( + private val dataSource: CalendarDataSource, + @IoDispatcher private val io: CoroutineDispatcher, +) : CalendarRepository { + + private val ticks = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + ) + + init { + dataSource.registerChangeListener { ticks.tryEmit(Unit) } + } + + override fun calendars(): Flow> = + ticks + .onStart { emit(Unit) } + .reQuery { dataSource.calendars() } + .flowOn(io) + + override fun instances(range: ClosedRange): Flow> = + ticks + .onStart { emit(Unit) } + .reQuery { + dataSource.instances( + beginMillis = range.start.toEpochMillis(), + endMillis = range.endInclusive.toEpochMillis(), + ) + } + .flowOn(io) + + override suspend fun eventDetail(eventId: Long): EventDetail = withContext(io) { + dataSource.eventDetail(eventId) ?: throw NoSuchEventException(eventId) + } +} + +private fun Flow.reQuery(block: suspend () -> T): Flow = flow { + collect { emit(block()) } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/Qualifiers.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/Qualifiers.kt new file mode 100644 index 0000000..47ad77d --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/Qualifiers.kt @@ -0,0 +1,7 @@ +package de.jeanlucmakiola.calendula.data.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt new file mode 100644 index 0000000..b168b02 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImplTest.kt @@ -0,0 +1,111 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventInstance +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.time.Instant +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CalendarRepositoryImplTest { + + private fun makeCal(id: Long, name: String = "Cal $id"): CalendarSource = + CalendarSource(id, name, "x@y", "LOCAL", 0xFF112233.toInt(), true) + + private fun makeEvent(id: Long, title: String = "E $id"): EventInstance = EventInstance( + instanceId = id, eventId = id, calendarId = 1L, + title = title, + start = Instant.fromEpochMilliseconds(1_000_000_000L), + end = Instant.fromEpochMilliseconds(1_000_003_600L), + isAllDay = false, color = 0xFF000000.toInt(), location = null, + ) + + @Test + fun `calendars emits initial query result on subscribe`() = runTest { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(makeCal(1L), makeCal(2L)) + } + val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler)) + + repo.calendars().test { + val first = awaitItem() + assertThat(first.map { it.id }).containsExactly(1L, 2L).inOrder() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `calendars re-emits after change listener tick`() = runTest { + val fake = FakeCalendarDataSource().apply { + calendarsResult = listOf(makeCal(1L)) + } + val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler)) + + repo.calendars().test { + assertThat(awaitItem().map { it.id }).containsExactly(1L) + + fake.calendarsResult = listOf(makeCal(1L), makeCal(2L)) + fake.tick() + + assertThat(awaitItem().map { it.id }).containsExactly(1L, 2L).inOrder() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `instances forwards epoch-millis bounds to data source`() = runTest { + var observedBegin: Long? = null + var observedEnd: Long? = null + val fake = FakeCalendarDataSource().apply { + instancesResult = {b, e -> + observedBegin = b + observedEnd = e + listOf(makeEvent(10L)) + } + } + val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler)) + + val range = Instant.fromEpochMilliseconds(1_000L)..Instant.fromEpochMilliseconds(2_000L) + repo.instances(range).test { + awaitItem() + cancelAndIgnoreRemainingEvents() + } + assertThat(observedBegin).isEqualTo(1_000L) + assertThat(observedEnd).isEqualTo(2_000L) + } + + @Test + fun `instances passes-through whatever the data source returns`() = runTest { + val fake = FakeCalendarDataSource().apply { + instancesResult = {_, _ -> listOf(makeEvent(10L, "Good")) } + } + val repo = CalendarRepositoryImpl(fake, UnconfinedTestDispatcher(testScheduler)) + + val range = Instant.fromEpochMilliseconds(0)..Instant.fromEpochMilliseconds(10_000L) + repo.instances(range).test { + val first = awaitItem() + assertThat(first.map { it.title }).containsExactly("Good") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `eventDetail throws NoSuchEventException when data source returns null`() = runTest { + val fake = FakeCalendarDataSource().apply { + eventDetailResult = { null } + } + val repo = CalendarRepositoryImpl(fake, Dispatchers.Unconfined) + + try { + repo.eventDetail(eventId = 999L) + error("Expected NoSuchEventException") + } catch (expected: NoSuchEventException) { + assertThat(expected.message).contains("999") + } + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt new file mode 100644 index 0000000..7c98cd7 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -0,0 +1,33 @@ +package de.jeanlucmakiola.calendula.data.calendar + +import de.jeanlucmakiola.calendula.domain.CalendarSource +import de.jeanlucmakiola.calendula.domain.EventDetail +import de.jeanlucmakiola.calendula.domain.EventInstance + +/** + * Test-only fake. Tunable via the three `var` properties; `tick()` simulates + * a provider change so the repository re-queries. + */ +internal class FakeCalendarDataSource : CalendarDataSource { + + var calendarsResult: List = emptyList() + var instancesResult: (Long, Long) -> List = { _, _ -> emptyList() } + var eventDetailResult: (Long) -> EventDetail? = { null } + + private val listeners = mutableListOf<() -> Unit>() + + override fun calendars(): List = calendarsResult + override fun instances(beginMillis: Long, endMillis: Long): List = + instancesResult(beginMillis, endMillis) + override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId) + override fun registerChangeListener(listener: () -> Unit) { + listeners += listener + } + override fun unregisterChangeListener(listener: () -> Unit) { + listeners -= listener + } + + fun tick() { + listeners.forEach { it() } + } +}