Better state change subscriptions in entity state and media player widgets (#2814)

Implement subscribe_trigger and use it in widgets

 - Implement support for subscribing using subscribe_trigger, currently used to get state changes only for specific entities
 - Change the entity state and media player widgets to use this new subscription instead of subscribing to all state changes, based on the template widget
 - Removed some placeholder code from media player widget
This commit is contained in:
Joris Pelgröm 2022-09-05 15:56:44 +02:00 committed by GitHub
parent 2cf5a7d215
commit 232fbb2b2d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 143 additions and 89 deletions

View file

@ -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<Entity<*>>? = null
var widgetScope: CoroutineScope? = null
val widgetEntities = mutableMapOf<Int, List<String>>()
val widgetJobs = mutableMapOf<Int, Job>()
}
@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<Map<String, Any>>? = null): RemoteViews
abstract suspend fun getAllWidgetIds(context: Context): List<Int>
abstract suspend fun getAllWidgetIdsWithEntities(context: Context): Map<Int, List<String>>
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<*>)
}

View file

@ -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<Int> {
return staticWidgetDao.getAll().map { it.id }
}
override suspend fun getAllWidgetIdsWithEntities(context: Context): Map<Int, List<String>> =
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<Map<String, Any>>)
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<Map<String, Any>>)
AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, views)
}
}
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
mainScope.launch {
widgetScope?.launch {
staticWidgetDao.deleteAll(appWidgetIds)
appWidgetIds.forEach { removeSubscription(it) }
}
}
}

View file

@ -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<Int> {
return mediaPlayCtrlWidgetDao.getAll().map { it.id }
}
override suspend fun getAllWidgetIdsWithEntities(context: Context): Map<Int, List<String>> =
mediaPlayCtrlWidgetDao.getAll().associate { it.id to it.entityId.split(",") }
private suspend fun getEntity(context: Context, entityIds: List<String>, suggestedEntity: Entity<Map<String, Any>>?): Entity<Map<String, Any>>? {
val entity: Entity<Map<String, Any>>?
@ -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<ConnectivityManager>()
val activeNetworkInfo = connectivityManager?.activeNetworkInfo

View file

@ -64,6 +64,7 @@ interface IntegrationRepository {
suspend fun getEntities(): List<Entity<Any>>?
suspend fun getEntity(entityId: String): Entity<Map<String, Any>>?
suspend fun getEntityUpdates(): Flow<Entity<*>>?
suspend fun getEntityUpdates(entityIds: List<String>): Flow<Entity<*>>?
suspend fun callService(domain: String, service: String, serviceData: HashMap<String, Any>)

View file

@ -589,6 +589,21 @@ class IntegrationRepositoryImpl @Inject constructor(
}
}
override suspend fun getEntityUpdates(entityIds: List<String>): Flow<Entity<*>>? {
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<Any>) {
val canRegisterCategoryStateClass = isHomeAssistantVersionAtLeast(2021, 11, 0)
val canRegisterEntityDisabledState = isHomeAssistantVersionAtLeast(2022, 6, 0)

View file

@ -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<EntityRegistryResponse>?
suspend fun getServices(): List<DomainResponse>?
suspend fun getStateChanges(): Flow<StateChangedEvent>?
suspend fun getStateChanges(entityIds: List<String>): Flow<TriggerEvent>?
suspend fun getAreaRegistryUpdates(): Flow<AreaRegistryUpdatedEvent>?
suspend fun getDeviceRegistryUpdates(): Flow<DeviceRegistryUpdatedEvent>?
suspend fun getEntityRegistryUpdates(): Flow<EntityRegistryUpdatedEvent>?

View file

@ -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<StateChangedEvent>? =
subscribeToEventsForType(EVENT_STATE_CHANGED)
override suspend fun getStateChanges(entityIds: List<String>): Flow<TriggerEvent>? =
subscribeToTrigger("state", mapOf("entity_id" to entityIds))
override suspend fun getAreaRegistryUpdates(): Flow<AreaRegistryUpdatedEvent>? =
subscribeToEventsForType(EVENT_AREA_REGISTRY_UPDATED)
@ -191,6 +196,13 @@ class WebSocketRepositoryImpl @Inject constructor(
override suspend fun getTemplateUpdates(template: String): Flow<TemplateUpdatedEvent>? =
subscribeTo(SUBSCRIBE_TYPE_RENDER_TEMPLATE, mapOf("template" to template))
private suspend fun subscribeToTrigger(platform: String, data: Map<Any, Any>): Flow<TriggerEvent>? {
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 ->

View file

@ -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<*>?
)