release: v2.13.0 — special dates, custom fonts, quick-switch, es/it translations #58

Merged
makiolaj merged 65 commits from release/v2.13.0 into main 2026-07-03 14:47:43 +00:00
2 changed files with 31 additions and 8 deletions
Showing only changes of commit 84e6402031 - Show all commits

View File

@@ -30,6 +30,7 @@ object SpecialDatesScheduler {
private const val WORK_NAME = "special-dates-sync"
private const val WORK_NAME_NOW = "special-dates-sync-now"
private const val WORK_NAME_FOREGROUND = "special-dates-sync-foreground"
private const val KEY_FOREGROUND = "foreground"
/** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */
@@ -38,6 +39,7 @@ object SpecialDatesScheduler {
if (!enabled) {
workManager.cancelUniqueWork(WORK_NAME)
workManager.cancelUniqueWork(WORK_NAME_NOW)
workManager.cancelUniqueWork(WORK_NAME_FOREGROUND)
return
}
val request = PeriodicWorkRequestBuilder<SpecialDatesSyncWorker>(1, TimeUnit.DAYS)
@@ -48,15 +50,19 @@ object SpecialDatesScheduler {
}
/**
* Run one reconcile immediately. [foreground] runs are debounced (the app can
* resume often); enable/"Sync now" runs ([foreground] false) always sync.
* Run one reconcile immediately. [foreground] runs (the app resuming) are
* debounced inside the worker and use their own work name, so a frequent
* foreground resync can never REPLACE — and swallow — a pending enable /
* "Sync now" run, which always syncs. The engine serializes the two if they
* overlap.
*/
fun runNow(context: Context, foreground: Boolean = false) {
val request = OneTimeWorkRequestBuilder<SpecialDatesSyncWorker>()
.setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build())
.build()
val name = if (foreground) WORK_NAME_FOREGROUND else WORK_NAME_NOW
WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME_NOW, ExistingWorkPolicy.REPLACE, request)
.enqueueUniqueWork(name, ExistingWorkPolicy.REPLACE, request)
}
internal const val INPUT_FOREGROUND = KEY_FOREGROUND
@@ -107,6 +113,13 @@ class SpecialDatesSyncWorker(
}
if (foreground) prefs.setSpecialDatesLastForegroundSync(now)
Result.success()
} catch (e: SecurityException) {
// A revoked calendar/contacts permission won't fix itself on retry —
// park the feature in a stalled state (surfaced in settings) instead
// of retrying with backoff forever.
Log.w(TAG, "Special-dates sync lacks a required permission", e)
prefs.recordSpecialDatesRun(now, SpecialDatesStalledReason.PermissionRevoked)
Result.success()
} catch (e: Exception) {
Log.w(TAG, "Special-dates sync failed", e)
Result.retry()

View File

@@ -13,10 +13,12 @@ import de.jeanlucmakiola.calendula.domain.SimpleRecurrence
import de.jeanlucmakiola.calendula.domain.contacts.ContactSpecialDate
import de.jeanlucmakiola.calendula.domain.contacts.SpecialDateType
import de.jeanlucmakiola.calendula.domain.contacts.anchorDate
import de.jeanlucmakiola.calendula.domain.contacts.managedEventUid
import de.jeanlucmakiola.calendula.domain.contacts.managedUid
import de.jeanlucmakiola.calendula.domain.contacts.renderSpecialDateTitle
import de.jeanlucmakiola.calendula.domain.toRRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import java.time.ZoneId
@@ -64,11 +66,19 @@ class SpecialDatesSyncEngine @Inject constructor(
private val spec: SpecialDatesCalendarSpec,
) {
// Serializes calendar-lifecycle work: two overlapping syncs (the daily job
// racing a "Sync now"/foreground run) would each see no managed calendar and
// both create one; a teardown racing an in-flight sync would delete calendars
// the sync then recreates. Holding this for the whole of sync()/teardown()
// makes those check-then-act sequences atomic, and — because sync() re-reads
// the enabled flag inside the lock — a teardown always wins the race.
private val lifecycleMutex = Mutex()
/**
* Reconcile every enabled type against the device's contacts. Returns why it
* stopped early (disabled / permission gone) or [SpecialDatesSyncResult.Success].
*/
suspend fun sync(): SpecialDatesSyncResult {
suspend fun sync(): SpecialDatesSyncResult = lifecycleMutex.withLock {
if (!prefs.specialDatesEnabled.first()) return SpecialDatesSyncResult.Disabled
if (!contacts.hasPermission()) return SpecialDatesSyncResult.PermissionMissing
@@ -91,7 +101,7 @@ class SpecialDatesSyncEngine @Inject constructor(
reminderCtx = reminderCtx,
)
}
return SpecialDatesSyncResult.Success
SpecialDatesSyncResult.Success
}
/**
@@ -117,7 +127,7 @@ class SpecialDatesSyncEngine @Inject constructor(
}
/** Delete every managed calendar and forget its id — used when the feature is turned off. */
suspend fun teardown() {
suspend fun teardown() = lifecycleMutex.withLock {
calendars.findManagedCalendars().forEach { calendars.deleteCalendar(it.id) }
SpecialDateType.entries.forEach { prefs.setSpecialDatesCalendarId(it, null) }
}
@@ -211,7 +221,7 @@ class SpecialDatesSyncEngine @Inject constructor(
val dtStartMillis = form.toWriteTimes(ZoneId.systemDefault()).dtStartMillis
return BuiltManagedEvent(
desired = ManagedEventDesired(
uid = managedEventUid(type, sd.lookupKey),
uid = sd.managedUid(),
title = title,
dtStartMillis = dtStartMillis,
rrule = YEARLY_RRULE,