From fb5cb226ee723e1fd3dbbc9fc37047e159e0e2d9 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 13 Nov 2026 18:27:51 +0100 Subject: [PATCH] feat(#303, #240): nudge sync adapters after writes, add Sync now to the drawer --- .../data/calendar/CalendarDataSource.kt | 17 +- .../data/calendar/CalendarRepository.kt | 9 + .../data/calendar/CalendarRepositoryImpl.kt | 73 ++++++- .../calendula/data/calendar/SyncRequester.kt | 57 ++++++ .../calendula/data/di/DataModule.kt | 8 + .../calendula/ui/CalendarHost.kt | 27 ++- .../calendula/ui/common/CalendarDrawer.kt | 28 ++- .../calendula/ui/common/SyncViewModel.kt | 82 ++++++++ app/src/main/res/values/strings.xml | 4 + .../calendula/TestRepositories.kt | 17 +- .../data/calendar/FakeCalendarDataSource.kt | 9 + .../calendula/data/calendar/SyncNudgeTest.kt | 190 ++++++++++++++++++ .../calendula/ui/common/SyncViewModelTest.kt | 87 ++++++++ docs/ARCHITECTURE.md | 6 + 14 files changed, 594 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SyncRequester.kt create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModel.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SyncNudgeTest.kt create mode 100644 app/src/test/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModelTest.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 index 7f96a33e..1cfd6f4b 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarDataSource.kt @@ -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 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 index 3d1308c7..7f5f8204 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepository.kt @@ -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 get() = emptyFlow() } class NoSuchEventException(eventId: Long) : 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 index 481ae7a4..b8c4da8e 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/CalendarRepositoryImpl.kt @@ -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 write(block: suspend () -> T): T = withContext(io) { + override val providerChanges: Flow 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 write( + syncCalendars: suspend () -> Collection = { 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): Set = + 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 = + { 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, - ): 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 = 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) } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SyncRequester.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SyncRequester.kt new file mode 100644 index 00000000..52eb42eb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/calendar/SyncRequester.kt @@ -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.syncAccounts(): Set = + 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 } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt index 3d5e5800..197715e5 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/di/DataModule.kt @@ -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( diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt index d3b8bfb1..013b52ff 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/CalendarHost.kt @@ -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). diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt index e2a1d028..b6f655bf 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/CalendarDrawer.kt @@ -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 = 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 diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModel.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModel.kt new file mode 100644 index 00000000..6b45c1cb --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModel.kt @@ -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 = repository.calendars() + .map { it.syncAccounts().isNotEmpty() } + .catch { emit(false) } + .stateWhileSubscribed(scope = viewModelScope, initialValue = false) + + private val _inFlight = MutableStateFlow(false) + val inFlight: StateFlow = _inFlight.asStateFlow() + + private val _result = MutableStateFlow(null) + + /** The last request's outcome, until [consumeResult]. */ + val result: StateFlow = _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 +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 278cc7ca..20c67931 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -395,6 +395,10 @@ Jump to date + Sync now + Sync requested + Couldn\'t request a sync + No synced calendars to sync Today diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/TestRepositories.kt b/app/src/test/java/de/jeanlucmakiola/calendula/TestRepositories.kt index 51de87a3..dc7809bc 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/TestRepositories.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/TestRepositories.kt @@ -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() + + 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) 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 index e22ec492..493b2708 100644 --- a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/FakeCalendarDataSource.kt @@ -64,6 +64,15 @@ internal class FakeCalendarDataSource : CalendarDataSource { val updatedCalendars = mutableListOf() val deletedCalendarIds = mutableListOf() + /** eventId → calendarId, for [eventCalendarId]. */ + val eventCalendars = mutableMapOf() + + 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. */ diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SyncNudgeTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SyncNudgeTest.kt new file mode 100644 index 00000000..d307e6e1 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/data/calendar/SyncNudgeTest.kt @@ -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 { 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) + } +} diff --git a/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModelTest.kt b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModelTest.kt new file mode 100644 index 00000000..0aa294a4 --- /dev/null +++ b/app/src/test/java/de/jeanlucmakiola/calendula/ui/common/SyncViewModelTest.kt @@ -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 { + 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() + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 771d9bd7..9a2d8327 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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.