Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Entity historical data widget #4603

Draft
wants to merge 4 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,19 @@
android:resource="@xml/entity_widget_info" />
</receiver>

<receiver android:name=".widgets.history.HistoryWidget" android:label="@string/widget_history_description"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="io.homeassistant.companion.android.widgets.history.HistoryWidget.RECEIVE_DATA" />
<action android:name="io.homeassistant.companion.android.widgets.history.HistoryWidget.UPDATE_VIEW" />
</intent-filter>

<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/history_widget_info" />
</receiver>

<receiver android:name=".widgets.mediaplayer.MediaPlayerControlsWidget" android:label="@string/widget_media_player_description"
android:exported="false">
<intent-filter>
Expand Down Expand Up @@ -206,6 +219,13 @@
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity android:name=".widgets.history.HistoryWidgetConfigureActivity"
android:configChanges="orientation|screenSize"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
<activity android:name=".widgets.mediaplayer.MediaPlayerControlsWidgetConfigureActivity"
android:configChanges="orientation|screenSize"
android:exported="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.database.widget.ButtonWidgetDao
import io.homeassistant.companion.android.database.widget.CameraWidgetDao
import io.homeassistant.companion.android.database.widget.HistoryWidgetDao
import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao
import io.homeassistant.companion.android.database.widget.StaticWidgetDao
import io.homeassistant.companion.android.database.widget.TemplateWidgetDao
Expand All @@ -24,6 +25,7 @@ import kotlinx.coroutines.launch
class ManageWidgetsViewModel @Inject constructor(
buttonWidgetDao: ButtonWidgetDao,
cameraWidgetDao: CameraWidgetDao,
historyWidgetDao: HistoryWidgetDao,
staticWidgetDao: StaticWidgetDao,
mediaPlayerControlsWidgetDao: MediaPlayerControlsWidgetDao,
templateWidgetDao: TemplateWidgetDao,
Expand All @@ -38,6 +40,7 @@ class ManageWidgetsViewModel @Inject constructor(

val buttonWidgetList = buttonWidgetDao.getAllFlow().collectAsState()
val cameraWidgetList = cameraWidgetDao.getAllFlow().collectAsState()
val historyWidgetList = historyWidgetDao.getAllFlow().collectAsState()
val staticWidgetList = staticWidgetDao.getAllFlow().collectAsState()
val mediaWidgetList = mediaPlayerControlsWidgetDao.getAllFlow().collectAsState()
val templateWidgetList = templateWidgetDao.getAllFlow().collectAsState()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import io.homeassistant.companion.android.util.compose.MdcAlertDialog
import io.homeassistant.companion.android.widgets.button.ButtonWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.camera.CameraWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.entity.EntityWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.history.HistoryWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.mediaplayer.MediaPlayerControlsWidgetConfigureActivity
import io.homeassistant.companion.android.widgets.template.TemplateWidgetConfigureActivity

Expand All @@ -50,14 +51,16 @@ enum class WidgetType(val widgetIcon: IIcon) {
CAMERA(CommunityMaterial.Icon.cmd_camera_image),
STATE(CommunityMaterial.Icon3.cmd_shape),
MEDIA(CommunityMaterial.Icon3.cmd_play_box_multiple),
TEMPLATE(CommunityMaterial.Icon.cmd_code_braces);
TEMPLATE(CommunityMaterial.Icon.cmd_code_braces),
HISTORY(CommunityMaterial.Icon3.cmd_sun_clock);

fun configureActivity() = when (this) {
BUTTON -> ButtonWidgetConfigureActivity::class.java
CAMERA -> CameraWidgetConfigureActivity::class.java
MEDIA -> MediaPlayerControlsWidgetConfigureActivity::class.java
STATE -> EntityWidgetConfigureActivity::class.java
TEMPLATE -> TemplateWidgetConfigureActivity::class.java
HISTORY -> HistoryWidgetConfigureActivity::class.java
}
}

Expand All @@ -81,6 +84,7 @@ fun ManageWidgetsView(
val availableWidgets = listOf(
stringResource(R.string.widget_button_image_description) to WidgetType.BUTTON,
stringResource(R.string.widget_camera_description) to WidgetType.CAMERA,
stringResource(R.string.widget_history_description) to WidgetType.HISTORY,
stringResource(R.string.widget_static_image_description) to WidgetType.STATE,
stringResource(R.string.widget_media_player_description) to WidgetType.MEDIA,
stringResource(R.string.template_widget) to WidgetType.TEMPLATE
Expand Down Expand Up @@ -110,7 +114,7 @@ fun ManageWidgetsView(
) {
if (viewModel.buttonWidgetList.value.isEmpty() && viewModel.staticWidgetList.value.isEmpty() &&
viewModel.mediaWidgetList.value.isEmpty() && viewModel.templateWidgetList.value.isEmpty() &&
viewModel.cameraWidgetList.value.isEmpty()
viewModel.cameraWidgetList.value.isEmpty() && viewModel.historyWidgetList.value.isEmpty()
) {
item {
EmptyState(
Expand All @@ -135,6 +139,12 @@ fun ManageWidgetsView(
title = R.string.camera_widgets,
widgetLabel = { item -> item.entityId }
)
widgetItems(
viewModel.historyWidgetList.value,
widgetType = WidgetType.HISTORY,
title = R.string.history_widgets,
widgetLabel = { item -> item.entityId }
)
widgetItems(
viewModel.staticWidgetList.value,
widgetType = WidgetType.STATE,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
package io.homeassistant.companion.android.widgets.history

import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.os.Bundle
import android.util.Log
import android.util.TypedValue
import android.view.View
import android.widget.RemoteViews
import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt
import androidx.core.os.BundleCompat
import com.google.android.material.color.DynamicColors
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.canSupportPrecision
import io.homeassistant.companion.android.common.data.integration.friendlyName
import io.homeassistant.companion.android.common.data.integration.friendlyState
import io.homeassistant.companion.android.common.data.websocket.impl.entities.EntityRegistryOptions
import io.homeassistant.companion.android.database.widget.HistoryWidgetDao
import io.homeassistant.companion.android.database.widget.HistoryWidgetEntity
import io.homeassistant.companion.android.database.widget.WidgetBackgroundType
import io.homeassistant.companion.android.util.getAttribute
import io.homeassistant.companion.android.widgets.BaseWidgetProvider
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import kotlinx.coroutines.launch

@AndroidEntryPoint
class HistoryWidget : BaseWidgetProvider() {

companion object {
private const val TAG = "HistoryWidget"

internal const val EXTRA_SERVER_ID = "EXTRA_SERVER_ID"
internal const val EXTRA_ENTITY_ID = "EXTRA_ENTITY_ID"
internal const val EXTRA_LABEL = "EXTRA_LABEL"
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"
internal const val DEFAULT_TEXT_SIZE = 30F
internal const val DEFAULT_ENTITY_ID_SEPARATOR = ","

private data class ResolvedText(val text: CharSequence?, val exception: Boolean = false)
}

@Inject
lateinit var historyWidgetDao: HistoryWidgetDao

override fun getWidgetProvider(context: Context): ComponentName =
ComponentName(context, HistoryWidget::class.java)

override suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int, suggestedEntity: Entity<Map<String, Any>>?): RemoteViews {
val widget = historyWidgetDao.get(appWidgetId)

val intent = Intent(context, HistoryWidget::class.java).apply {
action = UPDATE_VIEW
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
}

val useDynamicColors = widget?.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable()
val views = RemoteViews(context.packageName, if (useDynamicColors) R.layout.widget_history_wrapper_dynamiccolor else R.layout.widget_history_wrapper_default).apply {
if (widget != null) {
val serverId = widget.serverId
val entityIds: String = widget.entityId
val label: String? = widget.label
val textSize: Float = widget.textSize

// Theming
if (widget.backgroundType == WidgetBackgroundType.TRANSPARENT) {
var textColor = context.getAttribute(R.attr.colorWidgetOnBackground, ContextCompat.getColor(context, commonR.color.colorWidgetButtonLabel))
widget.textColor?.let { textColor = it.toColorInt() }

setInt(R.id.widgetLayout, "setBackgroundColor", Color.TRANSPARENT)
setTextColor(R.id.widgetText, textColor)
setTextColor(R.id.widgetLabel, textColor)
}

// Content
setViewVisibility(
R.id.widgetTextLayout,
View.VISIBLE
)
setViewVisibility(
R.id.widgetProgressBar,
View.INVISIBLE
)
val resolvedText = resolveTextToShow(
context,
serverId,
entityIds,
suggestedEntity,
appWidgetId
)
setTextViewTextSize(
R.id.widgetText,
TypedValue.COMPLEX_UNIT_SP,
textSize
)
setTextViewText(
R.id.widgetText,
resolvedText.text
)
setTextViewText(
R.id.widgetLabel,
label ?: entityIds
)
setViewVisibility(
R.id.widgetStaticError,
if (resolvedText.exception) View.VISIBLE else View.GONE
)
setOnClickPendingIntent(
R.id.widgetTextLayout,
PendingIntent.getBroadcast(
context,
appWidgetId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
} else {
setTextViewText(R.id.widgetText, "")
setTextViewText(R.id.widgetLabel, "")
}
}

return views
}

override suspend fun getAllWidgetIdsWithEntities(context: Context): Map<Int, Pair<Int, List<String>>> =
historyWidgetDao.getAll().associate { it.id to (it.serverId to listOf(it.entityId)) }

private suspend fun resolveTextToShow(
context: Context,
serverId: Int,
entityIds: String?,
suggestedEntity: Entity<Map<String, Any>>?,
appWidgetId: Int
): ResolvedText {
var entitiesStatesList: List<List<Entity<Map<String, Any>>>>? = null
var entityCaughtException = false
try {
if (suggestedEntity != null) {
entitiesStatesList = serverManager.integrationRepository(serverId).getHistory(listOf(suggestedEntity.entityId))
} else {
entityIds?.let { ids -> entitiesStatesList = serverManager.integrationRepository(serverId).getHistory(ids.split(DEFAULT_ENTITY_ID_SEPARATOR)) }
}
} catch (e: Exception) {
Log.e(TAG, "Unable to fetch entity", e)
entityCaughtException = true
}

val entityOptionsList = mutableMapOf<String, EntityRegistryOptions?>()

entitiesStatesList?.forEach { entityStateList ->
if (entityStateList.all { it.canSupportPrecision() && serverManager.getServer(serverId)?.version?.isAtLeast(2023, 3) == true }) {
entityOptionsList[entityStateList.first().entityId] = serverManager.webSocketRepository(serverId).getEntityRegistryFor(entityStateList.first().entityId)?.options
}
}

try {
val textBuilder = StringBuilder()
entitiesStatesList?.forEachIndexed { index, entityStatesList ->
if (index > 0) textBuilder.append("\n---\n")
textBuilder.append(getLastUpdateFromEntityStatesList(entityStatesList, context, entityOptionsList[entityStatesList.first().entityId]))
}
historyWidgetDao.updateWidgetLastUpdate(
appWidgetId,
textBuilder.toString()
)
return ResolvedText(historyWidgetDao.get(appWidgetId)?.lastUpdate, entityCaughtException)
} catch (e: Exception) {
Log.e(TAG, "Unable to fetch entity state and attributes", e)
return ResolvedText(historyWidgetDao.get(appWidgetId)?.lastUpdate, true)
}
}

override fun saveEntityConfiguration(context: Context, extras: Bundle?, appWidgetId: Int) {
if (extras == null) return

val serverId = if (extras.containsKey(EXTRA_SERVER_ID)) extras.getInt(EXTRA_SERVER_ID) else null
val entityIds: String? = extras.getString(EXTRA_ENTITY_ID)
val labelSelection: String? = extras.getString(EXTRA_LABEL)
val textSizeSelection: String? = extras.getString(EXTRA_TEXT_SIZE)
val backgroundTypeSelection = BundleCompat.getSerializable(extras, EXTRA_BACKGROUND_TYPE, WidgetBackgroundType::class.java)
?: WidgetBackgroundType.DAYNIGHT
val textColorSelection: String? = extras.getString(EXTRA_TEXT_COLOR)

if (serverId == null || entityIds == null) {
Log.e(TAG, "Did not receive complete service call data")
return
}

widgetScope?.launch {
Log.d(
TAG,
"Saving entity state config data:" + System.lineSeparator() +
"entity id: " + entityIds + System.lineSeparator()
)
historyWidgetDao.add(
HistoryWidgetEntity(
appWidgetId,
serverId,
entityIds,
labelSelection,
textSizeSelection?.toFloatOrNull() ?: DEFAULT_TEXT_SIZE,
historyWidgetDao.get(appWidgetId)?.lastUpdate ?: "",
backgroundTypeSelection,
textColorSelection
)
)

onUpdate(context, AppWidgetManager.getInstance(context), intArrayOf(appWidgetId))
}
}

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) {
widgetScope?.launch {
historyWidgetDao.deleteAll(appWidgetIds)
appWidgetIds.forEach { removeSubscription(it) }
}
}

private fun getLastUpdateFromEntityStatesList(
entityList: List<Entity<Map<String, Any>>>?,
context: Context,
entityOptions: EntityRegistryOptions?
): String = entityList?.fold("") { acc, entity ->

val localDate = with(entity.lastUpdated) {
LocalDateTime.ofInstant(toInstant(), timeZone.toZoneId())
}
val entityTextToVisualize = StringBuilder("(")
entityTextToVisualize.append(localDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"))).append(") ")
entityTextToVisualize.append(entity.friendlyState(context, entityOptions))
if (acc.isEmpty()) {
acc.plus(entity.friendlyName).plus(": ").plus(entityTextToVisualize)
} else {
acc.plus("\n").plus(entity.friendlyName).plus(": ").plus(entityTextToVisualize)
}
} ?: ""
}
Loading