release: v2.13.0 — special dates, custom fonts, quick-switch, es/it translations #58
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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)
|
||||
|
||||
Reference in New Issue
Block a user