feat(#303, #240): nudge sync adapters after writes, add Sync now to the drawer

This commit is contained in:
makiolaj committed 2026-11-13 18:27:51 +01:00
1 parent e9cf7d66ba
commit fb5cb226ee
14 files changed
+594 -20

No files matched your search

@@ -301,6 +301,12 @@ interface CalendarDataSource {
*/
fun deleteOccurrence(eventId: Long, beginMillis: Long)
/** The account [calendarId] belongs to; null when the row is gone or unreadable. */
fun calendarAccount(calendarId: Long): SyncAccount?
/** The calendar event [eventId] lives in; null when the row is gone or unreadable. */
fun eventCalendarId(eventId: Long): Long?
fun registerChangeListener(listener: () -> Unit)
fun unregisterChangeListener(listener: () -> Unit)
}
@@ -881,8 +887,7 @@ class AndroidCalendarDataSource @Inject constructor(
return eventId
}
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
override fun calendarAccount(calendarId: Long): SyncAccount? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
arrayOf(
CalendarContract.Calendars.ACCOUNT_NAME,
@@ -891,13 +896,17 @@ class AndroidCalendarDataSource @Inject constructor(
null, null, null,
)?.use { c ->
if (c.moveToFirst()) {
CalendarAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
SyncAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
} else {
null
}
}
private data class CalendarAccount(val name: String, val type: String)
override fun eventCalendarId(eventId: Long): Long? = resolver.query(
ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId),
arrayOf(CalendarContract.Events.CALENDAR_ID),
null, null, null,
)?.use { c -> if (c.moveToFirst() && !c.isNull(0)) c.getLong(0) else null }
/**
* The raw provider `MINUTES` to store for one of [form]'s reminders: an
@@ -134,6 +134,15 @@ interface CalendarRepository {
/** Delete a recurring event from the occurrence at [beginMillis] onwards. */
suspend fun deleteEventFromOccurrence(eventId: Long, beginMillis: Long)
/**
* Ask every account behind a visible synced calendar to sync now (#240).
* Only a request: the adapter may still run it late, or fail.
*/
suspend fun requestSync(): SyncRequestResult = SyncRequestResult.NothingToSync
/** Every provider notification and [refresh], including the app's own writes. */
val providerChanges: Flow<Unit> get() = emptyFlow()
}
class NoSuchEventException(eventId: Long) :
@@ -47,6 +47,7 @@ class CalendarRepositoryImpl @Inject constructor(
private val prefs: CalendarPrefs,
private val settingsPrefs: SettingsPrefs,
@IoDispatcher private val io: CoroutineDispatcher,
private val syncRequester: SyncRequester,
) : CalendarRepository {
/** The configured wall-clock fire time for all-day reminders, read per write. */
@@ -84,13 +85,63 @@ class CalendarRepositoryImpl @Inject constructor(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Run a provider write on [io], then re-query whether or not it threw. */
private suspend fun <T> write(block: suspend () -> T): T = withContext(io) {
override val providerChanges: Flow<Unit> get() = ticks
/**
* Run a provider write on [io], then re-query whether or not it threw. The
* accounts behind [syncCalendars] (read before the write, which may delete
* the row) get one sync nudge each afterwards (#303) — once per write, so a
* multi-row save or a whole import asks once per account.
*/
private suspend fun <T> write(
syncCalendars: suspend () -> Collection<Long?> = { emptyList() },
block: suspend () -> T,
): T = withContext(io) {
val accounts = syncAccountsOf(syncCalendars)
try {
block()
} finally {
refresh()
writes.tryEmit(Unit)
accounts.forEach { requestSync(it, userInitiated = false) }
}
}
private suspend fun syncAccountsOf(calendarIds: suspend () -> Collection<Long?>): Set<SyncAccount> =
try {
calendarIds().filterNotNull().distinct()
.mapNotNull { dataSource.calendarAccount(it) }
.filterTo(linkedSetOf()) { it.isSyncable }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "No sync nudge: the calendar account could not be read", e)
emptySet()
}
private fun requestSync(account: SyncAccount, userInitiated: Boolean): Boolean = try {
syncRequester.requestSync(account, userInitiated)
} catch (e: Exception) {
Log.w(TAG, "Sync request failed", e)
false
}
private fun eventCalendar(eventId: Long): suspend () -> Collection<Long?> =
{ listOf(dataSource.eventCalendarId(eventId)) }
override suspend fun requestSync(): SyncRequestResult = withContext(io) {
val accounts = try {
dataSource.calendars().syncAccounts()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Sync request skipped: calendars unreadable", e)
return@withContext SyncRequestResult.Failed
}
when {
accounts.isEmpty() -> SyncRequestResult.NothingToSync
accounts.count { requestSync(it, userInitiated = true) } > 0 -> SyncRequestResult.Requested
else -> SyncRequestResult.Failed
}
}
@@ -253,7 +304,7 @@ class CalendarRepositoryImpl @Inject constructor(
override suspend fun importEvents(
targetCalendarId: Long,
events: List<ParsedIcsEvent>,
): IcsImportSummary = write {
): IcsImportSummary = write({ listOf(targetCalendarId) }) {
val existing = dataSource.existingUids(targetCalendarId)
// Both are per-calendar, not per-event: looking them up once keeps a
// thousand-event restore to two extra queries. The palette is the
@@ -304,7 +355,7 @@ class CalendarRepositoryImpl @Inject constructor(
)
}
override suspend fun createEvent(form: EventForm): Long = write {
override suspend fun createEvent(form: EventForm): Long = write({ listOf(form.calendarId) }) {
dataSource.insertEvent(form, allDayReminderTimeMinutes())
}
@@ -312,11 +363,11 @@ class CalendarRepositoryImpl @Inject constructor(
eventId: Long,
original: EventForm,
updated: EventForm,
) = write {
) = write({ listOf(dataSource.eventCalendarId(eventId), updated.calendarId) }) {
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
}
override suspend fun deleteEvent(eventId: Long) = write {
override suspend fun deleteEvent(eventId: Long) = write(eventCalendar(eventId)) {
dataSource.deleteEvent(eventId)
}
@@ -325,7 +376,7 @@ class CalendarRepositoryImpl @Inject constructor(
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
): Long = write {
): Long = write({ listOf(dataSource.eventCalendarId(eventId), targetCalendarId) }) {
dataSource.moveEvent(
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
)
@@ -336,7 +387,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long,
original: EventForm,
form: EventForm,
): Long = write {
): Long = write(eventCalendar(eventId)) {
dataSource.updateOccurrence(
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
)
@@ -347,7 +398,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long = write {
): Long = write(eventCalendar(eventId)) {
dataSource.updateEventFromOccurrence(
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
)
@@ -357,14 +408,14 @@ class CalendarRepositoryImpl @Inject constructor(
override val seriesSplits: Flow<SeriesSplit> = splits
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write(eventCalendar(eventId)) {
dataSource.deleteOccurrence(eventId, beginMillis)
}
override suspend fun deleteEventFromOccurrence(
eventId: Long,
beginMillis: Long,
) = write {
) = write(eventCalendar(eventId)) {
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
}
@@ -0,0 +1,57 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.accounts.Account
import android.content.ContentResolver
import android.os.Bundle
import android.provider.CalendarContract
import android.util.Log
import de.jeanlucmakiola.calendula.domain.CalendarSource
import javax.inject.Inject
import javax.inject.Singleton
/** The account behind a calendar row (`ACCOUNT_NAME` / `ACCOUNT_TYPE`). */
data class SyncAccount(val name: String, val type: String) {
/** Whether a sync adapter can stand behind it: device-only calendars have none. */
val isSyncable: Boolean
get() = name.isNotBlank() && type.isNotBlank() && type != CalendarContract.ACCOUNT_TYPE_LOCAL
}
/**
* Asks the system to run a calendar sync adapter sooner (#303, #240). The sync
* itself stays the adapter's job; this only schedules it.
*/
interface SyncRequester {
/**
* Returns whether the request reached the system. [userInitiated] marks it
* manual, which also overrides master-sync-off — only for a user's tap.
*/
fun requestSync(account: SyncAccount, userInitiated: Boolean): Boolean
}
@Singleton
class AndroidSyncRequester @Inject constructor() : SyncRequester {
override fun requestSync(account: SyncAccount, userInitiated: Boolean): Boolean = try {
val extras = Bundle().apply {
putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true)
if (userInitiated) putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true)
}
ContentResolver.requestSync(
Account(account.name, account.type),
CalendarContract.AUTHORITY,
extras,
)
true
} catch (e: Exception) {
Log.w("SyncRequester", "Sync request refused", e)
false
}
}
/** The distinct accounts behind the visible, synced calendars — what a manual refresh asks. */
fun List<CalendarSource>.syncAccounts(): Set<SyncAccount> =
filter { !it.isLocal && it.isVisibleInSystem }
.map { SyncAccount(it.accountName, it.accountType) }
.filterTo(linkedSetOf()) { it.isSyncable }
/** Outcome of a user-triggered sync request (#240). */
enum class SyncRequestResult { Requested, NothingToSync, Failed }
@@ -11,9 +11,11 @@ import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import de.jeanlucmakiola.calendula.data.calendar.AndroidCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.AndroidSyncRequester
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.SyncRequester
import de.jeanlucmakiola.calendula.data.contacts.AndroidContactSpecialDatesDataSource
import de.jeanlucmakiola.calendula.data.contacts.AndroidSpecialDatesCalendarSpec
import de.jeanlucmakiola.calendula.data.contacts.ContactSpecialDatesDataSource
@@ -49,6 +51,12 @@ abstract class DataBindModule {
impl: CalendarRepositoryImpl,
): CalendarRepository
@Binds
@Singleton
abstract fun bindSyncRequester(
impl: AndroidSyncRequester,
): SyncRequester
@Binds
@Singleton
abstract fun bindReminderInstanceSource(
@@ -35,6 +35,7 @@ import androidx.compose.runtime.saveable.listSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import de.jeanlucmakiola.calendula.ui.common.rememberHeld
import de.jeanlucmakiola.calendula.ui.common.screen
import de.jeanlucmakiola.calendula.ui.common.ViewScreen
@@ -52,6 +53,9 @@ import de.jeanlucmakiola.calendula.ui.calendars.CalendarsScreen
import de.jeanlucmakiola.calendula.ui.common.BelowTopBarClip
import de.jeanlucmakiola.calendula.ui.common.StayingPut
import de.jeanlucmakiola.calendula.ui.common.CalendarDrawer
import de.jeanlucmakiola.calendula.ui.common.DrawerSync
import de.jeanlucmakiola.calendula.ui.common.SyncViewModel
import de.jeanlucmakiola.calendula.ui.common.messageRes
import de.jeanlucmakiola.calendula.ui.common.CalendarFab
import de.jeanlucmakiola.calendula.ui.common.CalendarTopBar
import de.jeanlucmakiola.calendula.ui.common.ChromeHost
@@ -441,6 +445,13 @@ fun CalendarHost(
val eventActions: EventActionsViewModel = hiltViewModel()
val moveOutcome by reschedule.outcome.collectAsStateWithLifecycle()
val selectionFailure by eventActions.failure.collectAsStateWithLifecycle()
// "Sync now" in the drawer (#240); its receipt shares the move chip.
val sync: SyncViewModel = hiltViewModel()
val syncAvailable by sync.available.collectAsStateWithLifecycle()
val syncInFlight by sync.inFlight.collectAsStateWithLifecycle()
val syncResult by sync.result.collectAsStateWithLifecycle()
val selectionFailureMessage = eventActionFailureMessage(selectionFailure)
val syncMessage = syncResult?.let { stringResource(it.messageRes()) }
val onEditEvent: (EventInstance) -> Unit = { event -> editKey = event.occurrenceKey() }
// A tap opens the edit form instead of the detail screen when the user chose
// so (#178) — only for events whose calendar takes writes. Both are read at
@@ -588,6 +599,13 @@ fun CalendarHost(
onOpenSettings()
switchScope.launch { drawerState.close() }
},
sync = DrawerSync(
inFlight = syncInFlight,
onSync = {
sync.requestSync()
switchScope.launch { drawerState.close() }
},
).takeIf { syncAvailable },
)
},
) {
@@ -613,10 +631,11 @@ fun CalendarHost(
// A fresh event, like a duplicate from the detail screen (#52).
openImportForm(form, ImportSource.Insert)
},
chipShown = moveOutcome != null || selectionFailure != null,
chipShown = moveOutcome != null || selectionFailure != null || syncResult != null,
onDismissChip = {
reschedule.consumeOutcome()
eventActions.consumeFailure()
sync.consumeResult()
},
)
CalendarFab(chromeHost, todayInToolbar, fabSelection)
@@ -679,8 +698,10 @@ fun CalendarHost(
EventMoveHost(
reschedule,
modifier = Modifier.fillMaxSize(),
actionFailure = eventActionFailureMessage(selectionFailure),
onActionFailureShown = eventActions::consumeFailure,
actionFailure = selectionFailureMessage ?: syncMessage,
onActionFailureShown = {
if (selectionFailureMessage != null) eventActions.consumeFailure() else sync.consumeResult()
},
)
// The delete prompt and failure chip of a selected event's actions (#141).
@@ -20,8 +20,11 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Sync
import androidx.compose.material3.DrawerState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.Text
@@ -61,6 +64,7 @@ import kotlinx.datetime.LocalDate
*
* [currentDate] seeds the jump-to-date picker (the visible day/week-start/month
* anchor); [onJumpToDate] navigates the active view to the chosen day.
* [sync] adds "Sync now" beside it while there is a synced calendar (#240).
*/
@Composable
fun CalendarDrawer(
@@ -71,6 +75,7 @@ fun CalendarDrawer(
onJumpToDate: (LocalDate) -> Unit,
onSettings: () -> Unit,
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
sync: DrawerSync? = null,
) {
var showDatePicker by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
@@ -103,11 +108,12 @@ fun CalendarDrawer(
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.drawer_jump_to_date),
position = Position.Alone,
position = if (sync != null) Position.Top else Position.Alone,
minHeight = 56.dp,
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
onClick = { showDatePicker = true },
)
if (sync != null) SyncNowRow(sync)
Spacer(Modifier.height(16.dp))
@@ -138,6 +144,26 @@ fun CalendarDrawer(
}
}
/** The drawer's "Sync now" state: [inFlight] shows progress and ignores taps. */
data class DrawerSync(val inFlight: Boolean, val onSync: () -> Unit)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun SyncNowRow(sync: DrawerSync) {
GroupedRow(
title = stringResource(R.string.drawer_sync_now),
position = Position.Bottom,
minHeight = 56.dp,
leading = { DrawerLeadingIcon(Icons.Filled.Sync) },
trailing = if (sync.inFlight) {
{ LoadingIndicator(Modifier.size(32.dp)) }
} else {
null
},
onClick = { if (!sync.inFlight) sync.onSync() },
)
}
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
@Composable
@@ -0,0 +1,82 @@
package de.jeanlucmakiola.calendula.ui.common
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.SyncRequestResult
import de.jeanlucmakiola.calendula.data.calendar.syncAccounts
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
/**
* The drawer's "Sync now" (#240): asks the sync adapters of every visible synced
* calendar to run now. [inFlight] holds until the provider next reports a change
* or [SETTLE_MILLIS] pass — the request itself says nothing about when, or
* whether, the sync completes.
*/
@HiltViewModel
class SyncViewModel @Inject constructor(
private val repository: CalendarRepository,
) : ViewModel() {
/** Whether any visible calendar has a sync adapter behind it. */
val available: StateFlow<Boolean> = repository.calendars()
.map { it.syncAccounts().isNotEmpty() }
.catch { emit(false) }
.stateWhileSubscribed(scope = viewModelScope, initialValue = false)
private val _inFlight = MutableStateFlow(false)
val inFlight: StateFlow<Boolean> = _inFlight.asStateFlow()
private val _result = MutableStateFlow<SyncRequestResult?>(null)
/** The last request's outcome, until [consumeResult]. */
val result: StateFlow<SyncRequestResult?> = _result.asStateFlow()
fun requestSync() {
if (_inFlight.value) return
_inFlight.value = true
viewModelScope.launch {
try {
// Listening before asking, so a sync that lands at once still counts.
val settled = async(start = CoroutineStart.UNDISPATCHED) {
withTimeoutOrNull(SETTLE_MILLIS) { repository.providerChanges.first() }
}
val outcome = repository.requestSync()
_result.value = outcome
if (outcome == SyncRequestResult.Requested) settled.await() else settled.cancel()
} finally {
_inFlight.value = false
}
}
}
fun consumeResult() {
_result.value = null
}
private companion object {
const val SETTLE_MILLIS = 15_000L
}
}
/** The chip text for a sync request's outcome. */
@StringRes
fun SyncRequestResult.messageRes(): Int = when (this) {
SyncRequestResult.Requested -> R.string.sync_requested
SyncRequestResult.NothingToSync -> R.string.sync_nothing_to_sync
SyncRequestResult.Failed -> R.string.sync_request_failed
}
+4
View File
@@ -395,6 +395,10 @@
<!-- Jump to date (drawer) -->
<string name="drawer_jump_to_date">Jump to date</string>
<string name="drawer_sync_now">Sync now</string>
<string name="sync_requested">Sync requested</string>
<string name="sync_request_failed">Couldn\'t request a sync</string>
<string name="sync_nothing_to_sync">No synced calendars to sync</string>
<!-- Agenda view -->
<string name="agenda_today_action">Today</string>
@@ -5,6 +5,8 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import de.jeanlucmakiola.calendula.data.calendar.CalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepositoryImpl
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
import de.jeanlucmakiola.calendula.data.calendar.SyncRequester
import de.jeanlucmakiola.calendula.data.prefs.CalendarPrefs
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
import kotlinx.coroutines.CoroutineDispatcher
@@ -43,7 +45,20 @@ fun testRepository(
io: CoroutineDispatcher,
prefs: CalendarPrefs = testPrefs(dir),
settings: SettingsPrefs = testSettings(dir),
): CalendarRepositoryImpl = CalendarRepositoryImpl(source, prefs, settings, io)
syncRequester: SyncRequester = RecordingSyncRequester(),
): CalendarRepositoryImpl = CalendarRepositoryImpl(source, prefs, settings, io, syncRequester)
/** Records every sync request; [accepts] false models the system refusing one. */
class RecordingSyncRequester(var accepts: Boolean = true) : SyncRequester {
data class Request(val account: SyncAccount, val userInitiated: Boolean)
val requests = mutableListOf<Request>()
override fun requestSync(account: SyncAccount, userInitiated: Boolean): Boolean {
requests += Request(account, userInitiated)
return accepts
}
}
/** Installs [dispatcher] as `Dispatchers.Main` around each test. */
@OptIn(ExperimentalCoroutinesApi::class)
@@ -64,6 +64,15 @@ internal class FakeCalendarDataSource : CalendarDataSource {
val updatedCalendars = mutableListOf<UpdatedCalendar>()
val deletedCalendarIds = mutableListOf<Long>()
/** eventId → calendarId, for [eventCalendarId]. */
val eventCalendars = mutableMapOf<Long, Long>()
override fun calendarAccount(calendarId: Long): SyncAccount? =
calendarsResult.firstOrNull { it.id == calendarId }
?.let { SyncAccount(it.accountName, it.accountType) }
override fun eventCalendarId(eventId: Long): Long? = eventCalendars[eventId]
private val listeners = mutableListOf<() -> Unit>()
/** How often [calendars] was queried — the repository shares one read per tick. */
@@ -0,0 +1,190 @@
package de.jeanlucmakiola.calendula.data.calendar
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.RecordingSyncRequester
import de.jeanlucmakiola.calendula.RecordingSyncRequester.Request
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.domain.EventForm
import de.jeanlucmakiola.calendula.domain.FormTime
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import de.jeanlucmakiola.calendula.testRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.time.Instant
class SyncNudgeTest {
private val dav = SyncAccount("me@dav", "bitfire.at.davdroid")
private val google = SyncAccount("me@gmail.com", "com.google")
private fun cal(id: Long, account: SyncAccount, visible: Boolean = true) = CalendarSource(
id = id,
displayName = "Cal $id",
accountName = account.name,
accountType = account.type,
color = 0,
isVisibleInSystem = visible,
isLocal = account.type == "LOCAL",
)
private val local = SyncAccount("Calendula", "LOCAL")
private fun fake() = FakeCalendarDataSource().apply {
calendarsResult = listOf(cal(1L, dav), cal(2L, dav), cal(3L, google), cal(4L, local))
eventCalendars += mapOf(10L to 1L, 30L to 3L, 40L to 4L)
}
private fun form(calendarId: Long) = EventForm(
calendarId = calendarId,
title = "Stand-up",
start = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 0))),
end = FormTime(LocalDateTime(LocalDate(2026, 6, 12), LocalTime(9, 15))),
)
private fun auto(account: SyncAccount) = Request(account, userInitiated = false)
@Test
fun `a write to a synced calendar nudges its account once, not manually`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester()
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
repo.createEvent(form(1L))
assertThat(sync.requests).containsExactly(auto(dav))
}
@Test
fun `a write to a local calendar asks for no sync`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester()
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
repo.createEvent(form(4L))
repo.deleteEvent(40L)
assertThat(sync.requests).isEmpty()
}
@Test
fun `event writes resolve the account from the event's calendar`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester()
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
repo.updateEventFromOccurrence(10L, 0L, form(1L), form(1L))
repo.deleteOccurrence(30L, 0L)
assertThat(sync.requests).containsExactly(auto(dav), auto(google)).inOrder()
}
@Test
fun `a move between accounts nudges both, each once`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester()
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
repo.moveEvent(10L, 3L, form(1L), form(3L))
assertThat(sync.requests).containsExactly(auto(dav), auto(google))
}
@Test
fun `a move between calendars of one account nudges it once`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester()
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
repo.moveEvent(10L, 2L, form(1L), form(2L))
assertThat(sync.requests).containsExactly(auto(dav))
}
@Test
fun `an import nudges once at the end, however many events`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester()
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
val events = (1..5).map {
ParsedIcsEvent(
uid = "u$it",
summary = "E$it",
start = Instant.fromEpochMilliseconds(1_000_000_000L),
end = Instant.fromEpochMilliseconds(1_000_003_600L),
isAllDay = false,
zoneId = "UTC",
)
}
repo.importEvents(3L, events)
assertThat(sync.requests).containsExactly(auto(google))
}
@Test
fun `a failed write still nudges, since part of it may have landed`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester()
val source = fake().apply { writeError = IllegalStateException("boom") }
val repo = testRepository(source, dir, Dispatchers.Unconfined, syncRequester = sync)
assertThrows<IllegalStateException> { repo.deleteEvent(10L) }
assertThat(sync.requests).containsExactly(auto(dav))
}
@Test
fun `a throwing requester never fails the save`(@TempDir dir: Path) = runTest {
val throwing = object : SyncRequester {
override fun requestSync(account: SyncAccount, userInitiated: Boolean): Boolean =
throw SecurityException("no")
}
val source = fake().apply { nextInsertId = 77L }
val repo = testRepository(source, dir, Dispatchers.Unconfined, syncRequester = throwing)
assertThat(repo.createEvent(form(1L))).isEqualTo(77L)
}
@Test
fun `manual sync asks each visible synced account once, as manual`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester()
val source = fake().apply {
calendarsResult = calendarsResult + cal(5L, SyncAccount("hidden@dav", "bitfire.at.davdroid"), visible = false)
}
val repo = testRepository(source, dir, Dispatchers.Unconfined, syncRequester = sync)
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.Requested)
assertThat(sync.requests).containsExactly(Request(dav, true), Request(google, true))
}
@Test
fun `manual sync with only local calendars has nothing to sync`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester()
val source = FakeCalendarDataSource().apply { calendarsResult = listOf(cal(4L, local)) }
val repo = testRepository(source, dir, Dispatchers.Unconfined, syncRequester = sync)
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.NothingToSync)
assertThat(sync.requests).isEmpty()
}
@Test
fun `manual sync the system refuses reports a failure`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester(accepts = false)
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.Failed)
}
@Test
fun `syncAccounts skips local, invisible and blank accounts`() {
val calendars = listOf(
cal(1L, dav),
cal(2L, dav),
cal(3L, google, visible = false),
cal(4L, local),
cal(5L, SyncAccount("", "com.google")),
)
assertThat(calendars.syncAccounts()).containsExactly(dav)
}
}
@@ -0,0 +1,87 @@
package de.jeanlucmakiola.calendula.ui.common
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.MainDispatcherExtension
import de.jeanlucmakiola.calendula.RecordingSyncRequester
import de.jeanlucmakiola.calendula.data.calendar.FakeCalendarDataSource
import de.jeanlucmakiola.calendula.data.calendar.SyncRequestResult
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.testRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
@OptIn(ExperimentalCoroutinesApi::class)
class SyncViewModelTest {
@JvmField @RegisterExtension
val mainDispatcher = MainDispatcherExtension(StandardTestDispatcher())
private val dispatcher = mainDispatcher.dispatcher
private val synced = CalendarSource(1L, "Work", "me@dav", "bitfire.at.davdroid", 0, true)
private fun viewModel(dir: Path, sync: RecordingSyncRequester = RecordingSyncRequester()):
Pair<SyncViewModel, FakeCalendarDataSource> {
val source = FakeCalendarDataSource().apply { calendarsResult = listOf(synced) }
return SyncViewModel(testRepository(source, dir, dispatcher, syncRequester = sync)) to source
}
@Test
fun `in flight until the provider next reports a change`(@TempDir dir: Path) = runTest(dispatcher) {
val (vm, source) = viewModel(dir)
vm.requestSync()
runCurrent()
assertThat(vm.inFlight.value).isTrue()
assertThat(vm.result.value).isEqualTo(SyncRequestResult.Requested)
source.tick()
runCurrent()
assertThat(vm.inFlight.value).isFalse()
}
@Test
fun `in flight ends on its own when nothing changes`(@TempDir dir: Path) = runTest(dispatcher) {
val (vm, _) = viewModel(dir)
vm.requestSync()
advanceTimeBy(15_001L)
assertThat(vm.inFlight.value).isFalse()
}
@Test
fun `a tap while in flight asks only once`(@TempDir dir: Path) = runTest(dispatcher) {
val sync = RecordingSyncRequester()
val (vm, _) = viewModel(dir, sync)
vm.requestSync()
runCurrent()
vm.requestSync()
runCurrent()
assertThat(sync.requests).hasSize(1)
}
@Test
fun `a refused request stops at once and says so`(@TempDir dir: Path) = runTest(dispatcher) {
val (vm, _) = viewModel(dir, RecordingSyncRequester(accepts = false))
vm.requestSync()
runCurrent()
assertThat(vm.inFlight.value).isFalse()
assertThat(vm.result.value).isEqualTo(SyncRequestResult.Failed)
vm.consumeResult()
assertThat(vm.result.value).isNull()
}
}
+6
View File
@@ -10,6 +10,12 @@ the package list (recurring writes, save conflicts, reminder delivery).
1. **`CalendarContract` is the single source of truth.** No app database,
no caching layer, no sync code. Reads query the provider; writes go
straight back to it. Sync is DAVx5's / Google's / the system's job.
The app only *asks* for one through `ContentResolver.requestSync`: an
expedited nudge for the account after each of its own writes to a synced
calendar (#303, once per write, so a multi-row save or an import asks once
per account, and never `MANUAL`, which would override master-sync-off), and
a manual request from the drawer's "Sync now" (#240). Both go through
`SyncRequester`; whether and when the adapter syncs stays its call.
2. **Observer-driven UI.** A `ContentObserver` on the provider triggers
re-queries; every screen recomposes from fresh provider state. After a
write, nothing is patched by hand — the views refresh from the provider.