Compare commits

..
19 changed files with 1018 additions and 35 deletions

No files matched your search

+1 -4
View File
@@ -318,16 +318,13 @@ jobs:
# not provide node inside custom job containers, so JavaScript actions
# (checkout, download-artifact) can't run there. The Renovate job gets away
# with a container because its only step is a shell command. Ruby is installed
# the same way sshpass, jq and fdroidserver are in the job above, from trixie:
# fastlane >= 2.241 needs Ruby 3.2+, and bookworm's apt only has 3.1.
# the same way sshpass, jq and fdroidserver are in the job above.
play:
needs: [detect, release]
# workflow_dispatch is the F-Droid re-sign recovery path — it must never
# touch Play, so gate on a real release only.
if: needs.detect.outputs.is_release == 'true'
runs-on: docker
container:
image: node:22-trixie
env:
VERSION: ${{ needs.detect.outputs.version }}
VERSION_CODE: ${{ needs.detect.outputs.version_code }}
@@ -37,6 +37,7 @@ class CalendarRepositorySmokeTest {
CalendarPrefs(store),
SettingsPrefs(store),
Dispatchers.IO,
AndroidSyncRequester(context),
)
}
+4
View File
@@ -56,6 +56,10 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
<!-- Sync adapters to ask for a calendar sync (#240, #303). -->
<intent>
<action android:name="android.content.SyncAdapter" />
</intent>
</queries>
<application
@@ -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
@@ -11,6 +11,7 @@ import de.jeanlucmakiola.calendula.domain.ics.IcsImportSummary
import de.jeanlucmakiola.calendula.domain.ics.ParsedIcsEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlin.time.Instant
interface CalendarRepository {
@@ -134,6 +135,22 @@ 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
/**
* The accounts behind visible calendars with a sync adapter, split by
* whether [requestSync] can reach them yet. Re-reads account access on
* every collection, so a grant or revoke shows on the next one.
*/
val syncTargets: Flow<SyncTargets> get() = flowOf(SyncTargets.None)
/** 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,131 @@ 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) then get one sync nudge each (#303), also after a failure, which
* may have landed part of a multi-row write. 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?>,
block: suspend () -> T,
): T = withContext(io) {
val accounts = syncAccountsOf(syncCalendars)
try {
block()
} finally {
refresh()
writes.tryEmit(Unit)
requestSyncs(accounts, userInitiated = false)
}
}
private val noSync: suspend () -> Collection<Long?> = { emptyList() }
private suspend fun syncAccountsOf(calendarIds: suspend () -> Collection<Long?>): Set<SyncAccount> =
try {
calendarIds().filterNotNull().distinct()
.mapNotNull { accountOf(it) }
.withAdapter()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "No sync nudge: the calendar account could not be read", e)
emptySet()
}
/** From the shared per-tick read; a calendar newer than it is read on its own. */
private suspend fun accountOf(calendarId: Long): SyncAccount? =
calendarsSnapshot().firstOrNull { it.id == calendarId }
?.let { SyncAccount(it.accountName, it.accountType) }
?: dataSource.calendarAccount(calendarId)
/** The syncable accounts among these whose type has a calendar sync adapter. */
private fun Collection<SyncAccount>.withAdapter(): Set<SyncAccount> {
val candidates = filter { it.isSyncable }
if (candidates.isEmpty()) return emptySet()
val adapterTypes = try {
syncRequester.adapterAccountTypes()
} catch (e: Exception) {
Log.w(TAG, "Sync adapters unreadable", e)
return emptySet()
}
return candidates.filterTo(linkedSetOf()) { it.type in adapterTypes }
}
/**
* Splits [accounts] by whether a request may name them (see
* [SyncRequester.addressableAccounts]). The rest wait for the user's grant:
* an account-less request would reach them, but also every other calendar
* account on the device, so none is sent.
*/
private fun reach(accounts: Set<SyncAccount>): SyncTargets {
if (accounts.isEmpty()) return SyncTargets.None
// Unreadable access counts as reachable: a grant can't fix a failing read.
val addressable = try {
syncRequester.addressableAccounts(accounts.mapTo(mutableSetOf()) { it.type })
} catch (e: Exception) {
Log.w(TAG, "Account visibility unreadable", e)
null
}
if (addressable == null) return SyncTargets(reachable = accounts, needsAccess = emptySet())
val (reachable, hidden) = accounts.partition { it in addressable }
return SyncTargets(reachable.toSet(), hidden.toSet())
}
/** Ask each reachable one of [accounts] once. */
private fun requestSyncs(accounts: Set<SyncAccount>, userInitiated: Boolean) {
reach(accounts).reachable.forEach { requestSync(it, userInitiated) }
}
private fun requestSync(account: SyncAccount, userInitiated: Boolean): Boolean = try {
syncRequester.requestSync(account, userInitiated)
true
} catch (e: Exception) {
Log.w(TAG, "Sync request refused", e)
false
}
/** The event's own calendar (from [original] when given, else read), plus [others]. */
private fun eventCalendar(
eventId: Long,
original: EventForm? = null,
vararg others: Long?,
): suspend () -> Collection<Long?> =
{ listOf(original?.calendarId ?: dataSource.eventCalendarId(eventId), *others) }
private fun targetsOf(calendars: List<CalendarSource>): SyncTargets =
reach(calendars.syncAccounts().withAdapter())
override val syncTargets: Flow<SyncTargets>
get() = calendars().map { targetsOf(it) }.flowOn(io)
override suspend fun requestSync(): SyncRequestResult = withContext(io) {
val targets = try {
targetsOf(calendars().first())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Sync request skipped: calendars unreadable", e)
return@withContext SyncRequestResult.Failed
}
if (targets.reachable.isEmpty()) {
return@withContext if (targets.needsAccess.isEmpty()) {
SyncRequestResult.NothingToSync
} else {
SyncRequestResult.NeedsAccess
}
}
val sent = targets.reachable.count { requestSync(it, userInitiated = true) }
when {
sent == 0 -> SyncRequestResult.Failed
sent < targets.reachable.size -> SyncRequestResult.PartlyFailed
targets.needsAccess.isNotEmpty() -> SyncRequestResult.SomeNeedAccess
else -> SyncRequestResult.Requested
}
}
@@ -218,7 +337,7 @@ class CalendarRepositoryImpl @Inject constructor(
displayName: String,
color: Int,
description: String?,
): Long = write {
): Long = write(noSync) {
dataSource.createLocalCalendar(displayName, color, description)
}
@@ -227,13 +346,14 @@ class CalendarRepositoryImpl @Inject constructor(
displayName: String,
color: Int,
description: String?,
) = write { dataSource.updateCalendar(id, displayName, color, description) }
) = write(noSync) { dataSource.updateCalendar(id, displayName, color, description) }
override suspend fun deleteCalendar(id: Long) =
write { dataSource.deleteCalendar(id) }
write(noSync) { dataSource.deleteCalendar(id) }
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
write {
// VISIBLE is device-local: nothing for an adapter to upload.
write(noSync) {
val canWrite = dataSource.canWriteCalendars()
if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) }
// Nothing of ours is left waiting for the provider once the write
@@ -253,7 +373,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 +424,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 +432,11 @@ class CalendarRepositoryImpl @Inject constructor(
eventId: Long,
original: EventForm,
updated: EventForm,
) = write {
) = write(eventCalendar(eventId, original, 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 +445,7 @@ class CalendarRepositoryImpl @Inject constructor(
targetCalendarId: Long,
original: EventForm,
updated: EventForm,
): Long = write {
): Long = write(eventCalendar(eventId, original, targetCalendarId)) {
dataSource.moveEvent(
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
)
@@ -336,7 +456,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long,
original: EventForm,
form: EventForm,
): Long = write {
): Long = write(eventCalendar(eventId, original)) {
dataSource.updateOccurrence(
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
)
@@ -347,7 +467,7 @@ class CalendarRepositoryImpl @Inject constructor(
beginMillis: Long,
original: EventForm,
updated: EventForm,
): Long = write {
): Long = write(eventCalendar(eventId, original)) {
dataSource.updateEventFromOccurrence(
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
)
@@ -357,14 +477,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,107 @@
package de.jeanlucmakiola.calendula.data.calendar
import android.accounts.Account
import android.accounts.AccountManager
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.CalendarContract
import dagger.hilt.android.qualifiers.ApplicationContext
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 {
/** Account types with a calendar sync adapter installed; for any other a request does nothing. */
fun adapterAccountTypes(): Set<String>
/**
* The accounts of [types] a request may name, or null when any may. From
* Android 13 a request for an account the app can't see is dropped without a word.
*/
fun addressableAccounts(types: Set<String>): Set<SyncAccount>?
/**
* Schedule a sync of [account]; may throw if the system refuses. [userInitiated]
* marks it manual, which also overrides master-sync-off — only for a user's tap.
*/
fun requestSync(account: SyncAccount, userInitiated: Boolean)
}
@Singleton
class AndroidSyncRequester @Inject constructor(
@ApplicationContext private val context: Context,
) : SyncRequester {
// Lists only adapters in packages visible to the app — hence the SyncAdapter <queries> entry.
override fun adapterAccountTypes(): Set<String> =
ContentResolver.getSyncAdapterTypes()
.filter { it.authority == CalendarContract.AUTHORITY }
.mapTo(mutableSetOf()) { it.accountType }
override fun addressableAccounts(types: Set<String>): Set<SyncAccount>? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return null
val manager = AccountManager.get(context)
return types.flatMapTo(mutableSetOf()) { type ->
manager.getAccountsByType(type).map { SyncAccount(it.name, it.type) }
}
}
override fun requestSync(account: SyncAccount, userInitiated: Boolean) {
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,
)
}
}
/** The distinct non-local accounts behind the visible calendars — what a manual refresh asks. */
fun List<CalendarSource>.syncAccounts(): Set<SyncAccount> =
filter { it.isVisibleInSystem }
.map { SyncAccount(it.accountName, it.accountType) }
.filterTo(linkedSetOf()) { it.isSyncable }
/**
* Who a sync request can go to: [reachable] by name, [needsAccess] only once the
* user lets the app see the account (Android 13+, see [accountAccessIntent]).
*/
data class SyncTargets(val reachable: Set<SyncAccount>, val needsAccess: Set<SyncAccount>) {
companion object {
val None = SyncTargets(emptySet(), emptySet())
}
}
/**
* The system account picker, limited to [accounts]: the one the user picks
* becomes visible to the app, so sync requests can name it. One account per pick.
*/
fun accountAccessIntent(accounts: Collection<SyncAccount>, description: String): Intent =
AccountManager.newChooseAccountIntent(
null,
accounts.map { Account(it.name, it.type) },
accounts.map { it.type }.distinct().toTypedArray(),
description,
null,
null,
null,
)
/** Outcome of a user-triggered sync request (#240). */
enum class SyncRequestResult { Requested, SomeNeedAccess, PartlyFailed, NeedsAccess, 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,11 @@ 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.RefreshSyncAccessOnOpen
import de.jeanlucmakiola.calendula.ui.common.rememberSyncAccessPicker
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 +447,14 @@ 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 syncTargets by sync.targets.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 launchAccessPicker = rememberSyncAccessPicker(sync)
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
@@ -538,6 +552,7 @@ fun CalendarHost(
val stayingPut = remember { StayingPut() }
val chromeHost = remember { ChromeHost() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
RefreshSyncAccessOnOpen(drawerState, sync)
val density = LocalDensity.current
val topBarBottom = TopAppBarDefaults.windowInsets.getTop(density) +
with(density) { TopAppBarDefaults.TopAppBarExpandedHeight.toPx() }
@@ -588,6 +603,18 @@ fun CalendarHost(
onOpenSettings()
switchScope.launch { drawerState.close() }
},
sync = remember(syncTargets, syncInFlight, launchAccessPicker) {
DrawerSync(
canSync = syncTargets.reachable.isNotEmpty(),
inFlight = syncInFlight,
needsAccess = syncTargets.needsAccess.isNotEmpty(),
onSync = {
sync.requestSync()
switchScope.launch { drawerState.close() }
},
onGrantAccess = { launchAccessPicker(syncTargets.needsAccess) },
)
},
)
},
) {
@@ -613,10 +640,12 @@ 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()
// Only the chip on show: a receipt queued behind it still gets its turn.
if (moveOutcome == null && selectionFailure == null) sync.consumeResult()
},
)
CalendarFab(chromeHost, todayInToolbar, fabSelection)
@@ -679,8 +708,10 @@ fun CalendarHost(
EventMoveHost(
reschedule,
modifier = Modifier.fillMaxSize(),
actionFailure = eventActionFailureMessage(selectionFailure),
onActionFailureShown = eventActions::consumeFailure,
notice = selectionFailureMessage ?: syncMessage,
onNoticeShown = {
if (selectionFailureMessage != null) eventActions.consumeFailure() else sync.consumeResult()
},
)
// The delete prompt and failure chip of a selected event's actions (#141).
@@ -20,8 +20,12 @@ 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.ManageAccounts
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
@@ -38,6 +42,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.ui.filter.CalendarFilterList
@@ -61,6 +67,8 @@ 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),
* and a row to grant account access while some account can't be reached yet.
*/
@Composable
fun CalendarDrawer(
@@ -71,6 +79,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 +112,12 @@ fun CalendarDrawer(
Spacer(Modifier.height(16.dp))
GroupedRow(
title = stringResource(R.string.drawer_jump_to_date),
position = Position.Alone,
position = if (sync?.hasRows == true) Position.Top else Position.Alone,
minHeight = 56.dp,
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
onClick = { showDatePicker = true },
)
if (sync != null) SyncRows(sync)
Spacer(Modifier.height(16.dp))
@@ -138,6 +148,61 @@ fun CalendarDrawer(
}
}
/**
* The drawer's sync rows: "Sync now" when [canSync] ([inFlight] shows progress
* and ignores taps), and a grant row while some account [needsAccess].
*/
data class DrawerSync(
val canSync: Boolean,
val inFlight: Boolean,
val needsAccess: Boolean,
val onSync: () -> Unit,
val onGrantAccess: () -> Unit,
) {
val hasRows: Boolean get() = canSync || needsAccess
}
@Composable
private fun SyncRows(sync: DrawerSync) {
if (sync.canSync) {
SyncNowRow(sync, if (sync.needsAccess) Position.Middle else Position.Bottom)
}
if (sync.needsAccess) {
GroupedRow(
title = stringResource(R.string.drawer_sync_access),
summary = stringResource(R.string.drawer_sync_access_summary),
position = Position.Bottom,
minHeight = 56.dp,
leading = { DrawerLeadingIcon(Icons.Filled.ManageAccounts) },
onClick = sync.onGrantAccess,
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun SyncNowRow(sync: DrawerSync, position: Position) {
val inFlightText = stringResource(R.string.sync_requested)
GroupedRow(
title = stringResource(R.string.drawer_sync_now),
position = position,
modifier = if (sync.inFlight) {
Modifier.semantics { stateDescription = inFlightText }
} else {
Modifier
},
dimmed = sync.inFlight,
minHeight = 56.dp,
leading = { DrawerLeadingIcon(Icons.Filled.Sync) },
trailing = if (sync.inFlight) {
{ LoadingIndicator(Modifier.size(32.dp)) }
} else {
null
},
onClick = sync.onSync.takeIf { !sync.inFlight },
)
}
/** Leading slot for the drawer's plain icons: the same 40.dp footprint
* [CalendarColorChip] takes, so every leading glyph shares one vertical axis. */
@Composable
@@ -51,8 +51,8 @@ private val FAB_BAND = 88.dp
fun EventMoveHost(
viewModel: RescheduleViewModel,
modifier: Modifier = Modifier,
actionFailure: String? = null,
onActionFailureShown: () -> Unit = {},
notice: String? = null,
onNoticeShown: () -> Unit = {},
) {
val prompt by viewModel.scopePrompt.collectAsStateWithLifecycle()
val outcome by viewModel.outcome.collectAsStateWithLifecycle()
@@ -81,7 +81,7 @@ fun EventMoveHost(
}
// A failed selection action (#141) shares the chip rather than stacking on it.
val message = when (outcome) {
null -> actionFailure
null -> notice
is MoveOutcome.Moved -> if (resizedLabel != null) {
stringResource(R.string.event_resize_done, resizedLabel)
} else {
@@ -116,10 +116,10 @@ fun EventMoveHost(
viewModel.consumeOutcome()
}
// A failure waits for the chip to be free, then has its own turn.
LaunchedEffect(actionFailure, outcome == null) {
if (actionFailure == null || outcome != null) return@LaunchedEffect
LaunchedEffect(notice, outcome == null) {
if (notice == null || outcome != null) return@LaunchedEffect
delay(CHIP_MILLIS)
onActionFailureShown()
onNoticeShown()
}
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
@@ -138,7 +138,7 @@ fun EventMoveHost(
// write anyway, so offering it would be a button that does nothing.
val undo = content.undo?.takeIf { !writeInFlight }
SnackChip(
visible = outcome != null || actionFailure != null,
visible = outcome != null || notice != null,
message = content.message,
maxWidth = chipMaxWidth,
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
@@ -0,0 +1,55 @@
package de.jeanlucmakiola.calendula.ui.common
import android.content.ActivityNotFoundException
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import de.jeanlucmakiola.calendula.R
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
import de.jeanlucmakiola.calendula.data.calendar.accountAccessIntent
/**
* Launches the system account picker for accounts sync requests can't reach yet
* (#240), one launch at a time. Access is re-read on every resume, which covers
* the picker's return and grants or revokes made in system settings.
*/
@Composable
fun rememberSyncAccessPicker(sync: SyncViewModel): (Set<SyncAccount>) -> Unit {
var open by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
open = false
}
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { sync.refreshAccess() }
val description = stringResource(R.string.sync_access_picker_description)
return remember(launcher, description) {
{ accounts ->
if (!open) {
try {
launcher.launch(accountAccessIntent(accounts, description))
open = true
} catch (e: ActivityNotFoundException) {
Log.w("SyncAccessPicker", "No account picker", e)
}
}
}
}
}
/** Re-reads account access whenever [drawerState] opens; the app may stay resumed in multi-window. */
@Composable
fun RefreshSyncAccessOnOpen(drawerState: DrawerState, sync: SyncViewModel) {
LaunchedEffect(drawerState.targetValue) {
if (drawerState.targetValue == DrawerValue.Open) sync.refreshAccess()
}
}
@@ -0,0 +1,100 @@
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.SyncTargets
import de.jeanlucmakiola.floret.android.stateWhileSubscribed
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
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() {
private val accessChecks = MutableStateFlow(0)
/**
* Who "Sync now" can reach, and who first needs the user's grant. Account
* access isn't observable, so it is re-read on every [refreshAccess].
*/
@OptIn(ExperimentalCoroutinesApi::class)
val targets: StateFlow<SyncTargets> = accessChecks
.flatMapLatest { repository.syncTargets.catch { emit(SyncTargets.None) } }
.stateWhileSubscribed(scope = viewModelScope, initialValue = SyncTargets.None)
/** Re-read account access: whenever the app resumes, which includes the picker returning. */
fun refreshAccess() {
accessChecks.update { it + 1 }
}
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.firstOrNull() }
}
val outcome = repository.requestSync()
_result.value = outcome
val sent = outcome == SyncRequestResult.Requested ||
outcome == SyncRequestResult.SomeNeedAccess ||
outcome == SyncRequestResult.PartlyFailed
if (sent) 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.PartlyFailed -> R.string.sync_request_partly_failed
SyncRequestResult.NeedsAccess -> R.string.sync_request_needs_access
SyncRequestResult.SomeNeedAccess -> R.string.sync_request_some_need_access
SyncRequestResult.NothingToSync -> R.string.sync_nothing_to_sync
SyncRequestResult.Failed -> R.string.sync_request_failed
}
+10
View File
@@ -395,6 +395,16 @@
<!-- Jump to date (drawer) -->
<string name="drawer_jump_to_date">Jump to date</string>
<string name="drawer_sync_now">Sync now</string>
<string name="drawer_sync_access">Allow sync requests</string>
<string name="drawer_sync_access_summary">Pick each account Calendula may ask to sync</string>
<string name="sync_access_picker_description">Choose an account Calendula may ask to sync your calendars</string>
<string name="sync_requested">Sync requested</string>
<string name="sync_request_failed">Couldn\'t request a sync</string>
<string name="sync_request_some_need_access">Sync requested. Allow sync requests in the menu for the other accounts</string>
<string name="sync_request_needs_access">Allow sync requests in the menu first</string>
<string name="sync_request_partly_failed">Sync requested, but not for every account</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,48 @@ 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 all,
* [refusedTypes] refusing those account types, [adapterTypes] the installed
* calendar sync adapters (null: a fixed set covering the test accounts),
* [hiddenTypes] account types the app can't address by name.
*/
class RecordingSyncRequester(
var accepts: Boolean = true,
var adapterTypes: Set<String>? = null,
var refusedTypes: Set<String> = emptySet(),
var hiddenTypes: Set<String> = emptySet(),
) : SyncRequester {
data class Request(val account: SyncAccount, val userInitiated: Boolean)
val requests = mutableListOf<Request>()
override fun adapterAccountTypes(): Set<String> =
adapterTypes ?: setOf("bitfire.at.davdroid", "com.google", "LOCAL")
override fun addressableAccounts(types: Set<String>): Set<SyncAccount>? {
visibilityError?.let { throw it }
return if (hiddenTypes.isEmpty()) {
null
} else {
knownAccounts.filterTo(mutableSetOf()) { it.type in types && it.type !in hiddenTypes }
}
}
/** Thrown by [addressableAccounts] when set. */
var visibilityError: Exception? = null
/** The accounts on the "device", for [addressableAccounts]. */
var knownAccounts: Set<SyncAccount> = emptySet()
override fun requestSync(account: SyncAccount, userInitiated: Boolean) {
if (!accepts || account.type in refusedTypes) throw SecurityException("refused")
requests += Request(account, userInitiated)
}
}
/** 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,274 @@
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.flow.first
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 = RecordingSyncRequester(accepts = false)
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)
}
@Test
fun `accounts without a calendar sync adapter are never asked`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester(adapterTypes = setOf(dav.type))
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
repo.createEvent(form(3L))
assertThat(sync.requests).isEmpty()
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.Requested)
assertThat(sync.requests).containsExactly(Request(dav, true))
}
@Test
fun `syncTargets needs a visible calendar with an adapter`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester(adapterTypes = setOf(google.type))
val source = fake().apply {
calendarsResult = listOf(cal(1L, dav), cal(3L, google, visible = false), cal(4L, local))
}
val repo = testRepository(source, dir, Dispatchers.Unconfined, syncRequester = sync)
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets.None)
sync.adapterTypes = setOf(dav.type)
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(setOf(dav), emptySet()))
}
@Test
fun `manual sync refused for some accounts says so`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester(refusedTypes = setOf(google.type))
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.PartlyFailed)
assertThat(sync.requests).containsExactly(Request(dav, true))
}
@Test
fun `accounts the app can't see wait for access instead of being asked`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester(hiddenTypes = setOf(google.type))
sync.knownAccounts = setOf(dav, google)
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
repo.moveEvent(10L, 3L, form(1L), form(3L))
assertThat(sync.requests).containsExactly(auto(dav))
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(setOf(dav), setOf(google)))
sync.requests.clear()
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.SomeNeedAccess)
assertThat(sync.requests).containsExactly(Request(dav, true))
}
@Test
fun `once access is granted the account is asked by name`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester(hiddenTypes = setOf(google.type))
sync.knownAccounts = setOf(dav, google)
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
sync.hiddenTypes = setOf("other")
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(setOf(dav, google), emptySet()))
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.Requested)
assertThat(sync.requests).containsExactly(Request(dav, true), Request(google, true))
}
@Test
fun `only hidden accounts make manual sync ask for access, asking no one`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester(hiddenTypes = setOf(dav.type, google.type))
sync.knownAccounts = setOf(dav, google)
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.NeedsAccess)
repo.createEvent(form(1L))
assertThat(sync.requests).isEmpty()
}
@Test
fun `unreadable account visibility counts as reachable`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester(hiddenTypes = setOf("other"))
sync.visibilityError = SecurityException("no")
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
repo.createEvent(form(1L))
assertThat(sync.requests).containsExactly(auto(dav))
}
}
@@ -0,0 +1,117 @@
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.SyncAccount
import de.jeanlucmakiola.calendula.data.calendar.SyncRequestResult
import de.jeanlucmakiola.calendula.data.calendar.SyncTargets
import kotlinx.coroutines.launch
import de.jeanlucmakiola.calendula.domain.CalendarSource
import de.jeanlucmakiola.calendula.testPrefs
import de.jeanlucmakiola.calendula.testRepository
import de.jeanlucmakiola.calendula.testSettings
import kotlinx.coroutines.CoroutineScope
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 val davAccount = SyncAccount("me@dav", "bitfire.at.davdroid")
private fun viewModel(dir: Path, sync: RecordingSyncRequester = RecordingSyncRequester()):
Pair<SyncViewModel, FakeCalendarDataSource> {
val source = FakeCalendarDataSource().apply { calendarsResult = listOf(synced) }
val repo = testRepository(
source, dir, dispatcher,
prefs = testPrefs(dir, CoroutineScope(dispatcher)),
settings = testSettings(dir, CoroutineScope(dispatcher)),
syncRequester = sync,
)
return SyncViewModel(repo) 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()
}
@Test
fun `refreshAccess picks up a grant`(@TempDir dir: Path) = runTest(dispatcher) {
val sync = RecordingSyncRequester(hiddenTypes = setOf(davAccount.type))
sync.knownAccounts = setOf(davAccount)
val (vm, _) = viewModel(dir, sync)
backgroundScope.launch { vm.targets.collect {} }
runCurrent()
assertThat(vm.targets.value).isEqualTo(SyncTargets(emptySet(), setOf(davAccount)))
sync.hiddenTypes = setOf("other")
vm.refreshAccess()
runCurrent()
assertThat(vm.targets.value).isEqualTo(SyncTargets(setOf(davAccount), emptySet()))
}
}
+16
View File
@@ -10,6 +10,22 @@ 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), which like the
system's own "Sync now" also runs for accounts whose auto-sync is off. Both
go through `SyncRequester` and only ask account types with a calendar sync
adapter (visible to the app via a `<queries>` entry). From Android 13 a
request naming an account the app can't see is silently dropped, and
without `GET_ACCOUNTS` that is most of them. Such accounts are not asked at
all (an account-less request would sync every account on the device); the
drawer offers "Allow sync requests" instead, which opens the system account
picker (`AccountManager.newChooseAccountIntent`) — the picked account
becomes visible to the app. Access is re-read on every request and on
every resume and drawer open, so a revoke simply puts the account back behind the
grant row. 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.