diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 78b629540f..52784aaf53 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,6 +22,7 @@
+
@@ -172,6 +173,16 @@
+
+
+
+
+
+
+
+
{
Logger.debug("ACTION_STOP_SERVICE_AND_APP detected", context = TAG)
+ appWidgetRefreshScheduler.ensureScheduled(AppWidgetRefreshReason.SERVICE_STOP_ACTION)
+ appWidgetRefreshScheduler.requestCatchUp(AppWidgetRefreshReason.SERVICE_STOP_ACTION)
serviceScope.launch {
lightningRepo.stop()
activityManager.appTasks.forEach { it.finishAndRemoveTask() }
diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt
index 4473a4f45f..c64d559123 100644
--- a/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt
+++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt
@@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.dataStore
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
+import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.Flow
@@ -12,6 +13,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import to.bitkit.appwidget.model.AppWidgetData
import to.bitkit.appwidget.model.AppWidgetEntry
+import to.bitkit.appwidget.model.AppWidgetRefreshMetadata
import to.bitkit.appwidget.model.AppWidgetType
import to.bitkit.data.dto.ArticleDTO
import to.bitkit.data.dto.BlockDTO
@@ -33,9 +35,15 @@ private val Context.appWidgetDataStore: DataStore by dataStore(
interface AppWidgetEntryPoint {
fun appWidgetPreferencesStore(): AppWidgetPreferencesStore
fun appWidgetDataRepository(): AppWidgetDataRepository
+ fun appWidgetRefreshScheduler(): AppWidgetRefreshScheduler
fun currencyRepo(): CurrencyRepo
}
+val Context.appWidgetRefreshScheduler: AppWidgetRefreshScheduler
+ get() = EntryPointAccessors
+ .fromApplication(applicationContext, AppWidgetEntryPoint::class.java)
+ .appWidgetRefreshScheduler()
+
@Singleton
@Suppress("TooManyFunctions")
class AppWidgetPreferencesStore @Inject constructor(
@@ -80,6 +88,17 @@ class AppWidgetPreferencesStore @Inject constructor(
.map { it.pricePreferences.period }
.toSet()
+ suspend fun getRefreshMetadata(type: AppWidgetType): AppWidgetRefreshMetadata =
+ store.data.first().refreshMetadata[type] ?: AppWidgetRefreshMetadata()
+
+ suspend fun markRefreshAttempt(type: AppWidgetType, timestampMs: Long) {
+ updateRefreshMetadata(type) { it.copy(lastAttemptAtMs = timestampMs) }
+ }
+
+ suspend fun markRefreshSuccess(type: AppWidgetType, timestampMs: Long) {
+ updateRefreshMetadata(type) { it.copy(lastSuccessAtMs = timestampMs) }
+ }
+
fun hasWidgetsOfType(type: AppWidgetType): Flow =
data.map { it.entries.any { entry -> entry.type == type } }
@@ -112,4 +131,14 @@ class AppWidgetPreferencesStore @Inject constructor(
suspend fun cacheWeather(weather: WeatherDTO) {
store.updateData { it.copy(cachedWeather = weather) }
}
+
+ private suspend fun updateRefreshMetadata(
+ type: AppWidgetType,
+ transform: (AppWidgetRefreshMetadata) -> AppWidgetRefreshMetadata,
+ ) {
+ store.updateData {
+ val current = it.refreshMetadata[type] ?: AppWidgetRefreshMetadata()
+ it.copy(refreshMetadata = it.refreshMetadata + (type to transform(current)))
+ }
+ }
}
diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshPolicy.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshPolicy.kt
new file mode 100644
index 0000000000..77192663d8
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshPolicy.kt
@@ -0,0 +1,18 @@
+package to.bitkit.appwidget
+
+import to.bitkit.appwidget.model.AppWidgetRefreshMetadata
+import to.bitkit.appwidget.model.AppWidgetType
+
+object AppWidgetRefreshPolicy {
+ fun shouldRefreshRemote(
+ type: AppWidgetType,
+ metadata: AppWidgetRefreshMetadata,
+ nowMs: Long,
+ ): Boolean {
+ if (!type.isRemoteBacked()) return false
+ if (metadata.lastSuccessAtMs <= 0L) return true
+ return nowMs - metadata.lastSuccessAtMs >= AppWidgetRefreshScheduler.REFRESH_INTERVAL.inWholeMilliseconds
+ }
+
+ fun AppWidgetType.isRemoteBacked(): Boolean = this != AppWidgetType.FACTS
+}
diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshReceiver.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshReceiver.kt
new file mode 100644
index 0000000000..6802f8fbfb
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshReceiver.kt
@@ -0,0 +1,37 @@
+package to.bitkit.appwidget
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import to.bitkit.utils.Logger
+
+class AppWidgetRefreshReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ Intent.ACTION_BOOT_COMPLETED -> scheduleAfterSystemEvent(
+ context,
+ AppWidgetRefreshReason.BOOT_COMPLETED,
+ )
+
+ Intent.ACTION_MY_PACKAGE_REPLACED -> scheduleAfterSystemEvent(
+ context,
+ AppWidgetRefreshReason.PACKAGE_REPLACED,
+ )
+
+ AppWidgetRefreshScheduler.CATCH_UP_ALARM_ACTION -> {
+ Logger.debug("Received widget refresh alarm", context = TAG)
+ context.appWidgetRefreshScheduler.handleCatchUpAlarm(AppWidgetRefreshReason.CATCH_UP_ALARM)
+ }
+ }
+ }
+
+ private fun scheduleAfterSystemEvent(context: Context, reason: AppWidgetRefreshReason) {
+ Logger.debug("Received widget refresh event for '${reason.name}'", context = TAG)
+ context.appWidgetRefreshScheduler.ensureScheduled(reason)
+ context.appWidgetRefreshScheduler.requestCatchUp(reason)
+ }
+
+ private companion object {
+ const val TAG = "AppWidgetRefreshReceiver"
+ }
+}
diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshScheduler.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshScheduler.kt
new file mode 100644
index 0000000000..251b409c6e
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshScheduler.kt
@@ -0,0 +1,284 @@
+package to.bitkit.appwidget
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.os.SystemClock
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+import androidx.work.Constraints
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.NetworkType
+import androidx.work.OneTimeWorkRequest
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.PeriodicWorkRequest
+import androidx.work.PeriodicWorkRequestBuilder
+import androidx.work.WorkManager
+import androidx.work.workDataOf
+import dagger.hilt.android.qualifiers.ApplicationContext
+import to.bitkit.appwidget.model.AppWidgetType
+import to.bitkit.appwidget.ui.blocks.BlocksGlanceReceiver
+import to.bitkit.appwidget.ui.facts.FactsGlanceReceiver
+import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceReceiver
+import to.bitkit.appwidget.ui.price.PriceGlanceReceiver
+import to.bitkit.appwidget.ui.weather.WeatherGlanceReceiver
+import to.bitkit.ext.alarmManager
+import to.bitkit.utils.Logger
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.toJavaDuration
+
+@Singleton
+class AppWidgetRefreshScheduler @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val activeWidgets: AppWidgetActiveWidgets,
+ private val workClient: AppWidgetWorkClient,
+ private val alarmClient: AppWidgetAlarmClient,
+ private val elapsedRealtimeProvider: ElapsedRealtimeProvider,
+) {
+ fun ensureScheduled(reason: AppWidgetRefreshReason) {
+ if (!activeWidgets.hasActiveWidgets()) {
+ cancelAll(reason)
+ return
+ }
+
+ workClient.enqueueUniquePeriodicWork(
+ PERIODIC_WORK_NAME,
+ ExistingPeriodicWorkPolicy.KEEP,
+ periodicRequest(reason),
+ )
+ scheduleCatchUpAlarm(reason)
+ Logger.debug("Ensured widget refresh schedule for '${reason.name}'", context = TAG)
+ }
+
+ fun requestCatchUp(reason: AppWidgetRefreshReason) {
+ if (!activeWidgets.hasActiveWidgets()) {
+ cancelAll(reason)
+ return
+ }
+
+ workClient.enqueueUniqueWork(
+ CATCH_UP_WORK_NAME,
+ ExistingWorkPolicy.KEEP,
+ oneTimeRequest(reason),
+ )
+ Logger.debug("Requested widget catch-up refresh for '${reason.name}'", context = TAG)
+ }
+
+ fun cancelIfNoWidgets(reason: AppWidgetRefreshReason) {
+ if (activeWidgets.hasActiveWidgets()) return
+ cancelAll(reason)
+ }
+
+ fun handleCatchUpAlarm(reason: AppWidgetRefreshReason) {
+ requestCatchUp(reason)
+ scheduleCatchUpAlarm(reason)
+ }
+
+ private fun scheduleCatchUpAlarm(reason: AppWidgetRefreshReason) {
+ if (!activeWidgets.hasActiveWidgets()) {
+ cancelAll(reason)
+ return
+ }
+
+ val triggerAt = elapsedRealtimeProvider.elapsedRealtime() + REFRESH_INTERVAL.inWholeMilliseconds
+ runCatching {
+ alarmClient.setAndAllowWhileIdle(
+ AlarmManager.ELAPSED_REALTIME_WAKEUP,
+ triggerAt,
+ checkNotNull(
+ catchUpAlarmPendingIntent(PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE),
+ ) { "Expected catch-up alarm PendingIntent" },
+ )
+ }.onSuccess {
+ Logger.debug("Scheduled widget catch-up alarm for '${reason.name}'", context = TAG)
+ }.onFailure {
+ Logger.error("Failed to schedule widget catch-up alarm for '${reason.name}'", it, context = TAG)
+ }
+ }
+
+ private fun cancelAll(reason: AppWidgetRefreshReason) {
+ workClient.cancelUniqueWork(PERIODIC_WORK_NAME)
+ workClient.cancelUniqueWork(CATCH_UP_WORK_NAME)
+ cancelCatchUpAlarm()
+ Logger.debug("Canceled widget refresh schedule for '${reason.name}'", context = TAG)
+ }
+
+ private fun cancelCatchUpAlarm() {
+ val pendingIntent = catchUpAlarmPendingIntent(
+ PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
+ ) ?: return
+
+ alarmClient.cancel(pendingIntent)
+ pendingIntent.cancel()
+ }
+
+ private fun catchUpAlarmPendingIntent(flags: Int): PendingIntent? =
+ PendingIntent.getBroadcast(
+ context,
+ CATCH_UP_ALARM_REQUEST_CODE,
+ catchUpAlarmIntent(),
+ flags,
+ )
+
+ private fun catchUpAlarmIntent(): Intent =
+ Intent(context, AppWidgetRefreshReceiver::class.java)
+ .setAction(CATCH_UP_ALARM_ACTION)
+
+ private fun periodicRequest(reason: AppWidgetRefreshReason): PeriodicWorkRequest =
+ PeriodicWorkRequestBuilder(REFRESH_INTERVAL.toJavaDuration())
+ .setConstraints(networkConstraints())
+ .setInputData(workDataOf(WORK_INPUT_REASON to reason.name))
+ .build()
+
+ private fun oneTimeRequest(reason: AppWidgetRefreshReason): OneTimeWorkRequest =
+ OneTimeWorkRequestBuilder()
+ .setConstraints(networkConstraints())
+ .setInputData(workDataOf(WORK_INPUT_REASON to reason.name))
+ .build()
+
+ private fun networkConstraints(): Constraints =
+ Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build()
+
+ companion object {
+ const val CATCH_UP_ALARM_ACTION = "to.bitkit.appwidget.REFRESH_ALARM"
+ const val WORK_INPUT_REASON = "reason"
+ const val PERIODIC_WORK_NAME = "appwidget_refresh"
+ const val CATCH_UP_WORK_NAME = "appwidget_refresh_catch_up"
+ private const val TAG = "AppWidgetRefreshScheduler"
+ private const val CATCH_UP_ALARM_REQUEST_CODE = 0
+ val REFRESH_INTERVAL = 15.minutes
+ }
+}
+
+enum class AppWidgetRefreshReason {
+ APP_START,
+ APP_FOREGROUND,
+ BLOCKS_WIDGET_DISABLED,
+ BLOCKS_WIDGET_ENABLED,
+ BLOCKS_WIDGET_UPDATE,
+ BOOT_COMPLETED,
+ CATCH_UP_ALARM,
+ FACTS_WIDGET_DISABLED,
+ FACTS_WIDGET_ENABLED,
+ FACTS_WIDGET_REGISTERED,
+ FACTS_WIDGET_UPDATE,
+ HEADLINES_WIDGET_DISABLED,
+ HEADLINES_WIDGET_ENABLED,
+ HEADLINES_WIDGET_UPDATE,
+ PACKAGE_REPLACED,
+ PRICE_WIDGET_DISABLED,
+ PRICE_WIDGET_ENABLED,
+ PRICE_WIDGET_UPDATE,
+ SERVICE_STOP_ACTION,
+ WEATHER_WIDGET_DISABLED,
+ WEATHER_WIDGET_ENABLED,
+ WEATHER_WIDGET_UPDATE,
+ WIDGET_CONFIG_CONFIRM,
+}
+
+fun AppWidgetType.receiverClass(): Class = when (this) {
+ AppWidgetType.PRICE -> PriceGlanceReceiver::class.java
+ AppWidgetType.HEADLINES -> HeadlinesGlanceReceiver::class.java
+ AppWidgetType.BLOCKS -> BlocksGlanceReceiver::class.java
+ AppWidgetType.FACTS -> FactsGlanceReceiver::class.java
+ AppWidgetType.WEATHER -> WeatherGlanceReceiver::class.java
+}
+
+interface AppWidgetActiveWidgets {
+ fun hasActiveWidgets(): Boolean
+}
+
+@Singleton
+class AndroidAppWidgetActiveWidgets @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : AppWidgetActiveWidgets {
+ override fun hasActiveWidgets(): Boolean {
+ val manager = AppWidgetManager.getInstance(context)
+ return AppWidgetType.entries.any {
+ manager.getAppWidgetIds(ComponentName(context, it.receiverClass())).isNotEmpty()
+ }
+ }
+}
+
+interface AppWidgetWorkClient {
+ fun enqueueUniquePeriodicWork(
+ uniqueWorkName: String,
+ existingPeriodicWorkPolicy: ExistingPeriodicWorkPolicy,
+ request: PeriodicWorkRequest,
+ )
+
+ fun enqueueUniqueWork(
+ uniqueWorkName: String,
+ existingWorkPolicy: ExistingWorkPolicy,
+ request: OneTimeWorkRequest,
+ )
+
+ fun cancelUniqueWork(uniqueWorkName: String)
+}
+
+@Singleton
+class AndroidAppWidgetWorkClient @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : AppWidgetWorkClient {
+ override fun enqueueUniquePeriodicWork(
+ uniqueWorkName: String,
+ existingPeriodicWorkPolicy: ExistingPeriodicWorkPolicy,
+ request: PeriodicWorkRequest,
+ ) {
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(
+ uniqueWorkName,
+ existingPeriodicWorkPolicy,
+ request,
+ )
+ }
+
+ override fun enqueueUniqueWork(
+ uniqueWorkName: String,
+ existingWorkPolicy: ExistingWorkPolicy,
+ request: OneTimeWorkRequest,
+ ) {
+ WorkManager.getInstance(context).enqueueUniqueWork(
+ uniqueWorkName,
+ existingWorkPolicy,
+ request,
+ )
+ }
+
+ override fun cancelUniqueWork(uniqueWorkName: String) {
+ WorkManager.getInstance(context).cancelUniqueWork(uniqueWorkName)
+ }
+}
+
+interface AppWidgetAlarmClient {
+ fun setAndAllowWhileIdle(type: Int, triggerAtMillis: Long, operation: PendingIntent)
+ fun cancel(operation: PendingIntent)
+}
+
+@Singleton
+class AndroidAppWidgetAlarmClient @Inject constructor(
+ @ApplicationContext private val context: Context,
+) : AppWidgetAlarmClient {
+ override fun setAndAllowWhileIdle(type: Int, triggerAtMillis: Long, operation: PendingIntent) {
+ context.alarmManager.setAndAllowWhileIdle(type, triggerAtMillis, operation)
+ }
+
+ override fun cancel(operation: PendingIntent) {
+ context.alarmManager.cancel(operation)
+ }
+}
+
+interface ElapsedRealtimeProvider {
+ fun elapsedRealtime(): Long
+}
+
+@Singleton
+class AndroidElapsedRealtimeProvider @Inject constructor() : ElapsedRealtimeProvider {
+ override fun elapsedRealtime(): Long = SystemClock.elapsedRealtime()
+}
diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt
index 20621a0cbe..a3012bf60a 100644
--- a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt
+++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt
@@ -1,141 +1,118 @@
package to.bitkit.appwidget
-import android.appwidget.AppWidgetManager
-import android.content.ComponentName
import android.content.Context
-import androidx.glance.appwidget.GlanceAppWidgetReceiver
-import androidx.glance.appwidget.updateAll
import androidx.hilt.work.HiltWorker
-import androidx.work.Constraints
import androidx.work.CoroutineWorker
-import androidx.work.ExistingPeriodicWorkPolicy
-import androidx.work.NetworkType
-import androidx.work.PeriodicWorkRequestBuilder
-import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import to.bitkit.appwidget.model.AppWidgetType
-import to.bitkit.appwidget.ui.blocks.BlocksGlanceReceiver
-import to.bitkit.appwidget.ui.blocks.BlocksGlanceWidget
-import to.bitkit.appwidget.ui.facts.FactsGlanceReceiver
-import to.bitkit.appwidget.ui.facts.FactsGlanceWidget
-import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceReceiver
-import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceWidget
-import to.bitkit.appwidget.ui.price.PriceGlanceReceiver
-import to.bitkit.appwidget.ui.price.PriceGlanceWidget
-import to.bitkit.appwidget.ui.weather.WeatherGlanceReceiver
-import to.bitkit.appwidget.ui.weather.WeatherGlanceWidget
+import to.bitkit.ext.nowMs
import to.bitkit.utils.Logger
-import kotlin.time.Duration.Companion.minutes
-import kotlin.time.toJavaDuration
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+@OptIn(ExperimentalTime::class)
@HiltWorker
class AppWidgetRefreshWorker @AssistedInject constructor(
@Assisted private val appContext: Context,
@Assisted workerParams: WorkerParameters,
private val dataRepository: AppWidgetDataRepository,
private val preferencesStore: AppWidgetPreferencesStore,
+ private val appWidgetUpdater: AppWidgetUpdater,
+ private val clock: Clock,
) : CoroutineWorker(appContext, workerParams) {
- companion object {
+ private companion object {
private const val TAG = "AppWidgetRefreshWorker"
- private const val WORK_NAME = "appwidget_refresh"
-
- fun enqueue(context: Context) {
- val constraints = Constraints.Builder()
- .setRequiredNetworkType(NetworkType.CONNECTED)
- .build()
-
- val request = PeriodicWorkRequestBuilder(15.minutes.toJavaDuration())
- .setConstraints(constraints)
- .build()
-
- WorkManager.getInstance(context).enqueueUniquePeriodicWork(
- WORK_NAME,
- ExistingPeriodicWorkPolicy.KEEP,
- request,
- )
- }
-
- fun cancelIfNoWidgets(context: Context) {
- val manager = AppWidgetManager.getInstance(context)
- val hasAny = AppWidgetType.entries.any { type ->
- manager.getAppWidgetIds(ComponentName(context, receiverClassFor(type))).isNotEmpty()
- }
- if (!hasAny) {
- WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
- }
- }
-
- private fun receiverClassFor(type: AppWidgetType): Class = when (type) {
- AppWidgetType.PRICE -> PriceGlanceReceiver::class.java
- AppWidgetType.HEADLINES -> HeadlinesGlanceReceiver::class.java
- AppWidgetType.BLOCKS -> BlocksGlanceReceiver::class.java
- AppWidgetType.FACTS -> FactsGlanceReceiver::class.java
- AppWidgetType.WEATHER -> WeatherGlanceReceiver::class.java
- }
}
override suspend fun doWork(): Result {
- val activeTypes = preferencesStore.getActiveWidgetTypes()
+ val activeTypes = AppWidgetType.entries.filter { it in preferencesStore.getActiveWidgetTypes() }
if (activeTypes.isEmpty()) return Result.success()
- Logger.debug("Refreshing data for widget types: '$activeTypes'", context = TAG)
+ val reason = inputData.getString(AppWidgetRefreshScheduler.WORK_INPUT_REASON) ?: "unknown"
+ val nowMs = clock.nowMs()
+ Logger.debug("Refreshing widget types '$activeTypes' for '$reason'", context = TAG)
for (type in activeTypes) {
- when (type) {
- AppWidgetType.PRICE -> {
- val periods = preferencesStore.getActivePricePeriods()
- periods.forEach { period ->
- dataRepository.fetchPriceData(period)
- .onSuccess { preferencesStore.cachePriceData(period, it) }
- .onFailure {
- Logger.warn("Failed to refresh price for '$period'", it, context = TAG)
- }
- }
- PriceGlanceWidget().updateAll(appContext)
- }
+ runCatching { refresh(type, nowMs) }.onFailure {
+ if (it is CancellationException) throw it
+ Logger.warn("Failed to refresh widget type '$type'", it, context = TAG)
+ }
+ }
- AppWidgetType.HEADLINES -> {
- dataRepository.fetchArticles()
- .onSuccess { preferencesStore.cacheArticlesAndRotate(it) }
- .onFailure {
- Logger.warn("Failed to refresh headlines", it, context = TAG)
- }
- HeadlinesGlanceWidget().updateAll(appContext)
- }
+ return Result.success()
+ }
- AppWidgetType.BLOCKS -> {
- dataRepository.fetchBlock()
- .onSuccess { preferencesStore.cacheBlock(it) }
- .onFailure {
- Logger.warn("Failed to refresh block", it, context = TAG)
- }
- BlocksGlanceWidget().updateAll(appContext)
- }
+ private suspend fun refresh(type: AppWidgetType, nowMs: Long) {
+ if (type == AppWidgetType.FACTS) {
+ refreshFacts()
+ return
+ }
- AppWidgetType.FACTS -> {
- dataRepository.fetchFacts()
- .onSuccess { preferencesStore.cacheFacts(it) }
- .onFailure {
- Logger.warn("Failed to refresh facts", it, context = TAG)
- }
- preferencesStore.bumpFactsRotationTick()
- FactsGlanceWidget().updateAll(appContext)
- }
+ val metadata = preferencesStore.getRefreshMetadata(type)
+ if (!AppWidgetRefreshPolicy.shouldRefreshRemote(type, metadata, nowMs)) {
+ Logger.debug("Skipped fresh widget type '$type'", context = TAG)
+ appWidgetUpdater.update(type, appContext)
+ return
+ }
+
+ preferencesStore.markRefreshAttempt(type, nowMs)
+ if (refreshRemote(type)) {
+ preferencesStore.markRefreshSuccess(type, nowMs)
+ }
+ appWidgetUpdater.update(type, appContext)
+ }
+
+ private suspend fun refreshRemote(type: AppWidgetType): Boolean = when (type) {
+ AppWidgetType.PRICE -> refreshPrice()
+ AppWidgetType.HEADLINES -> refreshHeadlines()
+ AppWidgetType.BLOCKS -> refreshBlocks()
+ AppWidgetType.WEATHER -> refreshWeather()
+ AppWidgetType.FACTS -> false
+ }
+
+ private suspend fun refreshPrice(): Boolean {
+ val periods = preferencesStore.getActivePricePeriods()
+ var didSucceed = periods.isNotEmpty()
- AppWidgetType.WEATHER -> {
- dataRepository.fetchWeather()
- .onSuccess { preferencesStore.cacheWeather(it) }
- .onFailure {
- Logger.warn("Failed to refresh weather", it, context = TAG)
- }
- WeatherGlanceWidget().updateAll(appContext)
+ periods.forEach { period ->
+ dataRepository.fetchPriceData(period)
+ .onSuccess { preferencesStore.cachePriceData(period, it) }
+ .onFailure {
+ didSucceed = false
+ Logger.warn("Failed to refresh price for '$period'", it, context = TAG)
}
- }
}
- return Result.success()
+ return didSucceed
}
+
+ private suspend fun refreshHeadlines(): Boolean =
+ dataRepository.fetchArticles()
+ .onSuccess { preferencesStore.cacheArticlesAndRotate(it) }
+ .onFailure { Logger.warn("Failed to refresh headlines", it, context = TAG) }
+ .isSuccess
+
+ private suspend fun refreshBlocks(): Boolean =
+ dataRepository.fetchBlock()
+ .onSuccess { preferencesStore.cacheBlock(it) }
+ .onFailure { Logger.warn("Failed to refresh block", it, context = TAG) }
+ .isSuccess
+
+ private suspend fun refreshFacts() {
+ dataRepository.fetchFacts()
+ .onSuccess { preferencesStore.cacheFacts(it) }
+ .onFailure { Logger.warn("Failed to refresh facts", it, context = TAG) }
+ preferencesStore.bumpFactsRotationTick()
+ appWidgetUpdater.update(AppWidgetType.FACTS, appContext)
+ }
+
+ private suspend fun refreshWeather(): Boolean =
+ dataRepository.fetchWeather()
+ .onSuccess { preferencesStore.cacheWeather(it) }
+ .onFailure { Logger.warn("Failed to refresh weather", it, context = TAG) }
+ .isSuccess
}
diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetUpdater.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetUpdater.kt
new file mode 100644
index 0000000000..2ec77c4686
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetUpdater.kt
@@ -0,0 +1,25 @@
+package to.bitkit.appwidget
+
+import android.content.Context
+import androidx.glance.appwidget.updateAll
+import to.bitkit.appwidget.model.AppWidgetType
+import to.bitkit.appwidget.ui.blocks.BlocksGlanceWidget
+import to.bitkit.appwidget.ui.facts.FactsGlanceWidget
+import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceWidget
+import to.bitkit.appwidget.ui.price.PriceGlanceWidget
+import to.bitkit.appwidget.ui.weather.WeatherGlanceWidget
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AppWidgetUpdater @Inject constructor() {
+ suspend fun update(type: AppWidgetType, context: Context) {
+ when (type) {
+ AppWidgetType.PRICE -> PriceGlanceWidget().updateAll(context)
+ AppWidgetType.HEADLINES -> HeadlinesGlanceWidget().updateAll(context)
+ AppWidgetType.BLOCKS -> BlocksGlanceWidget().updateAll(context)
+ AppWidgetType.FACTS -> FactsGlanceWidget().updateAll(context)
+ AppWidgetType.WEATHER -> WeatherGlanceWidget().updateAll(context)
+ }
+ }
+}
diff --git a/app/src/main/java/to/bitkit/appwidget/RefreshingGlanceReceiver.kt b/app/src/main/java/to/bitkit/appwidget/RefreshingGlanceReceiver.kt
new file mode 100644
index 0000000000..0cb9944661
--- /dev/null
+++ b/app/src/main/java/to/bitkit/appwidget/RefreshingGlanceReceiver.kt
@@ -0,0 +1,32 @@
+package to.bitkit.appwidget
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import androidx.glance.appwidget.GlanceAppWidgetReceiver
+
+abstract class RefreshingGlanceReceiver(
+ private val enabledReason: AppWidgetRefreshReason,
+ private val updateReason: AppWidgetRefreshReason,
+ private val disabledReason: AppWidgetRefreshReason,
+) : GlanceAppWidgetReceiver() {
+ override fun onEnabled(context: Context) {
+ super.onEnabled(context)
+ context.appWidgetRefreshScheduler.ensureScheduled(enabledReason)
+ context.appWidgetRefreshScheduler.requestCatchUp(enabledReason)
+ }
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
+ context.appWidgetRefreshScheduler.ensureScheduled(updateReason)
+ context.appWidgetRefreshScheduler.requestCatchUp(updateReason)
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+ context.appWidgetRefreshScheduler.cancelIfNoWidgets(disabledReason)
+ }
+}
diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt
index 2866db9fb9..b7daa84708 100644
--- a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt
+++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt
@@ -8,7 +8,8 @@ import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.glance.appwidget.updateAll
import dagger.hilt.android.AndroidEntryPoint
-import to.bitkit.appwidget.AppWidgetRefreshWorker
+import to.bitkit.appwidget.AppWidgetRefreshReason
+import to.bitkit.appwidget.AppWidgetRefreshScheduler
import to.bitkit.appwidget.model.AppWidgetType
import to.bitkit.appwidget.ui.blocks.BlocksGlanceReceiver
import to.bitkit.appwidget.ui.blocks.BlocksGlanceWidget
@@ -21,6 +22,7 @@ import to.bitkit.appwidget.ui.weather.WeatherGlanceWidget
import to.bitkit.ui.theme.AppThemeSurface
import to.bitkit.ui.utils.enableAppEdgeToEdge
import to.bitkit.utils.Logger
+import javax.inject.Inject
@AndroidEntryPoint
class AppWidgetConfigActivity : ComponentActivity() {
@@ -32,6 +34,9 @@ class AppWidgetConfigActivity : ComponentActivity() {
private val viewModel: AppWidgetConfigViewModel by viewModels()
+ @Inject
+ lateinit var appWidgetRefreshScheduler: AppWidgetRefreshScheduler
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableAppEdgeToEdge()
@@ -66,7 +71,8 @@ class AppWidgetConfigActivity : ComponentActivity() {
AppWidgetType.FACTS -> Unit
AppWidgetType.WEATHER -> WeatherGlanceWidget().updateAll(this@AppWidgetConfigActivity)
}
- AppWidgetRefreshWorker.enqueue(this@AppWidgetConfigActivity)
+ appWidgetRefreshScheduler.ensureScheduled(AppWidgetRefreshReason.WIDGET_CONFIG_CONFIRM)
+ appWidgetRefreshScheduler.requestCatchUp(AppWidgetRefreshReason.WIDGET_CONFIG_CONFIRM)
val result = Intent().putExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID,
appWidgetId,
diff --git a/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt b/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt
index 91a3f88b26..6f91ad60e9 100644
--- a/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt
+++ b/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt
@@ -65,6 +65,7 @@ data class HomeWeatherPreferences(
data class AppWidgetData(
val entries: List = emptyList(),
val cachedPrices: Map = emptyMap(),
+ val refreshMetadata: Map = emptyMap(),
val cachedArticles: List = emptyList(),
val articleRotationTick: Int = 0,
val cachedBlock: BlockDTO? = null,
@@ -72,3 +73,10 @@ data class AppWidgetData(
val factsRotationTick: Int = 0,
val cachedWeather: WeatherDTO? = null,
)
+
+@Stable
+@Serializable
+data class AppWidgetRefreshMetadata(
+ val lastAttemptAtMs: Long = 0L,
+ val lastSuccessAtMs: Long = 0L,
+)
diff --git a/app/src/main/java/to/bitkit/appwidget/ui/blocks/BlocksGlanceReceiver.kt b/app/src/main/java/to/bitkit/appwidget/ui/blocks/BlocksGlanceReceiver.kt
index 35d2406fcb..c2ce8763c1 100644
--- a/app/src/main/java/to/bitkit/appwidget/ui/blocks/BlocksGlanceReceiver.kt
+++ b/app/src/main/java/to/bitkit/appwidget/ui/blocks/BlocksGlanceReceiver.kt
@@ -1,20 +1,13 @@
package to.bitkit.appwidget.ui.blocks
-import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
-import androidx.glance.appwidget.GlanceAppWidgetReceiver
-import to.bitkit.appwidget.AppWidgetRefreshWorker
-
-class BlocksGlanceReceiver : GlanceAppWidgetReceiver() {
+import to.bitkit.appwidget.AppWidgetRefreshReason
+import to.bitkit.appwidget.RefreshingGlanceReceiver
+
+class BlocksGlanceReceiver : RefreshingGlanceReceiver(
+ enabledReason = AppWidgetRefreshReason.BLOCKS_WIDGET_ENABLED,
+ updateReason = AppWidgetRefreshReason.BLOCKS_WIDGET_UPDATE,
+ disabledReason = AppWidgetRefreshReason.BLOCKS_WIDGET_DISABLED,
+) {
override val glanceAppWidget: GlanceAppWidget = BlocksGlanceWidget()
-
- override fun onEnabled(context: Context) {
- super.onEnabled(context)
- AppWidgetRefreshWorker.enqueue(context)
- }
-
- override fun onDisabled(context: Context) {
- super.onDisabled(context)
- AppWidgetRefreshWorker.cancelIfNoWidgets(context)
- }
}
diff --git a/app/src/main/java/to/bitkit/appwidget/ui/facts/FactsGlanceReceiver.kt b/app/src/main/java/to/bitkit/appwidget/ui/facts/FactsGlanceReceiver.kt
index 304fb0f1b9..053aeeba84 100644
--- a/app/src/main/java/to/bitkit/appwidget/ui/facts/FactsGlanceReceiver.kt
+++ b/app/src/main/java/to/bitkit/appwidget/ui/facts/FactsGlanceReceiver.kt
@@ -1,20 +1,13 @@
package to.bitkit.appwidget.ui.facts
-import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
-import androidx.glance.appwidget.GlanceAppWidgetReceiver
-import to.bitkit.appwidget.AppWidgetRefreshWorker
-
-class FactsGlanceReceiver : GlanceAppWidgetReceiver() {
+import to.bitkit.appwidget.AppWidgetRefreshReason
+import to.bitkit.appwidget.RefreshingGlanceReceiver
+
+class FactsGlanceReceiver : RefreshingGlanceReceiver(
+ enabledReason = AppWidgetRefreshReason.FACTS_WIDGET_ENABLED,
+ updateReason = AppWidgetRefreshReason.FACTS_WIDGET_UPDATE,
+ disabledReason = AppWidgetRefreshReason.FACTS_WIDGET_DISABLED,
+) {
override val glanceAppWidget: GlanceAppWidget = FactsGlanceWidget()
-
- override fun onEnabled(context: Context) {
- super.onEnabled(context)
- AppWidgetRefreshWorker.enqueue(context)
- }
-
- override fun onDisabled(context: Context) {
- super.onDisabled(context)
- AppWidgetRefreshWorker.cancelIfNoWidgets(context)
- }
}
diff --git a/app/src/main/java/to/bitkit/appwidget/ui/facts/FactsGlanceWidget.kt b/app/src/main/java/to/bitkit/appwidget/ui/facts/FactsGlanceWidget.kt
index 73417e8605..ea564a4486 100644
--- a/app/src/main/java/to/bitkit/appwidget/ui/facts/FactsGlanceWidget.kt
+++ b/app/src/main/java/to/bitkit/appwidget/ui/facts/FactsGlanceWidget.kt
@@ -11,7 +11,7 @@ import androidx.glance.appwidget.provideContent
import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.flow.first
import to.bitkit.appwidget.AppWidgetEntryPoint
-import to.bitkit.appwidget.AppWidgetRefreshWorker
+import to.bitkit.appwidget.AppWidgetRefreshReason
import to.bitkit.appwidget.model.AppWidgetData
import to.bitkit.appwidget.model.AppWidgetType
@@ -24,6 +24,7 @@ class FactsGlanceWidget : GlanceAppWidget() {
.fromApplication(context, AppWidgetEntryPoint::class.java)
val store = accessor.appWidgetPreferencesStore()
val repo = accessor.appWidgetDataRepository()
+ val appWidgetRefreshScheduler = accessor.appWidgetRefreshScheduler()
val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id)
val current = store.data.first()
@@ -33,7 +34,8 @@ class FactsGlanceWidget : GlanceAppWidget() {
if (current.cachedFacts.isEmpty()) {
repo.fetchFacts().onSuccess { store.cacheFacts(it) }
}
- AppWidgetRefreshWorker.enqueue(context)
+ appWidgetRefreshScheduler.ensureScheduled(AppWidgetRefreshReason.FACTS_WIDGET_REGISTERED)
+ appWidgetRefreshScheduler.requestCatchUp(AppWidgetRefreshReason.FACTS_WIDGET_REGISTERED)
}
provideContent {
diff --git a/app/src/main/java/to/bitkit/appwidget/ui/headlines/HeadlinesGlanceReceiver.kt b/app/src/main/java/to/bitkit/appwidget/ui/headlines/HeadlinesGlanceReceiver.kt
index 4c4c0327ca..bd2699c94b 100644
--- a/app/src/main/java/to/bitkit/appwidget/ui/headlines/HeadlinesGlanceReceiver.kt
+++ b/app/src/main/java/to/bitkit/appwidget/ui/headlines/HeadlinesGlanceReceiver.kt
@@ -1,20 +1,13 @@
package to.bitkit.appwidget.ui.headlines
-import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
-import androidx.glance.appwidget.GlanceAppWidgetReceiver
-import to.bitkit.appwidget.AppWidgetRefreshWorker
-
-class HeadlinesGlanceReceiver : GlanceAppWidgetReceiver() {
+import to.bitkit.appwidget.AppWidgetRefreshReason
+import to.bitkit.appwidget.RefreshingGlanceReceiver
+
+class HeadlinesGlanceReceiver : RefreshingGlanceReceiver(
+ enabledReason = AppWidgetRefreshReason.HEADLINES_WIDGET_ENABLED,
+ updateReason = AppWidgetRefreshReason.HEADLINES_WIDGET_UPDATE,
+ disabledReason = AppWidgetRefreshReason.HEADLINES_WIDGET_DISABLED,
+) {
override val glanceAppWidget: GlanceAppWidget = HeadlinesGlanceWidget()
-
- override fun onEnabled(context: Context) {
- super.onEnabled(context)
- AppWidgetRefreshWorker.enqueue(context)
- }
-
- override fun onDisabled(context: Context) {
- super.onDisabled(context)
- AppWidgetRefreshWorker.cancelIfNoWidgets(context)
- }
}
diff --git a/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceReceiver.kt b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceReceiver.kt
index 7b810c2288..d5ace3c032 100644
--- a/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceReceiver.kt
+++ b/app/src/main/java/to/bitkit/appwidget/ui/price/PriceGlanceReceiver.kt
@@ -1,20 +1,13 @@
package to.bitkit.appwidget.ui.price
-import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
-import androidx.glance.appwidget.GlanceAppWidgetReceiver
-import to.bitkit.appwidget.AppWidgetRefreshWorker
-
-class PriceGlanceReceiver : GlanceAppWidgetReceiver() {
+import to.bitkit.appwidget.AppWidgetRefreshReason
+import to.bitkit.appwidget.RefreshingGlanceReceiver
+
+class PriceGlanceReceiver : RefreshingGlanceReceiver(
+ enabledReason = AppWidgetRefreshReason.PRICE_WIDGET_ENABLED,
+ updateReason = AppWidgetRefreshReason.PRICE_WIDGET_UPDATE,
+ disabledReason = AppWidgetRefreshReason.PRICE_WIDGET_DISABLED,
+) {
override val glanceAppWidget: GlanceAppWidget = PriceGlanceWidget()
-
- override fun onEnabled(context: Context) {
- super.onEnabled(context)
- AppWidgetRefreshWorker.enqueue(context)
- }
-
- override fun onDisabled(context: Context) {
- super.onDisabled(context)
- AppWidgetRefreshWorker.cancelIfNoWidgets(context)
- }
}
diff --git a/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceReceiver.kt b/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceReceiver.kt
index 66e6dc0694..1412886f12 100644
--- a/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceReceiver.kt
+++ b/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceReceiver.kt
@@ -1,20 +1,13 @@
package to.bitkit.appwidget.ui.weather
-import android.content.Context
import androidx.glance.appwidget.GlanceAppWidget
-import androidx.glance.appwidget.GlanceAppWidgetReceiver
-import to.bitkit.appwidget.AppWidgetRefreshWorker
-
-class WeatherGlanceReceiver : GlanceAppWidgetReceiver() {
+import to.bitkit.appwidget.AppWidgetRefreshReason
+import to.bitkit.appwidget.RefreshingGlanceReceiver
+
+class WeatherGlanceReceiver : RefreshingGlanceReceiver(
+ enabledReason = AppWidgetRefreshReason.WEATHER_WIDGET_ENABLED,
+ updateReason = AppWidgetRefreshReason.WEATHER_WIDGET_UPDATE,
+ disabledReason = AppWidgetRefreshReason.WEATHER_WIDGET_DISABLED,
+) {
override val glanceAppWidget: GlanceAppWidget = WeatherGlanceWidget()
-
- override fun onEnabled(context: Context) {
- super.onEnabled(context)
- AppWidgetRefreshWorker.enqueue(context)
- }
-
- override fun onDisabled(context: Context) {
- super.onDisabled(context)
- AppWidgetRefreshWorker.cancelIfNoWidgets(context)
- }
}
diff --git a/app/src/main/java/to/bitkit/di/AppWidgetRefreshSchedulerModule.kt b/app/src/main/java/to/bitkit/di/AppWidgetRefreshSchedulerModule.kt
new file mode 100644
index 0000000000..650be65ee3
--- /dev/null
+++ b/app/src/main/java/to/bitkit/di/AppWidgetRefreshSchedulerModule.kt
@@ -0,0 +1,30 @@
+package to.bitkit.di
+
+import dagger.Binds
+import dagger.Module
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import to.bitkit.appwidget.AndroidAppWidgetActiveWidgets
+import to.bitkit.appwidget.AndroidAppWidgetAlarmClient
+import to.bitkit.appwidget.AndroidAppWidgetWorkClient
+import to.bitkit.appwidget.AndroidElapsedRealtimeProvider
+import to.bitkit.appwidget.AppWidgetActiveWidgets
+import to.bitkit.appwidget.AppWidgetAlarmClient
+import to.bitkit.appwidget.AppWidgetWorkClient
+import to.bitkit.appwidget.ElapsedRealtimeProvider
+
+@Module
+@InstallIn(SingletonComponent::class)
+interface AppWidgetRefreshSchedulerModule {
+ @Binds
+ fun bindActiveWidgets(impl: AndroidAppWidgetActiveWidgets): AppWidgetActiveWidgets
+
+ @Binds
+ fun bindWorkClient(impl: AndroidAppWidgetWorkClient): AppWidgetWorkClient
+
+ @Binds
+ fun bindAlarmClient(impl: AndroidAppWidgetAlarmClient): AppWidgetAlarmClient
+
+ @Binds
+ fun bindElapsedRealtimeProvider(impl: AndroidElapsedRealtimeProvider): ElapsedRealtimeProvider
+}
diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt
index 6720b83663..053c0cc41d 100644
--- a/app/src/main/java/to/bitkit/ext/Context.kt
+++ b/app/src/main/java/to/bitkit/ext/Context.kt
@@ -2,6 +2,7 @@ package to.bitkit.ext
import android.app.Activity
import android.app.ActivityManager
+import android.app.AlarmManager
import android.app.NotificationManager
import android.bluetooth.BluetoothManager
import android.content.ClipData
@@ -12,6 +13,7 @@ import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.hardware.usb.UsbManager
+import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
@@ -33,12 +35,18 @@ val Context.clipboardManager: ClipboardManager
val Context.activityManager: ActivityManager
get() = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
+val Context.alarmManager: AlarmManager
+ get() = getSystemService(Context.ALARM_SERVICE) as AlarmManager
+
val Context.usbManager: UsbManager
get() = getSystemService(Context.USB_SERVICE) as UsbManager
val Context.bluetoothManager: BluetoothManager
get() = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+val Context.powerManager: PowerManager
+ get() = getSystemService(Context.POWER_SERVICE) as PowerManager
+
// Permissions
fun Context.requiresPermission(permission: String): Boolean =
diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt
index ae9fc35c6e..e054b9b657 100644
--- a/app/src/main/java/to/bitkit/ui/ContentView.kt
+++ b/app/src/main/java/to/bitkit/ui/ContentView.kt
@@ -45,6 +45,8 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
+import to.bitkit.appwidget.AppWidgetRefreshReason
+import to.bitkit.appwidget.appWidgetRefreshScheduler
import to.bitkit.env.Env
import to.bitkit.models.NodeLifecycleState
import to.bitkit.models.Toast
@@ -224,6 +226,7 @@ fun ContentView(
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val context = LocalContext.current
+ val appWidgetRefreshScheduler = remember(context) { context.appWidgetRefreshScheduler }
val lifecycle = LocalLifecycleOwner.current.lifecycle
val walletUiState by walletViewModel.walletState.collectAsStateWithLifecycle()
@@ -245,6 +248,8 @@ fun ContentView(
appViewModel.consumePaymentReceivedInBackground()
+ appWidgetRefreshScheduler.ensureScheduled(AppWidgetRefreshReason.APP_FOREGROUND)
+ appWidgetRefreshScheduler.requestCatchUp(AppWidgetRefreshReason.APP_FOREGROUND)
currencyViewModel.triggerRefresh()
blocktankViewModel.refreshOrders()
appViewModel.refreshPublicPaykitEndpoints()
diff --git a/app/src/main/res/xml/appwidget_info_blocks.xml b/app/src/main/res/xml/appwidget_info_blocks.xml
index 9cab01b61e..05a253124a 100644
--- a/app/src/main/res/xml/appwidget_info_blocks.xml
+++ b/app/src/main/res/xml/appwidget_info_blocks.xml
@@ -15,5 +15,5 @@
android:description="@string/widgets__blocks__description"
android:configure="to.bitkit.appwidget.config.AppWidgetConfigActivity"
android:widgetFeatures="reconfigurable"
- android:updatePeriodMillis="0"
+ android:updatePeriodMillis="1800000"
tools:targetApi="31" />
diff --git a/app/src/main/res/xml/appwidget_info_facts.xml b/app/src/main/res/xml/appwidget_info_facts.xml
index 2525459188..7033b23637 100644
--- a/app/src/main/res/xml/appwidget_info_facts.xml
+++ b/app/src/main/res/xml/appwidget_info_facts.xml
@@ -12,5 +12,5 @@
android:initialLayout="@layout/glance_default_loading_layout"
android:previewLayout="@layout/appwidget_preview_facts"
android:description="@string/widgets__facts__description"
- android:updatePeriodMillis="0"
+ android:updatePeriodMillis="1800000"
tools:targetApi="31" />
diff --git a/app/src/main/res/xml/appwidget_info_headlines.xml b/app/src/main/res/xml/appwidget_info_headlines.xml
index 98eab9286e..8ec026eb67 100644
--- a/app/src/main/res/xml/appwidget_info_headlines.xml
+++ b/app/src/main/res/xml/appwidget_info_headlines.xml
@@ -14,5 +14,5 @@
android:description="@string/widgets__news__description"
android:configure="to.bitkit.appwidget.config.AppWidgetConfigActivity"
android:widgetFeatures="reconfigurable"
- android:updatePeriodMillis="0"
+ android:updatePeriodMillis="1800000"
tools:targetApi="31" />
diff --git a/app/src/main/res/xml/appwidget_info_price.xml b/app/src/main/res/xml/appwidget_info_price.xml
index db4c37b042..2349a2be25 100644
--- a/app/src/main/res/xml/appwidget_info_price.xml
+++ b/app/src/main/res/xml/appwidget_info_price.xml
@@ -14,5 +14,5 @@
android:description="@string/appwidget__price__description"
android:configure="to.bitkit.appwidget.config.AppWidgetConfigActivity"
android:widgetFeatures="reconfigurable"
- android:updatePeriodMillis="0"
+ android:updatePeriodMillis="1800000"
tools:targetApi="31" />
diff --git a/app/src/main/res/xml/appwidget_info_weather.xml b/app/src/main/res/xml/appwidget_info_weather.xml
index db5b3d58f3..d875e54483 100644
--- a/app/src/main/res/xml/appwidget_info_weather.xml
+++ b/app/src/main/res/xml/appwidget_info_weather.xml
@@ -14,5 +14,5 @@
android:description="@string/widgets__weather__description"
android:configure="to.bitkit.appwidget.config.AppWidgetConfigActivity"
android:widgetFeatures="reconfigurable"
- android:updatePeriodMillis="0"
+ android:updatePeriodMillis="1800000"
tools:targetApi="31" />
diff --git a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt
index 0e7bbafc31..cd01eb7a9f 100644
--- a/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt
+++ b/app/src/test/java/to/bitkit/androidServices/LightningNodeServiceTest.kt
@@ -5,6 +5,7 @@ import android.app.Activity
import android.app.Application
import android.app.Notification
import android.content.Context
+import android.content.Intent
import androidx.test.core.app.ApplicationProvider
import com.google.firebase.messaging.FirebaseMessaging
import dagger.hilt.android.testing.BindValue
@@ -24,6 +25,7 @@ import org.lightningdevkit.ldknode.Event
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.doAnswer
+import org.mockito.kotlin.inOrder
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.stub
@@ -36,6 +38,8 @@ import org.robolectric.annotation.Config
import to.bitkit.App
import to.bitkit.CurrentActivity
import to.bitkit.R
+import to.bitkit.appwidget.AppWidgetRefreshReason
+import to.bitkit.appwidget.AppWidgetRefreshScheduler
import to.bitkit.data.AppCacheData
import to.bitkit.data.CacheStore
import to.bitkit.di.DbModule
@@ -89,6 +93,9 @@ class LightningNodeServiceTest : BaseUnitTest() {
@BindValue
val cacheStore = mock()
+ @BindValue
+ val appWidgetRefreshScheduler = mock()
+
private var capturedHandler: NodeEventHandler? = null
private val cacheData = MutableSharedFlow(replay = 1)
private val context = ApplicationProvider.getApplicationContext()
@@ -377,4 +384,22 @@ class LightningNodeServiceTest : BaseUnitTest() {
}
assertNull(notification, "Non-pending payment should NOT trigger notification")
}
+
+ @Test
+ fun `stop service action schedules widget catch-up before shutdown`() = test {
+ val controller = Robolectric.buildService(LightningNodeService::class.java)
+ val service = controller.create().get()
+ val intent = Intent(context, LightningNodeService::class.java).apply {
+ action = LightningNodeService.ACTION_STOP_SERVICE_AND_APP
+ }
+
+ service.onStartCommand(intent, 0, 0)
+ testScheduler.advanceUntilIdle()
+
+ inOrder(appWidgetRefreshScheduler, lightningRepo) {
+ verify(appWidgetRefreshScheduler).ensureScheduled(AppWidgetRefreshReason.SERVICE_STOP_ACTION)
+ verify(appWidgetRefreshScheduler).requestCatchUp(AppWidgetRefreshReason.SERVICE_STOP_ACTION)
+ verify(lightningRepo).stop()
+ }
+ }
}
diff --git a/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshPolicyTest.kt b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshPolicyTest.kt
new file mode 100644
index 0000000000..ee472d4886
--- /dev/null
+++ b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshPolicyTest.kt
@@ -0,0 +1,58 @@
+package to.bitkit.appwidget
+
+import org.junit.Test
+import to.bitkit.appwidget.model.AppWidgetRefreshMetadata
+import to.bitkit.appwidget.model.AppWidgetType
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.minutes
+
+class AppWidgetRefreshPolicyTest {
+ @Test
+ fun `fresh remote widget type is skipped`() {
+ val nowMs = 1_000_000L
+ val metadata = AppWidgetRefreshMetadata(
+ lastAttemptAtMs = nowMs - 1.minutes.inWholeMilliseconds,
+ lastSuccessAtMs = nowMs - 1.minutes.inWholeMilliseconds,
+ )
+
+ val result = AppWidgetRefreshPolicy.shouldRefreshRemote(
+ AppWidgetType.HEADLINES,
+ metadata,
+ nowMs,
+ )
+
+ assertFalse(result)
+ }
+
+ @Test
+ fun `stale remote widget type is refreshed`() {
+ val nowMs = 1_000_000L
+ val metadata = AppWidgetRefreshMetadata(
+ lastAttemptAtMs = nowMs - 16.minutes.inWholeMilliseconds,
+ lastSuccessAtMs = nowMs - 16.minutes.inWholeMilliseconds,
+ )
+
+ val result = AppWidgetRefreshPolicy.shouldRefreshRemote(
+ AppWidgetType.WEATHER,
+ metadata,
+ nowMs,
+ )
+
+ assertTrue(result)
+ }
+
+ @Test
+ fun `facts widget type is never treated as remote backed`() {
+ val nowMs = 1_000_000L
+ val metadata = AppWidgetRefreshMetadata()
+
+ val result = AppWidgetRefreshPolicy.shouldRefreshRemote(
+ AppWidgetType.FACTS,
+ metadata,
+ nowMs,
+ )
+
+ assertFalse(result)
+ }
+}
diff --git a/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshReceiverTest.kt b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshReceiverTest.kt
new file mode 100644
index 0000000000..54677d23a5
--- /dev/null
+++ b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshReceiverTest.kt
@@ -0,0 +1,60 @@
+package to.bitkit.appwidget
+
+import android.content.Context
+import android.content.Intent
+import androidx.test.core.app.ApplicationProvider
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.HiltTestApplication
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.verify
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import to.bitkit.test.BaseUnitTest
+
+@HiltAndroidTest
+@Config(application = HiltTestApplication::class, sdk = [34])
+@RunWith(RobolectricTestRunner::class)
+class AppWidgetRefreshReceiverTest : BaseUnitTest() {
+ @get:Rule(order = 1)
+ val hiltRule = HiltAndroidRule(this)
+
+ @BindValue
+ val appWidgetRefreshScheduler = mock()
+
+ private val context = ApplicationProvider.getApplicationContext()
+ private val receiver = AppWidgetRefreshReceiver()
+
+ @Before
+ fun setUp() {
+ hiltRule.inject()
+ }
+
+ @Test
+ fun `boot completed delegates to scheduler`() {
+ receiver.onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
+
+ verify(appWidgetRefreshScheduler).ensureScheduled(AppWidgetRefreshReason.BOOT_COMPLETED)
+ verify(appWidgetRefreshScheduler).requestCatchUp(AppWidgetRefreshReason.BOOT_COMPLETED)
+ }
+
+ @Test
+ fun `package replaced delegates to scheduler`() {
+ receiver.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
+
+ verify(appWidgetRefreshScheduler).ensureScheduled(AppWidgetRefreshReason.PACKAGE_REPLACED)
+ verify(appWidgetRefreshScheduler).requestCatchUp(AppWidgetRefreshReason.PACKAGE_REPLACED)
+ }
+
+ @Test
+ fun `catch-up alarm delegates to scheduler`() {
+ receiver.onReceive(context, Intent(AppWidgetRefreshScheduler.CATCH_UP_ALARM_ACTION))
+
+ verify(appWidgetRefreshScheduler).handleCatchUpAlarm(AppWidgetRefreshReason.CATCH_UP_ALARM)
+ }
+}
diff --git a/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshSchedulerTest.kt b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshSchedulerTest.kt
new file mode 100644
index 0000000000..22a659da21
--- /dev/null
+++ b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshSchedulerTest.kt
@@ -0,0 +1,171 @@
+package to.bitkit.appwidget
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.test.core.app.ApplicationProvider
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.PeriodicWorkRequest
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertEquals
+
+@Config(sdk = [34])
+@RunWith(RobolectricTestRunner::class)
+class AppWidgetRefreshSchedulerTest {
+ private val context = ApplicationProvider.getApplicationContext()
+ private val activeWidgets = FakeActiveWidgets()
+ private val workClient = FakeWorkClient()
+ private val alarmClient = FakeAlarmClient()
+ private val elapsedRealtimeProvider = FakeElapsedRealtimeProvider()
+ private val scheduler = AppWidgetRefreshScheduler(
+ context = context,
+ activeWidgets = activeWidgets,
+ workClient = workClient,
+ alarmClient = alarmClient,
+ elapsedRealtimeProvider = elapsedRealtimeProvider,
+ )
+
+ @After
+ fun tearDown() {
+ PendingIntent.getBroadcast(
+ context,
+ 0,
+ Intent(context, AppWidgetRefreshReceiver::class.java)
+ .setAction(AppWidgetRefreshScheduler.CATCH_UP_ALARM_ACTION),
+ PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
+ )?.cancel()
+ }
+
+ @Test
+ fun `ensure scheduled enqueues periodic work and alarm`() {
+ activeWidgets.hasActiveWidgets = true
+
+ scheduler.ensureScheduled(AppWidgetRefreshReason.APP_START)
+
+ assertEquals(listOf(AppWidgetRefreshScheduler.PERIODIC_WORK_NAME), workClient.periodicNames)
+ assertEquals(listOf(ExistingPeriodicWorkPolicy.KEEP), workClient.periodicPolicies)
+ assertEquals(AlarmManager.ELAPSED_REALTIME_WAKEUP, alarmClient.lastType)
+ assertEquals(
+ elapsedRealtimeProvider.nowMs + AppWidgetRefreshScheduler.REFRESH_INTERVAL.inWholeMilliseconds,
+ alarmClient.lastTriggerAtMs,
+ )
+ }
+
+ @Test
+ fun `ensure scheduled keeps periodic work when alarm scheduling fails`() {
+ activeWidgets.hasActiveWidgets = true
+ alarmClient.throwOnSet = true
+
+ scheduler.ensureScheduled(AppWidgetRefreshReason.APP_START)
+
+ assertEquals(listOf(AppWidgetRefreshScheduler.PERIODIC_WORK_NAME), workClient.periodicNames)
+ assertEquals(0, alarmClient.setCount)
+ }
+
+ @Test
+ fun `request catch up enqueues one-time work`() {
+ activeWidgets.hasActiveWidgets = true
+
+ scheduler.requestCatchUp(AppWidgetRefreshReason.APP_FOREGROUND)
+
+ assertEquals(listOf(AppWidgetRefreshScheduler.CATCH_UP_WORK_NAME), workClient.oneTimeNames)
+ assertEquals(listOf(ExistingWorkPolicy.KEEP), workClient.oneTimePolicies)
+ }
+
+ @Test
+ fun `cancel if no widgets cancels all work and alarm`() {
+ activeWidgets.hasActiveWidgets = true
+ scheduler.ensureScheduled(AppWidgetRefreshReason.APP_START)
+
+ activeWidgets.hasActiveWidgets = false
+ scheduler.cancelIfNoWidgets(AppWidgetRefreshReason.PRICE_WIDGET_DISABLED)
+
+ assertEquals(
+ listOf(
+ AppWidgetRefreshScheduler.PERIODIC_WORK_NAME,
+ AppWidgetRefreshScheduler.CATCH_UP_WORK_NAME,
+ ),
+ workClient.canceledNames,
+ )
+ assertEquals(1, alarmClient.cancelCount)
+ }
+
+ @Test
+ fun `catch-up alarm requests work and schedules next alarm`() {
+ activeWidgets.hasActiveWidgets = true
+
+ scheduler.handleCatchUpAlarm(AppWidgetRefreshReason.CATCH_UP_ALARM)
+
+ assertEquals(listOf(AppWidgetRefreshScheduler.CATCH_UP_WORK_NAME), workClient.oneTimeNames)
+ assertEquals(1, alarmClient.setCount)
+ }
+}
+
+private class FakeActiveWidgets : AppWidgetActiveWidgets {
+ var hasActiveWidgets = false
+
+ override fun hasActiveWidgets(): Boolean = hasActiveWidgets
+}
+
+private class FakeWorkClient : AppWidgetWorkClient {
+ val periodicNames = mutableListOf()
+ val periodicPolicies = mutableListOf()
+ val oneTimeNames = mutableListOf()
+ val oneTimePolicies = mutableListOf()
+ val canceledNames = mutableListOf()
+
+ override fun enqueueUniquePeriodicWork(
+ uniqueWorkName: String,
+ existingPeriodicWorkPolicy: ExistingPeriodicWorkPolicy,
+ request: PeriodicWorkRequest,
+ ) {
+ periodicNames += uniqueWorkName
+ periodicPolicies += existingPeriodicWorkPolicy
+ }
+
+ override fun enqueueUniqueWork(
+ uniqueWorkName: String,
+ existingWorkPolicy: ExistingWorkPolicy,
+ request: OneTimeWorkRequest,
+ ) {
+ oneTimeNames += uniqueWorkName
+ oneTimePolicies += existingWorkPolicy
+ }
+
+ override fun cancelUniqueWork(uniqueWorkName: String) {
+ canceledNames += uniqueWorkName
+ }
+}
+
+private class FakeAlarmClient : AppWidgetAlarmClient {
+ var setCount = 0
+ var cancelCount = 0
+ var lastType: Int? = null
+ var lastTriggerAtMs: Long? = null
+ var throwOnSet = false
+
+ override fun setAndAllowWhileIdle(type: Int, triggerAtMillis: Long, operation: PendingIntent) {
+ if (throwOnSet) error("Alarm failure")
+
+ setCount += 1
+ lastType = type
+ lastTriggerAtMs = triggerAtMillis
+ }
+
+ override fun cancel(operation: PendingIntent) {
+ cancelCount += 1
+ }
+}
+
+private class FakeElapsedRealtimeProvider : ElapsedRealtimeProvider {
+ val nowMs = 10_000L
+
+ override fun elapsedRealtime(): Long = nowMs
+}
diff --git a/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshWorkerTest.kt b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshWorkerTest.kt
new file mode 100644
index 0000000000..2779bf309c
--- /dev/null
+++ b/app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshWorkerTest.kt
@@ -0,0 +1,139 @@
+package to.bitkit.appwidget
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.work.WorkerParameters
+import androidx.work.workDataOf
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.kotlin.any
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import to.bitkit.appwidget.model.AppWidgetRefreshMetadata
+import to.bitkit.appwidget.model.AppWidgetType
+import to.bitkit.test.BaseUnitTest
+import to.bitkit.utils.AppError
+import kotlin.coroutines.cancellation.CancellationException
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.time.Clock
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
+
+@OptIn(ExperimentalTime::class)
+@Config(sdk = [34])
+@RunWith(RobolectricTestRunner::class)
+class AppWidgetRefreshWorkerTest : BaseUnitTest() {
+ private val context = ApplicationProvider.getApplicationContext()
+ private val dataRepository = mock()
+ private val preferencesStore = mock()
+ private val appWidgetUpdater = mock()
+ private val clock = mock()
+ private val workerParameters = mock()
+
+ @Before
+ fun setUp() {
+ whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(NOW_MS))
+ whenever(workerParameters.inputData).thenReturn(
+ workDataOf(AppWidgetRefreshScheduler.WORK_INPUT_REASON to AppWidgetRefreshReason.APP_START.name),
+ )
+ }
+
+ @Test
+ fun `fresh remote widget type skips network refresh`() = test {
+ whenever(preferencesStore.getActiveWidgetTypes()).thenReturn(setOf(AppWidgetType.HEADLINES))
+ whenever(preferencesStore.getRefreshMetadata(AppWidgetType.HEADLINES)).thenReturn(
+ AppWidgetRefreshMetadata(
+ lastAttemptAtMs = NOW_MS - 1.minutes.inWholeMilliseconds,
+ lastSuccessAtMs = NOW_MS - 1.minutes.inWholeMilliseconds,
+ ),
+ )
+
+ val result = worker().doWork()
+
+ assertEquals(androidx.work.ListenableWorker.Result.success(), result)
+ verify(dataRepository, never()).fetchArticles()
+ verify(preferencesStore, never()).markRefreshAttempt(any(), any())
+ verify(preferencesStore, never()).markRefreshSuccess(any(), any())
+ verify(appWidgetUpdater).update(AppWidgetType.HEADLINES, context)
+ }
+
+ @Test
+ fun `failed remote refresh marks attempt but not success`() = test {
+ whenever(preferencesStore.getActiveWidgetTypes()).thenReturn(setOf(AppWidgetType.HEADLINES))
+ whenever(preferencesStore.getRefreshMetadata(AppWidgetType.HEADLINES)).thenReturn(
+ AppWidgetRefreshMetadata(
+ lastAttemptAtMs = NOW_MS - 16.minutes.inWholeMilliseconds,
+ lastSuccessAtMs = NOW_MS - 16.minutes.inWholeMilliseconds,
+ ),
+ )
+ whenever(dataRepository.fetchArticles()).thenReturn(Result.failure(AppWidgetRefreshWorkerTestError("failed")))
+
+ val result = worker().doWork()
+
+ assertEquals(androidx.work.ListenableWorker.Result.success(), result)
+ verify(preferencesStore).markRefreshAttempt(AppWidgetType.HEADLINES, NOW_MS)
+ verify(dataRepository).fetchArticles()
+ verify(preferencesStore, never()).markRefreshSuccess(any(), any())
+ verify(appWidgetUpdater).update(AppWidgetType.HEADLINES, context)
+ }
+
+ @Test
+ fun `remote refresh cancellation is rethrown`() = test {
+ whenever(preferencesStore.getActiveWidgetTypes()).thenReturn(setOf(AppWidgetType.HEADLINES))
+ whenever(preferencesStore.getRefreshMetadata(AppWidgetType.HEADLINES)).thenReturn(
+ AppWidgetRefreshMetadata(
+ lastAttemptAtMs = NOW_MS - 16.minutes.inWholeMilliseconds,
+ lastSuccessAtMs = NOW_MS - 16.minutes.inWholeMilliseconds,
+ ),
+ )
+ whenever(dataRepository.fetchArticles()).thenThrow(CancellationException("cancelled"))
+
+ assertFailsWith {
+ worker().doWork()
+ }
+
+ verify(preferencesStore).markRefreshAttempt(AppWidgetType.HEADLINES, NOW_MS)
+ verify(appWidgetUpdater, never()).update(any(), any())
+ }
+
+ @Test
+ fun `facts refresh rotates locally and does not mark remote success`() = test {
+ val facts = listOf("Bitcoin does not have a CEO.")
+ whenever(preferencesStore.getActiveWidgetTypes()).thenReturn(setOf(AppWidgetType.FACTS))
+ whenever(dataRepository.fetchFacts()).thenReturn(Result.success(facts))
+
+ val result = worker().doWork()
+
+ assertEquals(androidx.work.ListenableWorker.Result.success(), result)
+ verify(dataRepository).fetchFacts()
+ verify(preferencesStore).cacheFacts(facts)
+ verify(preferencesStore).bumpFactsRotationTick()
+ verify(preferencesStore, never()).getRefreshMetadata(any())
+ verify(preferencesStore, never()).markRefreshAttempt(any(), any())
+ verify(preferencesStore, never()).markRefreshSuccess(any(), any())
+ verify(appWidgetUpdater).update(AppWidgetType.FACTS, context)
+ }
+
+ private fun worker(): AppWidgetRefreshWorker =
+ AppWidgetRefreshWorker(
+ appContext = context,
+ workerParams = workerParameters,
+ dataRepository = dataRepository,
+ preferencesStore = preferencesStore,
+ appWidgetUpdater = appWidgetUpdater,
+ clock = clock,
+ )
+
+ private companion object {
+ const val NOW_MS = 1_000_000L
+ }
+}
+
+private class AppWidgetRefreshWorkerTestError(message: String) : AppError(message)
diff --git a/changelog.d/next/978.fixed.md b/changelog.d/next/978.fixed.md
new file mode 100644
index 0000000000..9130b4cf26
--- /dev/null
+++ b/changelog.d/next/978.fixed.md
@@ -0,0 +1 @@
+Android home-screen widgets now refresh shortly after unlocking the device so stale data catches up after idle periods.