Support multiple subscriptions of same type in websocket and use in template widget (#2801)

* Update WebSocketRepository for multiple subscriptions of same type

 - Change the structure of how the websocket repository tracks individual subscriptions to allow subscribing multiple times with the same type, but different data

* Update TemplateWidget to use template subscriptions

 - Instead of depending on entity state changes and refreshing any time there is a change, use the render_template subscription for the template widget to limit the amount of data and power used. To make this possible without too much abstractions, the TemplateWidget no longer implements BaseWidgetProvider.
 - Fix hardcoded "Loading" string

* Handle potential null subscription ID

 - Implements #2795 for changes to tracked subscription ID

* Handle failed subscribing similar to other failures (null)

 - When subscribing fails, return null and don't store an active subscription instead of continuing and returning a flow (which would never emit messages)

* Update manifest
This commit is contained in:
Joris Pelgröm 2022-08-30 02:23:48 +02:00 committed by GitHub
parent ebc6b76dda
commit a995d9410b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 275 additions and 88 deletions

View file

@ -161,7 +161,7 @@
<action android:name="android.bluetooth.device.action.ACL_DISCONNECTED" />
<action android:name="io.homeassistant.companion.android.background.REQUEST_SENSORS_UPDATE" />
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="io.homeassistant.companion.android.widgets.template.BaseWidgetProvider.RECEIVE_DATA" />
<action android:name="io.homeassistant.companion.android.widgets.template.TemplateWidget.RECEIVE_DATA" />
</intent-filter>
<meta-data

View file

@ -2,9 +2,11 @@ package io.homeassistant.companion.android.widgets.template
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Color
import android.os.Bundle
import android.text.Html.fromHtml
@ -17,52 +19,184 @@ import androidx.core.graphics.toColorInt
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.database.widget.TemplateWidgetDao
import io.homeassistant.companion.android.database.widget.TemplateWidgetEntity
import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
import io.homeassistant.companion.android.util.getAttribute
import io.homeassistant.companion.android.widgets.BaseWidgetProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
@AndroidEntryPoint
class TemplateWidget : BaseWidgetProvider() {
class TemplateWidget : AppWidgetProvider() {
companion object {
private const val TAG = "TemplateWidget"
const val UPDATE_VIEW =
"io.homeassistant.companion.android.widgets.template.TemplateWidget.UPDATE_VIEW"
const val RECEIVE_DATA =
"io.homeassistant.companion.android.widgets.template.TemplateWidget.RECEIVE_DATA"
internal const val EXTRA_TEMPLATE = "extra_template"
internal const val EXTRA_TEXT_SIZE = "EXTRA_TEXT_SIZE"
internal const val EXTRA_BACKGROUND_TYPE = "EXTRA_BACKGROUND_TYPE"
internal const val EXTRA_TEXT_COLOR = "EXTRA_TEXT_COLOR"
private var isSubscribed = false
private var widgetScope: CoroutineScope? = null
private val widgetTemplates = mutableMapOf<Int, String>()
private val widgetJobs = mutableMapOf<Int, Job>()
}
@Inject
lateinit var integrationUseCase: IntegrationRepository
@Inject
lateinit var templateWidgetDao: TemplateWidgetDao
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
// When the user deletes the widget, delete the preference associated with it.
mainScope.launch {
templateWidgetDao.deleteAll(appWidgetIds)
private var thisSetScope = false
private var lastIntent = ""
init {
setupWidgetScope()
}
private fun setupWidgetScope() {
if (widgetScope == null || !widgetScope!!.isActive) {
widgetScope = CoroutineScope(Dispatchers.Main + Job())
thisSetScope = true
}
}
override fun isSubscribed(): Boolean = isSubscribed
override fun setSubscribed(subscribed: Boolean) {
isSubscribed = subscribed
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
// There may be multiple widgets active, so update all of them
for (appWidgetId in appWidgetIds) {
widgetScope?.launch {
val views = getWidgetRemoteViews(context, appWidgetId)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
}
override fun getWidgetProvider(context: Context): ComponentName =
ComponentName(context, TemplateWidget::class.java)
override suspend fun getAllWidgetIds(context: Context): List<Int> {
return templateWidgetDao.getAll().map { it.id }
override fun onDeleted(context: Context, appWidgetIds: IntArray) {
// When the user deletes the widget, delete the preference associated with it.
widgetScope?.launch {
templateWidgetDao.deleteAll(appWidgetIds)
appWidgetIds.forEach {
widgetTemplates.remove(it)
widgetJobs[it]?.cancel()
widgetJobs.remove(it)
}
}
}
override suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int, suggestedEntity: Entity<Map<String, Any>>?): RemoteViews {
override fun onReceive(context: Context, intent: Intent) {
lastIntent = intent.action.toString()
val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1)
super.onReceive(context, intent)
when (lastIntent) {
UPDATE_VIEW -> updateView(context, appWidgetId)
RECEIVE_DATA -> {
saveEntityConfiguration(
context,
intent.extras,
appWidgetId
)
onScreenOn(context)
}
Intent.ACTION_SCREEN_ON -> onScreenOn(context)
Intent.ACTION_SCREEN_OFF -> onScreenOff()
}
}
private fun onScreenOn(context: Context) {
setupWidgetScope()
widgetScope!!.launch {
if (!integrationUseCase.isRegistered()) {
return@launch
}
updateAllWidgets(context)
val allWidgets = templateWidgetDao.getAll()
val widgetsWithDifferentTemplate = allWidgets.filter { it.template != widgetTemplates[it.id] }
if (widgetsWithDifferentTemplate.isNotEmpty()) {
if (thisSetScope) {
context.applicationContext.registerReceiver(
this@TemplateWidget,
IntentFilter(Intent.ACTION_SCREEN_OFF)
)
}
widgetsWithDifferentTemplate.forEach { widget ->
widgetJobs[widget.id]?.cancel()
val templateUpdates = integrationUseCase.getTemplateUpdates(widget.template)
if (templateUpdates != null) {
widgetTemplates[widget.id] = widget.template
widgetJobs[widget.id] = widgetScope!!.launch {
templateUpdates.collect {
onTemplateChanged(context, widget.id, it)
}
}
} else { // Remove data to make it retry on the next update
widgetTemplates.remove(widget.id)
widgetJobs.remove(widget.id)
}
}
}
}
}
private fun onScreenOff() {
if (thisSetScope) {
widgetScope?.cancel()
thisSetScope = false
widgetTemplates.clear()
widgetJobs.clear()
}
}
private suspend fun updateAllWidgets(
context: Context
) {
val systemWidgetIds = AppWidgetManager.getInstance(context)
.getAppWidgetIds(ComponentName(context, TemplateWidget::class.java))
.toSet()
val dbWidgetIds = templateWidgetDao.getAll().map { it.id }
val invalidWidgetIds = dbWidgetIds.minus(systemWidgetIds)
if (invalidWidgetIds.isNotEmpty()) {
Log.i(TAG, "Found widgets $invalidWidgetIds in database, but not in AppWidgetManager - sending onDeleted")
onDeleted(context, invalidWidgetIds.toIntArray())
}
dbWidgetIds.filter { systemWidgetIds.contains(it) }.forEach {
updateView(context, it)
}
}
private fun updateView(
context: Context,
appWidgetId: Int,
appWidgetManager: AppWidgetManager = AppWidgetManager.getInstance(context)
) {
widgetScope?.launch {
val views = getWidgetRemoteViews(context, appWidgetId)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
}
private suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int, suggestedTemplate: String? = null): RemoteViews {
// Every time AppWidgetManager.updateAppWidget(...) is called, the button listener
// and label need to be re-assigned, or the next time the layout updates
// (e.g home screen rotation) the widget will fall back on its default layout
@ -97,9 +231,9 @@ class TemplateWidget : BaseWidgetProvider() {
}
// Content
var renderedTemplate: String? = templateWidgetDao.get(appWidgetId)?.lastUpdate ?: "Loading"
var renderedTemplate: String? = templateWidgetDao.get(appWidgetId)?.lastUpdate ?: context.getString(commonR.string.loading)
try {
renderedTemplate = integrationUseCase.renderTemplate(widget.template, mapOf()).toString()
renderedTemplate = suggestedTemplate ?: integrationUseCase.renderTemplate(widget.template, mapOf()).toString()
templateWidgetDao.updateTemplateWidgetLastUpdate(
appWidgetId,
renderedTemplate
@ -124,7 +258,7 @@ class TemplateWidget : BaseWidgetProvider() {
}
}
override fun saveEntityConfiguration(context: Context, extras: Bundle?, appWidgetId: Int) {
private fun saveEntityConfiguration(context: Context, extras: Bundle?, appWidgetId: Int) {
if (extras == null) return
val template: String? = extras.getString(EXTRA_TEMPLATE)
@ -137,7 +271,7 @@ class TemplateWidget : BaseWidgetProvider() {
return
}
mainScope.launch {
widgetScope?.launch {
templateWidgetDao.add(
TemplateWidgetEntity(
appWidgetId,
@ -152,13 +286,10 @@ class TemplateWidget : BaseWidgetProvider() {
}
}
override suspend fun onEntityStateChanged(context: Context, entity: Entity<*>) {
getAllWidgetIds(context).forEach { appWidgetId ->
val intent = Intent(context, TemplateWidget::class.java).apply {
action = UPDATE_VIEW
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}
context.sendBroadcast(intent)
private fun onTemplateChanged(context: Context, appWidgetId: Int, template: String) {
widgetScope?.launch {
val views = getWidgetRemoteViews(context, appWidgetId, template)
AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, views)
}
}
}

View file

@ -27,7 +27,6 @@ import io.homeassistant.companion.android.databinding.WidgetTemplateConfigureBin
import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel
import io.homeassistant.companion.android.util.getHexForColor
import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.BaseWidgetProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -170,7 +169,7 @@ class TemplateWidgetConfigureActivity : BaseWidgetConfigureActivity() {
}
val createIntent = Intent().apply {
action = BaseWidgetProvider.RECEIVE_DATA
action = TemplateWidget.RECEIVE_DATA
component = ComponentName(applicationContext, TemplateWidget::class.java)
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
putExtra(TemplateWidget.EXTRA_TEMPLATE, binding.templateText.text.toString())

View file

@ -15,6 +15,7 @@ interface IntegrationRepository {
suspend fun getNotificationRateLimits(): RateLimitResponse
suspend fun renderTemplate(template: String, variables: Map<String, String>): String?
suspend fun getTemplateUpdates(template: String): Flow<String>?
suspend fun updateLocation(updateLocation: UpdateLocation)

View file

@ -178,6 +178,13 @@ class IntegrationRepositoryImpl @Inject constructor(
else throw IntegrationException("Error calling integration request render_template")
}
override suspend fun getTemplateUpdates(template: String): Flow<String>? {
return webSocketRepository.getTemplateUpdates(template)
?.map {
it.result
}
}
override suspend fun updateLocation(updateLocation: UpdateLocation) {
val updateLocationRequest = createUpdateLocation(updateLocation)

View file

@ -10,6 +10,7 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.En
import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryUpdatedEvent
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 kotlinx.coroutines.flow.Flow
interface WebSocketRepository {
@ -25,6 +26,7 @@ interface WebSocketRepository {
suspend fun getAreaRegistryUpdates(): Flow<AreaRegistryUpdatedEvent>?
suspend fun getDeviceRegistryUpdates(): Flow<DeviceRegistryUpdatedEvent>?
suspend fun getEntityRegistryUpdates(): Flow<EntityRegistryUpdatedEvent>?
suspend fun getTemplateUpdates(template: String): Flow<TemplateUpdatedEvent>?
suspend fun getNotifications(): Flow<Map<String, Any>>?
suspend fun ackNotification(confirmId: String): Boolean
}

View file

@ -26,6 +26,7 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.Ev
import io.homeassistant.companion.android.common.data.websocket.impl.entities.GetConfigResponse
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 kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@ -63,6 +64,8 @@ class WebSocketRepositoryImpl @Inject constructor(
companion object {
private const val TAG = "WebSocketRepository"
private const val SUBSCRIBE_TYPE_SUBSCRIBE_EVENTS = "subscribe_events"
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"
private const val EVENT_DEVICE_REGISTRY_UPDATED = "device_registry_updated"
@ -82,12 +85,12 @@ class WebSocketRepositoryImpl @Inject constructor(
private val connectedMutex = Mutex()
private var connected = CompletableDeferred<Boolean>()
private val eventSubscriptionMutex = Mutex()
private val eventSubscriptionFlow = mutableMapOf<String, SharedFlow<*>>()
private var eventSubscriptionProducerScope = mutableMapOf<String, ProducerScope<Any>>()
private var eventSubscriptionId = mutableMapOf<Map<Any, Any>, Long?>()
private val eventSubscriptionFlow = mutableMapOf<Map<Any, Any>, SharedFlow<*>>()
private var eventSubscriptionProducerScope = mutableMapOf<Map<Any, Any>, ProducerScope<Any>>()
private val notificationMutex = Mutex()
private var notificationFlow: Flow<Map<String, Any>>? = null
private var notificationProducerScope: ProducerScope<Map<String, Any>>? = null
private var lastResponseID = mutableMapOf<String, Long?>()
override fun getConnectionState(): WebSocketState? = connectionState
@ -179,46 +182,68 @@ class WebSocketRepositoryImpl @Inject constructor(
override suspend fun getEntityRegistryUpdates(): Flow<EntityRegistryUpdatedEvent>? =
subscribeToEventsForType(EVENT_ENTITY_REGISTRY_UPDATED)
private suspend fun <T : Any> subscribeToEventsForType(eventType: String): Flow<T>? {
eventSubscriptionMutex.withLock {
if (eventSubscriptionFlow[eventType] == null) {
private suspend fun <T : Any> subscribeToEventsForType(eventType: String): Flow<T>? =
subscribeTo(SUBSCRIBE_TYPE_SUBSCRIBE_EVENTS, mapOf("event_type" to eventType))
val response = sendMessage(
mapOf(
"type" to "subscribe_events",
"event_type" to eventType
)
)
lastResponseID[eventType] = response?.id
if (response == null) {
Log.e(TAG, "Unable to register for events of type $eventType")
override suspend fun getTemplateUpdates(template: String): Flow<TemplateUpdatedEvent>? =
subscribeTo(SUBSCRIBE_TYPE_RENDER_TEMPLATE, mapOf("template" to template))
/**
* 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
* using `unsubscribe_events`. If the subscription already exists, the existing Flow is returned.
*
* @param type value for the `type` key in the subscription message, for example `subscribe_events`
* @param data a key/value map of additional data to be included in the subscription message, for
* example the `event_type` + value when subscribing with `subscribe_events`
* @return a Flow that will emit messages delivered to this subscription, or `null` if an error
* occurred
*/
private suspend fun <T : Any> subscribeTo(type: String, data: Map<Any, Any>): Flow<T>? {
val subscribeMessage = mapOf(
"type" to type
).plus(data)
eventSubscriptionMutex.withLock {
if (eventSubscriptionId[subscribeMessage] == null) {
val response = sendMessage(subscribeMessage)
if (response == null || response.success != true) {
Log.e(TAG, "Unable to subscribe to $type with data $data")
return null
} else {
eventSubscriptionId[subscribeMessage] = response.id
}
eventSubscriptionFlow[eventType] = callbackFlow<T> {
eventSubscriptionProducerScope[eventType] = this as ProducerScope<Any>
// Subscriptions are stored by subscribe message instead of ID, because the ID will
// change when the app needs to resubscribe
eventSubscriptionFlow[subscribeMessage] = callbackFlow<T> {
eventSubscriptionProducerScope[subscribeMessage] = this as ProducerScope<Any>
awaitClose {
if (lastResponseID[eventType] != null) {
Log.d(TAG, "Unsubscribing from $eventType")
if (eventSubscriptionId[subscribeMessage] != null) {
Log.d(TAG, "Unsubscribing from $type with data $data")
ioScope.launch {
sendMessage(
mapOf(
"type" to "unsubscribe_events",
"subscription" to lastResponseID[eventType]
"subscription" to eventSubscriptionId[subscribeMessage]!!
)
)
lastResponseID.remove(eventType)
eventSubscriptionId.remove(subscribeMessage)
}
}
eventSubscriptionProducerScope.remove(eventType)
eventSubscriptionFlow.remove(eventType)
eventSubscriptionProducerScope.remove(subscribeMessage)
eventSubscriptionFlow.remove(subscribeMessage)
}
}.shareIn(ioScope, SharingStarted.WhileSubscribed())
}
}
return eventSubscriptionFlow[eventType]!! as Flow<T>
return eventSubscriptionFlow[subscribeMessage]!! as Flow<T>
}
private fun getSubscriptionMessageById(id: Long): Map<Any, Any>? =
eventSubscriptionId.filterValues { it == id }.keys.firstOrNull()
override suspend fun getNotifications(): Flow<Map<String, Any>>? {
notificationMutex.withLock {
if (notificationFlow == null) {
@ -355,29 +380,45 @@ class WebSocketRepositoryImpl @Inject constructor(
}
private suspend fun handleEvent(response: SocketResponse) {
val eventResponseType = response.event?.get("event_type")
if (eventResponseType != null && eventResponseType.isTextual) {
val eventResponseClass = when (eventResponseType.textValue()) {
EVENT_STATE_CHANGED -> object : TypeReference<EventResponse<StateChangedEvent>>() {}
EVENT_AREA_REGISTRY_UPDATED ->
object :
TypeReference<EventResponse<AreaRegistryUpdatedEvent>>() {}
EVENT_DEVICE_REGISTRY_UPDATED ->
object :
TypeReference<EventResponse<DeviceRegistryUpdatedEvent>>() {}
EVENT_ENTITY_REGISTRY_UPDATED ->
object :
TypeReference<EventResponse<EntityRegistryUpdatedEvent>>() {}
else -> {
Log.d(TAG, "Unknown event type received")
object : TypeReference<EventResponse<Any>>() {}
val subscriptionId = response.id
if (subscriptionId != null && eventSubscriptionId.values.contains(subscriptionId)) {
val subscriptionMessage = getSubscriptionMessageById(subscriptionId)
val subscriptionType = subscriptionMessage?.get("type")
val eventResponseType = response.event?.get("event_type")
val message: Any =
if (subscriptionType == SUBSCRIBE_TYPE_RENDER_TEMPLATE) {
mapper.convertValue(response.event, TemplateUpdatedEvent::class.java)
} else if (eventResponseType != null && eventResponseType.isTextual) {
val eventResponseClass = when (eventResponseType.textValue()) {
EVENT_STATE_CHANGED ->
object :
TypeReference<EventResponse<StateChangedEvent>>() {}
EVENT_AREA_REGISTRY_UPDATED ->
object :
TypeReference<EventResponse<AreaRegistryUpdatedEvent>>() {}
EVENT_DEVICE_REGISTRY_UPDATED ->
object :
TypeReference<EventResponse<DeviceRegistryUpdatedEvent>>() {}
EVENT_ENTITY_REGISTRY_UPDATED ->
object :
TypeReference<EventResponse<EntityRegistryUpdatedEvent>>() {}
else -> {
Log.d(TAG, "Unknown event type received")
object : TypeReference<EventResponse<Any>>() {}
}
}
mapper.convertValue(
response.event,
eventResponseClass
).data
} else {
Log.d(TAG, "Unknown event for subscription received, skipping")
return
}
}
val eventResponse = mapper.convertValue(
response.event,
eventResponseClass
)
eventSubscriptionProducerScope[eventResponse.eventType]?.send(eventResponse.data)
eventSubscriptionProducerScope[subscriptionMessage]?.send(message)
} else if (response.event?.contains("hass_confirm_id") == true) {
if (notificationProducerScope?.isActive == true) {
notificationProducerScope?.send(
@ -404,16 +445,13 @@ class WebSocketRepositoryImpl @Inject constructor(
ioScope.launch {
delay(10000)
if (connect()) {
eventSubscriptionFlow.forEach { (eventType, _) ->
val response = sendMessage(
mapOf(
"type" to "subscribe_events",
"event_type" to eventType
)
)
lastResponseID[eventType] = response?.id
if (response == null) {
Log.e(TAG, "Issue re-registering event subscriptions")
eventSubscriptionFlow.forEach { (subscribeMessage, _) ->
val response = sendMessage(subscribeMessage)
if (response == null || response.success != true) {
Log.e(TAG, "Issue re-registering subscription with $subscribeMessage")
eventSubscriptionId[subscribeMessage] = null
} else {
eventSubscriptionId[subscribeMessage] = response.id
}
}
if (notificationFlow != null) {

View file

@ -0,0 +1,9 @@
package io.homeassistant.companion.android.common.data.websocket.impl.entities
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class TemplateUpdatedEvent(
val result: String,
val listeners: Map<String, Any>
)