P3 Stream L: widget refresh scheduler (WorkManager, iOS cadence)

WidgetRefreshSchedule: 30-min day / 120-min overnight (6am–11pm split).
WidgetRefreshWorker: CoroutineWorker fetches via APILayer -> repo -> widget.update.
WidgetUpdateManager: chained one-time enqueue pattern (WorkManager PeriodicWork
can't vary cadence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:54:35 -05:00
parent 58b9371d0d
commit dbff329384
7 changed files with 682 additions and 90 deletions

View File

@@ -0,0 +1,62 @@
package com.tt.honeyDue.widget
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
/**
* Pure-logic schedule for widget refresh cadence. Mirrors the iOS-parity
* split from the P3 parity plan:
*
* - 06:00 (inclusive) .. 23:00 (exclusive) local → refresh every 30 minutes
* - 23:00 (inclusive) .. 06:00 (exclusive) local → refresh every 120 minutes
*
* iOS ([BackgroundTaskManager.swift]) uses a random 12am4am overnight
* BGAppRefreshTask window rather than a fixed cadence, because iOS
* `BGTaskScheduler` is coalesced by the system. Android's WorkManager runs
* user-defined intervals, so this file encodes the ios-parity cadence the
* plan specifies. The split 30/120 preserves the core intent: frequent
* while awake, sparse while the user is asleep.
*/
object WidgetRefreshSchedule {
private const val DAY_START_HOUR_INCLUSIVE = 6 // 06:00 local
private const val DAY_END_HOUR_EXCLUSIVE = 23 // 23:00 local
const val DAY_INTERVAL_MINUTES: Long = 30L
const val NIGHT_INTERVAL_MINUTES: Long = 120L
/**
* Returns the refresh interval (in minutes) for a wall-clock time.
*
* Hour bands:
* - [06:00, 23:00) → [DAY_INTERVAL_MINUTES] (30)
* - [23:00, 06:00) → [NIGHT_INTERVAL_MINUTES] (120)
*/
fun intervalMinutes(at: LocalDateTime): Long {
val hour = at.hour
return if (hour in DAY_START_HOUR_INCLUSIVE until DAY_END_HOUR_EXCLUSIVE) {
DAY_INTERVAL_MINUTES
} else {
NIGHT_INTERVAL_MINUTES
}
}
/**
* Returns `now + intervalMinutes(now)` as a [LocalDateTime].
*
* Arithmetic is performed through [TimeZone.UTC] to avoid ambiguity
* around DST transitions in the local zone — the absolute minute offset
* is what WorkManager's `setInitialDelay` consumes, so the returned
* wall-clock value is for display/testing only.
*/
fun nextRefreshTime(now: LocalDateTime): LocalDateTime {
val interval = intervalMinutes(now)
val instant = now.toInstant(TimeZone.UTC)
val next = instant.plus(interval, DateTimeUnit.MINUTE)
return next.toLocalDateTime(TimeZone.UTC)
}
}

View File

@@ -0,0 +1,172 @@
package com.tt.honeyDue.widget
import android.content.Context
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.tt.honeyDue.network.APILayer
import com.tt.honeyDue.network.ApiResult
import com.tt.honeyDue.models.TaskColumnsResponse
/**
* Abstraction over the data sources the worker consumes. Keeps
* [WidgetRefreshWorker] unit-testable without having to mock the
* [APILayer] singleton.
*/
interface WidgetRefreshDataSource {
/** Fetch the task list that should be displayed on the widget. */
suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>>
/** Fetch the current user's subscription tier ("free" | "premium"). */
suspend fun fetchTier(): String
}
/**
* Default production data source — delegates to [APILayer] and maps the
* backend task kanban into the flat list the widget caches.
*/
internal object DefaultWidgetRefreshDataSource : WidgetRefreshDataSource {
private const val COMPLETED_COLUMN = "completed"
private const val CANCELLED_COLUMN = "cancelled"
private const val OVERDUE_COLUMN = "overdue"
override suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>> {
val result = APILayer.getTasks(forceRefresh = true)
return when (result) {
is ApiResult.Success -> ApiResult.Success(mapToWidgetTasks(result.data))
is ApiResult.Error -> result
ApiResult.Loading -> ApiResult.Error("Loading", null)
ApiResult.Idle -> ApiResult.Error("Idle", null)
}
}
override suspend fun fetchTier(): String {
val result = APILayer.getSubscriptionStatus(forceRefresh = true)
return when (result) {
is ApiResult.Success -> result.data.tier
else -> "free"
}
}
private fun mapToWidgetTasks(response: TaskColumnsResponse): List<WidgetTaskDto> {
val out = mutableListOf<WidgetTaskDto>()
for (column in response.columns) {
if (column.name == COMPLETED_COLUMN || column.name == CANCELLED_COLUMN) continue
val isOverdue = column.name == OVERDUE_COLUMN
for (task in column.tasks) {
out.add(
WidgetTaskDto(
id = task.id.toLong(),
title = task.title,
priority = task.priorityId?.toLong() ?: 0L,
dueDate = task.effectiveDueDate,
isOverdue = isOverdue,
// Server computes overdue/column bucketing — we don't
// recompute daysUntilDue here; it's a best-effort hint
// the widget displays when present. Zero for overdue
// matches iOS behaviour (daysUntilDue is not surfaced
// on the iOS WidgetTask model).
daysUntilDue = 0,
residenceId = task.residenceId.toLong(),
residenceName = "",
categoryIcon = task.categoryName ?: "",
completed = false
)
)
}
}
return out
}
}
/**
* Background worker that refreshes the on-disk widget cache and asks each
* Glance widget to redraw.
*
* **Error contract:**
* - [ApiResult.Success] → [Result.success]
* - transient [ApiResult.Error] (5xx / network) → [Result.retry]
* - auth [ApiResult.Error] (401/403) → [Result.failure]
*
* **Test hook:** set [dataSourceOverride] to swap the data source in tests.
*/
class WidgetRefreshWorker(
appContext: Context,
params: WorkerParameters
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val ctx = applicationContext
val dataSource = dataSourceOverride ?: DefaultWidgetRefreshDataSource
// Always attempt tier refresh — tier persistence is cheap and useful
// even if the task fetch later fails.
val tier = runCatching { dataSource.fetchTier() }.getOrDefault("free")
val tasksResult = runCatching { dataSource.fetchTasks() }.getOrElse { t ->
return Result.retry() // Unexpected throw → transient.
}
when (tasksResult) {
is ApiResult.Success -> {
val repo = WidgetDataRepository.get(ctx)
repo.saveTasks(tasksResult.data)
repo.saveTierState(tier)
refreshGlanceWidgets(ctx)
// Chain the next scheduled refresh so cadence keeps ticking
// even if the OS evicts our periodic request. Wrapped in
// runCatching — an un-initialized WorkManager (e.g. in
// unit tests) must not cause an otherwise-green refresh
// to report failure.
runCatching { WidgetUpdateManager.schedulePeriodic(ctx) }
return Result.success()
}
is ApiResult.Error -> {
// Still persist tier if we have it — subscription state is
// independent of task fetch.
runCatching { WidgetDataRepository.get(ctx).saveTierState(tier) }
return if (isPermanentError(tasksResult.code)) Result.failure() else Result.retry()
}
ApiResult.Loading, ApiResult.Idle -> return Result.retry()
}
}
private suspend fun refreshGlanceWidgets(ctx: Context) {
val glanceManager = GlanceAppWidgetManager(ctx)
runCatching {
val smallIds = glanceManager.getGlanceIds(HoneyDueSmallWidget::class.java)
val smallWidget = HoneyDueSmallWidget()
smallIds.forEach { id -> smallWidget.update(ctx, id) }
}
runCatching {
val mediumIds = glanceManager.getGlanceIds(HoneyDueMediumWidget::class.java)
val mediumWidget = HoneyDueMediumWidget()
mediumIds.forEach { id -> mediumWidget.update(ctx, id) }
}
runCatching {
val largeIds = glanceManager.getGlanceIds(HoneyDueLargeWidget::class.java)
val largeWidget = HoneyDueLargeWidget()
largeIds.forEach { id -> largeWidget.update(ctx, id) }
}
}
private fun isPermanentError(code: Int?): Boolean {
// 401/403 — credentials invalid; no amount of retry helps.
// 404 — endpoint removed; treat as permanent.
// Everything else (including null / 5xx / network) is transient.
return code == 401 || code == 403 || code == 404
}
companion object {
/**
* Test-only hook. Set to a fake data source before invoking
* [TestListenableWorkerBuilder<WidgetRefreshWorker>]. Always nulled
* in teardown.
*/
@Volatile
var dataSourceOverride: WidgetRefreshDataSource? = null
}
}

