Compare commits

...
Author SHA1 Message Date
makiolaj 145ef6c6bf refactor(#240): one access refresh in the repository, loading state in the access sheet 2026-11-14 01:09:00 +01:00
makiolaj 6161efab40 fix(#240): access sheet waits for the first read, manager re-reads access, shared account row 2026-11-14 01:02:58 +01:00
makiolaj 34e8e8571c fix(#240): list only dismissals that still apply, keep the access sheet across recreation 2026-11-14 00:56:47 +01:00
makiolaj a026f24bed feat(#240): per-account Don't ask for sync access, undone from the calendar manager 2026-11-14 00:51:00 +01:00
makiolaj 14a0725b21 refactor(#240): grant-flow state in its own helper, clearer receipt when accounts need access 2026-11-13 22:18:09 +01:00
makiolaj e88ad7f533 fix(#240): guard double picker launch, flag skipped accounts in the receipt, translatable grant summary 2026-11-13 22:10:04 +01:00
makiolaj 67052352ac fix(#240): access re-read on resume, needs-access receipt, drawer stays open for the next grant 2026-11-13 22:04:44 +01:00
makiolaj 88de8f42b0 feat(#240, #303): grant account access for sync requests instead of an account-less fallback 2026-11-13 21:57:55 +01:00
makiolaj 8c31e0f6ee fix(#303): nudge after failed writes again, per-type account visibility 2026-11-13 19:04:07 +01:00
makiolaj 1688b206a5 fix(#303, #240): one account-less request when any account is hidden, nudge only after a successful write 2026-11-13 18:58:19 +01:00
makiolaj 55934664e2 fix(#303, #240): fall back to an account-less request for accounts the app can't see 2026-11-13 18:52:42 +01:00
makiolaj d1c601a6e2 fix(#303, #240): see sync adapters behind package visibility, review fixes 2026-11-13 18:44:49 +01:00
makiolaj 1dcbffd6f2 fix(#303, #240): only ask accounts with a calendar sync adapter, review fixes 2026-11-13 18:35:03 +01:00
makiolaj fb5cb226ee feat(#303, #240): nudge sync adapters after writes, add Sync now to the drawer 2026-11-13 18:27:51 +01:00
22 changed files with 1275 additions and 31 deletions

No files matched your search

@@ -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,28 @@ 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. Account access isn't observable:
* it is re-read on each collection and on every [refreshSyncAccess].
*/
val syncTargets: Flow<SyncTargets> get() = flowOf(SyncTargets.None)
/** Re-read account access for every [syncTargets] collector; it can change outside the app. */
fun refreshSyncAccess() {}
/** Stop or resume asking for [account]'s sync-request access. */
suspend fun setSyncAccessDismissed(account: SyncAccount, dismissed: Boolean) {}
/** Every provider notification and [refresh], including the app's own writes. */
val providerChanges: Flow<Unit> get() = emptyFlow()
}
class NoSuchEventException(eventId: Long) :
@@ -18,6 +18,8 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
@@ -47,6 +49,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 +87,159 @@ 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>, dismissed: Set<SyncAccount>): SyncTargets =
reach(
calendars.syncAccounts().withAdapter()
.sortedWith(compareBy({ it.name.lowercase() }, { it.type }))
.toSet(),
).let {
if (dismissed.isEmpty()) {
it
} else {
it.copy(needsAccess = it.needsAccess - dismissed, dismissed = it.needsAccess intersect dismissed)
}
}
private val accessChecks = MutableStateFlow(0)
override val syncTargets: Flow<SyncTargets>
get() = combine(calendars(), prefs.syncAccessDismissed, accessChecks) { calendars, dismissed, _ ->
targetsOf(calendars, dismissed)
}.flowOn(io)
override fun refreshSyncAccess() {
accessChecks.update { it + 1 }
}
override suspend fun setSyncAccessDismissed(account: SyncAccount, dismissed: Boolean) {
try {
prefs.setSyncAccessDismissed(account, dismissed)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w(TAG, "Sync access dismissal not saved", e)
}
}
override suspend fun requestSync(): SyncRequestResult = withContext(io) {
val targets = try {
targetsOf(calendars().first(), prefs.syncAccessDismissed.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 +367,7 @@ class CalendarRepositoryImpl @Inject constructor(
displayName: String,
color: Int,
description: String?,
): Long = write {
): Long = write(noSync) {
dataSource.createLocalCalendar(displayName, color, description)
}
@@ -227,13 +376,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 +403,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 +454,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 +462,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 +475,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 +486,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 +497,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 +507,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,112 @@
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]).
* [dismissed] are hidden accounts the user said not to ask about.
*/
data class SyncTargets(
val reachable: Set<SyncAccount>,
val needsAccess: Set<SyncAccount>,
val dismissed: Set<SyncAccount> = emptySet(),
) {
companion object {
val None = SyncTargets(emptySet(), emptySet())
}
}
/**
* The system account picker, limited to [account]: once picked it becomes
* visible to the app, so sync requests can name it.
*/
fun accountAccessIntent(account: SyncAccount, description: String): Intent =
AccountManager.newChooseAccountIntent(
null,
listOf(Account(account.name, account.type)),
arrayOf(account.type),
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(
@@ -7,6 +7,8 @@ import de.jeanlucmakiola.floret.prefs.PrefStore
import de.jeanlucmakiola.floret.prefs.nullableBooleanPref
import de.jeanlucmakiola.floret.prefs.nullableLongPref
import de.jeanlucmakiola.floret.prefs.nullableStringPref
import de.jeanlucmakiola.floret.prefs.stringSetPref
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import javax.inject.Singleton
@@ -72,7 +74,28 @@ class CalendarPrefs @Inject constructor(
suspend fun setLastUsedCalendarId(id: Long) = prefs.set(LAST_USED_CALENDAR, id)
/**
* Synced accounts the user chose never to be asked to grant sync-request
* access for (#240): no grant row, no "other accounts" receipt.
*/
val syncAccessDismissed: Flow<Set<SyncAccount>> = prefs.flow(SYNC_ACCESS_DISMISSED)
suspend fun setSyncAccessDismissed(account: SyncAccount, dismissed: Boolean) {
prefs.update(SYNC_ACCESS_DISMISSED) { if (dismissed) it + account else it - account }
}
private companion object {
val SYNC_ACCESS_DISMISSED = stringSetPref("sync_access_dismissed").map<Set<SyncAccount>>(
{ stored -> stored.mapNotNullTo(mutableSetOf()) { it.toSyncAccount() } },
{ accounts -> accounts.mapTo(mutableSetOf()) { "${it.type}$FIELD_SEPARATOR${it.name}" } },
)
const val FIELD_SEPARATOR = '\u001F'
fun String.toSyncAccount(): SyncAccount? {
val split = indexOf(FIELD_SEPARATOR)
return if (split <= 0) null else SyncAccount(name = substring(split + 1), type = substring(0, split))
}
val HIDDEN_IDS = idSetPref("hidden_calendar_ids")
val DISABLED_IDS = idSetPref("disabled_calendar_ids")
val VISIBILITY_NOTICE = nullableBooleanPref("visibility_notice_pending")
@@ -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,13 @@ 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.SyncAccessSheet
import de.jeanlucmakiola.calendula.data.calendar.SyncTargets
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 +449,16 @@ 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 loadedSyncTargets by sync.targets.collectAsStateWithLifecycle()
val syncTargets = loadedSyncTargets ?: SyncTargets.None
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)
var showSyncAccess by rememberSaveable { mutableStateOf(false) }
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 +556,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 +607,18 @@ fun CalendarHost(
onOpenSettings()
switchScope.launch { drawerState.close() }
},
sync = remember(syncTargets, syncInFlight) {
DrawerSync(
canSync = syncTargets.reachable.isNotEmpty(),
inFlight = syncInFlight,
needsAccess = syncTargets.needsAccess.isNotEmpty(),
onSync = {
sync.requestSync()
switchScope.launch { drawerState.close() }
},
onGrantAccess = { showSyncAccess = true },
)
},
)
},
) {
@@ -613,10 +644,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,10 +712,21 @@ 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()
},
)
if (showSyncAccess) {
SyncAccessSheet(
accounts = loadedSyncTargets?.needsAccess?.toList(),
onGrant = launchAccessPicker,
onDismissAccount = sync::dismissAccess,
onDismiss = { showSyncAccess = false },
)
}
// The delete prompt and failure chip of a selected event's actions (#141).
EventSelectionHost(host = chromeHost, viewModel = eventActions)
@@ -85,6 +85,11 @@ import de.jeanlucmakiola.calendula.ui.common.curatedSourcePackage
import de.jeanlucmakiola.calendula.ui.common.orUnnamed
import de.jeanlucmakiola.floret.components.CollapsingScaffold
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
import de.jeanlucmakiola.calendula.ui.common.SyncAccountRow
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import de.jeanlucmakiola.floret.components.positionOf
import de.jeanlucmakiola.floret.components.GroupedSectionHeader
import de.jeanlucmakiola.floret.identity.LocalFloretDarkTheme
import de.jeanlucmakiola.floret.identity.collapseExit
@@ -130,6 +135,8 @@ fun CalendarsScreen(
val calendars by viewModel.calendars.collectAsStateWithLifecycle()
val deleteLockedIds by viewModel.deleteLockedCalendarIds.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val syncAccessDismissed by viewModel.syncAccessDismissed.collectAsStateWithLifecycle()
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshAccess() }
// null = list; NEW_CALENDAR_ID = create; any other id = edit that calendar.
// [editorSession] bumps on every open so the editor's field state resets for
@@ -174,6 +181,8 @@ fun CalendarsScreen(
onEdit = { calendar -> editorSession++; editorId = calendar.id },
onSetVisible = viewModel::setCalendarVisible,
onSetAccountVisible = viewModel::setAccountVisible,
syncAccessDismissed = syncAccessDismissed,
onAskSyncAccessAgain = viewModel::askSyncAccessAgain,
)
}
}
@@ -191,6 +200,8 @@ private fun CalendarsList(
onEdit: (CalendarSource) -> Unit,
onSetVisible: (Long, Boolean) -> Unit,
onSetAccountVisible: (Collection<Long>, Boolean) -> Unit,
syncAccessDismissed: List<SyncAccount>,
onAskSyncAccessAgain: (SyncAccount) -> Unit,
) {
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
@@ -321,6 +332,20 @@ private fun CalendarsList(
}
}
}
if (syncAccessDismissed.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
GroupedSectionHeader(stringResource(R.string.calendars_sync_access_header))
GroupedHint(stringResource(R.string.calendars_sync_access_hint), style = MaterialTheme.typography.bodySmall)
syncAccessDismissed.forEachIndexed { index, account ->
SyncAccountRow(
account = account,
position = positionOf(index, syncAccessDismissed.size),
actionLabel = stringResource(R.string.calendars_sync_access_ask_again),
onAction = { onAskSyncAccessAgain(account) },
)
}
}
}
}
@@ -9,6 +9,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
import de.jeanlucmakiola.calendula.data.calendar.CalendarRepository
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
import de.jeanlucmakiola.calendula.data.ics.IcsExporter
import de.jeanlucmakiola.calendula.data.prefs.BackupStatus
import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs
@@ -56,6 +57,23 @@ class CalendarsViewModel @Inject constructor(
initialValue = emptyList(),
)
/**
* Accounts the drawer was told not to ask sync-request access for (#240),
* limited to those still behind a visible calendar and still unreachable.
*/
val syncAccessDismissed: StateFlow<List<SyncAccount>> =
repository.syncTargets
.map { it.dismissed.toList() }
.catch { emit(emptyList()) }
.stateWhileSubscribed(scope = viewModelScope, initialValue = emptyList())
/** Re-read account access, which can change outside the app; called on resume. */
fun refreshAccess() = repository.refreshSyncAccess()
fun askSyncAccessAgain(account: SyncAccount) {
viewModelScope.launch { repository.setSyncAccessDismissed(account, dismissed = false) }
}
/**
* The Backup & restore screen's own view of that list, with the loading and
* failure states [calendars] flattens away — it starts empty and catches to
@@ -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,121 @@
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.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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
import de.jeanlucmakiola.floret.components.FullScreenPicker
import de.jeanlucmakiola.floret.components.GroupedHint
import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.Position
import de.jeanlucmakiola.floret.components.positionOf
/**
* Launches the system account picker for an account 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): (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) {
{ account ->
if (!open) {
try {
launcher.launch(accountAccessIntent(account, 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()
}
}
/**
* The accounts sync requests can't reach yet (#240), one row each: tapping one
* opens the system picker for just that account, "Don't ask" stops offering it.
* Closes itself once no account is left.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SyncAccessSheet(
accounts: List<SyncAccount>?,
onGrant: (SyncAccount) -> Unit,
onDismissAccount: (SyncAccount) -> Unit,
onDismiss: () -> Unit,
) {
LaunchedEffect(accounts?.isEmpty()) { if (accounts?.isEmpty() == true) onDismiss() }
FullScreenPicker(title = stringResource(R.string.drawer_sync_access), onDismiss = onDismiss, predictiveBack = true) {
GroupedHint(stringResource(R.string.sync_access_hint), verticalPadding = 8.dp)
if (accounts == null) {
Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { LoadingIndicator() }
}
accounts.orEmpty().forEachIndexed { index, account ->
SyncAccountRow(
account = account,
position = positionOf(index, accounts.orEmpty().size),
actionLabel = stringResource(R.string.sync_access_dont_ask),
onAction = { onDismissAccount(account) },
onClick = { onGrant(account) },
)
}
}
}
/** One synced account with a text action, as the access sheet and the calendar manager list them. */
@Composable
fun SyncAccountRow(
account: SyncAccount,
position: Position,
actionLabel: String,
onAction: () -> Unit,
onClick: (() -> Unit)? = null,
) {
GroupedRow(
title = account.name,
summary = sourceAppName(account.type),
position = position,
minHeight = 64.dp,
leading = { SourceLogo(account.type) },
trailing = { TextButton(onClick = onAction) { Text(actionLabel) } },
onClick = onClick,
)
}
@@ -0,0 +1,95 @@
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.SyncAccount
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.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.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() {
/** Who "Sync now" can reach, and who first needs the user's grant; null until first read. */
val targets: StateFlow<SyncTargets?> = repository.syncTargets
.catch { emit(SyncTargets.None) }
.stateWhileSubscribed(scope = viewModelScope, initialValue = null)
/** Re-read account access: on resume (which includes the picker returning) and drawer open. */
fun refreshAccess() = repository.refreshSyncAccess()
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
}
}
}
/** Stop asking for [account]'s access (#240); undone from the calendar manager. */
fun dismissAccess(account: SyncAccount) {
viewModelScope.launch { repository.setSyncAccessDismissed(account, dismissed = true) }
}
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
}
+15
View File
@@ -395,6 +395,21 @@
<!-- 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_hint">Android only lets Calendula ask an account to sync once you pick it here. Tap an account to allow it, or choose Don\'t ask to stop offering it.</string>
<string name="sync_access_dont_ask">Don\'t ask</string>
<string name="calendars_sync_access_header">Not asked for sync access</string>
<string name="calendars_sync_access_hint">Calendula won\'t offer to request syncs for these accounts. Ask again to bring them back to Allow sync requests in the menu.</string>
<string name="calendars_sync_access_ask_again">Ask again</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,314 @@
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))
}
@Test
fun `a dismissed hidden account no longer needs access`(@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.setSyncAccessDismissed(google, dismissed = true)
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(setOf(dav), emptySet(), dismissed = setOf(google)))
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.Requested)
repo.setSyncAccessDismissed(google, dismissed = false)
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(setOf(dav), setOf(google)))
}
@Test
fun `with every hidden account dismissed there is nothing to sync or grant`(@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)
repo.setSyncAccessDismissed(dav, dismissed = true)
repo.setSyncAccessDismissed(google, dismissed = true)
assertThat(repo.syncTargets.first()).isEqualTo(SyncTargets(emptySet(), emptySet(), dismissed = setOf(dav, google)))
assertThat(repo.requestSync()).isEqualTo(SyncRequestResult.NothingToSync)
}
@Test
fun `a dismissed account that becomes visible is still asked`(@TempDir dir: Path) = runTest {
val sync = RecordingSyncRequester()
val repo = testRepository(fake(), dir, Dispatchers.Unconfined, syncRequester = sync)
repo.setSyncAccessDismissed(dav, dismissed = true)
repo.createEvent(form(1L))
assertThat(sync.requests).containsExactly(auto(dav))
assertThat(repo.syncTargets.first().dismissed).isEmpty()
}
}
@@ -2,6 +2,7 @@ package de.jeanlucmakiola.calendula.data.prefs
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.calendula.data.calendar.SyncAccount
import de.jeanlucmakiola.calendula.testDataStore
import de.jeanlucmakiola.calendula.testPrefs
import kotlinx.coroutines.flow.first
@@ -108,4 +109,18 @@ class CalendarPrefsTest {
prefs.setVisibilityNoticePending(false)
assertThat(prefs.visibilityNoticePending.first()).isFalse()
}
@Test
fun `sync access dismissals round-trip per account, names with any characters`(@TempDir tempDir: Path) = runTest {
val prefs = testPrefs(tempDir)
val dav = SyncAccount("me@dav.example/cal, 1", "bitfire.at.davdroid")
val google = SyncAccount("me@dav.example/cal, 1", "com.google")
prefs.setSyncAccessDismissed(dav, dismissed = true)
prefs.setSyncAccessDismissed(google, dismissed = true)
assertThat(prefs.syncAccessDismissed.first()).containsExactly(dav, google)
prefs.setSyncAccessDismissed(dav, dismissed = false)
assertThat(prefs.syncAccessDismissed.first()).containsExactly(google)
}
}
@@ -0,0 +1,132 @@
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()))
}
@Test
fun `dismissing a hidden account takes it out of the grant list`(@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?.needsAccess).containsExactly(davAccount)
vm.dismissAccess(davAccount)
runCurrent()
assertThat(vm.targets.value).isEqualTo(SyncTargets(emptySet(), emptySet(), dismissed = setOf(davAccount)))
}
}
+18
View File
@@ -10,6 +10,24 @@ 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. Per account the user can choose "Don't ask" instead
(`CalendarPrefs.syncAccessDismissed`); such accounts drop out of the grant
row and the receipts, and the calendar manager lists them to ask again. 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.