From fa64a2e9477d3026e61211f3fabc603a119d9c24 Mon Sep 17 00:00:00 2001 From: PizzaMarinara Date: Wed, 28 Aug 2024 18:02:16 +0200 Subject: [PATCH 1/4] Implementation of history API call --- .../data/integration/IntegrationRepository.kt | 1 + .../history/HistoryRequestParams.kt | 42 +++++++++++++++++++ .../impl/IntegrationRepositoryImpl.kt | 29 +++++++++++++ .../integration/impl/IntegrationService.kt | 6 +++ 4 files changed, 78 insertions(+) create mode 100644 common/src/main/java/io/homeassistant/companion/android/common/data/integration/history/HistoryRequestParams.kt 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 9ce25f7174f..ada6dc847ff 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 @@ -41,6 +41,7 @@ interface IntegrationRepository { suspend fun getEntity(entityId: String): Entity>? suspend fun getEntityUpdates(): Flow>? suspend fun getEntityUpdates(entityIds: List): Flow>? + suspend fun getHistory(entityIds: List): List>>? suspend fun callAction(domain: String, action: String, actionData: HashMap) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/history/HistoryRequestParams.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/history/HistoryRequestParams.kt new file mode 100644 index 00000000000..82234ae28d7 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/history/HistoryRequestParams.kt @@ -0,0 +1,42 @@ +package io.homeassistant.companion.android.common.data.integration.history + +import okhttp3.HttpUrl + +data class HistoryRequestParams( + val timestamp: String? = null, + val filterEntityIds: List, + val endTime: String? = null, + val minimalResponse: String? = null, + val noAttributes: String? = null, + val significantChangesOnly: String? = null +) { + + companion object { + const val FILTER_ENTITY_IDS = "filter_entity_id" + const val END_TIME = "end_time" + const val MINIMAL_RESPONSE = "minimal_response" + const val NO_ATTRIBUTES = "no_attributes" + const val SIGNIFICANT_CHANGES_ONLY = "significant_changes_only" + } + + fun addToUrl(url: HttpUrl): HttpUrl { + val builder = url.newBuilder() + if (timestamp != null) { + builder.addPathSegments("$timestamp") + } + builder.addQueryParameter(FILTER_ENTITY_IDS, filterEntityIds.joinToString(separator = ",")) + if (endTime != null) { + builder.addQueryParameter(END_TIME, endTime) + } + if (minimalResponse != null) { + builder.addQueryParameter(MINIMAL_RESPONSE, minimalResponse) + } + if (noAttributes != null) { + builder.addQueryParameter(NO_ATTRIBUTES, noAttributes) + } + if (significantChangesOnly != null) { + builder.addQueryParameter(SIGNIFICANT_CHANGES_ONLY, significantChangesOnly) + } + return builder.build() + } +} 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 83f9c73e38d..fbdfb8cbda8 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 @@ -14,6 +14,7 @@ import io.homeassistant.companion.android.common.data.integration.IntegrationRep import io.homeassistant.companion.android.common.data.integration.SensorRegistration import io.homeassistant.companion.android.common.data.integration.UpdateLocation import io.homeassistant.companion.android.common.data.integration.ZoneAttributes +import io.homeassistant.companion.android.common.data.integration.history.HistoryRequestParams import io.homeassistant.companion.android.common.data.integration.impl.entities.ActionRequest import io.homeassistant.companion.android.common.data.integration.impl.entities.EntityResponse import io.homeassistant.companion.android.common.data.integration.impl.entities.FireEventRequest @@ -695,6 +696,34 @@ class IntegrationRepositoryImpl @AssistedInject constructor( } } + override suspend fun getHistory(entityIds: List): List>>? { + val url = server.connection.getUrl()?.toHttpUrlOrNull() + if (url == null) { + Log.e(TAG, "Unable to register device due to missing URL") + return null + } + val requestParams = HistoryRequestParams( + filterEntityIds = entityIds + ) + + val response = integrationService.getHistory( + requestParams.addToUrl(url.newBuilder().addPathSegments("api/history/period").build()), + serverManager.authenticationRepository(serverId).buildBearerToken() + ) + return response.map { statesList -> + statesList.map { + Entity( + it.entityId, + it.state, + it.attributes, + it.lastChanged, + it.lastUpdated, + it.context + ) + } + }.sortedBy { it.firstOrNull()?.entityId }.toList() + } + override suspend fun registerSensor(sensorRegistration: SensorRegistration) { // Version is read from server variable (cached) to prevent multiple failed requests in a // row and very long suspend if server is offline for a longer period of time. This function diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt index 621449175a9..b2ef5873880 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt @@ -32,6 +32,12 @@ interface IntegrationService { @Header("Authorization") auth: String ): EntityResponse> + @GET + suspend fun getHistory( + @Url url: HttpUrl, + @Header("Authorization") auth: String + ): Array>> + @POST suspend fun callWebhook( @Url url: HttpUrl, From 0fbcdae8b94d47940700175bd360b2561b6d7a8e Mon Sep 17 00:00:00 2001 From: PizzaMarinara Date: Mon, 2 Sep 2024 17:21:35 +0200 Subject: [PATCH 2/4] Implementation of History widget (WIP UI) --- app/src/main/AndroidManifest.xml | 20 + .../widgets/ManageWidgetsViewModel.kt | 3 + .../widgets/views/ManageWidgetsView.kt | 14 +- .../android/widgets/history/HistoryWidget.kt | 286 ++++ .../history/HistoryWidgetConfigureActivity.kt | 437 ++++++ .../widget_history_wrapper_dynamiccolor.xml | 9 + app/src/main/res/layout/widget_history.xml | 67 + .../res/layout/widget_history_configure.xml | 284 ++++ .../layout/widget_history_wrapper_default.xml | 9 + app/src/main/res/xml/history_widget_info.xml | 18 + .../48.json | 1216 +++++++++++++++++ .../data/integration/IntegrationRepository.kt | 2 +- .../impl/IntegrationRepositoryImpl.kt | 2 +- .../integration/impl/IntegrationService.kt | 2 +- .../companion/android/database/AppDatabase.kt | 9 +- .../android/database/DatabaseModule.kt | 4 + .../database/widget/HistoryWidgetDao.kt | 32 + .../database/widget/HistoryWidgetEntity.kt | 34 + common/src/main/res/values/strings.xml | 4 + 19 files changed, 2445 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidget.kt create mode 100644 app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt create mode 100644 app/src/main/res/layout-v31/widget_history_wrapper_dynamiccolor.xml create mode 100644 app/src/main/res/layout/widget_history.xml create mode 100644 app/src/main/res/layout/widget_history_configure.xml create mode 100644 app/src/main/res/layout/widget_history_wrapper_default.xml create mode 100644 app/src/main/res/xml/history_widget_info.xml create mode 100644 common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json create mode 100644 common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetDao.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetEntity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f32435df433..cfc804f9089 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -143,6 +143,19 @@ android:resource="@xml/entity_widget_info" /> + + + + + + + + + + @@ -206,6 +219,13 @@ + + + + + diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt index 87917e40d88..cc1046fc17f 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt @@ -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 @@ -24,6 +25,7 @@ import kotlinx.coroutines.launch class ManageWidgetsViewModel @Inject constructor( buttonWidgetDao: ButtonWidgetDao, cameraWidgetDao: CameraWidgetDao, + historyWidgetDao: HistoryWidgetDao, staticWidgetDao: StaticWidgetDao, mediaPlayerControlsWidgetDao: MediaPlayerControlsWidgetDao, templateWidgetDao: TemplateWidgetDao, @@ -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() diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt index d1c6575d4b9..9c20874b649 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt @@ -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 @@ -50,7 +51,8 @@ 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 @@ -58,6 +60,7 @@ enum class WidgetType(val widgetIcon: IIcon) { MEDIA -> MediaPlayerControlsWidgetConfigureActivity::class.java STATE -> EntityWidgetConfigureActivity::class.java TEMPLATE -> TemplateWidgetConfigureActivity::class.java + HISTORY -> HistoryWidgetConfigureActivity::class.java } } @@ -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 @@ -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( @@ -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, diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidget.kt new file mode 100644 index 00000000000..1aecf7936be --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidget.kt @@ -0,0 +1,286 @@ +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.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.database.widget.WidgetTapAction +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_ATTRIBUTE_IDS = "EXTRA_ATTRIBUTE_IDS" + internal const val EXTRA_LABEL = "EXTRA_LABEL" + internal const val EXTRA_TEXT_SIZE = "EXTRA_TEXT_SIZE" + internal const val EXTRA_STATE_SEPARATOR = "EXTRA_STATE_SEPARATOR" + internal const val EXTRA_ATTRIBUTE_SEPARATOR = "EXTRA_ATTRIBUTE_SEPARATOR" + internal const val EXTRA_TAP_ACTION = "EXTRA_TAP_ACTION" + 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 + + 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>?): 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 entityId: String = widget.entityId + val attributeIds: String? = widget.attributeIds + val label: String? = widget.label + val textSize: Float = widget.textSize + val stateSeparator: String = widget.stateSeparator + val attributeSeparator: String = widget.attributeSeparator + + // 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, + entityId, + suggestedEntity, + attributeIds, + stateSeparator, + attributeSeparator, + appWidgetId + ) + setTextViewTextSize( + R.id.widgetText, + TypedValue.COMPLEX_UNIT_SP, + textSize + ) + setTextViewText( + R.id.widgetText, + resolvedText.text + ) + setTextViewText( + R.id.widgetLabel, + label ?: entityId + ) + 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>> = + historyWidgetDao.getAll().associate { it.id to (it.serverId to listOf(it.entityId)) } + + private suspend fun resolveTextToShow( + context: Context, + serverId: Int, + entityId: String?, + suggestedEntity: Entity>?, + attributeIds: String?, + stateSeparator: String, + attributeSeparator: String, + appWidgetId: Int + ): ResolvedText { + var entityStatesList: List>>? = null + var entityCaughtException = false + try { + // NOTE: History allows to pass a list of entities, but we currently only support a single entity, hence the usage of firstOrNull + entityStatesList = entityId?.let { serverManager.integrationRepository(serverId).getHistory(listOf(it))?.firstOrNull() } + } catch (e: Exception) { + Log.e(TAG, "Unable to fetch entity", e) + entityCaughtException = true + } + val entityOptions = if ( + entityStatesList?.any { it.entityId == entityId } == true && + serverManager.getServer(serverId)?.version?.isAtLeast(2023, 3) == true + ) { + serverManager.webSocketRepository(serverId).getEntityRegistryFor(entityStatesList.first().entityId)?.options + } else { + null + } + + try { + historyWidgetDao.updateWidgetLastUpdate( + appWidgetId, + getLastUpdateFromEntityStatesList(entityStatesList, context, entityOptions, attributeIds, attributeSeparator, stateSeparator) + ) + 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 entitySelection: String? = extras.getString(EXTRA_ENTITY_ID) + val attributeSelection: ArrayList? = extras.getStringArrayList(EXTRA_ATTRIBUTE_IDS) + val labelSelection: String? = extras.getString(EXTRA_LABEL) + val textSizeSelection: String? = extras.getString(EXTRA_TEXT_SIZE) + val stateSeparatorSelection: String? = extras.getString(EXTRA_STATE_SEPARATOR) + val attributeSeparatorSelection: String? = extras.getString(EXTRA_ATTRIBUTE_SEPARATOR) + val tapActionSelection = BundleCompat.getSerializable(extras, EXTRA_TAP_ACTION, WidgetTapAction::class.java) + ?: WidgetTapAction.REFRESH + val backgroundTypeSelection = BundleCompat.getSerializable(extras, EXTRA_BACKGROUND_TYPE, WidgetBackgroundType::class.java) + ?: WidgetBackgroundType.DAYNIGHT + val textColorSelection: String? = extras.getString(EXTRA_TEXT_COLOR) + + if (serverId == null || entitySelection == 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: " + entitySelection + System.lineSeparator() + + "attribute: " + (attributeSelection ?: "N/A") + ) + historyWidgetDao.add( + HistoryWidgetEntity( + appWidgetId, + serverId, + entitySelection, + attributeSelection?.joinToString(","), + labelSelection, + textSizeSelection?.toFloatOrNull() ?: DEFAULT_TEXT_SIZE, + stateSeparatorSelection ?: "", + attributeSeparatorSelection ?: "", + tapActionSelection, + 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>) + 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>>?, + context: Context, + entityOptions: EntityRegistryOptions?, + attributeIds: String?, + attributeSeparator: String, + stateSeparator: String + ): String = entityList?.fold("") { acc, entity -> + + val localDate = with(entity.lastUpdated) { + LocalDateTime.ofInstant(toInstant(), timeZone.toZoneId()) + } + val entityTextToVisualize = StringBuilder(localDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"))) + entityTextToVisualize.append(": ") + if (attributeIds == null) { + entityTextToVisualize.append(entity.friendlyState(context, entityOptions)) + } else { + try { + val fetchedAttributes = entity.attributes as? Map<*, *> ?: mapOf() + val attributeValues = + attributeIds.split(",").map { id -> fetchedAttributes[id]?.toString() } + val lastUpdate = + entity.friendlyState(context, entityOptions).plus(if (attributeValues.isNotEmpty()) stateSeparator else "") + .plus(attributeValues.joinToString(attributeSeparator)) + entityTextToVisualize.append(lastUpdate) + } catch (e: Exception) { + Log.e(TAG, "Unable to fetch entity state and attributes", e) + } + } + if (acc.isEmpty()) { + acc.plus(entityTextToVisualize) + } else { + acc.plus(",\n").plus(entityTextToVisualize) + } + } ?: "" +} diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt new file mode 100644 index 00000000000..fabba8e70da --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt @@ -0,0 +1,437 @@ +package io.homeassistant.companion.android.widgets.history + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import android.widget.LinearLayout.VISIBLE +import android.widget.MultiAutoCompleteTextView.CommaTokenizer +import android.widget.Spinner +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.graphics.toColorInt +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.google.android.material.color.DynamicColors +import dagger.hilt.android.AndroidEntryPoint +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.EntityExt +import io.homeassistant.companion.android.common.data.integration.domain +import io.homeassistant.companion.android.common.data.integration.friendlyName +import io.homeassistant.companion.android.database.widget.HistoryWidgetDao +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.database.widget.WidgetTapAction +import io.homeassistant.companion.android.databinding.WidgetHistoryConfigureBinding +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 io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter +import javax.inject.Inject +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +@AndroidEntryPoint +class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { + + companion object { + private const val TAG: String = "HistoryWidgetConfigAct" + private const val PIN_WIDGET_CALLBACK = "io.homeassistant.companion.android.widgets.entity.HistoryWidgetConfigureActivity.PIN_WIDGET_CALLBACK" + } + + @Inject + lateinit var historyWidgetDao: HistoryWidgetDao + override val dao get() = historyWidgetDao + + private var entities = mutableMapOf>>() + + private var selectedEntity: Entity? = null + private var appendAttributes: Boolean = false + private var selectedAttributeIds: ArrayList = ArrayList() + private var labelFromEntity = false + + private lateinit var binding: WidgetHistoryConfigureBinding + + override val serverSelect: View + get() = binding.serverSelect + + override val serverSelectList: Spinner + get() = binding.serverSelectList + + private var requestLauncherSetup = false + + private var entityAdapter: SingleItemArrayAdapter>? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Set the result to CANCELED. This will cause the widget host to cancel + // out of the widget placement if the user presses the back button. + setResult(RESULT_CANCELED) + + binding = WidgetHistoryConfigureBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.addButton.setOnClickListener { + if (requestLauncherSetup) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + isValidServerId() + ) { + getSystemService()?.requestPinAppWidget( + ComponentName(this, HistoryWidget::class.java), + null, + PendingIntent.getActivity( + this, + System.currentTimeMillis().toInt(), + Intent(this, HistoryWidgetConfigureActivity::class.java).putExtra(PIN_WIDGET_CALLBACK, true).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + ) + } else { + showAddWidgetError() + } + } else { + onAddWidget() + } + } + + // Find the widget id from the intent. + val intent = intent + val extras = intent.extras + if (extras != null) { + appWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + requestLauncherSetup = extras.getBoolean( + ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, + false + ) + } + + // If this activity was started with an intent without an app widget ID, finish with an error. + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !requestLauncherSetup) { + finish() + return + } + + val historyWidget = historyWidgetDao.get(appWidgetId) + + val tapActionValues = listOf(getString(commonR.string.widget_tap_action_toggle), getString(commonR.string.refresh)) + binding.tapActionList.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, tapActionValues) + + val backgroundTypeValues = mutableListOf( + getString(commonR.string.widget_background_type_daynight), + getString(commonR.string.widget_background_type_transparent) + ) + if (DynamicColors.isDynamicColorAvailable()) { + backgroundTypeValues.add(0, getString(commonR.string.widget_background_type_dynamiccolor)) + } + binding.backgroundType.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, backgroundTypeValues) + + if (historyWidget != null) { + binding.widgetTextConfigEntityId.setText(historyWidget.entityId) + binding.label.setText(historyWidget.label) + binding.textSize.setText(historyWidget.textSize.toInt().toString()) + binding.stateSeparator.setText(historyWidget.stateSeparator) + val entity = runBlocking { + try { + serverManager.integrationRepository(historyWidget.serverId).getEntity(historyWidget.entityId) + } catch (e: Exception) { + Log.e(TAG, "Unable to get entity information", e) + Toast.makeText(applicationContext, commonR.string.widget_entity_fetch_error, Toast.LENGTH_LONG) + .show() + null + } + } + + val attributeIds = historyWidget.attributeIds + if (!attributeIds.isNullOrEmpty()) { + binding.appendAttributeValueCheckbox.isChecked = true + appendAttributes = true + for (item in attributeIds.split(',')) { + selectedAttributeIds.add(item) + } + binding.widgetTextConfigAttribute.setText(attributeIds.replace(",", ", ")) + binding.attributeValueLinearLayout.visibility = VISIBLE + binding.attributeSeparator.setText(historyWidget.attributeSeparator) + } + if (entity != null) { + selectedEntity = entity as Entity? + setupAttributes() + } + + val toggleable = entity?.domain in EntityExt.APP_PRESS_ACTION_DOMAINS + binding.tapAction.isVisible = toggleable + binding.tapActionList.setSelection(if (toggleable && historyWidget.tapAction == WidgetTapAction.TOGGLE) 0 else 1) + + binding.backgroundType.setSelection( + when { + historyWidget.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable() -> + backgroundTypeValues.indexOf(getString(commonR.string.widget_background_type_dynamiccolor)) + historyWidget.backgroundType == WidgetBackgroundType.TRANSPARENT -> + backgroundTypeValues.indexOf(getString(commonR.string.widget_background_type_transparent)) + else -> + backgroundTypeValues.indexOf(getString(commonR.string.widget_background_type_daynight)) + } + ) + binding.textColor.visibility = if (historyWidget.backgroundType == WidgetBackgroundType.TRANSPARENT) View.VISIBLE else View.GONE + binding.textColorWhite.isChecked = + historyWidget.textColor?.let { it.toColorInt() == ContextCompat.getColor(this, android.R.color.white) } ?: true + binding.textColorBlack.isChecked = + historyWidget.textColor?.let { it.toColorInt() == ContextCompat.getColor(this, commonR.color.colorWidgetButtonLabelBlack) } ?: false + + binding.addButton.setText(commonR.string.update_widget) + } else { + binding.backgroundType.setSelection(0) + } + entityAdapter = SingleItemArrayAdapter(this) { it?.entityId ?: "" } + + setupServerSelect(historyWidget?.serverId) + + binding.widgetTextConfigEntityId.setAdapter(entityAdapter) + binding.widgetTextConfigEntityId.onFocusChangeListener = dropDownOnFocus + binding.widgetTextConfigEntityId.onItemClickListener = entityDropDownOnItemClick + binding.widgetTextConfigAttribute.onFocusChangeListener = dropDownOnFocus + binding.widgetTextConfigAttribute.onItemClickListener = attributeDropDownOnItemClick + binding.widgetTextConfigAttribute.setOnClickListener { + if (!binding.widgetTextConfigAttribute.isPopupShowing) binding.widgetTextConfigAttribute.showDropDown() + } + + binding.appendAttributeValueCheckbox.setOnCheckedChangeListener { _, isChecked -> + binding.attributeValueLinearLayout.isVisible = isChecked + appendAttributes = isChecked + } + + binding.label.addTextChangedListener(labelTextChanged) + + binding.backgroundType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + binding.textColor.visibility = + if (parent?.adapter?.getItem(position) == getString(commonR.string.widget_background_type_transparent)) { + View.VISIBLE + } else { + View.GONE + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + binding.textColor.visibility = View.GONE + } + } + + serverManager.defaultServers.forEach { server -> + lifecycleScope.launch { + try { + val fetchedEntities = serverManager.integrationRepository(server.id).getEntities().orEmpty() + entities[server.id] = fetchedEntities + if (server.id == selectedServerId) setAdapterEntities(server.id) + } catch (e: Exception) { + // If entities fail to load, it's okay to pass + // an empty map to the dynamicFieldAdapter + Log.e(TAG, "Failed to query entities", e) + } + } + } + } + + override fun onServerSelected(serverId: Int) { + selectedEntity = null + binding.widgetTextConfigEntityId.setText("") + setupAttributes() + setAdapterEntities(serverId) + } + + private fun setAdapterEntities(serverId: Int) { + entityAdapter?.let { adapter -> + adapter.clearAll() + if (entities[serverId] != null) { + adapter.addAll(entities[serverId].orEmpty().toMutableList()) + adapter.sort() + } + runOnUiThread { adapter.notifyDataSetChanged() } + } + } + + private val dropDownOnFocus = View.OnFocusChangeListener { view, hasFocus -> + if (hasFocus && view is AutoCompleteTextView) { + view.showDropDown() + } + } + + private val entityDropDownOnItemClick = + AdapterView.OnItemClickListener { parent, _, position, _ -> + selectedEntity = parent.getItemAtPosition(position) as Entity? + if (binding.label.text.isNullOrBlank() || labelFromEntity) { + selectedEntity?.friendlyName?.takeIf { it != selectedEntity?.entityId }?.let { name -> + binding.label.removeTextChangedListener(labelTextChanged) + binding.label.setText(name) + labelFromEntity = true + binding.label.addTextChangedListener(labelTextChanged) + } + } + setupAttributes() + } + + private val attributeDropDownOnItemClick = + AdapterView.OnItemClickListener { parent, _, position, _ -> + selectedAttributeIds.add(parent.getItemAtPosition(position) as String) + } + + private val labelTextChanged = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // Not implemented + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + // Not implemented + } + + override fun afterTextChanged(s: Editable?) { + labelFromEntity = false + } + } + + private fun setupAttributes() { + val fetchedAttributes = selectedEntity?.attributes as? Map + val attributesAdapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line) + binding.widgetTextConfigAttribute.setAdapter(attributesAdapter) + attributesAdapter.addAll(*fetchedAttributes?.keys.orEmpty().toTypedArray()) + binding.widgetTextConfigAttribute.setTokenizer(CommaTokenizer()) + runOnUiThread { + val toggleable = selectedEntity?.domain in EntityExt.APP_PRESS_ACTION_DOMAINS + binding.tapAction.isVisible = toggleable + binding.tapActionList.setSelection(if (toggleable) 0 else 1) + attributesAdapter.notifyDataSetChanged() + } + } + + private fun onAddWidget() { + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + showAddWidgetError() + return + } + try { + val context = this@HistoryWidgetConfigureActivity + + // Set up a broadcast intent and pass the service call data as extras + val intent = Intent() + intent.action = BaseWidgetProvider.RECEIVE_DATA + intent.component = ComponentName(context, HistoryWidget::class.java) + + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + + intent.putExtra( + HistoryWidget.EXTRA_SERVER_ID, + selectedServerId!! + ) + + val entity = if (selectedEntity == null) { + binding.widgetTextConfigEntityId.text.toString() + } else { + selectedEntity!!.entityId + } + if (entity !in entities[selectedServerId].orEmpty().map { it.entityId }) { + showAddWidgetError() + return + } + intent.putExtra( + HistoryWidget.EXTRA_ENTITY_ID, + entity + ) + + intent.putExtra( + HistoryWidget.EXTRA_LABEL, + binding.label.text.toString() + ) + + intent.putExtra( + HistoryWidget.EXTRA_TEXT_SIZE, + binding.textSize.text.toString() + ) + + intent.putExtra( + HistoryWidget.EXTRA_STATE_SEPARATOR, + binding.stateSeparator.text.toString() + ) + + if (appendAttributes) { + val attributes = if (selectedAttributeIds.isEmpty()) { + binding.widgetTextConfigAttribute.text.toString() + } else { + selectedAttributeIds + } + intent.putExtra( + HistoryWidget.EXTRA_ATTRIBUTE_IDS, + attributes + ) + + intent.putExtra( + HistoryWidget.EXTRA_ATTRIBUTE_SEPARATOR, + binding.attributeSeparator.text.toString() + ) + } + + intent.putExtra( + HistoryWidget.EXTRA_TAP_ACTION, + when (binding.tapActionList.selectedItemPosition) { + 0 -> WidgetTapAction.TOGGLE + else -> WidgetTapAction.REFRESH + } + ) + + intent.putExtra( + HistoryWidget.EXTRA_BACKGROUND_TYPE, + when (binding.backgroundType.selectedItem as String?) { + getString(commonR.string.widget_background_type_dynamiccolor) -> WidgetBackgroundType.DYNAMICCOLOR + getString(commonR.string.widget_background_type_transparent) -> WidgetBackgroundType.TRANSPARENT + else -> WidgetBackgroundType.DAYNIGHT + } + ) + + intent.putExtra( + HistoryWidget.EXTRA_TEXT_COLOR, + if (binding.backgroundType.selectedItem as String? == getString(commonR.string.widget_background_type_transparent)) { + getHexForColor(if (binding.textColorWhite.isChecked) android.R.color.white else commonR.color.colorWidgetButtonLabelBlack) + } else { + null + } + ) + + context.sendBroadcast(intent) + + // Make sure we pass back the original appWidgetId + setResult( + RESULT_OK, + Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + ) + finish() + } catch (e: Exception) { + Log.e(TAG, "Issue configuring widget", e) + showAddWidgetError() + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (intent.extras != null && intent.hasExtra(PIN_WIDGET_CALLBACK)) { + appWidgetId = intent.extras!!.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + onAddWidget() + } + } +} diff --git a/app/src/main/res/layout-v31/widget_history_wrapper_dynamiccolor.xml b/app/src/main/res/layout-v31/widget_history_wrapper_dynamiccolor.xml new file mode 100644 index 00000000000..dab86b50c55 --- /dev/null +++ b/app/src/main/res/layout-v31/widget_history_wrapper_dynamiccolor.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_history.xml b/app/src/main/res/layout/widget_history.xml new file mode 100644 index 00000000000..0ed6a291549 --- /dev/null +++ b/app/src/main/res/layout/widget_history.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_history_configure.xml b/app/src/main/res/layout/widget_history_configure.xml new file mode 100644 index 00000000000..a0c6ed62012 --- /dev/null +++ b/app/src/main/res/layout/widget_history_configure.xml @@ -0,0 +1,284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_history_wrapper_default.xml b/app/src/main/res/layout/widget_history_wrapper_default.xml new file mode 100644 index 00000000000..8afa0f6f7a1 --- /dev/null +++ b/app/src/main/res/layout/widget_history_wrapper_default.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/history_widget_info.xml b/app/src/main/res/xml/history_widget_info.xml new file mode 100644 index 00000000000..822d29049bb --- /dev/null +++ b/app/src/main/res/xml/history_widget_info.xml @@ -0,0 +1,18 @@ + + \ No newline at end of file diff --git a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json new file mode 100644 index 00000000000..c3725e69e3b --- /dev/null +++ b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json @@ -0,0 +1,1216 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "dfe277a536c78cc675f17941a23dd9dd", + "entities": [ + { + "tableName": "sensor_attributes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))", + "fields": [ + { + "fieldPath": "sensorId", + "columnName": "sensor_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "value_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sensor_id", + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "authentication_list", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`host`))", + "fields": [ + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "host" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sensors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL, `registered` INTEGER DEFAULT NULL, `state` TEXT NOT NULL, `last_sent_state` TEXT DEFAULT NULL, `last_sent_icon` TEXT DEFAULT NULL, `state_type` TEXT NOT NULL, `type` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `device_class` TEXT, `unit_of_measurement` TEXT, `state_class` TEXT, `entity_category` TEXT, `core_registration` TEXT, `app_registration` TEXT, PRIMARY KEY(`id`, `server_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registered", + "columnName": "registered", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSentState", + "columnName": "last_sent_state", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastSentIcon", + "columnName": "last_sent_icon", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "stateType", + "columnName": "state_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceClass", + "columnName": "device_class", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unitOfMeasurement", + "columnName": "unit_of_measurement", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "stateClass", + "columnName": "state_class", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "entityCategory", + "columnName": "entity_category", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coreRegistration", + "columnName": "core_registration", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appRegistration", + "columnName": "app_registration", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "server_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sensor_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `entries` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))", + "fields": [ + { + "fieldPath": "sensorId", + "columnName": "sensor_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "value_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entries", + "columnName": "entries", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sensor_id", + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "button_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT NOT NULL, `domain` TEXT NOT NULL, `service` TEXT NOT NULL, `service_data` TEXT NOT NULL, `label` TEXT, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, `require_authentication` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "service", + "columnName": "service", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serviceData", + "columnName": "service_data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "requireAuthentication", + "columnName": "require_authentication", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "camera_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "media_player_controls_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `label` TEXT, `show_skip` INTEGER NOT NULL, `show_seek` INTEGER NOT NULL, `show_volume` INTEGER NOT NULL, `show_source` INTEGER NOT NULL DEFAULT false, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showSkip", + "columnName": "show_skip", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showSeek", + "columnName": "show_seek", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showVolume", + "columnName": "show_volume", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showSource", + "columnName": "show_source", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "static_widget", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` REAL NOT NULL, `state_separator` TEXT NOT NULL, `attribute_separator` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeIds", + "columnName": "attribute_ids", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "stateSeparator", + "columnName": "state_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeSeparator", + "columnName": "attribute_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "template_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `template` TEXT NOT NULL, `text_size` REAL NOT NULL DEFAULT 12.0, `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "template", + "columnName": "template", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true, + "defaultValue": "12.0" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "history_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` REAL NOT NULL, `state_separator` TEXT NOT NULL, `attribute_separator` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeIds", + "columnName": "attribute_ids", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "stateSeparator", + "columnName": "state_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeSeparator", + "columnName": "attribute_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `received` INTEGER NOT NULL, `message` TEXT NOT NULL, `data` TEXT NOT NULL, `source` TEXT NOT NULL, `server_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "received", + "columnName": "received", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "location_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created` INTEGER NOT NULL, `trigger` TEXT NOT NULL, `result` TEXT NOT NULL, `latitude` REAL, `longitude` REAL, `location_name` TEXT, `accuracy` INTEGER, `data` TEXT, `server_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "locationName", + "columnName": "location_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accuracy", + "columnName": "accuracy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "qs_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tile_id` TEXT NOT NULL, `added` INTEGER NOT NULL DEFAULT 1, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT, `entity_id` TEXT NOT NULL, `label` TEXT NOT NULL, `subtitle` TEXT, `should_vibrate` INTEGER NOT NULL DEFAULT 0, `auth_required` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tileId", + "columnName": "tile_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shouldVibrate", + "columnName": "should_vibrate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "authRequired", + "columnName": "auth_required", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `friendly_name` TEXT NOT NULL, `icon` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "friendlyName", + "columnName": "friendly_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "camera_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT, `refresh_interval` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refreshInterval", + "columnName": "refresh_interval", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "entity_state_complications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT NOT NULL, `show_title` INTEGER NOT NULL DEFAULT 1, `show_unit` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "showTitle", + "columnName": "show_title", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "showUnit", + "columnName": "show_unit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `_name` TEXT NOT NULL, `name_override` TEXT, `_version` TEXT, `device_registry_id` TEXT, `list_order` INTEGER NOT NULL, `device_name` TEXT, `external_url` TEXT NOT NULL, `internal_url` TEXT, `cloud_url` TEXT, `webhook_id` TEXT, `secret` TEXT, `cloudhook_url` TEXT, `use_cloud` INTEGER NOT NULL, `internal_ssids` TEXT NOT NULL, `prioritize_internal` INTEGER NOT NULL, `access_token` TEXT, `refresh_token` TEXT, `token_expiration` INTEGER, `token_type` TEXT, `install_id` TEXT, `user_id` TEXT, `user_name` TEXT, `user_is_owner` INTEGER, `user_is_admin` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "_name", + "columnName": "_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameOverride", + "columnName": "name_override", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "_version", + "columnName": "_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deviceRegistryId", + "columnName": "device_registry_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "device_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.externalUrl", + "columnName": "external_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connection.internalUrl", + "columnName": "internal_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.cloudUrl", + "columnName": "cloud_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.webhookId", + "columnName": "webhook_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.cloudhookUrl", + "columnName": "cloudhook_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.useCloud", + "columnName": "use_cloud", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "connection.internalSsids", + "columnName": "internal_ssids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connection.prioritizeInternal", + "columnName": "prioritize_internal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "session.accessToken", + "columnName": "access_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.refreshToken", + "columnName": "refresh_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.tokenExpiration", + "columnName": "token_expiration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "session.tokenType", + "columnName": "token_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.id", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.name", + "columnName": "user_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.isOwner", + "columnName": "user_is_owner", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "user.isAdmin", + "columnName": "user_is_admin", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websocket_setting` TEXT NOT NULL, `sensor_update_frequency` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "websocketSetting", + "columnName": "websocket_setting", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sensorUpdateFrequency", + "columnName": "sensor_update_frequency", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dfe277a536c78cc675f17941a23dd9dd')" + ] + } +} \ No newline at end of file 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 ada6dc847ff..d8cb49cc1ef 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 @@ -41,7 +41,7 @@ interface IntegrationRepository { suspend fun getEntity(entityId: String): Entity>? suspend fun getEntityUpdates(): Flow>? suspend fun getEntityUpdates(entityIds: List): Flow>? - suspend fun getHistory(entityIds: List): List>>? + suspend fun getHistory(entityIds: List): List>>>? suspend fun callAction(domain: String, action: String, actionData: 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 fbdfb8cbda8..f4450d40a30 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 @@ -696,7 +696,7 @@ class IntegrationRepositoryImpl @AssistedInject constructor( } } - override suspend fun getHistory(entityIds: List): List>>? { + override suspend fun getHistory(entityIds: List): List>>>? { val url = server.connection.getUrl()?.toHttpUrlOrNull() if (url == null) { Log.e(TAG, "Unable to register device due to missing URL") diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt index b2ef5873880..d4f8c5c8f24 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationService.kt @@ -36,7 +36,7 @@ interface IntegrationService { suspend fun getHistory( @Url url: HttpUrl, @Header("Authorization") auth: String - ): Array>> + ): Array>>> @POST suspend fun callWebhook( diff --git a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt index 3f3b64f5b60..bd5492fb95f 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt @@ -65,6 +65,8 @@ import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity import io.homeassistant.companion.android.database.widget.CameraWidgetDao import io.homeassistant.companion.android.database.widget.CameraWidgetEntity +import io.homeassistant.companion.android.database.widget.HistoryWidgetDao +import io.homeassistant.companion.android.database.widget.HistoryWidgetEntity import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetEntity import io.homeassistant.companion.android.database.widget.StaticWidgetDao @@ -87,6 +89,7 @@ import kotlinx.coroutines.runBlocking MediaPlayerControlsWidgetEntity::class, StaticWidgetEntity::class, TemplateWidgetEntity::class, + HistoryWidgetEntity::class, NotificationItem::class, LocationHistoryItem::class, TileEntity::class, @@ -97,7 +100,7 @@ import kotlinx.coroutines.runBlocking Server::class, Setting::class ], - version = 47, + version = 48, autoMigrations = [ AutoMigration(from = 24, to = 25), AutoMigration(from = 25, to = 26), @@ -120,7 +123,8 @@ import kotlinx.coroutines.runBlocking AutoMigration(from = 43, to = 44), AutoMigration(from = 44, to = 45), AutoMigration(from = 45, to = 46), - AutoMigration(from = 46, to = 47) + AutoMigration(from = 46, to = 47), + AutoMigration(from = 47, to = 48) ] ) @TypeConverters( @@ -136,6 +140,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun sensorDao(): SensorDao abstract fun buttonWidgetDao(): ButtonWidgetDao abstract fun cameraWidgetDao(): CameraWidgetDao + abstract fun historyWidgetDao(): HistoryWidgetDao abstract fun mediaPlayCtrlWidgetDao(): MediaPlayerControlsWidgetDao abstract fun staticWidgetDao(): StaticWidgetDao abstract fun templateWidgetDao(): TemplateWidgetDao diff --git a/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt b/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt index 0f92c5473ca..80039bbd937 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt @@ -19,6 +19,7 @@ import io.homeassistant.companion.android.database.wear.FavoriteCachesDao import io.homeassistant.companion.android.database.wear.FavoritesDao 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 @@ -45,6 +46,9 @@ object DatabaseModule { @Provides fun provideCameraWidgetDao(database: AppDatabase): CameraWidgetDao = database.cameraWidgetDao() + @Provides + fun provideHistoryWidgetDao(database: AppDatabase): HistoryWidgetDao = database.historyWidgetDao() + @Provides fun provideMediaPlayCtrlWidgetDao(database: AppDatabase): MediaPlayerControlsWidgetDao = database.mediaPlayCtrlWidgetDao() diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetDao.kt new file mode 100644 index 00000000000..951be5ea47c --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetDao.kt @@ -0,0 +1,32 @@ +package io.homeassistant.companion.android.database.widget + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface HistoryWidgetDao : WidgetDao { + + @Query("SELECT * FROM history_widgets WHERE id = :id") + fun get(id: Int): HistoryWidgetEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun add(historyWidgetEntity: HistoryWidgetEntity) + + @Query("DELETE FROM history_widgets WHERE id = :id") + override suspend fun delete(id: Int) + + @Query("DELETE FROM history_widgets WHERE id IN (:ids)") + suspend fun deleteAll(ids: IntArray) + + @Query("SELECT * FROM history_widgets") + suspend fun getAll(): List + + @Query("SELECT * FROM history_widgets") + fun getAllFlow(): Flow> + + @Query("UPDATE history_widgets SET last_update = :lastUpdate WHERE id = :widgetId") + suspend fun updateWidgetLastUpdate(widgetId: Int, lastUpdate: String) +} diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetEntity.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetEntity.kt new file mode 100644 index 00000000000..648a5b07606 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetEntity.kt @@ -0,0 +1,34 @@ +package io.homeassistant.companion.android.database.widget + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "history_widgets") +data class HistoryWidgetEntity( + @PrimaryKey + override val id: Int, + @ColumnInfo(name = "server_id", defaultValue = "0") + override val serverId: Int, + @ColumnInfo(name = "entity_id") + val entityId: String, + @ColumnInfo(name = "attribute_ids") + val attributeIds: String?, + @ColumnInfo(name = "label") + val label: String?, + @ColumnInfo(name = "text_size") + val textSize: Float, + @ColumnInfo(name = "state_separator") + val stateSeparator: String = "", + @ColumnInfo(name = "attribute_separator") + val attributeSeparator: String = "", + @ColumnInfo(name = "tap_action", defaultValue = "REFRESH") + val tapAction: WidgetTapAction, + @ColumnInfo(name = "last_update") + val lastUpdate: String, + @ColumnInfo(name = "background_type", defaultValue = "DAYNIGHT") + override val backgroundType: WidgetBackgroundType = WidgetBackgroundType.DAYNIGHT, + @ColumnInfo(name = "text_color") + override val textColor: String? = null +) : WidgetEntity, + ThemeableWidgetEntity diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 4b546c57782..73291de73dd 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -297,6 +297,7 @@ High accuracy location High accuracy (GPS) mode enabled History + View any entity\'s historical state Tap and hold to reorder Unable to find your\nHome Assistant instance Icon @@ -346,6 +347,7 @@ Lights Button widgets Entity state widgets + History widgets Instances Other Media player widgets @@ -997,6 +999,8 @@ A custom component is preventing action data from loading. Unable to create widget. Unable to fetch data for configured entity. + Separator between entity IDs: + History widget Home Assistant widget Toggle Living room From 05a5752c762bebdcdc169136db0ff25cf45fa33c Mon Sep 17 00:00:00 2001 From: PizzaMarinara Date: Mon, 2 Sep 2024 22:56:53 +0200 Subject: [PATCH 3/4] Multiple Entities support for HistoryWidget --- .../android/widgets/history/HistoryWidget.kt | 102 ++++------- .../history/HistoryWidgetConfigureActivity.kt | 160 ++++-------------- .../res/layout/widget_history_configure.xml | 116 +------------ app/src/main/res/xml/history_widget_info.xml | 2 +- .../48.json | 31 +--- .../database/widget/HistoryWidgetEntity.kt | 8 - 6 files changed, 73 insertions(+), 346 deletions(-) diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidget.kt index 1aecf7936be..3c443b3613c 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidget.kt @@ -19,12 +19,13 @@ 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.database.widget.WidgetTapAction import io.homeassistant.companion.android.util.getAttribute import io.homeassistant.companion.android.widgets.BaseWidgetProvider import java.time.LocalDateTime @@ -40,15 +41,12 @@ class HistoryWidget : BaseWidgetProvider() { internal const val EXTRA_SERVER_ID = "EXTRA_SERVER_ID" internal const val EXTRA_ENTITY_ID = "EXTRA_ENTITY_ID" - internal const val EXTRA_ATTRIBUTE_IDS = "EXTRA_ATTRIBUTE_IDS" internal const val EXTRA_LABEL = "EXTRA_LABEL" internal const val EXTRA_TEXT_SIZE = "EXTRA_TEXT_SIZE" - internal const val EXTRA_STATE_SEPARATOR = "EXTRA_STATE_SEPARATOR" - internal const val EXTRA_ATTRIBUTE_SEPARATOR = "EXTRA_ATTRIBUTE_SEPARATOR" - internal const val EXTRA_TAP_ACTION = "EXTRA_TAP_ACTION" 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) } @@ -71,12 +69,9 @@ class HistoryWidget : BaseWidgetProvider() { 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 entityId: String = widget.entityId - val attributeIds: String? = widget.attributeIds + val entityIds: String = widget.entityId val label: String? = widget.label val textSize: Float = widget.textSize - val stateSeparator: String = widget.stateSeparator - val attributeSeparator: String = widget.attributeSeparator // Theming if (widget.backgroundType == WidgetBackgroundType.TRANSPARENT) { @@ -100,11 +95,8 @@ class HistoryWidget : BaseWidgetProvider() { val resolvedText = resolveTextToShow( context, serverId, - entityId, + entityIds, suggestedEntity, - attributeIds, - stateSeparator, - attributeSeparator, appWidgetId ) setTextViewTextSize( @@ -118,7 +110,7 @@ class HistoryWidget : BaseWidgetProvider() { ) setTextViewText( R.id.widgetLabel, - label ?: entityId + label ?: entityIds ) setViewVisibility( R.id.widgetStaticError, @@ -148,35 +140,40 @@ class HistoryWidget : BaseWidgetProvider() { private suspend fun resolveTextToShow( context: Context, serverId: Int, - entityId: String?, + entityIds: String?, suggestedEntity: Entity>?, - attributeIds: String?, - stateSeparator: String, - attributeSeparator: String, appWidgetId: Int ): ResolvedText { - var entityStatesList: List>>? = null + var entitiesStatesList: List>>>? = null var entityCaughtException = false try { - // NOTE: History allows to pass a list of entities, but we currently only support a single entity, hence the usage of firstOrNull - entityStatesList = entityId?.let { serverManager.integrationRepository(serverId).getHistory(listOf(it))?.firstOrNull() } + 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 entityOptions = if ( - entityStatesList?.any { it.entityId == entityId } == true && - serverManager.getServer(serverId)?.version?.isAtLeast(2023, 3) == true - ) { - serverManager.webSocketRepository(serverId).getEntityRegistryFor(entityStatesList.first().entityId)?.options - } else { - null + + val entityOptionsList = mutableMapOf() + + 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, - getLastUpdateFromEntityStatesList(entityStatesList, context, entityOptions, attributeIds, attributeSeparator, stateSeparator) + textBuilder.toString() ) return ResolvedText(historyWidgetDao.get(appWidgetId)?.lastUpdate, entityCaughtException) } catch (e: Exception) { @@ -189,19 +186,14 @@ class HistoryWidget : BaseWidgetProvider() { if (extras == null) return val serverId = if (extras.containsKey(EXTRA_SERVER_ID)) extras.getInt(EXTRA_SERVER_ID) else null - val entitySelection: String? = extras.getString(EXTRA_ENTITY_ID) - val attributeSelection: ArrayList? = extras.getStringArrayList(EXTRA_ATTRIBUTE_IDS) + val entityIds: String? = extras.getString(EXTRA_ENTITY_ID) val labelSelection: String? = extras.getString(EXTRA_LABEL) val textSizeSelection: String? = extras.getString(EXTRA_TEXT_SIZE) - val stateSeparatorSelection: String? = extras.getString(EXTRA_STATE_SEPARATOR) - val attributeSeparatorSelection: String? = extras.getString(EXTRA_ATTRIBUTE_SEPARATOR) - val tapActionSelection = BundleCompat.getSerializable(extras, EXTRA_TAP_ACTION, WidgetTapAction::class.java) - ?: WidgetTapAction.REFRESH val backgroundTypeSelection = BundleCompat.getSerializable(extras, EXTRA_BACKGROUND_TYPE, WidgetBackgroundType::class.java) ?: WidgetBackgroundType.DAYNIGHT val textColorSelection: String? = extras.getString(EXTRA_TEXT_COLOR) - if (serverId == null || entitySelection == null) { + if (serverId == null || entityIds == null) { Log.e(TAG, "Did not receive complete service call data") return } @@ -210,20 +202,15 @@ class HistoryWidget : BaseWidgetProvider() { Log.d( TAG, "Saving entity state config data:" + System.lineSeparator() + - "entity id: " + entitySelection + System.lineSeparator() + - "attribute: " + (attributeSelection ?: "N/A") + "entity id: " + entityIds + System.lineSeparator() ) historyWidgetDao.add( HistoryWidgetEntity( appWidgetId, serverId, - entitySelection, - attributeSelection?.joinToString(","), + entityIds, labelSelection, textSizeSelection?.toFloatOrNull() ?: DEFAULT_TEXT_SIZE, - stateSeparatorSelection ?: "", - attributeSeparatorSelection ?: "", - tapActionSelection, historyWidgetDao.get(appWidgetId)?.lastUpdate ?: "", backgroundTypeSelection, textColorSelection @@ -251,36 +238,19 @@ class HistoryWidget : BaseWidgetProvider() { private fun getLastUpdateFromEntityStatesList( entityList: List>>?, context: Context, - entityOptions: EntityRegistryOptions?, - attributeIds: String?, - attributeSeparator: String, - stateSeparator: String + entityOptions: EntityRegistryOptions? ): String = entityList?.fold("") { acc, entity -> val localDate = with(entity.lastUpdated) { LocalDateTime.ofInstant(toInstant(), timeZone.toZoneId()) } - val entityTextToVisualize = StringBuilder(localDate.format(DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"))) - entityTextToVisualize.append(": ") - if (attributeIds == null) { - entityTextToVisualize.append(entity.friendlyState(context, entityOptions)) - } else { - try { - val fetchedAttributes = entity.attributes as? Map<*, *> ?: mapOf() - val attributeValues = - attributeIds.split(",").map { id -> fetchedAttributes[id]?.toString() } - val lastUpdate = - entity.friendlyState(context, entityOptions).plus(if (attributeValues.isNotEmpty()) stateSeparator else "") - .plus(attributeValues.joinToString(attributeSeparator)) - entityTextToVisualize.append(lastUpdate) - } catch (e: Exception) { - Log.e(TAG, "Unable to fetch entity state and attributes", e) - } - } + 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(entityTextToVisualize) + acc.plus(entity.friendlyName).plus(": ").plus(entityTextToVisualize) } else { - acc.plus(",\n").plus(entityTextToVisualize) + acc.plus("\n").plus(entity.friendlyName).plus(": ").plus(entityTextToVisualize) } } ?: "" } diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt index fabba8e70da..d4d38d7f494 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt @@ -13,31 +13,26 @@ import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView -import android.widget.LinearLayout.VISIBLE -import android.widget.MultiAutoCompleteTextView.CommaTokenizer +import android.widget.MultiAutoCompleteTextView import android.widget.Spinner import android.widget.Toast import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.graphics.toColorInt -import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.google.android.material.color.DynamicColors import dagger.hilt.android.AndroidEntryPoint 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.EntityExt -import io.homeassistant.companion.android.common.data.integration.domain -import io.homeassistant.companion.android.common.data.integration.friendlyName import io.homeassistant.companion.android.database.widget.HistoryWidgetDao import io.homeassistant.companion.android.database.widget.WidgetBackgroundType -import io.homeassistant.companion.android.database.widget.WidgetTapAction import io.homeassistant.companion.android.databinding.WidgetHistoryConfigureBinding 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 io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter +import java.util.LinkedList import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -55,10 +50,8 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { override val dao get() = historyWidgetDao private var entities = mutableMapOf>>() + private var selectedEntities: LinkedList?> = LinkedList() - private var selectedEntity: Entity? = null - private var appendAttributes: Boolean = false - private var selectedAttributeIds: ArrayList = ArrayList() private var labelFromEntity = false private lateinit var binding: WidgetHistoryConfigureBinding @@ -87,7 +80,10 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { if (requestLauncherSetup) { if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - isValidServerId() + isValidServerId() && + binding.widgetTextConfigEntityId.text.split(",").any { + entities[selectedServerId!!].orEmpty().any { e -> e.entityId == it.trim() } + } ) { getSystemService()?.requestPinAppWidget( ComponentName(this, HistoryWidget::class.java), @@ -129,9 +125,6 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { val historyWidget = historyWidgetDao.get(appWidgetId) - val tapActionValues = listOf(getString(commonR.string.widget_tap_action_toggle), getString(commonR.string.refresh)) - binding.tapActionList.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, tapActionValues) - val backgroundTypeValues = mutableListOf( getString(commonR.string.widget_background_type_daynight), getString(commonR.string.widget_background_type_transparent) @@ -145,37 +138,6 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { binding.widgetTextConfigEntityId.setText(historyWidget.entityId) binding.label.setText(historyWidget.label) binding.textSize.setText(historyWidget.textSize.toInt().toString()) - binding.stateSeparator.setText(historyWidget.stateSeparator) - val entity = runBlocking { - try { - serverManager.integrationRepository(historyWidget.serverId).getEntity(historyWidget.entityId) - } catch (e: Exception) { - Log.e(TAG, "Unable to get entity information", e) - Toast.makeText(applicationContext, commonR.string.widget_entity_fetch_error, Toast.LENGTH_LONG) - .show() - null - } - } - - val attributeIds = historyWidget.attributeIds - if (!attributeIds.isNullOrEmpty()) { - binding.appendAttributeValueCheckbox.isChecked = true - appendAttributes = true - for (item in attributeIds.split(',')) { - selectedAttributeIds.add(item) - } - binding.widgetTextConfigAttribute.setText(attributeIds.replace(",", ", ")) - binding.attributeValueLinearLayout.visibility = VISIBLE - binding.attributeSeparator.setText(historyWidget.attributeSeparator) - } - if (entity != null) { - selectedEntity = entity as Entity? - setupAttributes() - } - - val toggleable = entity?.domain in EntityExt.APP_PRESS_ACTION_DOMAINS - binding.tapAction.isVisible = toggleable - binding.tapActionList.setSelection(if (toggleable && historyWidget.tapAction == WidgetTapAction.TOGGLE) 0 else 1) binding.backgroundType.setSelection( when { @@ -193,6 +155,21 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { binding.textColorBlack.isChecked = historyWidget.textColor?.let { it.toColorInt() == ContextCompat.getColor(this, commonR.color.colorWidgetButtonLabelBlack) } ?: false + val entities = runBlocking { + try { + historyWidget.entityId.split(",").map { s -> + serverManager.integrationRepository(historyWidget.serverId).getEntity(s.trim()) + } + } catch (e: Exception) { + Log.e(TAG, "Unable to get entity information", e) + Toast.makeText(applicationContext, commonR.string.widget_entity_fetch_error, Toast.LENGTH_LONG) + .show() + null + } + } + if (entities != null) { + selectedEntities.addAll(entities) + } binding.addButton.setText(commonR.string.update_widget) } else { binding.backgroundType.setSelection(0) @@ -202,18 +179,8 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { setupServerSelect(historyWidget?.serverId) binding.widgetTextConfigEntityId.setAdapter(entityAdapter) + binding.widgetTextConfigEntityId.setTokenizer(MultiAutoCompleteTextView.CommaTokenizer()) binding.widgetTextConfigEntityId.onFocusChangeListener = dropDownOnFocus - binding.widgetTextConfigEntityId.onItemClickListener = entityDropDownOnItemClick - binding.widgetTextConfigAttribute.onFocusChangeListener = dropDownOnFocus - binding.widgetTextConfigAttribute.onItemClickListener = attributeDropDownOnItemClick - binding.widgetTextConfigAttribute.setOnClickListener { - if (!binding.widgetTextConfigAttribute.isPopupShowing) binding.widgetTextConfigAttribute.showDropDown() - } - - binding.appendAttributeValueCheckbox.setOnCheckedChangeListener { _, isChecked -> - binding.attributeValueLinearLayout.isVisible = isChecked - appendAttributes = isChecked - } binding.label.addTextChangedListener(labelTextChanged) @@ -248,9 +215,8 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { } override fun onServerSelected(serverId: Int) { - selectedEntity = null + selectedEntities.clear() binding.widgetTextConfigEntityId.setText("") - setupAttributes() setAdapterEntities(serverId) } @@ -271,25 +237,6 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { } } - private val entityDropDownOnItemClick = - AdapterView.OnItemClickListener { parent, _, position, _ -> - selectedEntity = parent.getItemAtPosition(position) as Entity? - if (binding.label.text.isNullOrBlank() || labelFromEntity) { - selectedEntity?.friendlyName?.takeIf { it != selectedEntity?.entityId }?.let { name -> - binding.label.removeTextChangedListener(labelTextChanged) - binding.label.setText(name) - labelFromEntity = true - binding.label.addTextChangedListener(labelTextChanged) - } - } - setupAttributes() - } - - private val attributeDropDownOnItemClick = - AdapterView.OnItemClickListener { parent, _, position, _ -> - selectedAttributeIds.add(parent.getItemAtPosition(position) as String) - } - private val labelTextChanged = object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { // Not implemented @@ -304,20 +251,6 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { } } - private fun setupAttributes() { - val fetchedAttributes = selectedEntity?.attributes as? Map - val attributesAdapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line) - binding.widgetTextConfigAttribute.setAdapter(attributesAdapter) - attributesAdapter.addAll(*fetchedAttributes?.keys.orEmpty().toTypedArray()) - binding.widgetTextConfigAttribute.setTokenizer(CommaTokenizer()) - runOnUiThread { - val toggleable = selectedEntity?.domain in EntityExt.APP_PRESS_ACTION_DOMAINS - binding.tapAction.isVisible = toggleable - binding.tapActionList.setSelection(if (toggleable) 0 else 1) - attributesAdapter.notifyDataSetChanged() - } - } - private fun onAddWidget() { if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { showAddWidgetError() @@ -338,18 +271,15 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { selectedServerId!! ) - val entity = if (selectedEntity == null) { - binding.widgetTextConfigEntityId.text.toString() - } else { - selectedEntity!!.entityId - } - if (entity !in entities[selectedServerId].orEmpty().map { it.entityId }) { - showAddWidgetError() - return + selectedEntities = LinkedList() + val se = binding.widgetTextConfigEntityId.text.split(",") + se.forEach { + val entity = entities[selectedServerId]!!.firstOrNull { e -> e.entityId == it.trim() } + if (entity != null) selectedEntities.add(entity) } intent.putExtra( HistoryWidget.EXTRA_ENTITY_ID, - entity + selectedEntities.map { e -> e?.entityId }.reduce { a, b -> "$a,$b" } ) intent.putExtra( @@ -362,36 +292,6 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { binding.textSize.text.toString() ) - intent.putExtra( - HistoryWidget.EXTRA_STATE_SEPARATOR, - binding.stateSeparator.text.toString() - ) - - if (appendAttributes) { - val attributes = if (selectedAttributeIds.isEmpty()) { - binding.widgetTextConfigAttribute.text.toString() - } else { - selectedAttributeIds - } - intent.putExtra( - HistoryWidget.EXTRA_ATTRIBUTE_IDS, - attributes - ) - - intent.putExtra( - HistoryWidget.EXTRA_ATTRIBUTE_SEPARATOR, - binding.attributeSeparator.text.toString() - ) - } - - intent.putExtra( - HistoryWidget.EXTRA_TAP_ACTION, - when (binding.tapActionList.selectedItemPosition) { - 0 -> WidgetTapAction.TOGGLE - else -> WidgetTapAction.REFRESH - } - ) - intent.putExtra( HistoryWidget.EXTRA_BACKGROUND_TYPE, when (binding.backgroundType.selectedItem as String?) { diff --git a/app/src/main/res/layout/widget_history_configure.xml b/app/src/main/res/layout/widget_history_configure.xml index a0c6ed62012..692d71c0977 100644 --- a/app/src/main/res/layout/widget_history_configure.xml +++ b/app/src/main/res/layout/widget_history_configure.xml @@ -50,9 +50,9 @@ android:layout_height="wrap_content" android:labelFor="@id/widget_text_config_entity_id" android:padding="5dp" - android:text="@string/label_entity_id" /> + android:text="@string/label_entity_ids" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:text="" /> @@ -196,28 +108,6 @@ android:inputType="text" /> - - - - - - - \ No newline at end of file + android:previewLayout="@layout/widget_history_wrapper_dynamiccolor" /> \ No newline at end of file diff --git a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json index c3725e69e3b..f7c8f21f7d5 100644 --- a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json +++ b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 48, - "identityHash": "dfe277a536c78cc675f17941a23dd9dd", + "identityHash": "0f958ba55579b89c341335658fd01d2a", "entities": [ { "tableName": "sensor_attributes", @@ -591,7 +591,7 @@ }, { "tableName": "history_widgets", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` REAL NOT NULL, `state_separator` TEXT NOT NULL, `attribute_separator` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `label` TEXT, `text_size` REAL NOT NULL, `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -612,12 +612,6 @@ "affinity": "TEXT", "notNull": true }, - { - "fieldPath": "attributeIds", - "columnName": "attribute_ids", - "affinity": "TEXT", - "notNull": false - }, { "fieldPath": "label", "columnName": "label", @@ -630,25 +624,6 @@ "affinity": "REAL", "notNull": true }, - { - "fieldPath": "stateSeparator", - "columnName": "state_separator", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "attributeSeparator", - "columnName": "attribute_separator", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tapAction", - "columnName": "tap_action", - "affinity": "TEXT", - "notNull": true, - "defaultValue": "'REFRESH'" - }, { "fieldPath": "lastUpdate", "columnName": "last_update", @@ -1210,7 +1185,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dfe277a536c78cc675f17941a23dd9dd')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0f958ba55579b89c341335658fd01d2a')" ] } } \ No newline at end of file diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetEntity.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetEntity.kt index 648a5b07606..979f7a170be 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetEntity.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/HistoryWidgetEntity.kt @@ -12,18 +12,10 @@ data class HistoryWidgetEntity( override val serverId: Int, @ColumnInfo(name = "entity_id") val entityId: String, - @ColumnInfo(name = "attribute_ids") - val attributeIds: String?, @ColumnInfo(name = "label") val label: String?, @ColumnInfo(name = "text_size") val textSize: Float, - @ColumnInfo(name = "state_separator") - val stateSeparator: String = "", - @ColumnInfo(name = "attribute_separator") - val attributeSeparator: String = "", - @ColumnInfo(name = "tap_action", defaultValue = "REFRESH") - val tapAction: WidgetTapAction, @ColumnInfo(name = "last_update") val lastUpdate: String, @ColumnInfo(name = "background_type", defaultValue = "DAYNIGHT") From 2febb51a2b17a04eab617f94f5febead45f71413 Mon Sep 17 00:00:00 2001 From: PizzaMarinara Date: Tue, 3 Sep 2024 09:53:55 +0200 Subject: [PATCH 4/4] Small UI fixes --- .../android/widgets/history/HistoryWidgetConfigureActivity.kt | 1 + app/src/main/res/layout/widget_history.xml | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt index d4d38d7f494..8e021c50eb5 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/history/HistoryWidgetConfigureActivity.kt @@ -172,6 +172,7 @@ class HistoryWidgetConfigureActivity : BaseWidgetConfigureActivity() { } binding.addButton.setText(commonR.string.update_widget) } else { + binding.textSize.setText(HistoryWidget.DEFAULT_TEXT_SIZE.toInt().toString()) binding.backgroundType.setSelection(0) } entityAdapter = SingleItemArrayAdapter(this) { it?.entityId ?: "" } diff --git a/app/src/main/res/layout/widget_history.xml b/app/src/main/res/layout/widget_history.xml index 0ed6a291549..b78f28e4cd6 100644 --- a/app/src/main/res/layout/widget_history.xml +++ b/app/src/main/res/layout/widget_history.xml @@ -19,7 +19,7 @@ android:id="@+id/widgetText" android:layout_width="match_parent" android:layout_height="0dp" - android:layout_weight="2" + android:layout_weight="1" android:layout_gravity="center" android:text="@string/widget_label_placeholder_text_static_state" android:textColor="?colorWidgetPrimary" /> @@ -28,7 +28,6 @@ android:id="@+id/widgetLabelLayout" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_weight="1" android:layout_margin="2dp">