diff --git a/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt b/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt index 380ad18c585..0e5ff2e8c61 100644 --- a/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt +++ b/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt @@ -87,7 +87,7 @@ fun SettingsWearTemplateTile( expanded = dropdownExpanded, onDismissRequest = { dropdownExpanded = false } ) { - val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60) + val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 2 * 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60) for (option in options) { DropdownMenuItem(onClick = { onRefreshIntervalChanged(option) diff --git a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/44.json b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/44.json new file mode 100644 index 00000000000..5d95687c3e7 --- /dev/null +++ b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/44.json @@ -0,0 +1,1040 @@ +{ + "formatVersion": 1, + "database": { + "version": 44, + "identityHash": "3201b89ecfb5c51a8600de7386008f21", + "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, 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 + } + ], + "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": "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": "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, `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": "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, '3201b89ecfb5c51a8600de7386008f21')" + ] + } +} \ No newline at end of file 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 450a652626b..cf87bcddb86 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 @@ -50,6 +50,8 @@ import io.homeassistant.companion.android.database.settings.LocalNotificationSet import io.homeassistant.companion.android.database.settings.LocalSensorSettingConverter import io.homeassistant.companion.android.database.settings.Setting import io.homeassistant.companion.android.database.settings.SettingsDao +import io.homeassistant.companion.android.database.wear.CameraTile +import io.homeassistant.companion.android.database.wear.CameraTileDao import io.homeassistant.companion.android.database.wear.EntityStateComplications import io.homeassistant.companion.android.database.wear.EntityStateComplicationsDao import io.homeassistant.companion.android.database.wear.FavoriteCaches @@ -87,11 +89,12 @@ import io.homeassistant.companion.android.common.R as commonR TileEntity::class, Favorites::class, FavoriteCaches::class, + CameraTile::class, EntityStateComplications::class, Server::class, Setting::class ], - version = 43, + version = 44, autoMigrations = [ AutoMigration(from = 24, to = 25), AutoMigration(from = 25, to = 26), @@ -110,7 +113,8 @@ import io.homeassistant.companion.android.common.R as commonR AutoMigration(from = 38, to = 39), AutoMigration(from = 39, to = 40), AutoMigration(from = 41, to = 42), - AutoMigration(from = 42, to = 43) + AutoMigration(from = 42, to = 43), + AutoMigration(from = 43, to = 44) ] ) @TypeConverters( @@ -133,6 +137,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun tileDao(): TileDao abstract fun favoritesDao(): FavoritesDao abstract fun favoriteCachesDao(): FavoriteCachesDao + abstract fun cameraTileDao(): CameraTileDao abstract fun entityStateComplicationsDao(): EntityStateComplicationsDao abstract fun serverDao(): ServerDao abstract fun settingsDao(): SettingsDao 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 b47c29553ab..99b57baf3cf 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 @@ -12,6 +12,7 @@ import io.homeassistant.companion.android.database.qs.TileDao import io.homeassistant.companion.android.database.sensor.SensorDao import io.homeassistant.companion.android.database.server.ServerDao import io.homeassistant.companion.android.database.settings.SettingsDao +import io.homeassistant.companion.android.database.wear.CameraTileDao import io.homeassistant.companion.android.database.wear.EntityStateComplicationsDao import io.homeassistant.companion.android.database.wear.FavoriteCachesDao import io.homeassistant.companion.android.database.wear.FavoritesDao @@ -72,6 +73,9 @@ object DatabaseModule { @Provides fun provideSettingsDao(database: AppDatabase): SettingsDao = database.settingsDao() + @Provides + fun provideCameraTileDao(database: AppDatabase): CameraTileDao = database.cameraTileDao() + @Provides fun provideEntityStateComplicationsDao(database: AppDatabase): EntityStateComplicationsDao = database.entityStateComplicationsDao() } diff --git a/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTile.kt b/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTile.kt new file mode 100644 index 00000000000..633bba35a63 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTile.kt @@ -0,0 +1,23 @@ +package io.homeassistant.companion.android.database.wear + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Represents the configuration of a camera tile. + * If the tile was added but not configured, everything except the tile ID will be `null`. + */ +@Entity(tableName = "camera_tiles") +data class CameraTile( + /** The system's tile ID */ + @PrimaryKey + @ColumnInfo(name = "id") + val id: Int, + /** The camera entity ID */ + @ColumnInfo(name = "entity_id") + val entityId: String? = null, + /** The refresh interval of this tile, in seconds */ + @ColumnInfo(name = "refresh_interval") + val refreshInterval: Long? = null +) diff --git a/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTileDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTileDao.kt new file mode 100644 index 00000000000..a2425854076 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTileDao.kt @@ -0,0 +1,23 @@ +package io.homeassistant.companion.android.database.wear + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface CameraTileDao { + + @Query("SELECT * FROM camera_tiles WHERE id = :id") + suspend fun get(id: Int): CameraTile? + + @Query("SELECT * FROM camera_tiles ORDER BY id ASC") + fun getAllFlow(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun add(tile: CameraTile) + + @Query("DELETE FROM camera_tiles where id = :id") + fun delete(id: Int) +} diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 9c1a9dc55d7..45f881f590b 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -120,6 +120,14 @@ Next Call any service Calendar + Camera + Camera tile + See what\'s on your camera + Log in to Home Assistant to add a camera tile + There are no camera tiles added yet - add one from the watch face to set it up + Edit the tile settings and select a camera to show + Camera tile #%d + Camera tiles Camera widgets Displays the latest image from the camera Cancel diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index aa63f4c4776..7052fc7148e 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -84,7 +84,7 @@ @@ -92,6 +92,11 @@ + + + + + @@ -167,6 +172,27 @@ + + + + + + + + + + + diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt b/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt index 4e618370723..c9ad3f8a01f 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.home.views.DEEPLINK_PREFIX_SET_CAMERA_TILE import io.homeassistant.companion.android.home.views.DEEPLINK_PREFIX_SET_SHORTCUT_TILE import io.homeassistant.companion.android.home.views.LoadHomePage import io.homeassistant.companion.android.onboarding.OnboardingActivity @@ -37,6 +38,16 @@ class HomeActivity : ComponentActivity(), HomeView { return Intent(context, HomeActivity::class.java) } + fun getCameraTileSettingsIntent( + context: Context, + tileId: Int + ) = Intent( + Intent.ACTION_VIEW, + "$DEEPLINK_PREFIX_SET_CAMERA_TILE/$tileId".toUri(), + context, + HomeActivity::class.java + ) + fun getShortcutsTileSettingsIntent( context: Context, tileId: Int diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt b/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt index 9a1341d646f..ed7cfd07160 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt @@ -26,6 +26,8 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.En import io.homeassistant.companion.android.common.sensors.SensorManager import io.homeassistant.companion.android.data.SimplifiedEntity import io.homeassistant.companion.android.database.sensor.SensorDao +import io.homeassistant.companion.android.database.wear.CameraTile +import io.homeassistant.companion.android.database.wear.CameraTileDao import io.homeassistant.companion.android.database.wear.FavoriteCaches import io.homeassistant.companion.android.database.wear.FavoriteCachesDao import io.homeassistant.companion.android.database.wear.FavoritesDao @@ -48,6 +50,7 @@ class MainViewModel @Inject constructor( private val favoritesDao: FavoritesDao, private val favoriteCachesDao: FavoriteCachesDao, private val sensorsDao: SensorDao, + private val cameraTileDao: CameraTileDao, application: Application ) : AndroidViewModel(application) { @@ -88,6 +91,10 @@ class MainViewModel @Inject constructor( val shortcutEntitiesMap = mutableStateMapOf>() + val cameraTiles = cameraTileDao.getAllFlow().collectAsState() + var cameraEntitiesMap = mutableStateMapOf>>() + private set + var areas = mutableListOf() private set @@ -221,6 +228,10 @@ class MainViewModel @Inject constructor( getEntities.await()?.also { entities.clear() it.forEach { state -> updateEntityStates(state) } + + // Special list: camera entities + val cameraEntities = it.filter { entity -> entity.domain == "camera" } + cameraEntitiesMap["camera"] = mutableStateListOf>().apply { addAll(cameraEntities) } } if (!isFavoritesOnly) { updateEntityDomains() @@ -412,6 +423,18 @@ class MainViewModel @Inject constructor( } } + fun setCameraTileEntity(tileId: Int, entityId: String) = viewModelScope.launch { + val current = cameraTileDao.get(tileId) + val updated = current?.copy(entityId = entityId) ?: CameraTile(id = tileId, entityId = entityId) + cameraTileDao.add(updated) + } + + fun setCameraTileRefreshInterval(tileId: Int, interval: Long) = viewModelScope.launch { + val current = cameraTileDao.get(tileId) + val updated = current?.copy(refreshInterval = interval) ?: CameraTile(id = tileId, refreshInterval = interval) + cameraTileDao.add(updated) + } + fun setTileShortcut(tileId: Int?, index: Int, entity: SimplifiedEntity) { viewModelScope.launch { val shortcutEntities = shortcutEntitiesMap[tileId]!! diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt index 58126e6f1cc..6f790e8731e 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt @@ -1,6 +1,9 @@ package io.homeassistant.companion.android.home.views import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavType import androidx.navigation.navArgument @@ -12,11 +15,13 @@ import androidx.wear.tiles.TileService import io.homeassistant.companion.android.common.sensors.id import io.homeassistant.companion.android.home.MainViewModel import io.homeassistant.companion.android.theme.WearAppTheme +import io.homeassistant.companion.android.tiles.CameraTile import io.homeassistant.companion.android.tiles.ShortcutsTile import io.homeassistant.companion.android.tiles.TemplateTile import io.homeassistant.companion.android.views.ChooseEntityView private const val ARG_SCREEN_SENSOR_MANAGER_ID = "sensorManagerId" +private const val ARG_SCREEN_CAMERA_TILE_ID = "cameraTileId" private const val ARG_SCREEN_SHORTCUTS_TILE_ID = "shortcutsTileId" private const val ARG_SCREEN_SHORTCUTS_TILE_ENTITY_INDEX = "shortcutsTileEntityIndex" @@ -27,6 +32,11 @@ private const val SCREEN_MANAGE_SENSORS = "manage_all_sensors" private const val SCREEN_SINGLE_SENSOR_MANAGER = "sensor_manager" private const val SCREEN_SETTINGS = "settings" private const val SCREEN_SET_FAVORITES = "set_favorites" +private const val ROUTE_CAMERA_TILE = "camera_tile" +private const val SCREEN_SELECT_CAMERA_TILE = "select_camera_tile" +private const val SCREEN_SET_CAMERA_TILE = "set_camera_tile" +private const val SCREEN_SET_CAMERA_TILE_ENTITY = "entity" +private const val SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL = "refresh_interval" private const val ROUTE_SHORTCUTS_TILE = "shortcuts_tile" private const val SCREEN_SELECT_SHORTCUTS_TILE = "select_shortcuts_tile" private const val SCREEN_SET_SHORTCUTS_TILE = "set_shortcuts_tile" @@ -35,6 +45,7 @@ private const val SCREEN_SET_TILE_TEMPLATE = "set_tile_template" private const val SCREEN_SET_TILE_TEMPLATE_REFRESH_INTERVAL = "set_tile_template_refresh_interval" const val DEEPLINK_SENSOR_MANAGER = "ha_wear://$SCREEN_SINGLE_SENSOR_MANAGER" +const val DEEPLINK_PREFIX_SET_CAMERA_TILE = "ha_wear://$SCREEN_SET_CAMERA_TILE" const val DEEPLINK_PREFIX_SET_SHORTCUT_TILE = "ha_wear://$SCREEN_SET_SHORTCUTS_TILE" @Composable @@ -147,6 +158,9 @@ fun LoadHomePage( onHapticEnabled = { mainViewModel.setHapticEnabled(it) }, onToastEnabled = { mainViewModel.setToastEnabled(it) }, setFavoritesOnly = { mainViewModel.setWearFavoritesOnly(it) }, + onClickCameraTile = { + swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$SCREEN_SELECT_CAMERA_TILE") + }, onClickTemplateTile = { swipeDismissableNavController.navigate(SCREEN_SET_TILE_TEMPLATE) }, onAssistantAppAllowed = mainViewModel::setAssistantApp ) @@ -163,6 +177,85 @@ fun LoadHomePage( } } } + composable("$ROUTE_CAMERA_TILE/$SCREEN_SELECT_CAMERA_TILE") { + SelectCameraTileView( + tiles = mainViewModel.cameraTiles.value, + onSelectTile = { tileId -> + swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$tileId/$SCREEN_SET_CAMERA_TILE") + } + ) + } + composable( + route = "$ROUTE_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}/$SCREEN_SET_CAMERA_TILE", + arguments = listOf( + navArgument(name = ARG_SCREEN_CAMERA_TILE_ID) { + type = NavType.IntType + } + ), + deepLinks = listOf( + navDeepLink { uriPattern = "$DEEPLINK_PREFIX_SET_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}" } + ) + ) { backStackEntry -> + val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_CAMERA_TILE_ID) + SetCameraTileView( + tile = mainViewModel.cameraTiles.value.firstOrNull { it.id == tileId }, + entities = mainViewModel.cameraEntitiesMap["camera"], + onSelectEntity = { + swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$tileId/$SCREEN_SET_CAMERA_TILE_ENTITY") + }, + onSelectRefreshInterval = { + swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$tileId/$SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL") + } + ) + } + composable( + route = "$ROUTE_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}/$SCREEN_SET_CAMERA_TILE_ENTITY", + arguments = listOf( + navArgument(name = ARG_SCREEN_CAMERA_TILE_ID) { + type = NavType.IntType + } + ) + ) { backStackEntry -> + val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_CAMERA_TILE_ID) + val cameraDomains = remember { mutableStateListOf("camera") } + val cameraFavorites = remember { mutableStateOf(emptyList()) } // There are no camera favorites + ChooseEntityView( + entitiesByDomainOrder = cameraDomains, + entitiesByDomain = mainViewModel.cameraEntitiesMap, + favoriteEntityIds = cameraFavorites, + onNoneClicked = {}, + onEntitySelected = { entity -> + tileId?.let { + mainViewModel.setCameraTileEntity(it, entity.entityId) + TileService.getUpdater(context).requestUpdate(CameraTile::class.java) + } + swipeDismissableNavController.navigateUp() + }, + allowNone = false + ) + } + composable( + route = "$ROUTE_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}/$SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL", + arguments = listOf( + navArgument(name = ARG_SCREEN_CAMERA_TILE_ID) { + type = NavType.IntType + } + ) + ) { backStackEntry -> + val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_CAMERA_TILE_ID) + RefreshIntervalPickerView( + currentInterval = ( + mainViewModel.cameraTiles.value + .firstOrNull { it.id == tileId }?.refreshInterval + ?: CameraTile.DEFAULT_REFRESH_INTERVAL + ).toInt() + ) { interval -> + tileId?.let { + mainViewModel.setCameraTileRefreshInterval(it, interval.toLong()) + } + swipeDismissableNavController.navigateUp() + } + } composable("$ROUTE_SHORTCUTS_TILE/$SCREEN_SELECT_SHORTCUTS_TILE") { SelectShortcutsTileView( shortcutTileEntitiesCountById = mainViewModel.shortcutEntitiesMap.mapValues { (_, entities) -> entities.size }, diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/RefreshIntervalPickerView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/RefreshIntervalPickerView.kt index 0a1657b6f5d..8ae4b4e18b3 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/RefreshIntervalPickerView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/RefreshIntervalPickerView.kt @@ -39,7 +39,7 @@ fun RefreshIntervalPickerView( currentInterval: Int, onSelectInterval: (Int) -> Unit ) { - val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60) + val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 2 * 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60) val initialIndex = options.indexOf(currentInterval) val state = rememberPickerState( initialNumberOfOptions = options.size, diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectCameraTileView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectCameraTileView.kt new file mode 100644 index 00000000000..081e685a049 --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectCameraTileView.kt @@ -0,0 +1,86 @@ +package io.homeassistant.companion.android.home.views + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.wear.compose.foundation.lazy.itemsIndexed +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.Text +import io.homeassistant.companion.android.database.wear.CameraTile +import io.homeassistant.companion.android.theme.WearAppTheme +import io.homeassistant.companion.android.views.ListHeader +import io.homeassistant.companion.android.views.ThemeLazyColumn +import io.homeassistant.companion.android.common.R as commonR + +@Composable +fun SelectCameraTileView( + tiles: List, + onSelectTile: (tileId: Int) -> Unit +) { + val scalingLazyListState = rememberScalingLazyListState() + WearAppTheme { + Scaffold( + positionIndicator = { + if (scalingLazyListState.isScrollInProgress) { + PositionIndicator(scalingLazyListState = scalingLazyListState) + } + }, + timeText = { TimeText(scalingLazyListState = scalingLazyListState) } + ) { + ThemeLazyColumn(state = scalingLazyListState) { + item { + ListHeader(id = commonR.string.camera_tiles) + } + if (tiles.isEmpty()) { + item { + Text( + text = stringResource(commonR.string.camera_tile_no_tiles_yet), + textAlign = TextAlign.Center + ) + } + } else { + itemsIndexed(tiles, key = { _, item -> "tile.${item.id}" }) { index, tile -> + Chip( + modifier = Modifier.fillMaxWidth(), + label = { + Text(stringResource(commonR.string.camera_tile_n, index + 1)) + }, + secondaryLabel = if (tile.entityId != null) { + { Text(tile.entityId!!) } + } else { + null + }, + onClick = { onSelectTile(tile.id) }, + colors = ChipDefaults.secondaryChipColors() + ) + } + } + } + } + } +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND) +@Composable +private fun PreviewSelectCameraTileViewOne() { + SelectCameraTileView( + tiles = listOf( + CameraTile(id = 1, entityId = "camera.buienradar", refreshInterval = 300) + ), + onSelectTile = {} + ) +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND) +@Composable +private fun PreviewSelectCameraTileViewEmpty() { + SelectCameraTileView(tiles = emptyList(), onSelectTile = {}) +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SetCameraTileView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SetCameraTileView.kt new file mode 100644 index 00000000000..7911dc79c0b --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SetCameraTileView.kt @@ -0,0 +1,103 @@ +package io.homeassistant.companion.android.home.views + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.Text +import com.mikepenz.iconics.compose.Image +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import io.homeassistant.companion.android.common.R +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.friendlyName +import io.homeassistant.companion.android.common.data.integration.getIcon +import io.homeassistant.companion.android.database.wear.CameraTile +import io.homeassistant.companion.android.theme.WearAppTheme +import io.homeassistant.companion.android.theme.wearColorPalette +import io.homeassistant.companion.android.tiles.CameraTile.Companion.DEFAULT_REFRESH_INTERVAL +import io.homeassistant.companion.android.util.intervalToString +import io.homeassistant.companion.android.views.ListHeader +import io.homeassistant.companion.android.views.ThemeLazyColumn +import io.homeassistant.companion.android.common.R as commonR + +@Composable +fun SetCameraTileView( + tile: CameraTile?, + entities: List>?, + onSelectEntity: () -> Unit, + onSelectRefreshInterval: () -> Unit +) { + val scalingLazyListState = rememberScalingLazyListState() + WearAppTheme { + Scaffold( + positionIndicator = { + if (scalingLazyListState.isScrollInProgress) { + PositionIndicator(scalingLazyListState = scalingLazyListState) + } + }, + timeText = { TimeText(scalingLazyListState = scalingLazyListState) } + ) { + ThemeLazyColumn(state = scalingLazyListState) { + item { + ListHeader(commonR.string.camera_tile) + } + item { + val entity = tile?.entityId?.let { tileEntityId -> + entities?.firstOrNull { it.entityId == tileEntityId } + } + val icon = entity?.getIcon(LocalContext.current) ?: CommunityMaterial.Icon3.cmd_video + Chip( + modifier = Modifier.fillMaxWidth(), + icon = { + Image( + asset = icon, + colorFilter = ColorFilter.tint(wearColorPalette.onSurface) + ) + }, + colors = ChipDefaults.secondaryChipColors(), + label = { + Text( + text = stringResource(id = R.string.choose_entity) + ) + }, + secondaryLabel = { + Text(entity?.friendlyName ?: tile?.entityId ?: "") + }, + onClick = onSelectEntity + ) + } + + item { + Chip( + modifier = Modifier.fillMaxWidth(), + icon = { + Image( + asset = CommunityMaterial.Icon3.cmd_timer_cog, + colorFilter = ColorFilter.tint(wearColorPalette.onSurface) + ) + }, + colors = ChipDefaults.secondaryChipColors(), + label = { + Text( + text = stringResource(id = R.string.refresh_interval) + ) + }, + secondaryLabel = { + Text( + intervalToString(LocalContext.current, (tile?.refreshInterval ?: DEFAULT_REFRESH_INTERVAL).toInt()) + ) + }, + onClick = onSelectRefreshInterval + ) + } + } + } + } +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt index b70492db57b..70978bb4612 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt @@ -73,6 +73,7 @@ fun SettingsView( onHapticEnabled: (Boolean) -> Unit, onToastEnabled: (Boolean) -> Unit, setFavoritesOnly: (Boolean) -> Unit, + onClickCameraTile: () -> Unit, onClickTemplateTile: () -> Unit, onAssistantAppAllowed: (Boolean) -> Unit ) { @@ -212,6 +213,13 @@ fun SettingsView( id = commonR.string.tile_settings ) } + item { + SecondarySettingsChip( + icon = CommunityMaterial.Icon3.cmd_video_box, + label = stringResource(commonR.string.camera_tiles), + onClick = onClickCameraTile + ) + } item { SecondarySettingsChip( icon = CommunityMaterial.Icon3.cmd_star_circle_outline, @@ -323,6 +331,7 @@ private fun PreviewSettingsView() { onHapticEnabled = {}, onToastEnabled = {}, setFavoritesOnly = {}, + onClickCameraTile = {}, onClickTemplateTile = {}, onAssistantAppAllowed = {} ) diff --git a/wear/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt b/wear/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt index 15c5bcc7d36..bce5de1b1a1 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt @@ -8,6 +8,7 @@ import io.homeassistant.companion.android.BuildConfig import io.homeassistant.companion.android.common.data.integration.DeviceRegistration import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.onboarding.getMessagingToken +import io.homeassistant.companion.android.tiles.CameraTile import io.homeassistant.companion.android.tiles.ConversationTile import io.homeassistant.companion.android.tiles.ShortcutsTile import io.homeassistant.companion.android.tiles.TemplateTile @@ -60,6 +61,7 @@ class MobileAppIntegrationPresenterImpl @Inject constructor( try { val context = view as Context val updater = TileService.getUpdater(context) + updater.requestUpdate(CameraTile::class.java) updater.requestUpdate(ConversationTile::class.java) updater.requestUpdate(ShortcutsTile::class.java) updater.requestUpdate(TemplateTile::class.java) diff --git a/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt b/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt index 57f7a6a20eb..6ad8bfefc55 100755 --- a/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt @@ -32,6 +32,7 @@ import io.homeassistant.companion.android.database.wear.replaceAll import io.homeassistant.companion.android.home.HomeActivity import io.homeassistant.companion.android.home.HomePresenterImpl import io.homeassistant.companion.android.onboarding.getMessagingToken +import io.homeassistant.companion.android.tiles.CameraTile import io.homeassistant.companion.android.tiles.ConversationTile import io.homeassistant.companion.android.tiles.ShortcutsTile import io.homeassistant.companion.android.tiles.TemplateTile @@ -223,6 +224,7 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange private fun updateTiles() = mainScope.launch { try { val updater = TileService.getUpdater(applicationContext) + updater.requestUpdate(CameraTile::class.java) updater.requestUpdate(ConversationTile::class.java) updater.requestUpdate(ShortcutsTile::class.java) updater.requestUpdate(TemplateTile::class.java) diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/CameraTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/CameraTile.kt new file mode 100644 index 00000000000..8cc0b067190 --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/CameraTile.kt @@ -0,0 +1,235 @@ +package io.homeassistant.companion.android.tiles + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import androidx.wear.protolayout.DimensionBuilders +import androidx.wear.protolayout.LayoutElementBuilders +import androidx.wear.protolayout.LayoutElementBuilders.CONTENT_SCALE_MODE_FIT +import androidx.wear.protolayout.ResourceBuilders +import androidx.wear.protolayout.ResourceBuilders.ImageResource +import androidx.wear.protolayout.ResourceBuilders.InlineImageResource +import androidx.wear.protolayout.ResourceBuilders.Resources +import androidx.wear.protolayout.TimelineBuilders.Timeline +import androidx.wear.tiles.EventBuilders +import androidx.wear.tiles.RequestBuilders +import androidx.wear.tiles.TileBuilders.Tile +import androidx.wear.tiles.TileService +import com.google.common.util.concurrent.ListenableFuture +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.AppDatabase +import io.homeassistant.companion.android.database.wear.CameraTile +import io.homeassistant.companion.android.util.UrlUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.guava.future +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.ByteArrayOutputStream +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR + +@AndroidEntryPoint +class CameraTile : TileService() { + + companion object { + private const val TAG = "CameraTile" + + const val DEFAULT_REFRESH_INTERVAL = 3600L // 1 hour, matching phone widget + + private const val RESOURCE_SNAPSHOT = "snapshot" + } + + private val serviceJob = Job() + private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + + @Inject + lateinit var serverManager: ServerManager + + @Inject + lateinit var wearPrefsRepository: WearPrefsRepository + + @Inject + lateinit var okHttpClient: OkHttpClient + + override fun onTileRequest(requestParams: RequestBuilders.TileRequest): ListenableFuture = + serviceScope.future { + val tileId = requestParams.tileId + val tileConfig = AppDatabase.getInstance(this@CameraTile) + .cameraTileDao() + .get(tileId) + + if (requestParams.currentState.lastClickableId == MODIFIER_CLICK_REFRESH) { + if (wearPrefsRepository.getWearHapticFeedback()) hapticClick(applicationContext) + } + + Tile.Builder() + .setResourcesVersion("$TAG$tileId.${System.currentTimeMillis()}") + .setFreshnessIntervalMillis( + TimeUnit.SECONDS.toMillis(tileConfig?.refreshInterval ?: DEFAULT_REFRESH_INTERVAL) + ) + .setTileTimeline( + if (serverManager.isRegistered()) { + timeline( + requestParams.deviceConfiguration.screenWidthDp, + requestParams.deviceConfiguration.screenHeightDp, + tileConfig?.entityId.isNullOrBlank() + ) + } else { + loggedOutTimeline( + this@CameraTile, + requestParams, + commonR.string.camera, + commonR.string.camera_tile_log_in + ) + } + ) + .build() + } + + override fun onTileResourcesRequest(requestParams: RequestBuilders.ResourcesRequest): ListenableFuture = + serviceScope.future { + var imageWidth = 0 + var imageHeight = 0 + val imageData = if (serverManager.isRegistered()) { + val tileId = requestParams.tileId + val tileConfig = AppDatabase.getInstance(this@CameraTile) + .cameraTileDao() + .get(tileId) + + try { + val entity = tileConfig?.entityId?.let { + serverManager.integrationRepository().getEntity(it) + } + val picture = entity?.attributes?.get("entity_picture")?.toString() + val url = UrlUtil.handle(serverManager.getServer()?.connection?.getUrl(), picture ?: "") + if (picture != null && url != null) { + var byteArray: ByteArray? + val maxWidth = requestParams.deviceConfiguration.screenWidthDp * requestParams.deviceConfiguration.screenDensity + val maxHeight = requestParams.deviceConfiguration.screenHeightDp * requestParams.deviceConfiguration.screenDensity + withContext(Dispatchers.IO) { + val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute() + byteArray = response.body?.byteStream()?.readBytes() + byteArray?.let { + var bitmap = BitmapFactory.decodeByteArray(it, 0, it.size) + if (bitmap.width > maxWidth || bitmap.height > maxHeight) { + Log.d(TAG, "Scaling camera snapshot to fit screen (${bitmap.width}x${bitmap.height} to ${maxWidth.toInt()}x${maxHeight.toInt()} max)") + val currentRatio = (bitmap.width.toFloat() / bitmap.height.toFloat()) + val screenRatio = (requestParams.deviceConfiguration.screenWidthDp.toFloat() / requestParams.deviceConfiguration.screenHeightDp.toFloat()) + imageWidth = maxWidth.toInt() + imageHeight = maxHeight.toInt() + if (currentRatio > screenRatio) { + imageWidth = (maxHeight * currentRatio).toInt() + } else { + imageHeight = (maxWidth / currentRatio).toInt() + } + bitmap = Bitmap.createScaledBitmap(bitmap, imageWidth, imageHeight, true) + ByteArrayOutputStream().use { stream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream) + byteArray = stream.toByteArray() + } + } else { + imageWidth = bitmap.width + imageHeight = bitmap.height + } + } + response.close() + } + byteArray + } else { + null + } + } catch (e: Exception) { + Log.e(TAG, "Unable to fetch entity ${tileConfig?.entityId}", e) + null + } + } else { + null + } + + val builder = Resources.Builder() + .setVersion(requestParams.version) + .addIdToImageMapping( + RESOURCE_REFRESH, + ImageResource.Builder() + .setAndroidResourceByResId( + ResourceBuilders.AndroidImageResourceByResId.Builder() + .setResourceId(R.drawable.ic_refresh) + .build() + ).build() + ) + if (imageData != null) { + builder.addIdToImageMapping( + RESOURCE_SNAPSHOT, + ImageResource.Builder() + .setInlineResource( + InlineImageResource.Builder() + .setData(imageData) + .setWidthPx(imageWidth) + .setHeightPx(imageHeight) + .setFormat(ResourceBuilders.IMAGE_FORMAT_UNDEFINED) + .build() + ) + .build() + ) + } + + builder.build() + } + + override fun onTileAddEvent(requestParams: EventBuilders.TileAddEvent) { + serviceScope.launch { + AppDatabase.getInstance(this@CameraTile) + .cameraTileDao() + .add(CameraTile(id = requestParams.tileId)) + } + } + + override fun onTileRemoveEvent(requestParams: EventBuilders.TileRemoveEvent) { + serviceScope.launch { + AppDatabase.getInstance(this@CameraTile) + .cameraTileDao() + .delete(requestParams.tileId) + } + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + } + + private fun timeline(width: Int, height: Int, requiresSetup: Boolean): Timeline = Timeline.fromLayoutElement( + LayoutElementBuilders.Box.Builder().apply { + // Camera image + if (requiresSetup) { + addContent( + LayoutElementBuilders.Text.Builder() + .setText(getString(commonR.string.camera_tile_no_entity_yet)) + .setMaxLines(10) + .build() + ) + } else { + addContent( + LayoutElementBuilders.Image.Builder() + .setResourceId(RESOURCE_SNAPSHOT) + .setWidth(DimensionBuilders.dp(width.toFloat())) + .setHeight(DimensionBuilders.dp(height.toFloat())) + .setContentScaleMode(CONTENT_SCALE_MODE_FIT) + .build() + ) + } + // Refresh button + addContent(getRefreshButton()) + // Click: refresh + setModifiers(getRefreshModifiers()) + }.build() + ) +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/LoggedOutTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/LoggedOutTile.kt deleted file mode 100644 index f738b31d8b4..00000000000 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/LoggedOutTile.kt +++ /dev/null @@ -1,76 +0,0 @@ -package io.homeassistant.companion.android.tiles - -import android.content.Context -import androidx.annotation.StringRes -import androidx.core.content.ContextCompat -import androidx.wear.protolayout.ActionBuilders -import androidx.wear.protolayout.ColorBuilders.argb -import androidx.wear.protolayout.ModifiersBuilders -import androidx.wear.protolayout.TimelineBuilders.Timeline -import androidx.wear.protolayout.material.ChipColors -import androidx.wear.protolayout.material.Colors -import androidx.wear.protolayout.material.CompactChip -import androidx.wear.protolayout.material.Text -import androidx.wear.protolayout.material.Typography -import androidx.wear.protolayout.material.layouts.PrimaryLayout -import androidx.wear.tiles.RequestBuilders -import io.homeassistant.companion.android.R -import io.homeassistant.companion.android.splash.SplashActivity -import io.homeassistant.companion.android.common.R as commonR - -/** - * A [Timeline] with a single entry, asking the user to log in to the app to start using the tile - * with a button to open the app. The tile is using the 'Dialog' style. - */ -fun loggedOutTimeline( - context: Context, - requestParams: RequestBuilders.TileRequest, - @StringRes title: Int, - @StringRes text: Int -): Timeline { - val theme = Colors( - ContextCompat.getColor(context, R.color.colorPrimary), // Primary - ContextCompat.getColor(context, R.color.colorOnPrimary), // On primary - ContextCompat.getColor(context, R.color.colorOverlay), // Surface - ContextCompat.getColor(context, android.R.color.white) // On surface - ) - val chipColors = ChipColors.primaryChipColors(theme) - val chipAction = ModifiersBuilders.Clickable.Builder() - .setId("login") - .setOnClick( - ActionBuilders.LaunchAction.Builder() - .setAndroidActivity( - ActionBuilders.AndroidActivity.Builder() - .setClassName(SplashActivity::class.java.name) - .setPackageName(context.packageName) - .build() - ).build() - ).build() - return Timeline.fromLayoutElement( - PrimaryLayout.Builder(requestParams.deviceConfiguration) - .setPrimaryLabelTextContent( - Text.Builder(context, context.getString(title)) - .setTypography(Typography.TYPOGRAPHY_CAPTION1) - .setColor(argb(theme.primary)) - .build() - ) - .setContent( - Text.Builder(context, context.getString(text)) - .setTypography(Typography.TYPOGRAPHY_BODY1) - .setMaxLines(10) - .setColor(argb(theme.onSurface)) - .build() - ) - .setPrimaryChipContent( - CompactChip.Builder( - context, - context.getString(commonR.string.login), - chipAction, - requestParams.deviceConfiguration - ) - .setChipColors(chipColors) - .build() - ) - .build() - ) -} diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/OpenShortcutTileSettingsActivity.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/OpenTileSettingsActivity.kt similarity index 50% rename from wear/src/main/java/io/homeassistant/companion/android/tiles/OpenShortcutTileSettingsActivity.kt rename to wear/src/main/java/io/homeassistant/companion/android/tiles/OpenTileSettingsActivity.kt index cee60dcbfa8..4ea43bbed98 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/OpenShortcutTileSettingsActivity.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/OpenTileSettingsActivity.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint -class OpenShortcutTileSettingsActivity : AppCompatActivity() { +class OpenTileSettingsActivity : AppCompatActivity() { @Inject lateinit var wearPrefsRepository: WearPrefsRepositoryImpl @@ -19,14 +19,25 @@ class OpenShortcutTileSettingsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val tileId = intent.extras?.getInt("com.google.android.clockwork.EXTRA_PROVIDER_CONFIG_TILE_ID") tileId?.takeIf { it != 0 }?.let { - lifecycleScope.launch { - wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId) + val settingsIntent = when (intent.action) { + "ConfigCameraTile" -> + HomeActivity.getCameraTileSettingsIntent( + context = this, + tileId = it + ) + "ConfigShortcutsTile" -> { + lifecycleScope.launch { + wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId) + } + HomeActivity.getShortcutsTileSettingsIntent( + context = this, + tileId = it + ) + } + else -> null } - val intent = HomeActivity.getShortcutsTileSettingsIntent( - context = this, - tileId = it - ) - startActivity(intent) + + settingsIntent?.let { startActivity(settingsIntent) } } finish() } diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt index 55486c8886f..e53476f4961 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt @@ -1,10 +1,6 @@ package io.homeassistant.companion.android.tiles import android.graphics.Typeface -import android.os.Build -import android.os.VibrationEffect -import android.os.Vibrator -import android.os.VibratorManager import android.text.style.AbsoluteSizeSpan import android.text.style.CharacterStyle import android.text.style.ForegroundColorSpan @@ -12,18 +8,14 @@ import android.text.style.RelativeSizeSpan import android.text.style.StyleSpan import android.text.style.UnderlineSpan import android.util.Log -import androidx.core.content.getSystemService import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.text.HtmlCompat.fromHtml -import androidx.wear.protolayout.ActionBuilders import androidx.wear.protolayout.ColorBuilders import androidx.wear.protolayout.DimensionBuilders -import androidx.wear.protolayout.DimensionBuilders.dp import androidx.wear.protolayout.LayoutElementBuilders import androidx.wear.protolayout.LayoutElementBuilders.Box import androidx.wear.protolayout.LayoutElementBuilders.FONT_WEIGHT_BOLD import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement -import androidx.wear.protolayout.ModifiersBuilders import androidx.wear.protolayout.ResourceBuilders import androidx.wear.protolayout.ResourceBuilders.Resources import androidx.wear.protolayout.TimelineBuilders.Timeline @@ -57,19 +49,8 @@ class TemplateTile : TileService() { override fun onTileRequest(requestParams: TileRequest): ListenableFuture = serviceScope.future { - val state = requestParams.currentState - if (state.lastClickableId == "refresh") { - if (wearPrefsRepository.getWearHapticFeedback()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = applicationContext.getSystemService() - val vibrator = vibratorManager?.defaultVibrator - vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) - } else { - val vibrator = applicationContext.getSystemService() - @Suppress("DEPRECATION") - vibrator?.vibrate(200) - } - } + if (requestParams.currentState.lastClickableId == MODIFIER_CLICK_REFRESH) { + if (wearPrefsRepository.getWearHapticFeedback()) hapticClick(applicationContext) } Tile.Builder() @@ -96,7 +77,7 @@ class TemplateTile : TileService() { Resources.Builder() .setVersion("1") .addIdToImageMapping( - "refresh", + RESOURCE_REFRESH, ResourceBuilders.ImageResource.Builder() .setAndroidResourceByResId( ResourceBuilders.AndroidImageResourceByResId.Builder() @@ -147,43 +128,11 @@ class TemplateTile : TileService() { parseHtml(renderedText) ) } - addContent( - LayoutElementBuilders.Arc.Builder() - .setAnchorAngle( - DimensionBuilders.DegreesProp.Builder(180f).build() - ) - .addContent( - LayoutElementBuilders.ArcAdapter.Builder() - .setContent( - LayoutElementBuilders.Image.Builder() - .setResourceId("refresh") - .setWidth(dp(24f)) - .setHeight(dp(24f)) - .setModifiers(getRefreshModifiers()) - .build() - ) - .setRotateContents(false) - .build() - ) - .build() - ) + addContent(getRefreshButton()) setModifiers(getRefreshModifiers()) } .build() - private fun getRefreshModifiers(): ModifiersBuilders.Modifiers { - return ModifiersBuilders.Modifiers.Builder() - .setClickable( - ModifiersBuilders.Clickable.Builder() - .setOnClick( - ActionBuilders.LoadAction.Builder().build() - ) - .setId("refresh") - .build() - ) - .build() - } - private fun parseHtml(renderedText: String): LayoutElementBuilders.Spannable { // Replace control char \r\n, \r, \n and also \r\n, \r, \n as text literals in strings to
val renderedSpanned = fromHtml(renderedText.replace("(\r\n|\r|\n)|(\\\\r\\\\n|\\\\r|\\\\n)".toRegex(), "
"), FROM_HTML_MODE_LEGACY) diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/TileActionReceiver.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/TileActionReceiver.kt index 91658e12742..30cf8dc63d6 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/TileActionReceiver.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/TileActionReceiver.kt @@ -3,12 +3,7 @@ package io.homeassistant.companion.android.tiles import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.os.Build -import android.os.VibrationEffect -import android.os.Vibrator -import android.os.VibratorManager import android.util.Log -import androidx.core.content.getSystemService import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.common.data.integration.onEntityPressedWithoutState import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository @@ -34,17 +29,7 @@ class TileActionReceiver : BroadcastReceiver() { if (entityId != null) { runBlocking { - if (wearPrefsRepository.getWearHapticFeedback()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = context?.getSystemService() - val vibrator = vibratorManager?.defaultVibrator - vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) - } else { - val vibrator = context?.getSystemService() - @Suppress("DEPRECATION") - vibrator?.vibrate(200) - } - } + if (wearPrefsRepository.getWearHapticFeedback() && context != null) hapticClick(context) try { onEntityPressedWithoutState( diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/TileViews.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/TileViews.kt new file mode 100644 index 00000000000..2209efa95e6 --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/TileViews.kt @@ -0,0 +1,157 @@ +package io.homeassistant.companion.android.tiles + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.wear.protolayout.ActionBuilders +import androidx.wear.protolayout.ColorBuilders.argb +import androidx.wear.protolayout.DimensionBuilders +import androidx.wear.protolayout.LayoutElementBuilders +import androidx.wear.protolayout.ModifiersBuilders +import androidx.wear.protolayout.TimelineBuilders.Timeline +import androidx.wear.protolayout.material.ChipColors +import androidx.wear.protolayout.material.Colors +import androidx.wear.protolayout.material.CompactChip +import androidx.wear.protolayout.material.Text +import androidx.wear.protolayout.material.Typography +import androidx.wear.protolayout.material.layouts.PrimaryLayout +import androidx.wear.tiles.RequestBuilders +import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.splash.SplashActivity +import io.homeassistant.companion.android.common.R as commonR + +const val RESOURCE_REFRESH = "refresh" +const val MODIFIER_CLICK_REFRESH = "refresh" + +/** Performs a [VibrationEffect.EFFECT_CLICK] or equivalent on older Android versions */ +fun hapticClick(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService() + val vibrator = vibratorManager?.defaultVibrator + vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) + } else { + val vibrator = context.getSystemService() + @Suppress("DEPRECATION") + vibrator?.vibrate(200) + } +} + +/** + * A [Timeline] with a single entry, asking the user to log in to the app to start using the tile + * with a button to open the app. The tile is using the 'Dialog' style. + */ +fun loggedOutTimeline( + context: Context, + requestParams: RequestBuilders.TileRequest, + @StringRes title: Int, + @StringRes text: Int +): Timeline = primaryLayoutTimeline( + context = context, + requestParams = requestParams, + title = title, + text = text, + actionText = commonR.string.login, + action = ActionBuilders.LaunchAction.Builder() + .setAndroidActivity( + ActionBuilders.AndroidActivity.Builder() + .setClassName(SplashActivity::class.java.name) + .setPackageName(context.packageName) + .build() + ).build() +) + +/** + * A [Timeline] with a single entry using the Material `PrimaryLayout`. The title is optional. + */ +fun primaryLayoutTimeline( + context: Context, + requestParams: RequestBuilders.TileRequest, + @StringRes title: Int?, + @StringRes text: Int, + @StringRes actionText: Int, + action: ActionBuilders.Action +): Timeline { + val theme = Colors( + ContextCompat.getColor(context, R.color.colorPrimary), // Primary + ContextCompat.getColor(context, R.color.colorOnPrimary), // On primary + ContextCompat.getColor(context, R.color.colorOverlay), // Surface + ContextCompat.getColor(context, android.R.color.white) // On surface + ) + val chipColors = ChipColors.primaryChipColors(theme) + val chipAction = ModifiersBuilders.Clickable.Builder() + .setId("action") + .setOnClick(action) + .build() + val builder = PrimaryLayout.Builder(requestParams.deviceConfiguration) + if (title != null) { + builder.setPrimaryLabelTextContent( + Text.Builder(context, context.getString(title)) + .setTypography(Typography.TYPOGRAPHY_CAPTION1) + .setColor(argb(theme.primary)) + .build() + ) + } + builder.setContent( + Text.Builder(context, context.getString(text)) + .setTypography(Typography.TYPOGRAPHY_BODY1) + .setMaxLines(10) + .setColor(argb(theme.onSurface)) + .build() + ) + builder.setPrimaryChipContent( + CompactChip.Builder( + context, + context.getString(actionText), + chipAction, + requestParams.deviceConfiguration + ) + .setChipColors(chipColors) + .build() + ) + return Timeline.fromLayoutElement(builder.build()) +} + +/** + * An [LayoutElementBuilders.Arc] with a refresh button at the bottom (centered). When added, it is + * expected that the TileService: + * - handles the refresh action ([MODIFIER_CLICK_REFRESH]) in `onTileRequest`; + * - adds a resource for [RESOURCE_REFRESH] in `onTileResourcesRequest`. + */ +fun getRefreshButton(): LayoutElementBuilders.Arc = + LayoutElementBuilders.Arc.Builder() + .setAnchorAngle( + DimensionBuilders.DegreesProp.Builder(180f).build() + ) + .addContent( + LayoutElementBuilders.ArcAdapter.Builder() + .setContent( + LayoutElementBuilders.Image.Builder() + .setResourceId(RESOURCE_REFRESH) + .setWidth(DimensionBuilders.dp(24f)) + .setHeight(DimensionBuilders.dp(24f)) + .setModifiers(getRefreshModifiers()) + .build() + ) + .setRotateContents(false) + .build() + ) + .build() + +/** @return a modifier for tiles that represents a 'tap to refresh' [ActionBuilders.LoadAction] */ +fun getRefreshModifiers(): ModifiersBuilders.Modifiers { + return ModifiersBuilders.Modifiers.Builder() + .setClickable( + ModifiersBuilders.Clickable.Builder() + .setOnClick( + ActionBuilders.LoadAction.Builder().build() + ) + .setId(MODIFIER_CLICK_REFRESH) + .build() + ) + .build() +} diff --git a/wear/src/main/res/drawable-round/camera_tile_example.png b/wear/src/main/res/drawable-round/camera_tile_example.png new file mode 100644 index 00000000000..114c090618c Binary files /dev/null and b/wear/src/main/res/drawable-round/camera_tile_example.png differ diff --git a/wear/src/main/res/drawable/camera_tile_example.png b/wear/src/main/res/drawable/camera_tile_example.png new file mode 100644 index 00000000000..c519212aa84 Binary files /dev/null and b/wear/src/main/res/drawable/camera_tile_example.png differ