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:
@@ -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 12am–4am 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Pure-logic tests for [WidgetRefreshSchedule]. No Android/framework deps.
|
||||
*
|
||||
* Cadence matches iOS-parity spec:
|
||||
* - 06:00 (inclusive) .. 23:00 (exclusive) local → 30-minute interval
|
||||
* - 23:00 (inclusive) .. 06:00 (exclusive) local → 120-minute interval
|
||||
*
|
||||
* (iOS [BackgroundTaskManager.swift] uses a random-window overnight refresh;
|
||||
* Android uses WorkManager and the plan specifies this fixed-cadence split
|
||||
* since WorkManager can't simulate the iOS random-BGTask scheduling.)
|
||||
*/
|
||||
class WidgetRefreshScheduleTest {
|
||||
|
||||
private fun dt(hour: Int, minute: Int = 0): LocalDateTime =
|
||||
LocalDateTime(year = 2026, monthNumber = 4, dayOfMonth = 16, hour = hour, minute = minute)
|
||||
|
||||
@Test
|
||||
fun intervalMinutes_at_09am_returns_30() {
|
||||
assertEquals(30L, WidgetRefreshSchedule.intervalMinutes(dt(9, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intervalMinutes_at_22_59_returns_30() {
|
||||
assertEquals(30L, WidgetRefreshSchedule.intervalMinutes(dt(22, 59)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intervalMinutes_at_23_00_returns_120() {
|
||||
assertEquals(120L, WidgetRefreshSchedule.intervalMinutes(dt(23, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intervalMinutes_at_05_59_returns_120() {
|
||||
assertEquals(120L, WidgetRefreshSchedule.intervalMinutes(dt(5, 59)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intervalMinutes_at_06_00_returns_30() {
|
||||
assertEquals(30L, WidgetRefreshSchedule.intervalMinutes(dt(6, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intervalMinutes_at_02_00_returns_120() {
|
||||
assertEquals(120L, WidgetRefreshSchedule.intervalMinutes(dt(2, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intervalMinutes_at_midnight_returns_120() {
|
||||
assertEquals(120L, WidgetRefreshSchedule.intervalMinutes(dt(0, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun intervalMinutes_at_noon_returns_30() {
|
||||
assertEquals(30L, WidgetRefreshSchedule.intervalMinutes(dt(12, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextRefreshTime_at_09am_is_09_30() {
|
||||
val next = WidgetRefreshSchedule.nextRefreshTime(dt(9, 0))
|
||||
assertEquals(9, next.hour)
|
||||
assertEquals(30, next.minute)
|
||||
assertEquals(16, next.dayOfMonth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextRefreshTime_at_23_00_is_01_00_next_day() {
|
||||
val next = WidgetRefreshSchedule.nextRefreshTime(dt(23, 0))
|
||||
assertEquals(1, next.hour)
|
||||
assertEquals(0, next.minute)
|
||||
assertEquals(17, next.dayOfMonth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextRefreshTime_at_22_45_is_23_15_same_day() {
|
||||
// 22:45 + 30min = 23:15 (still 30min because 22:45 < 23:00)
|
||||
val next = WidgetRefreshSchedule.nextRefreshTime(dt(22, 45))
|
||||
assertEquals(23, next.hour)
|
||||
assertEquals(15, next.minute)
|
||||
assertEquals(16, next.dayOfMonth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextRefreshTime_at_05_30_is_07_30_same_day() {
|
||||
// 05:30 + 120min = 07:30
|
||||
val next = WidgetRefreshSchedule.nextRefreshTime(dt(5, 30))
|
||||
assertEquals(7, next.hour)
|
||||
assertEquals(30, next.minute)
|
||||
assertEquals(16, next.dayOfMonth)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import com.tt.honeyDue.network.ApiResult
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Tests for [WidgetRefreshWorker] under Robolectric + WorkManager's
|
||||
* TestListenableWorkerBuilder.
|
||||
*
|
||||
* We avoid mocking the [com.tt.honeyDue.network.APILayer] singleton directly.
|
||||
* Instead the worker is parameterized by a [WidgetRefreshDataSource] that the
|
||||
* test swaps in via [WidgetRefreshWorker.dataSourceOverride].
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||
class WidgetRefreshWorkerTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var repo: WidgetDataRepository
|
||||
|
||||
@Before
|
||||
fun setUp() = runBlocking {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
repo = WidgetDataRepository.get(context)
|
||||
repo.clearAll()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = runBlocking {
|
||||
WidgetRefreshWorker.dataSourceOverride = null
|
||||
repo.clearAll()
|
||||
}
|
||||
|
||||
private fun sampleTask(id: Long = 1L) = WidgetTaskDto(
|
||||
id = id,
|
||||
title = "Change air filter",
|
||||
priority = 2L,
|
||||
dueDate = "2026-04-20",
|
||||
isOverdue = false,
|
||||
daysUntilDue = 4,
|
||||
residenceId = 10L,
|
||||
residenceName = "Home",
|
||||
categoryIcon = "house.fill",
|
||||
completed = false
|
||||
)
|
||||
|
||||
@Test
|
||||
fun worker_success_when_dataSource_returns_success() = runTest {
|
||||
val tasks = listOf(sampleTask(id = 1L), sampleTask(id = 2L))
|
||||
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
|
||||
tasksResult = ApiResult.Success(tasks),
|
||||
tier = "free"
|
||||
)
|
||||
|
||||
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
|
||||
val result = worker.doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.success(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun worker_persists_tasks_via_repository_on_success() = runTest {
|
||||
val tasks = listOf(
|
||||
sampleTask(id = 100L),
|
||||
sampleTask(id = 200L).copy(title = "Clean gutters")
|
||||
)
|
||||
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
|
||||
tasksResult = ApiResult.Success(tasks),
|
||||
tier = "free"
|
||||
)
|
||||
|
||||
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
|
||||
worker.doWork()
|
||||
|
||||
val stored = repo.loadTasks()
|
||||
assertEquals(2, stored.size)
|
||||
val byId = stored.associateBy { it.id }
|
||||
assertEquals("Change air filter", byId[100L]?.title)
|
||||
assertEquals("Clean gutters", byId[200L]?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun worker_persists_tier_state_on_success() = runTest {
|
||||
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
|
||||
tasksResult = ApiResult.Success(emptyList()),
|
||||
tier = "premium"
|
||||
)
|
||||
|
||||
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
|
||||
worker.doWork()
|
||||
|
||||
assertEquals("premium", repo.loadTierState())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun worker_returns_retry_when_api_returns_transient_error() = runTest {
|
||||
// 500/503/timeout-class errors are retryable.
|
||||
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
|
||||
tasksResult = ApiResult.Error("server unavailable", 503),
|
||||
tier = "free"
|
||||
)
|
||||
|
||||
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
|
||||
val result = worker.doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.retry(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun worker_returns_retry_when_api_returns_network_error() = runTest {
|
||||
// Unknown code (e.g. network failure) → retryable.
|
||||
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
|
||||
tasksResult = ApiResult.Error("network timeout", null),
|
||||
tier = "free"
|
||||
)
|
||||
|
||||
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
|
||||
val result = worker.doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.retry(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun worker_returns_failure_when_api_returns_auth_error() = runTest {
|
||||
// 401 is permanent — user logged out, widget should stop trying.
|
||||
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
|
||||
tasksResult = ApiResult.Error("not authenticated", 401),
|
||||
tier = "free"
|
||||
)
|
||||
|
||||
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
|
||||
val result = worker.doWork()
|
||||
|
||||
assertEquals(ListenableWorker.Result.failure(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun worker_does_not_clobber_tasks_on_api_failure() = runTest {
|
||||
// Pre-seed store with prior tasks.
|
||||
repo.saveTasks(listOf(sampleTask(id = 999L)))
|
||||
|
||||
WidgetRefreshWorker.dataSourceOverride = FakeDataSource(
|
||||
tasksResult = ApiResult.Error("server", 503),
|
||||
tier = "free"
|
||||
)
|
||||
|
||||
val worker = TestListenableWorkerBuilder<WidgetRefreshWorker>(context).build()
|
||||
worker.doWork()
|
||||
|
||||
val stored = repo.loadTasks()
|
||||
assertEquals(1, stored.size)
|
||||
assertTrue(stored.any { it.id == 999L })
|
||||
}
|
||||
|
||||
/** Minimal [WidgetRefreshDataSource] stub for tests. */
|
||||
private class FakeDataSource(
|
||||
private val tasksResult: ApiResult<List<WidgetTaskDto>>,
|
||||
private val tier: String
|
||||
) : WidgetRefreshDataSource {
|
||||
override suspend fun fetchTasks(): ApiResult<List<WidgetTaskDto>> = tasksResult
|
||||
override suspend fun fetchTier(): String = tier
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.tt.honeyDue.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/**
|
||||
* Tests for [WidgetUpdateManager] using WorkManager's in-memory test
|
||||
* infrastructure.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.TIRAMISU])
|
||||
class WidgetUpdateManagerTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var workManager: WorkManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
val config = Configuration.Builder()
|
||||
.setMinimumLoggingLevel(android.util.Log.DEBUG)
|
||||
.build()
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context, config)
|
||||
workManager = WorkManager.getInstance(context)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schedulePeriodic_enqueues_unique_work() = runBlocking {
|
||||
WidgetUpdateManager.schedulePeriodic(context)
|
||||
|
||||
val infos = workManager
|
||||
.getWorkInfosForUniqueWork(WidgetUpdateManager.UNIQUE_WORK_NAME)
|
||||
.get()
|
||||
|
||||
assertEquals(1, infos.size)
|
||||
val state = infos.first().state
|
||||
assertTrue(
|
||||
"Expected ENQUEUED or RUNNING, got $state",
|
||||
state == WorkInfo.State.ENQUEUED || state == WorkInfo.State.RUNNING
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun schedulePeriodic_twice_replaces_work() = runBlocking {
|
||||
WidgetUpdateManager.schedulePeriodic(context)
|
||||
val firstInfos = workManager
|
||||
.getWorkInfosForUniqueWork(WidgetUpdateManager.UNIQUE_WORK_NAME)
|
||||
.get()
|
||||
val firstId = firstInfos.first().id
|
||||
|
||||
WidgetUpdateManager.schedulePeriodic(context)
|
||||
val secondInfos = workManager
|
||||
.getWorkInfosForUniqueWork(WidgetUpdateManager.UNIQUE_WORK_NAME)
|
||||
.get()
|
||||
|
||||
// With REPLACE policy, only one active entry remains; the old id may
|
||||
// linger briefly in CANCELLED state but the active id is new.
|
||||
val activeSecond = secondInfos.filter {
|
||||
it.state != WorkInfo.State.CANCELLED && it.state != WorkInfo.State.SUCCEEDED
|
||||
}
|
||||
assertEquals(1, activeSecond.size)
|
||||
assertTrue(
|
||||
"REPLACE should have enqueued a new work id",
|
||||
activeSecond.first().id != firstId
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceRefresh_enqueues_separate_unique_work() = runBlocking {
|
||||
WidgetUpdateManager.forceRefresh(context)
|
||||
|
||||
val infos = workManager
|
||||
.getWorkInfosForUniqueWork(WidgetUpdateManager.FORCE_REFRESH_WORK_NAME)
|
||||
.get()
|
||||
|
||||
assertEquals(1, infos.size)
|
||||
val state = infos.first().state
|
||||
assertTrue(
|
||||
"Expected ENQUEUED or RUNNING, got $state",
|
||||
state == WorkInfo.State.ENQUEUED || state == WorkInfo.State.RUNNING
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancel_removes_scheduled_work() = runBlocking {
|
||||
WidgetUpdateManager.schedulePeriodic(context)
|
||||
WidgetUpdateManager.cancel(context)
|
||||
|
||||
val infos = workManager
|
||||
.getWorkInfosForUniqueWork(WidgetUpdateManager.UNIQUE_WORK_NAME)
|
||||
.get()
|
||||
|
||||
val active = infos.filter {
|
||||
it.state == WorkInfo.State.ENQUEUED || it.state == WorkInfo.State.RUNNING
|
||||
}
|
||||
assertTrue("No active work should remain after cancel", active.isEmpty())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user