From 295079f67eb32cee3938825b64ef3df5ca07a320 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sun, 31 May 2026 02:27:02 +0200 Subject: [PATCH 1/4] fix: refresh widgets after idle --- app/src/main/java/to/bitkit/App.kt | 19 +++++++++++ .../appwidget/AppWidgetRefreshReceiver.kt | 28 ++++++++++++++++ .../appwidget/AppWidgetRefreshWorker.kt | 33 ++++++++++++++++--- app/src/main/java/to/bitkit/ext/Context.kt | 4 +++ app/src/main/java/to/bitkit/ui/ContentView.kt | 2 ++ changelog.d/next/978.fixed.md | 1 + 6 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshReceiver.kt create mode 100644 changelog.d/next/978.fixed.md diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 1620b722a0..d7a4123468 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -4,12 +4,17 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.Application import android.app.Application.ActivityLifecycleCallbacks +import android.content.Intent +import android.content.IntentFilter import android.os.Bundle +import android.os.PowerManager +import androidx.core.content.ContextCompat import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import coil3.ImageLoader import coil3.SingletonImageLoader import dagger.hilt.android.HiltAndroidApp +import to.bitkit.appwidget.AppWidgetRefreshReceiver import to.bitkit.env.Env import to.bitkit.services.BluetoothInit import javax.inject.Inject @@ -31,11 +36,25 @@ internal open class App : Application(), Configuration.Provider { super.onCreate() SingletonImageLoader.setSafe { imageLoader } currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } + registerAppWidgetRefreshReceiver() Env.initAppStoragePath(filesDir.absolutePath) // Initialize btleplug for Bluetooth support (required before any BLE usage) BluetoothInit.ensureInitialized() } + private fun registerAppWidgetRefreshReceiver() { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_USER_PRESENT) + addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) + } + ContextCompat.registerReceiver( + this, + AppWidgetRefreshReceiver(), + filter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + companion object { @SuppressLint("StaticFieldLeak") // Should be safe given its manual memory management internal var currentActivity: CurrentActivity? = null 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..486406d791 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshReceiver.kt @@ -0,0 +1,28 @@ +package to.bitkit.appwidget + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.PowerManager +import to.bitkit.ext.powerManager +import to.bitkit.utils.Logger + +class AppWidgetRefreshReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_USER_PRESENT -> enqueueCatchUp(context, "user_present") + PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> { + if (!context.powerManager.isDeviceIdleMode) enqueueCatchUp(context, "device_idle_exit") + } + } + } + + private fun enqueueCatchUp(context: Context, reason: String) { + Logger.debug("Enqueued widget refresh for '$reason'", context = TAG) + AppWidgetRefreshWorker.enqueueCatchUp(context) + } + + private companion object { + const val TAG = "AppWidgetRefreshReceiver" + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt index 20621a0cbe..6b186e6b3b 100644 --- a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt @@ -9,7 +9,9 @@ import androidx.hilt.work.HiltWorker import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters @@ -41,6 +43,7 @@ class AppWidgetRefreshWorker @AssistedInject constructor( companion object { private const val TAG = "AppWidgetRefreshWorker" private const val WORK_NAME = "appwidget_refresh" + private const val CATCH_UP_WORK_NAME = "appwidget_refresh_catch_up" fun enqueue(context: Context) { val constraints = Constraints.Builder() @@ -58,14 +61,36 @@ class AppWidgetRefreshWorker @AssistedInject constructor( ) } + fun enqueueCatchUp(context: Context) { + if (!hasActiveWidgets(context)) return + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + CATCH_UP_WORK_NAME, + ExistingWorkPolicy.KEEP, + request, + ) + } + fun cancelIfNoWidgets(context: Context) { + if (!hasActiveWidgets(context)) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + WorkManager.getInstance(context).cancelUniqueWork(CATCH_UP_WORK_NAME) + } + } + + private fun hasActiveWidgets(context: Context): Boolean { val manager = AppWidgetManager.getInstance(context) - val hasAny = AppWidgetType.entries.any { type -> + return 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) { diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt index 6720b83663..a23138e6fc 100644 --- a/app/src/main/java/to/bitkit/ext/Context.kt +++ b/app/src/main/java/to/bitkit/ext/Context.kt @@ -12,6 +12,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 @@ -39,6 +40,9 @@ val Context.usbManager: 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 167ea4f32f..e96a95ed2f 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -44,6 +44,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import to.bitkit.appwidget.AppWidgetRefreshWorker import to.bitkit.env.Env import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast @@ -257,6 +258,7 @@ fun ContentView( appViewModel.consumePaymentReceivedInBackground() + AppWidgetRefreshWorker.enqueueCatchUp(context) currencyViewModel.triggerRefresh() blocktankViewModel.refreshOrders() appViewModel.refreshPublicPaykitEndpoints() 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. From 2c06cd9ac6151dcacd45cc648e7e5e428c0c372f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 1 Jun 2026 12:27:36 +0200 Subject: [PATCH 2/4] fix: schedule widget catch-up alarm --- app/src/main/AndroidManifest.xml | 4 ++ .../AppWidgetRefreshAlarmReceiver.kt | 20 +++++++ .../appwidget/AppWidgetRefreshWorker.kt | 52 ++++++++++++++++++- app/src/main/java/to/bitkit/ext/Context.kt | 4 ++ app/src/main/java/to/bitkit/ui/ContentView.kt | 1 + 5 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshAlarmReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 78b629540f..9b3c35e28b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -172,6 +172,10 @@ + + (15.minutes.toJavaDuration()) + val request = PeriodicWorkRequestBuilder(REFRESH_INTERVAL.toJavaDuration()) .setConstraints(constraints) .build() @@ -59,6 +69,7 @@ class AppWidgetRefreshWorker @AssistedInject constructor( ExistingPeriodicWorkPolicy.KEEP, request, ) + scheduleCatchUpAlarm(context) } fun enqueueCatchUp(context: Context) { @@ -83,9 +94,24 @@ class AppWidgetRefreshWorker @AssistedInject constructor( if (!hasActiveWidgets(context)) { WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) WorkManager.getInstance(context).cancelUniqueWork(CATCH_UP_WORK_NAME) + cancelCatchUpAlarm(context) } } + fun scheduleCatchUpAlarm(context: Context) { + if (!hasActiveWidgets(context)) { + cancelCatchUpAlarm(context) + return + } + + val triggerAt = SystemClock.elapsedRealtime() + REFRESH_INTERVAL.inWholeMilliseconds + context.alarmManager.setAndAllowWhileIdle( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + triggerAt, + catchUpAlarmPendingIntent(context), + ) + } + private fun hasActiveWidgets(context: Context): Boolean { val manager = AppWidgetManager.getInstance(context) return AppWidgetType.entries.any { type -> @@ -93,6 +119,30 @@ class AppWidgetRefreshWorker @AssistedInject constructor( } } + private fun cancelCatchUpAlarm(context: Context) { + val pendingIntent = PendingIntent.getBroadcast( + context, + CATCH_UP_ALARM_REQUEST_CODE, + catchUpAlarmIntent(context), + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE, + ) ?: return + + context.alarmManager.cancel(pendingIntent) + pendingIntent.cancel() + } + + private fun catchUpAlarmPendingIntent(context: Context): PendingIntent = + PendingIntent.getBroadcast( + context, + CATCH_UP_ALARM_REQUEST_CODE, + catchUpAlarmIntent(context), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + private fun catchUpAlarmIntent(context: Context): Intent = + Intent(context, AppWidgetRefreshAlarmReceiver::class.java) + .setAction(CATCH_UP_ALARM_ACTION) + private fun receiverClassFor(type: AppWidgetType): Class = when (type) { AppWidgetType.PRICE -> PriceGlanceReceiver::class.java AppWidgetType.HEADLINES -> HeadlinesGlanceReceiver::class.java diff --git a/app/src/main/java/to/bitkit/ext/Context.kt b/app/src/main/java/to/bitkit/ext/Context.kt index a23138e6fc..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 @@ -34,6 +35,9 @@ 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 diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index e96a95ed2f..8338531828 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -258,6 +258,7 @@ fun ContentView( appViewModel.consumePaymentReceivedInBackground() + AppWidgetRefreshWorker.enqueue(context) AppWidgetRefreshWorker.enqueueCatchUp(context) currencyViewModel.triggerRefresh() blocktankViewModel.refreshOrders() From de121651a7216455e69cdfd1557158a051c1148a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 1 Jun 2026 17:33:12 +0200 Subject: [PATCH 3/4] fix: refresh widgets on service stop --- app/src/main/java/to/bitkit/App.kt | 2 ++ .../java/to/bitkit/androidServices/LightningNodeService.kt | 3 +++ 2 files changed, 5 insertions(+) diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index d7a4123468..ebee4080e6 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -15,6 +15,7 @@ import coil3.ImageLoader import coil3.SingletonImageLoader import dagger.hilt.android.HiltAndroidApp import to.bitkit.appwidget.AppWidgetRefreshReceiver +import to.bitkit.appwidget.AppWidgetRefreshWorker import to.bitkit.env.Env import to.bitkit.services.BluetoothInit import javax.inject.Inject @@ -37,6 +38,7 @@ internal open class App : Application(), Configuration.Provider { SingletonImageLoader.setSafe { imageLoader } currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } registerAppWidgetRefreshReceiver() + AppWidgetRefreshWorker.enqueue(this) Env.initAppStoragePath(filesDir.absolutePath) // Initialize btleplug for Bluetooth support (required before any BLE usage) BluetoothInit.ensureInitialized() diff --git a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt index 2af729cc3e..e0fd8234aa 100644 --- a/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt +++ b/app/src/main/java/to/bitkit/androidServices/LightningNodeService.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.launch import org.lightningdevkit.ldknode.Event import to.bitkit.App import to.bitkit.R +import to.bitkit.appwidget.AppWidgetRefreshWorker import to.bitkit.data.CacheStore import to.bitkit.di.UiDispatcher import to.bitkit.domain.commands.NotifyPaymentReceived @@ -166,6 +167,8 @@ class LightningNodeService : Service() { override fun onDestroy() { Logger.debug("onDestroy", context = TAG) + AppWidgetRefreshWorker.enqueue(this) + AppWidgetRefreshWorker.enqueueCatchUp(this) // Safe to call even if already stopped — guarded by lifecycleMutex + isStoppedOrStopping() serviceScope.launch { lightningRepo.stop() } super.onDestroy() From 718d44570ab6b4936c618a7386b025bc6fb15ade Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Tue, 2 Jun 2026 17:11:01 +0200 Subject: [PATCH 4/4] fix: widget refresh scheduling --- app/src/main/AndroidManifest.xml | 11 +- app/src/main/java/to/bitkit/App.kt | 27 +- .../androidServices/LightningNodeService.kt | 10 +- .../appwidget/AppWidgetPreferencesStore.kt | 29 ++ .../AppWidgetRefreshAlarmReceiver.kt | 20 -- .../appwidget/AppWidgetRefreshPolicy.kt | 18 ++ .../appwidget/AppWidgetRefreshReceiver.kt | 25 +- .../appwidget/AppWidgetRefreshScheduler.kt | 284 ++++++++++++++++++ .../appwidget/AppWidgetRefreshWorker.kt | 252 +++++----------- .../to/bitkit/appwidget/AppWidgetUpdater.kt | 25 ++ .../appwidget/RefreshingGlanceReceiver.kt | 32 ++ .../config/AppWidgetConfigActivity.kt | 10 +- .../appwidget/model/AppWidgetPreferences.kt | 8 + .../ui/blocks/BlocksGlanceReceiver.kt | 23 +- .../appwidget/ui/facts/FactsGlanceReceiver.kt | 23 +- .../appwidget/ui/facts/FactsGlanceWidget.kt | 6 +- .../ui/headlines/HeadlinesGlanceReceiver.kt | 23 +- .../appwidget/ui/price/PriceGlanceReceiver.kt | 23 +- .../ui/weather/WeatherGlanceReceiver.kt | 23 +- .../di/AppWidgetRefreshSchedulerModule.kt | 30 ++ app/src/main/java/to/bitkit/ui/ContentView.kt | 8 +- .../main/res/xml/appwidget_info_blocks.xml | 2 +- app/src/main/res/xml/appwidget_info_facts.xml | 2 +- .../main/res/xml/appwidget_info_headlines.xml | 2 +- app/src/main/res/xml/appwidget_info_price.xml | 2 +- .../main/res/xml/appwidget_info_weather.xml | 2 +- .../LightningNodeServiceTest.kt | 25 ++ .../appwidget/AppWidgetRefreshPolicyTest.kt | 58 ++++ .../appwidget/AppWidgetRefreshReceiverTest.kt | 60 ++++ .../AppWidgetRefreshSchedulerTest.kt | 171 +++++++++++ .../appwidget/AppWidgetRefreshWorkerTest.kt | 139 +++++++++ 31 files changed, 1057 insertions(+), 316 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshAlarmReceiver.kt create mode 100644 app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshPolicy.kt create mode 100644 app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshScheduler.kt create mode 100644 app/src/main/java/to/bitkit/appwidget/AppWidgetUpdater.kt create mode 100644 app/src/main/java/to/bitkit/appwidget/RefreshingGlanceReceiver.kt create mode 100644 app/src/main/java/to/bitkit/di/AppWidgetRefreshSchedulerModule.kt create mode 100644 app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshPolicyTest.kt create mode 100644 app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshReceiverTest.kt create mode 100644 app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshSchedulerTest.kt create mode 100644 app/src/test/java/to/bitkit/appwidget/AppWidgetRefreshWorkerTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9b3c35e28b..52784aaf53 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,6 +22,7 @@ + @@ -173,8 +174,14 @@ + android:name=".appwidget.AppWidgetRefreshReceiver" + android:exported="false"> + + + + + + { 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() } @@ -167,8 +173,6 @@ class LightningNodeService : Service() { override fun onDestroy() { Logger.debug("onDestroy", context = TAG) - AppWidgetRefreshWorker.enqueue(this) - AppWidgetRefreshWorker.enqueueCatchUp(this) // Safe to call even if already stopped — guarded by lifecycleMutex + isStoppedOrStopping() serviceScope.launch { lightningRepo.stop() } super.onDestroy() 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/AppWidgetRefreshAlarmReceiver.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshAlarmReceiver.kt deleted file mode 100644 index 5c5f74b21a..0000000000 --- a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshAlarmReceiver.kt +++ /dev/null @@ -1,20 +0,0 @@ -package to.bitkit.appwidget - -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import to.bitkit.utils.Logger - -class AppWidgetRefreshAlarmReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action != AppWidgetRefreshWorker.CATCH_UP_ALARM_ACTION) return - - Logger.debug("Received widget refresh alarm", context = TAG) - AppWidgetRefreshWorker.enqueueCatchUp(context) - AppWidgetRefreshWorker.scheduleCatchUpAlarm(context) - } - - private companion object { - const val TAG = "AppWidgetRefreshAlarmReceiver" - } -} 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 index 486406d791..6802f8fbfb 100644 --- a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshReceiver.kt +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshReceiver.kt @@ -3,23 +3,32 @@ package to.bitkit.appwidget import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.os.PowerManager -import to.bitkit.ext.powerManager import to.bitkit.utils.Logger class AppWidgetRefreshReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { - Intent.ACTION_USER_PRESENT -> enqueueCatchUp(context, "user_present") - PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> { - if (!context.powerManager.isDeviceIdleMode) enqueueCatchUp(context, "device_idle_exit") + 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 enqueueCatchUp(context: Context, reason: String) { - Logger.debug("Enqueued widget refresh for '$reason'", context = TAG) - AppWidgetRefreshWorker.enqueueCatchUp(context) + 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 { 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 91bf8e6194..a3012bf60a 100644 --- a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt @@ -1,216 +1,118 @@ 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.glance.appwidget.updateAll import androidx.hilt.work.HiltWorker -import androidx.work.Constraints import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.ExistingWorkPolicy -import androidx.work.NetworkType -import androidx.work.OneTimeWorkRequestBuilder -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.alarmManager +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" - private const val CATCH_UP_WORK_NAME = "appwidget_refresh_catch_up" - private const val CATCH_UP_ALARM_REQUEST_CODE = 0 - internal const val CATCH_UP_ALARM_ACTION = "to.bitkit.appwidget.REFRESH_ALARM" - private val REFRESH_INTERVAL = 15.minutes - - fun enqueue(context: Context) { - if (!hasActiveWidgets(context)) return - - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - - val request = PeriodicWorkRequestBuilder(REFRESH_INTERVAL.toJavaDuration()) - .setConstraints(constraints) - .build() - - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - WORK_NAME, - ExistingPeriodicWorkPolicy.KEEP, - request, - ) - scheduleCatchUpAlarm(context) - } - - fun enqueueCatchUp(context: Context) { - if (!hasActiveWidgets(context)) return - - val constraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() + } - val request = OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .build() + override suspend fun doWork(): Result { + val activeTypes = AppWidgetType.entries.filter { it in preferencesStore.getActiveWidgetTypes() } + if (activeTypes.isEmpty()) return Result.success() - WorkManager.getInstance(context).enqueueUniqueWork( - CATCH_UP_WORK_NAME, - ExistingWorkPolicy.KEEP, - request, - ) - } + val reason = inputData.getString(AppWidgetRefreshScheduler.WORK_INPUT_REASON) ?: "unknown" + val nowMs = clock.nowMs() + Logger.debug("Refreshing widget types '$activeTypes' for '$reason'", context = TAG) - fun cancelIfNoWidgets(context: Context) { - if (!hasActiveWidgets(context)) { - WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) - WorkManager.getInstance(context).cancelUniqueWork(CATCH_UP_WORK_NAME) - cancelCatchUpAlarm(context) + for (type in activeTypes) { + runCatching { refresh(type, nowMs) }.onFailure { + if (it is CancellationException) throw it + Logger.warn("Failed to refresh widget type '$type'", it, context = TAG) } } - fun scheduleCatchUpAlarm(context: Context) { - if (!hasActiveWidgets(context)) { - cancelCatchUpAlarm(context) - return - } - - val triggerAt = SystemClock.elapsedRealtime() + REFRESH_INTERVAL.inWholeMilliseconds - context.alarmManager.setAndAllowWhileIdle( - AlarmManager.ELAPSED_REALTIME_WAKEUP, - triggerAt, - catchUpAlarmPendingIntent(context), - ) - } + return Result.success() + } - private fun hasActiveWidgets(context: Context): Boolean { - val manager = AppWidgetManager.getInstance(context) - return AppWidgetType.entries.any { type -> - manager.getAppWidgetIds(ComponentName(context, receiverClassFor(type))).isNotEmpty() - } + private suspend fun refresh(type: AppWidgetType, nowMs: Long) { + if (type == AppWidgetType.FACTS) { + refreshFacts() + return } - private fun cancelCatchUpAlarm(context: Context) { - val pendingIntent = PendingIntent.getBroadcast( - context, - CATCH_UP_ALARM_REQUEST_CODE, - catchUpAlarmIntent(context), - PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE, - ) ?: return - - context.alarmManager.cancel(pendingIntent) - pendingIntent.cancel() + 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 } - private fun catchUpAlarmPendingIntent(context: Context): PendingIntent = - PendingIntent.getBroadcast( - context, - CATCH_UP_ALARM_REQUEST_CODE, - catchUpAlarmIntent(context), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - - private fun catchUpAlarmIntent(context: Context): Intent = - Intent(context, AppWidgetRefreshAlarmReceiver::class.java) - .setAction(CATCH_UP_ALARM_ACTION) - - 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 + preferencesStore.markRefreshAttempt(type, nowMs) + if (refreshRemote(type)) { + preferencesStore.markRefreshSuccess(type, nowMs) } + appWidgetUpdater.update(type, appContext) } - override suspend fun doWork(): Result { - val activeTypes = preferencesStore.getActiveWidgetTypes() - if (activeTypes.isEmpty()) return Result.success() - - Logger.debug("Refreshing data for widget types: '$activeTypes'", 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) - } - - AppWidgetType.HEADLINES -> { - dataRepository.fetchArticles() - .onSuccess { preferencesStore.cacheArticlesAndRotate(it) } - .onFailure { - Logger.warn("Failed to refresh headlines", it, context = TAG) - } - HeadlinesGlanceWidget().updateAll(appContext) - } - - AppWidgetType.BLOCKS -> { - dataRepository.fetchBlock() - .onSuccess { preferencesStore.cacheBlock(it) } - .onFailure { - Logger.warn("Failed to refresh block", it, context = TAG) - } - BlocksGlanceWidget().updateAll(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 + } - AppWidgetType.FACTS -> { - dataRepository.fetchFacts() - .onSuccess { preferencesStore.cacheFacts(it) } - .onFailure { - Logger.warn("Failed to refresh facts", it, context = TAG) - } - preferencesStore.bumpFactsRotationTick() - FactsGlanceWidget().updateAll(appContext) - } + 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/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 222e26b32a..e054b9b657 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -45,7 +45,8 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import to.bitkit.appwidget.AppWidgetRefreshWorker +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 @@ -225,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() @@ -246,8 +248,8 @@ fun ContentView( appViewModel.consumePaymentReceivedInBackground() - AppWidgetRefreshWorker.enqueue(context) - AppWidgetRefreshWorker.enqueueCatchUp(context) + 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)