Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754745586d | ||
|
|
1a1df07dc3 | ||
|
|
75315d92a7 | ||
|
|
95ad3a30cf | ||
|
|
8ce4831efa |
No files matched your search
@@ -318,13 +318,16 @@ 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.
|
||||
# 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.
|
||||
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 }}
|
||||
|
||||
-1
@@ -37,7 +37,6 @@ class CalendarRepositorySmokeTest {
|
||||
CalendarPrefs(store),
|
||||
SettingsPrefs(store),
|
||||
Dispatchers.IO,
|
||||
AndroidSyncRequester(context),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,10 +56,6 @@
|
||||
<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,12 +301,6 @@ 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)
|
||||
}
|
||||
@@ -887,7 +881,8 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
return eventId
|
||||
}
|
||||
|
||||
override fun calendarAccount(calendarId: Long): SyncAccount? = resolver.query(
|
||||
/** The account a calendar belongs to, for scoping a `Colors` lookup. */
|
||||
private fun calendarAccount(calendarId: Long): CalendarAccount? = resolver.query(
|
||||
ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarId),
|
||||
arrayOf(
|
||||
CalendarContract.Calendars.ACCOUNT_NAME,
|
||||
@@ -896,17 +891,13 @@ class AndroidCalendarDataSource @Inject constructor(
|
||||
null, null, null,
|
||||
)?.use { c ->
|
||||
if (c.moveToFirst()) {
|
||||
SyncAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
|
||||
CalendarAccount(name = c.getString(0).orEmpty(), type = c.getString(1).orEmpty())
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
private data class CalendarAccount(val name: String, val type: String)
|
||||
|
||||
/**
|
||||
* The raw provider `MINUTES` to store for one of [form]'s reminders: an
|
||||
|
||||
@@ -11,7 +11,6 @@ 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 {
|
||||
@@ -135,22 +134,6 @@ 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) :
|
||||
|
||||
+15
-135
@@ -47,7 +47,6 @@ 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. */
|
||||
@@ -85,131 +84,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
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)
|
||||
/** 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) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,7 +218,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
displayName: String,
|
||||
color: Int,
|
||||
description: String?,
|
||||
): Long = write(noSync) {
|
||||
): Long = write {
|
||||
dataSource.createLocalCalendar(displayName, color, description)
|
||||
}
|
||||
|
||||
@@ -346,14 +227,13 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
displayName: String,
|
||||
color: Int,
|
||||
description: String?,
|
||||
) = write(noSync) { dataSource.updateCalendar(id, displayName, color, description) }
|
||||
) = write { dataSource.updateCalendar(id, displayName, color, description) }
|
||||
|
||||
override suspend fun deleteCalendar(id: Long) =
|
||||
write(noSync) { dataSource.deleteCalendar(id) }
|
||||
write { dataSource.deleteCalendar(id) }
|
||||
|
||||
override suspend fun setCalendarsVisible(ids: Collection<Long>, visible: Boolean) =
|
||||
// VISIBLE is device-local: nothing for an adapter to upload.
|
||||
write(noSync) {
|
||||
write {
|
||||
val canWrite = dataSource.canWriteCalendars()
|
||||
if (canWrite) ids.forEach { dataSource.setCalendarVisible(it, visible) }
|
||||
// Nothing of ours is left waiting for the provider once the write
|
||||
@@ -373,7 +253,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
override suspend fun importEvents(
|
||||
targetCalendarId: Long,
|
||||
events: List<ParsedIcsEvent>,
|
||||
): IcsImportSummary = write({ listOf(targetCalendarId) }) {
|
||||
): IcsImportSummary = write {
|
||||
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
|
||||
@@ -424,7 +304,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun createEvent(form: EventForm): Long = write({ listOf(form.calendarId) }) {
|
||||
override suspend fun createEvent(form: EventForm): Long = write {
|
||||
dataSource.insertEvent(form, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
@@ -432,11 +312,11 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
eventId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
) = write(eventCalendar(eventId, original, updated.calendarId)) {
|
||||
) = write {
|
||||
dataSource.updateEvent(eventId, original, updated, allDayReminderTimeMinutes())
|
||||
}
|
||||
|
||||
override suspend fun deleteEvent(eventId: Long) = write(eventCalendar(eventId)) {
|
||||
override suspend fun deleteEvent(eventId: Long) = write {
|
||||
dataSource.deleteEvent(eventId)
|
||||
}
|
||||
|
||||
@@ -445,7 +325,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
targetCalendarId: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = write(eventCalendar(eventId, original, targetCalendarId)) {
|
||||
): Long = write {
|
||||
dataSource.moveEvent(
|
||||
eventId, targetCalendarId, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
@@ -456,7 +336,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
form: EventForm,
|
||||
): Long = write(eventCalendar(eventId, original)) {
|
||||
): Long = write {
|
||||
dataSource.updateOccurrence(
|
||||
eventId, beginMillis, original, form, allDayReminderTimeMinutes(),
|
||||
)
|
||||
@@ -467,7 +347,7 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
beginMillis: Long,
|
||||
original: EventForm,
|
||||
updated: EventForm,
|
||||
): Long = write(eventCalendar(eventId, original)) {
|
||||
): Long = write {
|
||||
dataSource.updateEventFromOccurrence(
|
||||
eventId, beginMillis, original, updated, allDayReminderTimeMinutes(),
|
||||
)
|
||||
@@ -477,14 +357,14 @@ class CalendarRepositoryImpl @Inject constructor(
|
||||
|
||||
override val seriesSplits: Flow<SeriesSplit> = splits
|
||||
|
||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write(eventCalendar(eventId)) {
|
||||
override suspend fun deleteOccurrence(eventId: Long, beginMillis: Long) = write {
|
||||
dataSource.deleteOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
override suspend fun deleteEventFromOccurrence(
|
||||
eventId: Long,
|
||||
beginMillis: Long,
|
||||
) = write(eventCalendar(eventId)) {
|
||||
) = write {
|
||||
dataSource.deleteEventFromOccurrence(eventId, beginMillis)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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,11 +11,9 @@ 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
|
||||
@@ -51,12 +49,6 @@ abstract class DataBindModule {
|
||||
impl: CalendarRepositoryImpl,
|
||||
): CalendarRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSyncRequester(
|
||||
impl: AndroidSyncRequester,
|
||||
): SyncRequester
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindReminderInstanceSource(
|
||||
|
||||
@@ -35,7 +35,6 @@ 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
|
||||
@@ -53,11 +52,6 @@ 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
|
||||
@@ -447,14 +441,6 @@ 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
|
||||
@@ -552,7 +538,6 @@ 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() }
|
||||
@@ -603,18 +588,6 @@ 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) },
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
@@ -640,12 +613,10 @@ fun CalendarHost(
|
||||
// A fresh event, like a duplicate from the detail screen (#52).
|
||||
openImportForm(form, ImportSource.Insert)
|
||||
},
|
||||
chipShown = moveOutcome != null || selectionFailure != null || syncResult != null,
|
||||
chipShown = moveOutcome != null || selectionFailure != 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)
|
||||
@@ -708,10 +679,8 @@ fun CalendarHost(
|
||||
EventMoveHost(
|
||||
reschedule,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
notice = selectionFailureMessage ?: syncMessage,
|
||||
onNoticeShown = {
|
||||
if (selectionFailureMessage != null) eventActions.consumeFailure() else sync.consumeResult()
|
||||
},
|
||||
actionFailure = eventActionFailureMessage(selectionFailure),
|
||||
onActionFailureShown = eventActions::consumeFailure,
|
||||
)
|
||||
|
||||
// The delete prompt and failure chip of a selected event's actions (#141).
|
||||
|
||||
@@ -20,12 +20,8 @@ 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
|
||||
@@ -42,8 +38,6 @@ 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
|
||||
@@ -67,8 +61,6 @@ 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(
|
||||
@@ -79,7 +71,6 @@ fun CalendarDrawer(
|
||||
onJumpToDate: (LocalDate) -> Unit,
|
||||
onSettings: () -> Unit,
|
||||
viewOrder: List<CalendarView> = IMPLEMENTED_VIEWS,
|
||||
sync: DrawerSync? = null,
|
||||
) {
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -112,12 +103,11 @@ fun CalendarDrawer(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
GroupedRow(
|
||||
title = stringResource(R.string.drawer_jump_to_date),
|
||||
position = if (sync?.hasRows == true) Position.Top else Position.Alone,
|
||||
position = Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
leading = { DrawerLeadingIcon(Icons.Filled.DateRange) },
|
||||
onClick = { showDatePicker = true },
|
||||
)
|
||||
if (sync != null) SyncRows(sync)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
@@ -148,61 +138,6 @@ 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,
|
||||
notice: String? = null,
|
||||
onNoticeShown: () -> Unit = {},
|
||||
actionFailure: String? = null,
|
||||
onActionFailureShown: () -> 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 -> notice
|
||||
null -> actionFailure
|
||||
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(notice, outcome == null) {
|
||||
if (notice == null || outcome != null) return@LaunchedEffect
|
||||
LaunchedEffect(actionFailure, outcome == null) {
|
||||
if (actionFailure == null || outcome != null) return@LaunchedEffect
|
||||
delay(CHIP_MILLIS)
|
||||
onNoticeShown()
|
||||
onActionFailureShown()
|
||||
}
|
||||
|
||||
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 || notice != null,
|
||||
visible = outcome != null || actionFailure != null,
|
||||
message = content.message,
|
||||
maxWidth = chipMaxWidth,
|
||||
actionLabel = stringResource(R.string.event_move_undo).takeIf { undo != null },
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -395,16 +395,6 @@
|
||||
|
||||
<!-- 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,8 +5,6 @@ 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
|
||||
@@ -45,48 +43,7 @@ fun testRepository(
|
||||
io: CoroutineDispatcher,
|
||||
prefs: CalendarPrefs = testPrefs(dir),
|
||||
settings: SettingsPrefs = testSettings(dir),
|
||||
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)
|
||||
}
|
||||
}
|
||||
): CalendarRepositoryImpl = CalendarRepositoryImpl(source, prefs, settings, io)
|
||||
|
||||
/** Installs [dispatcher] as `Dispatchers.Main` around each test. */
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
@@ -64,15 +64,6 @@ 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. */
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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()))
|
||||
}
|
||||
}
|
||||
@@ -10,22 +10,6 @@ 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.
|
||||
|
||||
Reference in new issue
Block a user