From 4540faf2a0d9c4954e08d70e25894e0cbe609a92 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Wed, 1 Jul 2026 11:01:29 +0200 Subject: [PATCH] feat(contacts): schedule + lifecycle for the special-dates mirror Mirrors the auto-backup infrastructure. A daily periodic WorkManager job plus an immediate run on enable/"Sync now" and a debounced foreground run keep the mirror fresh without a ContentObserver. - SpecialDatesScheduler.apply/runNow + SpecialDatesSyncWorker (EntryPoint): respects the toggle, debounces foreground runs (4h), and parks the feature in a PermissionRevoked stalled state (recorded for the settings banner) instead of retrying forever when READ_CONTACTS is gone. - CalendulaApp.onCreate reconciles the schedule on every launch. - RootScreen ON_RESUME enqueues a debounced foreground sync, gated on the READ_CONTACTS grant so opted-out users never enqueue it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jeanlucmakiola/calendula/CalendulaApp.kt | 20 +++ .../data/contacts/SpecialDatesScheduler.kt | 123 ++++++++++++++++++ .../jeanlucmakiola/calendula/ui/RootScreen.kt | 10 ++ 3 files changed, 153 insertions(+) create mode 100644 app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt index ac300d1..4ce0165 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/CalendulaApp.kt @@ -5,6 +5,8 @@ import dagger.hilt.android.EntryPointAccessors import dagger.hilt.android.HiltAndroidApp import de.jeanlucmakiola.calendula.data.backup.BackupScheduler import de.jeanlucmakiola.calendula.data.backup.BackupWorker +import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler +import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesSyncWorker import de.jeanlucmakiola.calendula.data.crash.CrashReporter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -25,6 +27,7 @@ class CalendulaApp : Application() { // respecting, on-device; the user submits the report by hand). CrashReporter.install(this) reconcileAutoBackup() + reconcileSpecialDates() } /** @@ -44,4 +47,21 @@ class CalendulaApp : Application() { ) } } + + /** + * Re-arm (or cancel) the daily special-dates reconcile from the saved + * settings on every launch — like [reconcileAutoBackup], this cancels + * orphaned work after the feature is turned off and re-schedules after a + * reinstall. The foreground trigger (RootScreen ON_RESUME) does the on-open + * refresh, so no immediate run is needed here. + */ + private fun reconcileSpecialDates() { + val deps = EntryPointAccessors.fromApplication(this, SpecialDatesSyncWorker.Deps::class.java) + CoroutineScope(SupervisorJob() + Dispatchers.Default).launch { + SpecialDatesScheduler.apply( + context = this@CalendulaApp, + enabled = deps.settingsPrefs().specialDatesEnabled.first(), + ) + } + } } diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt new file mode 100644 index 0000000..f5237b7 --- /dev/null +++ b/app/src/main/java/de/jeanlucmakiola/calendula/data/contacts/SpecialDatesScheduler.kt @@ -0,0 +1,123 @@ +package de.jeanlucmakiola.calendula.data.contacts + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import de.jeanlucmakiola.calendula.data.prefs.SettingsPrefs +import de.jeanlucmakiola.calendula.data.prefs.SpecialDatesStalledReason +import kotlinx.coroutines.flow.first +import kotlinx.datetime.TimeZone +import kotlinx.datetime.todayIn +import java.util.concurrent.TimeUnit +import kotlin.time.Clock + +/** + * Schedules the contact special-dates mirror. Birthdays change rarely, so this + * is deliberately cheap: a **daily** periodic reconcile, plus an immediate run + * on enable / "Sync now" and a debounced one when the app is foregrounded. No + * ContentObserver — it would need the process alive and buys almost nothing for + * once-a-year events. Everything stays offline (local contacts → local calendar). + */ +object SpecialDatesScheduler { + + private const val WORK_NAME = "special-dates-sync" + private const val WORK_NAME_NOW = "special-dates-sync-now" + private const val KEY_FOREGROUND = "foreground" + + /** Enqueue (or cancel) the daily periodic reconcile to match [enabled]. */ + fun apply(context: Context, enabled: Boolean) { + val workManager = WorkManager.getInstance(context) + if (!enabled) { + workManager.cancelUniqueWork(WORK_NAME) + workManager.cancelUniqueWork(WORK_NAME_NOW) + return + } + val request = PeriodicWorkRequestBuilder(1, TimeUnit.DAYS) + // Delay the first periodic run so it never overlaps an immediate run. + .setInitialDelay(1, TimeUnit.DAYS) + .build() + workManager.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request) + } + + /** + * Run one reconcile immediately. [foreground] runs are debounced (the app can + * resume often); enable/"Sync now" runs ([foreground] false) always sync. + */ + fun runNow(context: Context, foreground: Boolean = false) { + val request = OneTimeWorkRequestBuilder() + .setInputData(Data.Builder().putBoolean(KEY_FOREGROUND, foreground).build()) + .build() + WorkManager.getInstance(context) + .enqueueUniqueWork(WORK_NAME_NOW, ExistingWorkPolicy.REPLACE, request) + } + + internal const val INPUT_FOREGROUND = KEY_FOREGROUND +} + +/** + * Runs the [SpecialDatesSyncEngine]. Pulls its collaborators through a Hilt + * [EntryPoint] so it works under WorkManager's default worker factory. Records + * the run for the settings status line; a missing permission parks the feature + * in a stalled state (surfaced in settings) rather than retrying forever. + */ +class SpecialDatesSyncWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface Deps { + fun settingsPrefs(): SettingsPrefs + fun syncEngine(): SpecialDatesSyncEngine + } + + override suspend fun doWork(): Result { + val deps = EntryPointAccessors.fromApplication(applicationContext, Deps::class.java) + val prefs = deps.settingsPrefs() + // Respect the toggle even for already-queued work; never revive a + // disabled feature. + if (!prefs.specialDatesEnabled.first()) return Result.success() + + val now = System.currentTimeMillis() + // A foreground resume can fire often — skip if we synced recently. + val foreground = inputData.getBoolean(SpecialDatesScheduler.INPUT_FOREGROUND, false) + if (foreground && now - prefs.specialDatesLastForegroundSync.first() < FOREGROUND_DEBOUNCE_MILLIS) { + return Result.success() + } + + val today = Clock.System.todayIn(TimeZone.currentSystemDefault()) + return try { + val result = deps.syncEngine().sync(today) + val stalled = if (result == SpecialDatesSyncResult.PermissionMissing) { + SpecialDatesStalledReason.PermissionRevoked + } else { + null + } + prefs.recordSpecialDatesRun(now, stalled) + if (foreground) prefs.setSpecialDatesLastForegroundSync(now) + Result.success() + } catch (e: Exception) { + Log.w(TAG, "Special-dates sync failed", e) + Result.retry() + } + } + + companion object { + private const val TAG = "SpecialDatesSync" + + /** Skip a foreground-triggered sync if one ran within this window (4h). */ + private const val FOREGROUND_DEBOUNCE_MILLIS = 4L * 60 * 60 * 1000 + } +} diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt index 1ab1f0f..d3da90d 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/RootScreen.kt @@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver +import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel @@ -49,6 +50,15 @@ fun RootScreen( hasPermission = ContextCompat.checkSelfPermission( context, Manifest.permission.READ_CALENDAR ) == PackageManager.PERMISSION_GRANTED + // Refresh the contact special-dates mirror on foreground (the + // worker is debounced and no-ops when the feature is off). Gated + // on the permission so users without the opt-in never enqueue it. + if (ContextCompat.checkSelfPermission( + context, Manifest.permission.READ_CONTACTS + ) == PackageManager.PERMISSION_GRANTED + ) { + SpecialDatesScheduler.runNow(context, foreground = true) + } } } lifecycle.addObserver(obs)