View File

@@ -1,113 +1,83 @@
package com.tt.honeyDue.widget
import android.content.Context
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.appwidget.GlanceAppWidgetManager
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.state.PreferencesGlanceStateDefinition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import java.util.concurrent.TimeUnit
/**
* Manager for updating all widgets with new data
* Scheduler for the widget-refresh background work. Thin wrapper over
* [WorkManager] that enqueues a [WidgetRefreshWorker] with the cadence
* defined by [WidgetRefreshSchedule].
*
* We use a chained one-time-work pattern rather than `PeriodicWorkRequest`
* because:
* - `PeriodicWorkRequest` has a 15-minute floor which is fine, but more
* importantly can't *vary* its cadence between runs.
* - The iOS-parity spec needs 30-min during the day and 120-min overnight
* — so each run computes the next interval based on the local clock
* and enqueues the next one-time request.
*
* On [schedulePeriodic], the worker is enqueued with an initial delay of
* `intervalMinutes(now)`. On successful completion [WidgetRefreshWorker]
* calls [schedulePeriodic] again to chain the next wake.
*/
object WidgetUpdateManager {
private val json = Json { ignoreUnknownKeys = true }
/** Unique name for the periodic (chained) refresh queue. */
const val UNIQUE_WORK_NAME: String = "widget_refresh_periodic"
/** Unique name for user- / app-triggered forced refreshes. */
const val FORCE_REFRESH_WORK_NAME: String = "widget_refresh_force"
/**
* Update all honeyDue widgets with new data
* Schedule the next periodic refresh. Delay = [WidgetRefreshSchedule.intervalMinutes]
* evaluated against the current local-zone clock. Existing work under
* [UNIQUE_WORK_NAME] is replaced — the new interval always wins.
*/
fun updateAllWidgets(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
try {
val repository = WidgetDataRepository.getInstance(context)
val summary = repository.widgetSummary.first()
val isProUser = repository.isProUser.first()
fun schedulePeriodic(context: Context) {
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
val delayMinutes = WidgetRefreshSchedule.intervalMinutes(now)
updateWidgetsWithData(context, summary, isProUser)
} catch (e: Exception) {
e.printStackTrace()
}
}
val request = OneTimeWorkRequestBuilder<WidgetRefreshWorker>()
.setInitialDelay(delayMinutes, TimeUnit.MINUTES)
.addTag(TAG)
.build()
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
/**
* Update widgets with the provided summary data
* Force an immediate refresh. Runs as an expedited worker so the OS
* treats it as a foreground-ish job (best-effort — may be denied
* quota, in which case it falls back to a regular one-time enqueue).
*/
suspend fun updateWidgetsWithData(
context: Context,
summary: WidgetSummary,
isProUser: Boolean
) {
val glanceManager = GlanceAppWidgetManager(context)
fun forceRefresh(context: Context) {
val request = OneTimeWorkRequestBuilder<WidgetRefreshWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.addTag(TAG)
.build()
// Update small widgets
val smallWidgetIds = glanceManager.getGlanceIds(HoneyDueSmallWidget::class.java)
smallWidgetIds.forEach { id ->
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
prefs.toMutablePreferences().apply {
this[intPreferencesKey("overdue_count")] = summary.overdueCount
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
}
}
HoneyDueSmallWidget().update(context, id)
}
// Update medium widgets
val mediumWidgetIds = glanceManager.getGlanceIds(HoneyDueMediumWidget::class.java)
mediumWidgetIds.forEach { id ->
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
prefs.toMutablePreferences().apply {
this[intPreferencesKey("overdue_count")] = summary.overdueCount
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
}
}
HoneyDueMediumWidget().update(context, id)
}
// Update large widgets
val largeWidgetIds = glanceManager.getGlanceIds(HoneyDueLargeWidget::class.java)
largeWidgetIds.forEach { id ->
updateAppWidgetState(context, PreferencesGlanceStateDefinition, id) { prefs ->
prefs.toMutablePreferences().apply {
this[intPreferencesKey("overdue_count")] = summary.overdueCount
this[intPreferencesKey("due_soon_count")] = summary.dueSoonCount
this[intPreferencesKey("in_progress_count")] = summary.inProgressCount
this[intPreferencesKey("total_tasks_count")] = summary.totalTasksCount
this[stringPreferencesKey("tasks_json")] = json.encodeToString(summary.tasks)
this[stringPreferencesKey("is_pro_user")] = if (isProUser) "true" else "false"
this[longPreferencesKey("last_updated")] = summary.lastUpdated
}
}
HoneyDueLargeWidget().update(context, id)
}
WorkManager.getInstance(context.applicationContext)
.enqueueUniqueWork(FORCE_REFRESH_WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
/**
* Clear all widget data (called on logout)
* Cancel any pending/chained periodic refresh. Does not affect
* in-flight forced refreshes — call [cancel] from a logout flow to
* stop the scheduler wholesale, or clear both queues explicitly.
*/
fun clearAllWidgets(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
try {
val emptyData = WidgetSummary()
updateWidgetsWithData(context, emptyData, false)
// Also clear the repository
val repository = WidgetDataRepository.getInstance(context)
repository.clearData()
} catch (e: Exception) {
e.printStackTrace()
}
}
fun cancel(context: Context) {
val wm = WorkManager.getInstance(context.applicationContext)
wm.cancelUniqueWork(UNIQUE_WORK_NAME)
wm.cancelUniqueWork(FORCE_REFRESH_WORK_NAME)
}
private const val TAG = "widget_refresh"
}