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.