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) <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import dagger.hilt.android.EntryPointAccessors
|
|||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
import de.jeanlucmakiola.calendula.data.backup.BackupScheduler
|
||||||
import de.jeanlucmakiola.calendula.data.backup.BackupWorker
|
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 de.jeanlucmakiola.calendula.data.crash.CrashReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -25,6 +27,7 @@ class CalendulaApp : Application() {
|
|||||||
// respecting, on-device; the user submits the report by hand).
|
// respecting, on-device; the user submits the report by hand).
|
||||||
CrashReporter.install(this)
|
CrashReporter.install(this)
|
||||||
reconcileAutoBackup()
|
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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SpecialDatesSyncWorker>(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<SpecialDatesSyncWorker>()
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import de.jeanlucmakiola.calendula.data.contacts.SpecialDatesScheduler
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
import de.jeanlucmakiola.calendula.ui.permission.PermissionScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingScreen
|
||||||
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
import de.jeanlucmakiola.calendula.ui.permission.ReminderOnboardingViewModel
|
||||||
@@ -49,6 +50,15 @@ fun RootScreen(
|
|||||||
hasPermission = ContextCompat.checkSelfPermission(
|
hasPermission = ContextCompat.checkSelfPermission(
|
||||||
context, Manifest.permission.READ_CALENDAR
|
context, Manifest.permission.READ_CALENDAR
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
) == 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)
|
lifecycle.addObserver(obs)
|
||||||
|
|||||||
Reference in New Issue
Block a user