This commit is contained in:
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) :
|
||||
|
||||
+62
-11
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user