diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/BaseWidgetProvider.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/BaseWidgetProvider.kt index 40f765afd..766e8afa2 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/BaseWidgetProvider.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/BaseWidgetProvider.kt @@ -15,10 +15,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import javax.inject.Inject +/** + * A widget provider class for widgets that update based on entity state changes. + */ abstract class BaseWidgetProvider : AppWidgetProvider() { companion object { @@ -26,15 +29,29 @@ abstract class BaseWidgetProvider : AppWidgetProvider() { "io.homeassistant.companion.android.widgets.template.BaseWidgetProvider.UPDATE_VIEW" const val RECEIVE_DATA = "io.homeassistant.companion.android.widgets.template.TemplateWidget.RECEIVE_DATA" - } - private var entityUpdates: Flow>? = null + var widgetScope: CoroutineScope? = null + val widgetEntities = mutableMapOf>() + val widgetJobs = mutableMapOf() + } @Inject protected lateinit var integrationUseCase: IntegrationRepository - protected var mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job()) + + private var thisSetScope = false protected var lastIntent = "" + init { + setupWidgetScope() + } + + private fun setupWidgetScope() { + if (widgetScope == null || !widgetScope!!.isActive) { + widgetScope = CoroutineScope(Dispatchers.Main + Job()) + thisSetScope = true + } + } + override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, @@ -42,7 +59,7 @@ abstract class BaseWidgetProvider : AppWidgetProvider() { ) { // There may be multiple widgets active, so update all of them for (appWidgetId in appWidgetIds) { - mainScope.launch { + widgetScope?.launch { val views = getWidgetRemoteViews(context, appWidgetId) appWidgetManager.updateAppWidget(appWidgetId, views) } @@ -70,23 +87,35 @@ abstract class BaseWidgetProvider : AppWidgetProvider() { } fun onScreenOn(context: Context) { - mainScope = CoroutineScope(Dispatchers.Main + Job()) - if (!isSubscribed()) { - mainScope.launch { - if (!integrationUseCase.isRegistered()) { - return@launch - } - updateAllWidgets(context) - if (getAllWidgetIds(context).isNotEmpty()) { - context.applicationContext.registerReceiver( - this@BaseWidgetProvider, - IntentFilter(Intent.ACTION_SCREEN_OFF) - ) + setupWidgetScope() + widgetScope!!.launch { + if (!integrationUseCase.isRegistered()) { + return@launch + } + updateAllWidgets(context) - entityUpdates = integrationUseCase.getEntityUpdates() - setSubscribed(entityUpdates != null) - entityUpdates?.collect { - onEntityStateChanged(context, it) + val allWidgets = getAllWidgetIdsWithEntities(context) + val widgetsWithDifferentEntities = allWidgets.filter { it.value != widgetEntities[it.key] } + if (widgetsWithDifferentEntities.isNotEmpty()) { + context.applicationContext.registerReceiver( + this@BaseWidgetProvider, + IntentFilter(Intent.ACTION_SCREEN_OFF) + ) + + widgetsWithDifferentEntities.forEach { (id, entities) -> + widgetJobs[id]?.cancel() + + val entityUpdates = integrationUseCase.getEntityUpdates(entities) + if (entityUpdates != null) { + widgetEntities[id] = entities + widgetJobs[id] = widgetScope!!.launch { + entityUpdates.collect { + onEntityStateChanged(context, id, it) + } + } + } else { // Remove data to make it retry on the next update + widgetEntities.remove(id) + widgetJobs.remove(id) } } } @@ -94,9 +123,12 @@ abstract class BaseWidgetProvider : AppWidgetProvider() { } private fun onScreenOff() { - mainScope.cancel() - entityUpdates = null - setSubscribed(false) + if (thisSetScope) { + widgetScope?.cancel() + thisSetScope = false + widgetEntities.clear() + widgetJobs.clear() + } } private suspend fun updateAllWidgets( @@ -106,7 +138,7 @@ abstract class BaseWidgetProvider : AppWidgetProvider() { val systemWidgetIds = AppWidgetManager.getInstance(context) .getAppWidgetIds(widgetProvider) .toSet() - val dbWidgetIds = getAllWidgetIds(context) + val dbWidgetIds = getAllWidgetIdsWithEntities(context).keys val invalidWidgetIds = dbWidgetIds.minus(systemWidgetIds) if (invalidWidgetIds.isNotEmpty()) { @@ -127,17 +159,21 @@ abstract class BaseWidgetProvider : AppWidgetProvider() { appWidgetId: Int, appWidgetManager: AppWidgetManager = AppWidgetManager.getInstance(context) ) { - mainScope.launch { + widgetScope?.launch { val views = getWidgetRemoteViews(context, appWidgetId) appWidgetManager.updateAppWidget(appWidgetId, views) } } - abstract fun isSubscribed(): Boolean - abstract fun setSubscribed(subscribed: Boolean) + protected fun removeSubscription(appWidgetId: Int) { + widgetEntities.remove(appWidgetId) + widgetJobs[appWidgetId]?.cancel() + widgetJobs.remove(appWidgetId) + } + abstract fun getWidgetProvider(context: Context): ComponentName abstract suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int, suggestedEntity: Entity>? = null): RemoteViews - abstract suspend fun getAllWidgetIds(context: Context): List + abstract suspend fun getAllWidgetIdsWithEntities(context: Context): Map> abstract fun saveEntityConfiguration(context: Context, extras: Bundle?, appWidgetId: Int) - abstract suspend fun onEntityStateChanged(context: Context, entity: Entity<*>) + abstract suspend fun onEntityStateChanged(context: Context, appWidgetId: Int, entity: Entity<*>) } diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidget.kt index 1387d5ac2..f9b379a27 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidget.kt @@ -42,19 +42,11 @@ class EntityWidget : BaseWidgetProvider() { internal const val EXTRA_TEXT_COLOR = "EXTRA_TEXT_COLOR" private data class ResolvedText(val text: CharSequence?, val exception: Boolean = false) - - private var isSubscribed = false } @Inject lateinit var staticWidgetDao: StaticWidgetDao - override fun isSubscribed(): Boolean = isSubscribed - - override fun setSubscribed(subscribed: Boolean) { - isSubscribed = subscribed - } - override fun getWidgetProvider(context: Context): ComponentName = ComponentName(context, EntityWidget::class.java) @@ -130,9 +122,8 @@ class EntityWidget : BaseWidgetProvider() { return views } - override suspend fun getAllWidgetIds(context: Context): List { - return staticWidgetDao.getAll().map { it.id } - } + override suspend fun getAllWidgetIdsWithEntities(context: Context): Map> = + staticWidgetDao.getAll().associate { it.id to listOf(it.entityId) } private suspend fun resolveTextToShow( context: Context, @@ -197,7 +188,7 @@ class EntityWidget : BaseWidgetProvider() { return } - mainScope.launch { + widgetScope?.launch { Log.d( TAG, "Saving entity state config data:" + System.lineSeparator() + @@ -223,20 +214,17 @@ class EntityWidget : BaseWidgetProvider() { } } - override suspend fun onEntityStateChanged(context: Context, entity: Entity<*>) { - staticWidgetDao.getAll().forEach { - if (it.entityId == entity.entityId) { - mainScope.launch { - val views = getWidgetRemoteViews(context, it.id, entity as Entity>) - AppWidgetManager.getInstance(context).updateAppWidget(it.id, views) - } - } + override suspend fun onEntityStateChanged(context: Context, appWidgetId: Int, entity: Entity<*>) { + widgetScope?.launch { + val views = getWidgetRemoteViews(context, appWidgetId, entity as Entity>) + AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, views) } } override fun onDeleted(context: Context, appWidgetIds: IntArray) { - mainScope.launch { + widgetScope?.launch { staticWidgetDao.deleteAll(appWidgetIds) + appWidgetIds.forEach { removeSubscription(it) } } } } diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/media_player_controls/MediaPlayerControlsWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/media_player_controls/MediaPlayerControlsWidget.kt index 48b7251b1..6deb9cf76 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/media_player_controls/MediaPlayerControlsWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/media_player_controls/MediaPlayerControlsWidget.kt @@ -64,8 +64,6 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { internal const val EXTRA_SHOW_SKIP = "EXTRA_INCLUDE_SKIP" internal const val EXTRA_SHOW_SEEK = "EXTRA_INCLUDE_SEEK" internal const val EXTRA_BACKGROUND_TYPE = "EXTRA_BACKGROUND_TYPE" - - private var isSubscribed = false } @Inject @@ -74,12 +72,6 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { @Inject lateinit var mediaPlayCtrlWidgetDao: MediaPlayerControlsWidgetDao - override fun isSubscribed(): Boolean = isSubscribed - - override fun setSubscribed(subscribed: Boolean) { - isSubscribed = subscribed - } - override fun getWidgetProvider(context: Context): ComponentName = ComponentName(context, MediaPlayerControlsWidget::class.java) @@ -107,7 +99,7 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { Log.d(TAG, "Skipping widget update since network connection is not active") return } - mainScope.launch { + widgetScope?.launch { val views = getWidgetRemoteViews(context, appWidgetId) appWidgetManager.updateAppWidget(appWidgetId, views) onScreenOn(context) @@ -412,9 +404,8 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { } } - override suspend fun getAllWidgetIds(context: Context): List { - return mediaPlayCtrlWidgetDao.getAll().map { it.id } - } + override suspend fun getAllWidgetIdsWithEntities(context: Context): Map> = + mediaPlayCtrlWidgetDao.getAll().associate { it.id to it.entityId.split(",") } private suspend fun getEntity(context: Context, entityIds: List, suggestedEntity: Entity>?): Entity>? { val entity: Entity>? @@ -488,7 +479,7 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { return } - mainScope.launch { + widgetScope?.launch { Log.d( TAG, "Saving service call config data:" + System.lineSeparator() + @@ -511,20 +502,17 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { } } - override suspend fun onEntityStateChanged(context: Context, entity: Entity<*>) { - mediaPlayCtrlWidgetDao.getAll().forEach { - val entityIds = it.entityId.split(",") - if (entityIds.contains(entity.entityId)) { - mainScope.launch { - val views = getWidgetRemoteViews(context, it.id, getEntity(context, entityIds, null)) - AppWidgetManager.getInstance(context).updateAppWidget(it.id, views) - } + override suspend fun onEntityStateChanged(context: Context, appWidgetId: Int, entity: Entity<*>) { + mediaPlayCtrlWidgetDao.get(appWidgetId)?.let { + widgetScope?.launch { + val views = getWidgetRemoteViews(context, appWidgetId, getEntity(context, it.entityId.split(","), null)) + AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, views) } } } private fun callPreviousTrackService(context: Context, appWidgetId: Int) { - mainScope.launch { + widgetScope?.launch { Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId") val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId) @@ -550,7 +538,7 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { } private fun callRewindService(context: Context, appWidgetId: Int) { - mainScope.launch { + widgetScope?.launch { Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId") val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId) @@ -595,7 +583,7 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { } private fun callPlayPauseService(context: Context, appWidgetId: Int) { - mainScope.launch { + widgetScope?.launch { Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId") val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId) @@ -621,7 +609,7 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { } private fun callFastForwardService(context: Context, appWidgetId: Int) { - mainScope.launch { + widgetScope?.launch { Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId") val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId) @@ -666,7 +654,7 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { } private fun callNextTrackService(context: Context, appWidgetId: Int) { - mainScope.launch { + widgetScope?.launch { Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId") val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId) @@ -692,7 +680,7 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { } private fun callVolumeDownService(context: Context, appWidgetId: Int) { - mainScope.launch { + widgetScope?.launch { Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId") val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId) @@ -718,7 +706,7 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { } private fun callVolumeUpService(context: Context, appWidgetId: Int) { - mainScope.launch { + widgetScope?.launch { Log.d(TAG, "Retrieving media player entity for app widget $appWidgetId") val entity: MediaPlayerControlsWidgetEntity? = mediaPlayCtrlWidgetDao.get(appWidgetId) @@ -744,20 +732,12 @@ class MediaPlayerControlsWidget : BaseWidgetProvider() { } override fun onDeleted(context: Context, appWidgetIds: IntArray) { - // When the user deletes the widget, delete the preference associated with it. - mainScope.launch { + widgetScope?.launch { mediaPlayCtrlWidgetDao.deleteAll(appWidgetIds) + appWidgetIds.forEach { removeSubscription(it) } } } - override fun onEnabled(context: Context) { - // Enter relevant functionality for when the first widget is created - } - - override fun onDisabled(context: Context) { - // Enter relevant functionality for when the last widget is disabled - } - private fun isConnectionActive(context: Context): Boolean { val connectivityManager = context.getSystemService() val activeNetworkInfo = connectivityManager?.activeNetworkInfo diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt index 6451750de..4a0990629 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt @@ -64,6 +64,7 @@ interface IntegrationRepository { suspend fun getEntities(): List>? suspend fun getEntity(entityId: String): Entity>? suspend fun getEntityUpdates(): Flow>? + suspend fun getEntityUpdates(entityIds: List): Flow>? suspend fun callService(domain: String, service: String, serviceData: HashMap) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt index c2ef75a25..9df93c786 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt @@ -589,6 +589,21 @@ class IntegrationRepositoryImpl @Inject constructor( } } + override suspend fun getEntityUpdates(entityIds: List): Flow>? { + return webSocketRepository.getStateChanges(entityIds) + ?.filter { it.toState != null } + ?.map { + Entity( + it.toState!!.entityId, + it.toState.state, + it.toState.attributes, + it.toState.lastChanged, + it.toState.lastUpdated, + it.toState.context + ) + } + } + override suspend fun registerSensor(sensorRegistration: SensorRegistration) { val canRegisterCategoryStateClass = isHomeAssistantVersionAtLeast(2021, 11, 0) val canRegisterEntityDisabledState = isHomeAssistantVersionAtLeast(2022, 6, 0) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt index a091cbc07..d98b827ba 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/WebSocketRepository.kt @@ -11,6 +11,7 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.En import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse import io.homeassistant.companion.android.common.data.websocket.impl.entities.StateChangedEvent import io.homeassistant.companion.android.common.data.websocket.impl.entities.TemplateUpdatedEvent +import io.homeassistant.companion.android.common.data.websocket.impl.entities.TriggerEvent import kotlinx.coroutines.flow.Flow interface WebSocketRepository { @@ -23,6 +24,7 @@ interface WebSocketRepository { suspend fun getEntityRegistry(): List? suspend fun getServices(): List? suspend fun getStateChanges(): Flow? + suspend fun getStateChanges(entityIds: List): Flow? suspend fun getAreaRegistryUpdates(): Flow? suspend fun getDeviceRegistryUpdates(): Flow? suspend fun getEntityRegistryUpdates(): Flow? diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt index bd358df72..d9304e692 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/WebSocketRepositoryImpl.kt @@ -29,6 +29,7 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.Ge import io.homeassistant.companion.android.common.data.websocket.impl.entities.SocketResponse import io.homeassistant.companion.android.common.data.websocket.impl.entities.StateChangedEvent import io.homeassistant.companion.android.common.data.websocket.impl.entities.TemplateUpdatedEvent +import io.homeassistant.companion.android.common.data.websocket.impl.entities.TriggerEvent import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope @@ -67,6 +68,7 @@ class WebSocketRepositoryImpl @Inject constructor( private const val TAG = "WebSocketRepository" private const val SUBSCRIBE_TYPE_SUBSCRIBE_EVENTS = "subscribe_events" + private const val SUBSCRIBE_TYPE_SUBSCRIBE_TRIGGER = "subscribe_trigger" private const val SUBSCRIBE_TYPE_RENDER_TEMPLATE = "render_template" private const val EVENT_STATE_CHANGED = "state_changed" private const val EVENT_AREA_REGISTRY_UPDATED = "area_registry_updated" @@ -176,6 +178,9 @@ class WebSocketRepositoryImpl @Inject constructor( override suspend fun getStateChanges(): Flow? = subscribeToEventsForType(EVENT_STATE_CHANGED) + override suspend fun getStateChanges(entityIds: List): Flow? = + subscribeToTrigger("state", mapOf("entity_id" to entityIds)) + override suspend fun getAreaRegistryUpdates(): Flow? = subscribeToEventsForType(EVENT_AREA_REGISTRY_UPDATED) @@ -191,6 +196,13 @@ class WebSocketRepositoryImpl @Inject constructor( override suspend fun getTemplateUpdates(template: String): Flow? = subscribeTo(SUBSCRIBE_TYPE_RENDER_TEMPLATE, mapOf("template" to template)) + private suspend fun subscribeToTrigger(platform: String, data: Map): Flow? { + val triggerData = mapOf( + "platform" to platform + ).plus(data) + return subscribeTo(SUBSCRIBE_TYPE_SUBSCRIBE_TRIGGER, mapOf("trigger" to triggerData)) + } + /** * Start a subscription for events on the websocket connection and get a Flow for listening to * new messages. When there are no more listeners, the subscription will automatically be cancelled @@ -409,6 +421,14 @@ class WebSocketRepositoryImpl @Inject constructor( val message: Any = if (subscriptionType == SUBSCRIBE_TYPE_RENDER_TEMPLATE) { mapper.convertValue(response.event, TemplateUpdatedEvent::class.java) + } else if (subscriptionType == SUBSCRIBE_TYPE_SUBSCRIBE_TRIGGER) { + val trigger = response.event?.get("variables")?.get("trigger") + if (trigger != null) { + mapper.convertValue(trigger, TriggerEvent::class.java) + } else { + Log.w(TAG, "Received no trigger value for trigger subscription, skipping") + return + } } else if (eventResponseType != null && eventResponseType.isTextual) { val eventResponseClass = when (eventResponseType.textValue()) { EVENT_STATE_CHANGED -> diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/TriggerEvent.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/TriggerEvent.kt new file mode 100644 index 000000000..d0e2de91d --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/TriggerEvent.kt @@ -0,0 +1,12 @@ +package io.homeassistant.companion.android.common.data.websocket.impl.entities + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import io.homeassistant.companion.android.common.data.integration.Entity + +@JsonIgnoreProperties(ignoreUnknown = true) +data class TriggerEvent( + val platform: String, + val entityId: String?, + val fromState: Entity<*>?, + val toState: Entity<*>? +)