From 2649a85fd977fdff1e24137fc90558c5e9b0b562 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Thu, 21 Sep 2023 22:39:07 +0200 Subject: [PATCH 1/7] Add location history (location, trigger, result) - Initial setup saving data, no UI, cleanup or preferences yet --- .../android/sensors/LocationSensorManager.kt | 92 +- .../45.json | 1114 +++++++++++++++++ .../companion/android/database/AppDatabase.kt | 9 +- .../database/location/LocationHistoryDao.kt | 28 + .../database/location/LocationHistoryItem.kt | 49 + 5 files changed, 1279 insertions(+), 13 deletions(-) create mode 100644 common/schemas/io.homeassistant.companion.android.database.AppDatabase/45.json create mode 100644 common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt diff --git a/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt b/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt index 78c8a585a50..0e398733655 100644 --- a/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt +++ b/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt @@ -33,6 +33,9 @@ import io.homeassistant.companion.android.common.sensors.SensorManager import io.homeassistant.companion.android.common.sensors.SensorReceiverBase import io.homeassistant.companion.android.common.util.DisabledLocationHandler import io.homeassistant.companion.android.database.AppDatabase +import io.homeassistant.companion.android.database.location.LocationHistoryItem +import io.homeassistant.companion.android.database.location.LocationHistoryItemResult +import io.homeassistant.companion.android.database.location.LocationHistoryItemTrigger import io.homeassistant.companion.android.database.sensor.Attribute import io.homeassistant.companion.android.database.sensor.SensorSetting import io.homeassistant.companion.android.database.sensor.SensorSettingType @@ -162,6 +165,15 @@ class LocationSensorManager : LocationSensorManagerBase() { private var lastHighAccuracyTriggerRange: Int = 0 private var lastHighAccuracyZones: List = ArrayList() + enum class LocationUpdateTrigger(val isGeofence: Boolean = false) { + HIGH_ACCURACY_LOCATION, + BACKGROUND_LOCATION, + GEOFENCE_ENTER(isGeofence = true), + GEOFENCE_EXIT(isGeofence = true), + GEOFENCE_DWELL(isGeofence = true), + SINGLE_ACCURATE_LOCATION + } + fun setHighAccuracyModeSetting(context: Context, enabled: Boolean) { val sensorDao = AppDatabase.getInstance(context).sensorDao() sensorDao.add(SensorSetting(backgroundLocation.id, SETTING_HIGH_ACCURACY_MODE, enabled.toString(), SensorSettingType.TOGGLE)) @@ -676,13 +688,20 @@ class LocationSensorManager : LocationSensorManagerBase() { .firstOrNull { it.name == SETTING_ACCURACY }?.value?.toIntOrNull() ?: DEFAULT_MINIMUM_ACCURACY sensorDao.add(SensorSetting(backgroundLocation.id, SETTING_ACCURACY, minAccuracy.toString(), SensorSettingType.NUMBER)) + val trigger = + if (intent.action == ACTION_PROCESS_HIGH_ACCURACY_LOCATION) { + LocationUpdateTrigger.HIGH_ACCURACY_LOCATION + } else { + LocationUpdateTrigger.BACKGROUND_LOCATION + } if (location.accuracy > minAccuracy) { Log.w(TAG, "Location accuracy didn't meet requirements, disregarding: $location") + logLocationUpdate(location, null, null, trigger, LocationHistoryItemResult.SKIPPED_ACCURACY) } else { HighAccuracyLocationService.updateNotificationAddress(latestContext, location) // Send new location to Home Assistant serverIds.forEach { - ioScope.launch { sendLocationUpdate(location, it) } + ioScope.launch { sendLocationUpdate(location, it, trigger) } } } } @@ -770,16 +789,20 @@ class LocationSensorManager : LocationSensorManagerBase() { ?: DEFAULT_MINIMUM_ACCURACY sensorDao.add(SensorSetting(zoneLocation.id, SETTING_ACCURACY, minAccuracy.toString(), SensorSettingType.NUMBER)) + val trigger = when (geofencingEvent.geofenceTransition) { + Geofence.GEOFENCE_TRANSITION_ENTER -> LocationUpdateTrigger.GEOFENCE_ENTER + Geofence.GEOFENCE_TRANSITION_EXIT -> LocationUpdateTrigger.GEOFENCE_EXIT + Geofence.GEOFENCE_TRANSITION_DWELL -> LocationUpdateTrigger.GEOFENCE_DWELL + else -> null + } if (geofencingEvent.triggeringLocation!!.accuracy > minAccuracy) { - Log.w( - TAG, - "Geofence location accuracy didn't meet requirements, requesting new location." - ) + Log.w(TAG, "Geofence location accuracy didn't meet requirements, requesting new location.") + logLocationUpdate(geofencingEvent.triggeringLocation, null, null, trigger, LocationHistoryItemResult.SKIPPED_ACCURACY) requestSingleAccurateLocation() } else { getEnabledServers(latestContext, zoneLocation).forEach { ioScope.launch { - sendLocationUpdate(geofencingEvent.triggeringLocation!!, it, true) + sendLocationUpdate(geofencingEvent.triggeringLocation!!, it, trigger) } } } @@ -789,13 +812,14 @@ class LocationSensorManager : LocationSensorManagerBase() { } } - private fun sendLocationUpdate(location: Location, serverId: Int, geofenceUpdate: Boolean = false) { + private fun sendLocationUpdate(location: Location, serverId: Int, trigger: LocationUpdateTrigger?) { Log.d( TAG, "Last Location: " + "\nCoords:(${location.latitude}, ${location.longitude})" + "\nAccuracy: ${location.accuracy}" + - "\nBearing: ${location.bearing}" + "\nBearing: ${location.bearing}" + + "\nProvider: ${location.provider}" ) var accuracy = 0 if (location.accuracy.toInt() >= 0) { @@ -840,6 +864,7 @@ class LocationSensorManager : LocationSensorManagerBase() { Log.d(TAG, "Begin evaluating if location update should be skipped") if (now + 5000 < location.time && !highAccuracyModeEnabled) { Log.d(TAG, "Skipping location update that came from the future. ${now + 5000} should always be greater than ${location.time}") + logLocationUpdate(location, updateLocation, serverId, trigger, LocationHistoryItemResult.SKIPPED_FUTURE) return } @@ -848,6 +873,7 @@ class LocationSensorManager : LocationSensorManagerBase() { TAG, "Skipping old location update since time is before the last one we sent, received: ${location.time} last sent: $lastLocationSend" ) + logLocationUpdate(location, updateLocation, serverId, trigger, LocationHistoryItemResult.SKIPPED_NOT_LATEST) return } @@ -859,23 +885,27 @@ class LocationSensorManager : LocationSensorManagerBase() { if (lastUpdateLocation[serverId] == updateLocationString) { if (now < (lastLocationSend[serverId] ?: 0) + 900000) { Log.d(TAG, "Duplicate location received, not sending to HA") + logLocationUpdate(location, updateLocation, serverId, trigger, LocationHistoryItemResult.SKIPPED_DUPLICATE) return } } else { - if (now < (lastLocationSend[serverId] ?: 0) + 5000 && !geofenceUpdate && !highAccuracyModeEnabled) { + if (now < (lastLocationSend[serverId] ?: 0) + 5000 && trigger?.isGeofence != true && !highAccuracyModeEnabled) { Log.d( TAG, "New location update not possible within 5 seconds, not sending to HA" ) + logLocationUpdate(location, updateLocation, serverId, trigger, LocationHistoryItemResult.SKIPPED_DEBOUNCE) return } } } else { Log.d(TAG, "Skipping location update due to old timestamp ${location.time} compared to $now") + logLocationUpdate(location, updateLocation, serverId, trigger, LocationHistoryItemResult.SKIPPED_OLD) return } lastLocationSend[serverId] = now lastUpdateLocation[serverId] = updateLocationString + logLocationUpdate(location, updateLocation, serverId, trigger, LocationHistoryItemResult.SENT) val geocodeIncludeLocation = getSetting( latestContext, @@ -1096,7 +1126,7 @@ class LocationSensorManager : LocationSensorManagerBase() { runBlocking { locationResult.lastLocation?.let { getEnabledServers(latestContext, singleAccurateLocation).forEach { serverId -> - sendLocationUpdate(it, serverId) + sendLocationUpdate(it, serverId, LocationUpdateTrigger.SINGLE_ACCURATE_LOCATION) } } } @@ -1110,7 +1140,7 @@ class LocationSensorManager : LocationSensorManagerBase() { if (locationResult.lastLocation!!.accuracy <= minAccuracy * 2) { runBlocking { getEnabledServers(latestContext, singleAccurateLocation).forEach { serverId -> - sendLocationUpdate(locationResult.lastLocation!!, serverId) + sendLocationUpdate(locationResult.lastLocation!!, serverId, LocationUpdateTrigger.SINGLE_ACCURATE_LOCATION) } } } @@ -1186,4 +1216,44 @@ class LocationSensorManager : LocationSensorManagerBase() { sensorDao.add(SensorSetting(singleAccurateLocation.id, SETTING_INCLUDE_SENSOR_UPDATE, "false", SensorSettingType.TOGGLE)) } } + + private fun logLocationUpdate( + location: Location?, + updateLocation: UpdateLocation?, + serverId: Int?, + trigger: LocationUpdateTrigger?, + result: LocationHistoryItemResult + ) = ioScope.launch { + if (location == null) return@launch + + // TODO check if history is enabled + + val historyTrigger = when (trigger) { + LocationUpdateTrigger.HIGH_ACCURACY_LOCATION -> LocationHistoryItemTrigger.FLP_FOREGROUND + LocationUpdateTrigger.BACKGROUND_LOCATION -> LocationHistoryItemTrigger.FLP_BACKGROUND + LocationUpdateTrigger.GEOFENCE_ENTER -> LocationHistoryItemTrigger.GEOFENCE_ENTER + LocationUpdateTrigger.GEOFENCE_EXIT -> LocationHistoryItemTrigger.GEOFENCE_EXIT + LocationUpdateTrigger.GEOFENCE_DWELL -> LocationHistoryItemTrigger.GEOFENCE_DWELL + LocationUpdateTrigger.SINGLE_ACCURATE_LOCATION -> LocationHistoryItemTrigger.SINGLE_ACCURATE_LOCATION + else -> LocationHistoryItemTrigger.UNKNOWN + } + + try { + // Use updateLocation to preserve the 'send location as' setting + AppDatabase.getInstance(latestContext).locationHistoryDao().add( + LocationHistoryItem( + trigger = historyTrigger, + result = result, + latitude = if (updateLocation != null) updateLocation.gps?.getOrNull(0) else location.latitude, + longitude = if (updateLocation != null) updateLocation.gps?.getOrNull(1) else location.longitude, + locationName = updateLocation?.locationName, + accuracy = updateLocation?.gpsAccuracy ?: location.accuracy.toInt(), + data = updateLocation?.toString(), + serverId = serverId + ) + ) + } catch (e: Exception) { + // Context is null? Shouldn't happen but don't let the app crash. + } + } } diff --git a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/45.json b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/45.json new file mode 100644 index 00000000000..d8ec2e8814c --- /dev/null +++ b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/45.json @@ -0,0 +1,1114 @@ +{ + "formatVersion": 1, + "database": { + "version": 45, + "identityHash": "bff175408e95456b4d04b195bfe8ca1d", + "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": "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, `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, 'bff175408e95456b4d04b195bfe8ca1d')" + ] + } +} \ 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 cf87bcddb86..f4089a77486 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 @@ -34,6 +34,8 @@ import io.homeassistant.companion.android.common.data.integration.IntegrationRep import io.homeassistant.companion.android.common.util.databaseChannel import io.homeassistant.companion.android.database.authentication.Authentication import io.homeassistant.companion.android.database.authentication.AuthenticationDao +import io.homeassistant.companion.android.database.location.LocationHistoryDao +import io.homeassistant.companion.android.database.location.LocationHistoryItem import io.homeassistant.companion.android.database.notification.NotificationDao import io.homeassistant.companion.android.database.notification.NotificationItem import io.homeassistant.companion.android.database.qs.TileDao @@ -86,6 +88,7 @@ import io.homeassistant.companion.android.common.R as commonR StaticWidgetEntity::class, TemplateWidgetEntity::class, NotificationItem::class, + LocationHistoryItem::class, TileEntity::class, Favorites::class, FavoriteCaches::class, @@ -94,7 +97,7 @@ import io.homeassistant.companion.android.common.R as commonR Server::class, Setting::class ], - version = 44, + version = 45, autoMigrations = [ AutoMigration(from = 24, to = 25), AutoMigration(from = 25, to = 26), @@ -114,7 +117,8 @@ import io.homeassistant.companion.android.common.R as commonR AutoMigration(from = 39, to = 40), AutoMigration(from = 41, to = 42), AutoMigration(from = 42, to = 43), - AutoMigration(from = 43, to = 44) + AutoMigration(from = 43, to = 44), + AutoMigration(from = 44, to = 45) ] ) @TypeConverters( @@ -134,6 +138,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun staticWidgetDao(): StaticWidgetDao abstract fun templateWidgetDao(): TemplateWidgetDao abstract fun notificationDao(): NotificationDao + abstract fun locationHistoryDao(): LocationHistoryDao abstract fun tileDao(): TileDao abstract fun favoritesDao(): FavoritesDao abstract fun favoriteCachesDao(): FavoriteCachesDao diff --git a/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt new file mode 100644 index 00000000000..3eebbce0471 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt @@ -0,0 +1,28 @@ +package io.homeassistant.companion.android.database.location + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface LocationHistoryDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun add(item: LocationHistoryItem): Long + + @Query("SELECT * FROM location_history WHERE id = :id") + fun get(id: Int): LocationHistoryItem? + + @Query("SELECT * FROM location_history ORDER BY created DESC") + fun getAll(): List + + @Query("DELETE FROM location_history WHERE created < :created") + suspend fun deleteBefore(created: Long) + + @Query("DELETE FROM location_history WHERE server_id = :serverId") + suspend fun deleteForServer(serverId: Int) + + @Query("DELETE FROM location_history") + suspend fun deleteAll() +} diff --git a/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt new file mode 100644 index 00000000000..5f3d54837a0 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt @@ -0,0 +1,49 @@ +package io.homeassistant.companion.android.database.location + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "location_history") +data class LocationHistoryItem( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + @ColumnInfo(name = "created") + val created: Long = System.currentTimeMillis(), + @ColumnInfo(name = "trigger") + val trigger: LocationHistoryItemTrigger, + @ColumnInfo(name = "result") + val result: LocationHistoryItemResult, + @ColumnInfo(name = "latitude") + val latitude: Double?, + @ColumnInfo(name = "longitude") + val longitude: Double?, + @ColumnInfo(name = "location_name") + val locationName: String?, + @ColumnInfo(name = "accuracy") + val accuracy: Int?, + @ColumnInfo(name = "data") + val data: String?, + @ColumnInfo(name = "server_id") + val serverId: Int? +) + +enum class LocationHistoryItemTrigger { + FLP_BACKGROUND, + FLP_FOREGROUND, + GEOFENCE_ENTER, + GEOFENCE_EXIT, + GEOFENCE_DWELL, + SINGLE_ACCURATE_LOCATION, + UNKNOWN +} + +enum class LocationHistoryItemResult { + SKIPPED_ACCURACY, + SKIPPED_FUTURE, + SKIPPED_NOT_LATEST, + SKIPPED_DUPLICATE, + SKIPPED_DEBOUNCE, + SKIPPED_OLD, + SENT +} From c2aaf9b2da1f1bf77fbfc62dec6e654c85eb7b11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Fri, 22 Sep 2023 19:20:20 +0200 Subject: [PATCH 2/7] On/off setting, limit history size and basic UI - Add a on/off setting for location history (default on) - Limit the history size to 48 hours, any older entries will be deleted during regular sensor updates - Add a basic UI for location history based on paging considering the possible data size with multiserver/high accuracy. The list looks good and shows status at a glance, detail view still needs work. --- app/build.gradle.kts | 2 + .../android/sensors/LocationSensorManager.kt | 62 ++++-- .../developer/DeveloperSettingsFragment.kt | 13 ++ .../location/LocationTrackingFragment.kt | 47 +++++ .../location/LocationTrackingViewModel.kt | 53 +++++ .../location/views/LocationTrackingView.kt | 191 ++++++++++++++++++ .../main/res/xml/preferences_developer.xml | 5 + automotive/build.gradle.kts | 2 + common/build.gradle.kts | 1 + .../common/data/prefs/PrefsRepository.kt | 4 + .../common/data/prefs/PrefsRepositoryImpl.kt | 8 + .../sensors/LocationSensorManagerBase.kt | 10 - .../common/sensors/SensorReceiverBase.kt | 6 - .../android/database/DatabaseModule.kt | 4 + .../database/location/LocationHistoryDao.kt | 3 +- common/src/main/res/values/colors.xml | 1 + common/src/main/res/values/strings.xml | 13 ++ gradle/libs.versions.toml | 5 + 18 files changed, 400 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt create mode 100644 app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt create mode 100644 app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt delete mode 100644 common/src/main/java/io/homeassistant/companion/android/common/sensors/LocationSensorManagerBase.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bbf932fce7b..e3f0f7328a3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -185,6 +185,8 @@ dependencies { implementation(libs.iconics.compose) implementation(libs.community.material.typeface) + implementation(libs.bundles.paging) + implementation(libs.reorderable) implementation(libs.changeLog) diff --git a/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt b/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt index 0e398733655..ccd6cc38089 100644 --- a/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt +++ b/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt @@ -3,6 +3,7 @@ package io.homeassistant.companion.android.sensors import android.Manifest import android.app.PendingIntent import android.bluetooth.BluetoothAdapter +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.location.Location @@ -21,14 +22,18 @@ import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import io.homeassistant.companion.android.common.bluetooth.BluetoothUtils import io.homeassistant.companion.android.common.data.integration.Entity 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.containsWithAccuracy +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository import io.homeassistant.companion.android.common.notifications.DeviceCommandData -import io.homeassistant.companion.android.common.sensors.LocationSensorManagerBase import io.homeassistant.companion.android.common.sensors.SensorManager import io.homeassistant.companion.android.common.sensors.SensorReceiverBase import io.homeassistant.companion.android.common.util.DisabledLocationHandler @@ -49,10 +54,11 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.concurrent.TimeUnit +import javax.inject.Inject import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint -class LocationSensorManager : LocationSensorManagerBase() { +class LocationSensorManager : BroadcastReceiver(), SensorManager { companion object { private const val SETTING_SEND_LOCATION_AS = "location_send_as" @@ -78,6 +84,8 @@ class LocationSensorManager : LocationSensorManagerBase() { private const val ZONE_NAME_NOT_HOME = "not_home" + private const val HISTORY_DURATION = 60 * 60 * 48 * 1000L // 60(s) * 60(m) * 48(h) to millis + const val ACTION_REQUEST_LOCATION_UPDATES = "io.homeassistant.companion.android.background.REQUEST_UPDATES" const val ACTION_REQUEST_ACCURATE_LOCATION_UPDATE = @@ -191,6 +199,9 @@ class LocationSensorManager : LocationSensorManagerBase() { } } + @Inject + lateinit var prefsRepository: PrefsRepository + private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO) lateinit var latestContext: Context @@ -361,7 +372,7 @@ class LocationSensorManager : LocationSensorManagerBase() { lastHighAccuracyMode = highAccuracyModeEnabled lastHighAccuracyUpdateInterval = updateIntervalHighAccuracySeconds - serverManager.defaultServers.forEach { + serverManager(latestContext).defaultServers.forEach { getSendLocationAsSetting(it.id) // Sets up the setting, value isn't used right now } } @@ -583,7 +594,7 @@ class LocationSensorManager : LocationSensorManagerBase() { } private fun getSendLocationAsSetting(serverId: Int): String { - return if (serverManager.getServer(serverId)?.version?.isAtLeast(2022, 2, 0) == true) { + return if (serverManager(latestContext).getServer(serverId)?.version?.isAtLeast(2022, 2, 0) == true) { getSetting( context = latestContext, sensor = backgroundLocation, @@ -773,7 +784,7 @@ class LocationSensorManager : LocationSensorManagerBase() { val serverId = zone.split("_")[0].toIntOrNull() ?: return@launch val enabled = isEnabled(latestContext, zoneLocation, serverId) if (!enabled) return@launch - serverManager.integrationRepository(serverId).fireEvent(zoneStatusEvent, zoneAttr as Map) + serverManager(latestContext).integrationRepository(serverId).fireEvent(zoneStatusEvent, zoneAttr as Map) Log.d(TAG, "Event sent to Home Assistant") } catch (e: Exception) { Log.e(TAG, "Unable to send event to Home Assistant", e) @@ -917,7 +928,7 @@ class LocationSensorManager : LocationSensorManagerBase() { ioScope.launch { try { - serverManager.integrationRepository(serverId).updateLocation(updateLocation) + serverManager(latestContext).integrationRepository(serverId).updateLocation(updateLocation) Log.d(TAG, "Location update sent successfully for $serverId as $updateLocationAs") // Update Geocoded Location Sensor @@ -958,7 +969,7 @@ class LocationSensorManager : LocationSensorManagerBase() { (zonesLastReceived[serverId] ?: 0) < (System.currentTimeMillis() - TimeUnit.HOURS.toMillis(4)) ) { try { - zones[serverId] = serverManager.integrationRepository(serverId).getZones() + zones[serverId] = serverManager(latestContext).integrationRepository(serverId).getZones() zonesLastReceived[serverId] = System.currentTimeMillis() } catch (e: Exception) { Log.e(TAG, "Error receiving zones from Home Assistant", e) @@ -1194,13 +1205,12 @@ class LocationSensorManager : LocationSensorManagerBase() { } } - override fun requestSensorUpdate( - context: Context - ) { + override fun requestSensorUpdate(context: Context) { latestContext = context if (isEnabled(context, zoneLocation) || isEnabled(context, backgroundLocation)) { setupLocationTracking() } + cleanupLocationHistory(context) val sensorDao = AppDatabase.getInstance(latestContext).sensorDao() val sensorSetting = sensorDao.getSettings(singleAccurateLocation.id) val includeSensorUpdate = sensorSetting.firstOrNull { it.name == SETTING_INCLUDE_SENSOR_UPDATE }?.value ?: "false" @@ -1217,6 +1227,17 @@ class LocationSensorManager : LocationSensorManagerBase() { } } + private fun cleanupLocationHistory(context: Context) = ioScope.launch { + handleInject(context) + val historyDao = AppDatabase.getInstance(context).locationHistoryDao() + val historyEnabled = prefsRepository.isLocationHistoryEnabled() + if (historyEnabled) { + historyDao.deleteBefore(System.currentTimeMillis() - HISTORY_DURATION) + } else { + historyDao.deleteAll() + } + } + private fun logLocationUpdate( location: Location?, updateLocation: UpdateLocation?, @@ -1224,9 +1245,7 @@ class LocationSensorManager : LocationSensorManagerBase() { trigger: LocationUpdateTrigger?, result: LocationHistoryItemResult ) = ioScope.launch { - if (location == null) return@launch - - // TODO check if history is enabled + if (location == null || !prefsRepository.isLocationHistoryEnabled()) return@launch val historyTrigger = when (trigger) { LocationUpdateTrigger.HIGH_ACCURACY_LOCATION -> LocationHistoryItemTrigger.FLP_FOREGROUND @@ -1256,4 +1275,21 @@ class LocationSensorManager : LocationSensorManagerBase() { // Context is null? Shouldn't happen but don't let the app crash. } } + + private fun handleInject(context: Context) { + // requestSensorUpdate is called outside onReceive, which usually handles injection. + // Because we need the preferences for location history settings, inject it if required. + if (!this::prefsRepository.isInitialized) { + prefsRepository = EntryPointAccessors.fromApplication( + context, + LocationSensorManagerEntryPoint::class.java + ).prefsRepository() + } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface LocationSensorManagerEntryPoint { + fun prefsRepository(): PrefsRepository + } } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/DeveloperSettingsFragment.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/DeveloperSettingsFragment.kt index 8df92f1f0ff..35d279b9616 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/developer/DeveloperSettingsFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/DeveloperSettingsFragment.kt @@ -9,8 +9,10 @@ import androidx.fragment.app.commit import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.BuildConfig import io.homeassistant.companion.android.R import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.settings.developer.location.LocationTrackingFragment import io.homeassistant.companion.android.settings.log.LogFragment import io.homeassistant.companion.android.settings.server.ServerChooserFragment import javax.inject.Inject @@ -45,6 +47,17 @@ class DeveloperSettingsFragment : DeveloperSettingsView, PreferenceFragmentCompa return@setOnPreferenceClickListener true } + findPreference("location_tracking")?.let { + it.isVisible = BuildConfig.FLAVOR == "full" + it.setOnPreferenceClickListener { + parentFragmentManager.commit { + replace(R.id.content, LocationTrackingFragment::class.java, null) + addToBackStack(getString(io.homeassistant.companion.android.common.R.string.location_tracking)) + } + return@setOnPreferenceClickListener true + } + } + findPreference("thread_debug")?.let { it.isVisible = presenter.appSupportsThread() it.setOnPreferenceClickListener { diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt new file mode 100644 index 00000000000..900633dd0be --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt @@ -0,0 +1,47 @@ +package io.homeassistant.companion.android.settings.developer.location + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.google.accompanist.themeadapter.material.MdcTheme +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.common.R +import io.homeassistant.companion.android.settings.addHelpMenuProvider +import io.homeassistant.companion.android.settings.developer.location.views.LocationTrackingView + +@AndroidEntryPoint +class LocationTrackingFragment : Fragment() { + + val viewModel: LocationTrackingViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + return ComposeView(requireContext()).apply { + setContent { + MdcTheme { + LocationTrackingView( + useHistory = viewModel.historyEnabled, + onSetHistory = viewModel::enableHistory, + history = viewModel.historyPagerFlow + ) + } + } + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + addHelpMenuProvider("https://companion.home-assistant.io/docs/troubleshooting/faqs#device-tracker-is-not-updating-in-android-app") + } + + override fun onResume() { + super.onResume() + activity?.title = getString(R.string.location_tracking) + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt new file mode 100644 index 00000000000..850b29dabd2 --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt @@ -0,0 +1,53 @@ +package io.homeassistant.companion.android.settings.developer.location + +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository +import io.homeassistant.companion.android.database.location.LocationHistoryDao +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class LocationTrackingViewModel @Inject constructor( + private val locationHistoryDao: LocationHistoryDao, + private val prefsRepository: PrefsRepository, + application: Application +) : AndroidViewModel(application) { + + companion object { + private const val TAG = "LocationTrackingViewModel" + + private const val PAGE_SIZE = 25 + } + + val historyPagerFlow = Pager( + PagingConfig(pageSize = PAGE_SIZE, maxSize = PAGE_SIZE * 10) + ) { + locationHistoryDao.getAll() + }.flow + + var historyEnabled by mutableStateOf(false) + private set + + init { + viewModelScope.launch { + historyEnabled = prefsRepository.isLocationHistoryEnabled() + } + } + + fun enableHistory(enabled: Boolean) { + if (enabled == historyEnabled) return + historyEnabled = enabled + viewModelScope.launch { + prefsRepository.setLocationHistoryEnabled(enabled) + if (!enabled) locationHistoryDao.deleteAll() + } + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt new file mode 100644 index 00000000000..c6e0bd39e13 --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt @@ -0,0 +1,191 @@ +package io.homeassistant.companion.android.settings.developer.location.views + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ContentAlpha +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Surface +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.mikepenz.iconics.compose.Image +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import io.homeassistant.companion.android.database.location.LocationHistoryItem +import io.homeassistant.companion.android.database.location.LocationHistoryItemResult +import io.homeassistant.companion.android.database.location.LocationHistoryItemTrigger +import kotlinx.coroutines.flow.Flow +import java.text.DateFormat +import java.util.TimeZone +import io.homeassistant.companion.android.common.R as commonR + +@Composable +fun LocationTrackingView( + useHistory: Boolean, + onSetHistory: (Boolean) -> Unit, + history: Flow> +) { + val historyState = history.collectAsLazyPagingItems() + + LazyColumn( + contentPadding = PaddingValues(vertical = 16.dp) + ) { + item("history.use") { + Row( + modifier = Modifier + .padding(all = 16.dp) + .clickable { onSetHistory(!useHistory) } + ) { + Text( + text = stringResource(commonR.string.location_history_use), + modifier = Modifier.weight(1f) + ) + Switch( + checked = useHistory, + onCheckedChange = null // Handled by row + ) + } + } + // TODO emptystate + items( + count = historyState.itemCount, + key = historyState.itemKey { "history.${it.id}" } + ) { index -> + LocationTrackingHistoryRow(item = historyState[index]) + } + if (historyState.loadState.append == LoadState.Loading) { + // TODO + } + } +} + +@Composable +fun LocationTrackingHistoryRow(item: LocationHistoryItem?) { + var opened by remember { mutableStateOf(false) } + val elevation by animateDpAsState(if (opened) 8.dp else 0.dp, label = "historyRowElevation") + val date by remember(item?.id) { + mutableStateOf( + item?.created?.let { + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.DEFAULT).apply { + timeZone = TimeZone.getDefault() + }.format(it) + } + ) + } + + Box(Modifier.zIndex(if (opened) 1f else 0f)) { + Surface( + shape = RoundedCornerShape(elevation), + elevation = elevation + ) { + Column( + Modifier + .fillMaxWidth() + .clickable { opened = !opened } + .animateContentSize() + ) { + Column( + modifier = Modifier + .heightIn(min = 56.dp) + .padding(all = 16.dp), + verticalArrangement = Arrangement.Center + ) { + Text( + text = date ?: "", + style = MaterialTheme.typography.body1 + ) + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + Row(verticalAlignment = Alignment.CenterVertically) { + item?.let { + val sent = it.result == LocationHistoryItemResult.SENT + Text( + text = "${stringResource(item.trigger.toStringResource())} • ${stringResource(it.result.toStringResource())}", + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(end = 4.dp) + ) + Image( + asset = if (sent) CommunityMaterial.Icon.cmd_check else CommunityMaterial.Icon.cmd_debug_step_over, + contentDescription = stringResource( + if (sent) commonR.string.location_history_sent else commonR.string.location_history_skipped + ), + colorFilter = ColorFilter.tint( + if (sent) { + colorResource(commonR.color.colorOnAlertSuccess) + } else { + LocalContentColor.current + } + ), + alpha = if (sent) 1.0f else LocalContentAlpha.current, + modifier = Modifier.size(with(LocalDensity.current) { 16.sp.toDp() }) + ) + } + } + } + } + if (opened && item != null) { + Text("Location ${item.locationName ?: "${item.latitude},${item.longitude}"}") + Text("Accuracy ${item.accuracy}") + Text("Data ${item.data}") + TextButton(onClick = { /*TODO*/ }) { + Text("Show on map") + } + TextButton(onClick = { /*TODO*/ }) { + Text("Share") + } + } + } + } + } +} + +private fun LocationHistoryItemResult.toStringResource() = when (this) { + LocationHistoryItemResult.SKIPPED_ACCURACY -> commonR.string.location_history_skipped_accuracy + LocationHistoryItemResult.SKIPPED_FUTURE -> commonR.string.location_history_skipped_future + LocationHistoryItemResult.SKIPPED_NOT_LATEST -> commonR.string.location_history_skipped_not_latest + LocationHistoryItemResult.SKIPPED_DUPLICATE -> commonR.string.location_history_skipped_duplicate + LocationHistoryItemResult.SKIPPED_DEBOUNCE -> commonR.string.location_history_skipped_debounce + LocationHistoryItemResult.SKIPPED_OLD -> commonR.string.location_history_skipped_old + LocationHistoryItemResult.SENT -> commonR.string.location_history_sent +} + +private fun LocationHistoryItemTrigger.toStringResource() = when (this) { + LocationHistoryItemTrigger.FLP_BACKGROUND -> commonR.string.basic_sensor_name_location_background + LocationHistoryItemTrigger.FLP_FOREGROUND -> commonR.string.basic_sensor_name_high_accuracy_mode + LocationHistoryItemTrigger.GEOFENCE_ENTER -> commonR.string.location_history_geofence_enter + LocationHistoryItemTrigger.GEOFENCE_EXIT -> commonR.string.location_history_geofence_exit + LocationHistoryItemTrigger.GEOFENCE_DWELL -> commonR.string.location_history_geofence_dwell + LocationHistoryItemTrigger.SINGLE_ACCURATE_LOCATION -> commonR.string.basic_sensor_name_location_accurate + LocationHistoryItemTrigger.UNKNOWN -> commonR.string.state_unknown +} diff --git a/app/src/main/res/xml/preferences_developer.xml b/app/src/main/res/xml/preferences_developer.xml index 6c728c50648..0b5a9054f23 100644 --- a/app/src/main/res/xml/preferences_developer.xml +++ b/app/src/main/res/xml/preferences_developer.xml @@ -5,6 +5,11 @@ android:icon="@drawable/ic_notes" android:title="@string/show_share_logs" android:summary="@string/show_share_logs_summary" /> + ) + suspend fun isLocationHistoryEnabled(): Boolean + + suspend fun setLocationHistoryEnabled(enabled: Boolean) + /** Clean up any app-level preferences that might reference servers */ suspend fun removeServer(serverId: Int) } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt index 51c25e5fec3..e92e3a70be6 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/prefs/PrefsRepositoryImpl.kt @@ -34,6 +34,7 @@ class PrefsRepositoryImpl @Inject constructor( private const val PREF_CRASH_REPORTING_DISABLED = "crash_reporting" private const val PREF_IGNORED_SUGGESTIONS = "ignored_suggestions" private const val PREF_AUTO_FAVORITES = "auto_favorites" + private const val PREF_LOCATION_HISTORY_DISABLED = "location_history" } init { @@ -227,6 +228,13 @@ class PrefsRepositoryImpl @Inject constructor( localStorage.putString(PREF_AUTO_FAVORITES, favorites.toString()) } + override suspend fun isLocationHistoryEnabled(): Boolean = + !localStorage.getBoolean(PREF_LOCATION_HISTORY_DISABLED) + + override suspend fun setLocationHistoryEnabled(enabled: Boolean) { + localStorage.putBoolean(PREF_LOCATION_HISTORY_DISABLED, !enabled) + } + override suspend fun removeServer(serverId: Int) { val controlsAuthEntities = getControlsAuthEntities().filter { it.split(".")[0].toIntOrNull() != serverId } setControlsAuthEntities(controlsAuthEntities) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/LocationSensorManagerBase.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/LocationSensorManagerBase.kt deleted file mode 100644 index 7c45d45fde4..00000000000 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/LocationSensorManagerBase.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.homeassistant.companion.android.common.sensors - -import android.content.BroadcastReceiver -import io.homeassistant.companion.android.common.data.servers.ServerManager -import javax.inject.Inject - -abstract class LocationSensorManagerBase : BroadcastReceiver(), SensorManager { - @Inject - lateinit var serverManager: ServerManager -} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/SensorReceiverBase.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/SensorReceiverBase.kt index aa0a730bed0..9c7516a9ca7 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/SensorReceiverBase.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/sensors/SensorReceiverBase.kt @@ -176,12 +176,6 @@ abstract class SensorReceiverBase : BroadcastReceiver() { } managers.forEach { manager -> - // Since we don't have this manager injected it doesn't fulfil its injects, manually - // inject for now I guess? - if (manager is LocationSensorManagerBase) { - manager.serverManager = serverManager - } - val hasSensor = manager.hasSensor(context) if (hasSensor) { try { 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 99b57baf3cf..0f92c5473ca 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 @@ -7,6 +7,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import io.homeassistant.companion.android.database.authentication.AuthenticationDao +import io.homeassistant.companion.android.database.location.LocationHistoryDao import io.homeassistant.companion.android.database.notification.NotificationDao import io.homeassistant.companion.android.database.qs.TileDao import io.homeassistant.companion.android.database.sensor.SensorDao @@ -55,6 +56,9 @@ object DatabaseModule { fun provideTemplateWidgetDao(database: AppDatabase): TemplateWidgetDao = database.templateWidgetDao() + @Provides + fun provideLocationHistoryDao(database: AppDatabase): LocationHistoryDao = database.locationHistoryDao() + @Provides fun provideNotificationDao(database: AppDatabase): NotificationDao = database.notificationDao() diff --git a/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt index 3eebbce0471..af6694cb8b9 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt @@ -1,5 +1,6 @@ package io.homeassistant.companion.android.database.location +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -15,7 +16,7 @@ interface LocationHistoryDao { fun get(id: Int): LocationHistoryItem? @Query("SELECT * FROM location_history ORDER BY created DESC") - fun getAll(): List + fun getAll(): PagingSource @Query("DELETE FROM location_history WHERE created < :created") suspend fun deleteBefore(created: Long) diff --git a/common/src/main/res/values/colors.xml b/common/src/main/res/values/colors.xml index 69f8f10accf..cf9eb0734da 100644 --- a/common/src/main/res/values/colors.xml +++ b/common/src/main/res/values/colors.xml @@ -28,6 +28,7 @@ #f5f5f5 #1fffa600 #ffa600 + #43a047 #FDD663 #F6C344 #9AA0A6 diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index bfba4a55d89..199522070ed 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -348,8 +348,21 @@ Some setting(s) do not work. Click to enable location. Disable option Location is disabled + Zone entered + Zone exited + Zone dwell + Sent + Ignored + Inaccurate + Received too fast + Duplicate + Future location + Old location + Very old location + Use location history Android set up restrictions for apps which want to use your WiFi, because WiFi can be theoretically used to determine your location.\n\nAlso to ensure the app can access WiFi in background (URL decision making, sensors) you need to allow location access all the time.\n\nTherefore, to use this option location access all the time is needed.\n\nContinue granting location access? Location access + Location tracking Location disabled Location Use biometric or screen lock credential to unlock app diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index deb63dc81ae..a4869a57aac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,7 @@ material = "1.9.0" media3 = "1.1.1" navigation-compose = "2.7.2" okhttp = "4.11.0" +paging = "3.2.1" picasso = "2.8" play-services-threadnetwork = "16.0.0" play-services-home = "16.0.0" @@ -87,6 +88,7 @@ android-beacon-library = { module = "org.altbeacon:android-beacon-library", vers androidx-health-services-client = { module = "androidx.health:health-services-client", version.ref = "healthServicesClient" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } +androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } @@ -140,6 +142,8 @@ media3-datasource-cronet = { module = "androidx.media3:media3-datasource-cronet" media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" } media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" } +paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } +paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } play-services-threadnetwork = { module = "com.google.android.gms:play-services-threadnetwork", version.ref = "play-services-threadnetwork" } play-services-home = { module = "com.google.android.gms:play-services-home", version.ref = "play-services-home" } play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "play-services-location" } @@ -166,4 +170,5 @@ webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } [bundles] horologist = ["horologist-layout", "horologist-composables"] media3 = ["media3-exoplayer", "media3-exoplayer-hls", "media3-ui"] +paging = ["paging-runtime", "paging-compose"] wear-tiles = ["wear-tiles", "wear-protolayout-main", "wear-protolayout-expression", "wear-protolayout-material"] \ No newline at end of file From d7f2fa21e3bf305c96521c58f145ab1f12121556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sat, 23 Sep 2023 00:14:16 +0200 Subject: [PATCH 3/7] Finish expanded UI - Make the expanded UI nicer and show relevant data, buttons for opening the location in a maps app and sharing log data - Add empty states for location history --- .../location/LocationTrackingFragment.kt | 8 +- .../location/views/LocationTrackingView.kt | 232 ++++++++++++++---- .../android/settings/views/EmptyState.kt | 57 +++++ .../widgets/views/ManageWidgetsView.kt | 41 +--- .../main/res/drawable/ic_map_marker_path.xml | 9 + .../main/res/xml/preferences_developer.xml | 4 +- .../database/location/LocationHistoryItem.kt | 16 +- common/src/main/res/values/strings.xml | 12 +- 8 files changed, 287 insertions(+), 92 deletions(-) create mode 100644 app/src/main/java/io/homeassistant/companion/android/settings/views/EmptyState.kt create mode 100644 app/src/main/res/drawable/ic_map_marker_path.xml diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt index 900633dd0be..0ee21a615fb 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt @@ -10,12 +10,17 @@ import androidx.fragment.app.viewModels import com.google.accompanist.themeadapter.material.MdcTheme import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.common.R +import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.settings.addHelpMenuProvider import io.homeassistant.companion.android.settings.developer.location.views.LocationTrackingView +import javax.inject.Inject @AndroidEntryPoint class LocationTrackingFragment : Fragment() { + @Inject + lateinit var serverManager: ServerManager + val viewModel: LocationTrackingViewModel by viewModels() override fun onCreateView( @@ -29,7 +34,8 @@ class LocationTrackingFragment : Fragment() { LocationTrackingView( useHistory = viewModel.historyEnabled, onSetHistory = viewModel::enableHistory, - history = viewModel.historyPagerFlow + history = viewModel.historyPagerFlow, + serversList = serverManager.defaultServers ) } } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt index c6e0bd39e13..70dc753d0bb 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt @@ -1,42 +1,52 @@ package io.homeassistant.companion.android.settings.developer.location.views +import android.content.Intent +import android.net.Uri +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.ContentAlpha +import androidx.compose.material.IconButton import androidx.compose.material.LocalContentAlpha import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text -import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex +import androidx.core.app.ShareCompat import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems @@ -46,6 +56,8 @@ import com.mikepenz.iconics.typeface.library.community.material.CommunityMateria import io.homeassistant.companion.android.database.location.LocationHistoryItem import io.homeassistant.companion.android.database.location.LocationHistoryItemResult import io.homeassistant.companion.android.database.location.LocationHistoryItemTrigger +import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.settings.views.EmptyState import kotlinx.coroutines.flow.Flow import java.text.DateFormat import java.util.TimeZone @@ -55,46 +67,73 @@ import io.homeassistant.companion.android.common.R as commonR fun LocationTrackingView( useHistory: Boolean, onSetHistory: (Boolean) -> Unit, - history: Flow> + history: Flow>, + serversList: List ) { val historyState = history.collectAsLazyPagingItems() - LazyColumn( - contentPadding = PaddingValues(vertical = 16.dp) - ) { + LazyColumn { item("history.use") { - Row( - modifier = Modifier - .padding(all = 16.dp) - .clickable { onSetHistory(!useHistory) } - ) { - Text( - text = stringResource(commonR.string.location_history_use), - modifier = Modifier.weight(1f) - ) - Switch( - checked = useHistory, - onCheckedChange = null // Handled by row - ) + Box(Modifier.padding(all = 16.dp)) { + Surface( + shape = RoundedCornerShape(20.dp), + color = colorResource(commonR.color.colorSensorTopEnabled) + ) { + Row( + modifier = Modifier + .clickable { onSetHistory(!useHistory) } + .padding(horizontal = 16.dp, vertical = 20.dp) + ) { + Text( + text = stringResource(commonR.string.location_history_use), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f) + ) + Switch( + checked = useHistory, + onCheckedChange = null, // Handled by row + modifier = Modifier.padding(start = 16.dp), + colors = SwitchDefaults.colors(uncheckedThumbColor = colorResource(commonR.color.colorSwitchUncheckedThumb)) + ) + } + } } } - // TODO emptystate - items( - count = historyState.itemCount, - key = historyState.itemKey { "history.${it.id}" } - ) { index -> - LocationTrackingHistoryRow(item = historyState[index]) - } - if (historyState.loadState.append == LoadState.Loading) { - // TODO + if (!useHistory || (historyState.loadState.refresh !is LoadState.Loading && historyState.itemCount == 0)) { + item("history.empty") { + EmptyState( + icon = CommunityMaterial.Icon3.cmd_map_marker_path, + title = stringResource( + if (useHistory) { + commonR.string.location_history_empty_title + } else { + commonR.string.location_history_off_title + } + ), + subtitle = stringResource( + if (useHistory) { + commonR.string.location_history_empty_summary + } else { + commonR.string.location_history_off_summary + } + ) + ) + } + } else { + items( + count = historyState.itemCount, + key = historyState.itemKey { "history.${it.id}" } + ) { index -> + LocationTrackingHistoryRow(item = historyState[index], servers = serversList) + } } } } @Composable -fun LocationTrackingHistoryRow(item: LocationHistoryItem?) { - var opened by remember { mutableStateOf(false) } - val elevation by animateDpAsState(if (opened) 8.dp else 0.dp, label = "historyRowElevation") +fun LocationTrackingHistoryRow(item: LocationHistoryItem?, servers: List) { + var opened by rememberSaveable { mutableStateOf(false) } + val elevation by animateDpAsState(if (opened) 8.dp else 0.dp, label = "HistoryRow elevation") val date by remember(item?.id) { mutableStateOf( item?.created?.let { @@ -108,6 +147,7 @@ fun LocationTrackingHistoryRow(item: LocationHistoryItem?) { Box(Modifier.zIndex(if (opened) 1f else 0f)) { Surface( shape = RoundedCornerShape(elevation), + color = if (opened) MaterialTheme.colors.surface else MaterialTheme.colors.background, elevation = elevation ) { Column( @@ -116,17 +156,14 @@ fun LocationTrackingHistoryRow(item: LocationHistoryItem?) { .clickable { opened = !opened } .animateContentSize() ) { - Column( - modifier = Modifier - .heightIn(min = 56.dp) - .padding(all = 16.dp), - verticalArrangement = Arrangement.Center - ) { - Text( - text = date ?: "", - style = MaterialTheme.typography.body1 - ) - CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + ReadOnlyRow( + primarySlot = { + Text( + text = date ?: "", + style = MaterialTheme.typography.body1 + ) + }, + secondarySlot = { Row(verticalAlignment = Alignment.CenterVertically) { item?.let { val sent = it.result == LocationHistoryItemResult.SENT @@ -137,9 +174,7 @@ fun LocationTrackingHistoryRow(item: LocationHistoryItem?) { ) Image( asset = if (sent) CommunityMaterial.Icon.cmd_check else CommunityMaterial.Icon.cmd_debug_step_over, - contentDescription = stringResource( - if (sent) commonR.string.location_history_sent else commonR.string.location_history_skipped - ), + contentDescription = if (sent) null else stringResource(commonR.string.location_history_skipped), colorFilter = ColorFilter.tint( if (sent) { colorResource(commonR.color.colorOnAlertSuccess) @@ -153,16 +188,71 @@ fun LocationTrackingHistoryRow(item: LocationHistoryItem?) { } } } - } - if (opened && item != null) { - Text("Location ${item.locationName ?: "${item.latitude},${item.longitude}"}") - Text("Accuracy ${item.accuracy}") - Text("Data ${item.data}") - TextButton(onClick = { /*TODO*/ }) { - Text("Show on map") + ) + AnimatedVisibility(visible = opened) { + val context = LocalContext.current + val serverName by remember { + mutableStateOf( + if (item?.serverId != null) { + servers.firstOrNull { it.id == item.serverId }?.friendlyName + } else { + "-" + } + ) } - TextButton(onClick = { /*TODO*/ }) { - Text("Share") + Column { + ReadOnlyRow( + primaryText = stringResource(commonR.string.location), + secondaryText = (item?.locationName ?: "${item?.latitude}, ${item?.longitude}") + ) + ReadOnlyRow( + primaryText = stringResource(commonR.string.accuracy), + secondaryText = item?.accuracy.toString() + ) + if (servers.size > 1 || serverName == null) { // null serverName suggests deleted server + ReadOnlyRow( + primaryText = stringResource(commonR.string.server), + secondaryText = serverName ?: stringResource(commonR.string.state_unknown) + ) + } + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + ) { + if (item?.latitude != null && item.longitude != null) { + IconButton(onClick = { + val latlng = "${item.latitude},${item.longitude}" + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("geo:$latlng?q=$latlng(Home+Assistant)") + ) + ) + }) { + Image( + asset = CommunityMaterial.Icon3.cmd_map, + contentDescription = stringResource(commonR.string.show_on_map), + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colors.primary) + ) + } + Spacer(Modifier.width(16.dp)) + } + IconButton(onClick = { + ShareCompat.IntentBuilder(context) + .setText(item?.forSharing(serverName) ?: "") + .setType("text/plain") + .startChooser() + }) { + Image( + asset = CommunityMaterial.Icon3.cmd_share_variant, + contentDescription = stringResource(commonR.string.share_logs), + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(MaterialTheme.colors.primary) + ) + } + } } } } @@ -170,6 +260,40 @@ fun LocationTrackingHistoryRow(item: LocationHistoryItem?) { } } +@Composable +fun ReadOnlyRow( + primaryText: String, + secondaryText: String, + selectingEnabled: Boolean = true +) = ReadOnlyRow( + { Text(text = primaryText, style = MaterialTheme.typography.body1) }, + { + if (selectingEnabled) { + SelectionContainer { Text(text = secondaryText, style = MaterialTheme.typography.body2) } + } else { + Text(text = secondaryText, style = MaterialTheme.typography.body2) + } + } +) + +@Composable +fun ReadOnlyRow( + primarySlot: @Composable () -> Unit, + secondarySlot: @Composable () -> Unit +) { + Column( + modifier = Modifier + .heightIn(min = 56.dp) + .padding(all = 16.dp), + verticalArrangement = Arrangement.Center + ) { + primarySlot() + CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) { + secondarySlot() + } + } +} + private fun LocationHistoryItemResult.toStringResource() = when (this) { LocationHistoryItemResult.SKIPPED_ACCURACY -> commonR.string.location_history_skipped_accuracy LocationHistoryItemResult.SKIPPED_FUTURE -> commonR.string.location_history_skipped_future diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/views/EmptyState.kt b/app/src/main/java/io/homeassistant/companion/android/settings/views/EmptyState.kt new file mode 100644 index 00000000000..3f4ae482177 --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/settings/views/EmptyState.kt @@ -0,0 +1,57 @@ +package io.homeassistant.companion.android.settings.views + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.mikepenz.iconics.compose.Image +import com.mikepenz.iconics.typeface.IIcon + +@Composable +fun EmptyState( + icon: IIcon, + title: String?, + subtitle: String? +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(top = 64.dp) + ) { + Image( + asset = icon, + modifier = Modifier.size(48.dp), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ) + Spacer(Modifier.height(8.dp)) + if (!title.isNullOrBlank()) { + Text( + text = title, + style = MaterialTheme.typography.h6, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(0.7f) + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(0.7f) + ) + } + } +} 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 6dd24f7f6b5..e309d496b79 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 @@ -4,14 +4,11 @@ import android.appwidget.AppWidgetManager import android.content.Intent import androidx.annotation.StringRes import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items @@ -23,7 +20,6 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Widgets import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -34,13 +30,13 @@ 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.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.mikepenz.iconics.compose.Image import com.mikepenz.iconics.typeface.IIcon import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.database.widget.WidgetEntity +import io.homeassistant.companion.android.settings.views.EmptyState import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.util.compose.MdcAlertDialog import io.homeassistant.companion.android.widgets.button.ButtonWidgetConfigureActivity @@ -108,39 +104,20 @@ fun ManageWidgetsView( } LazyColumn( contentPadding = PaddingValues(all = 16.dp), - modifier = Modifier.padding(contentPadding).fillMaxWidth() + modifier = Modifier + .padding(contentPadding) + .fillMaxWidth() ) { if (viewModel.buttonWidgetList.value.isEmpty() && viewModel.staticWidgetList.value.isEmpty() && viewModel.mediaWidgetList.value.isEmpty() && viewModel.templateWidgetList.value.isEmpty() && viewModel.cameraWidgetList.value.isEmpty() ) { item { - Column( - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(top = 64.dp) - ) { - Icon( - imageVector = Icons.Default.Widgets, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Text( - text = stringResource(id = R.string.no_widgets), - style = MaterialTheme.typography.h6, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth(0.7f) - .padding(top = 8.dp) - ) - Text( - text = stringResource(id = R.string.no_widgets_summary), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(0.7f) - ) - } + EmptyState( + icon = CommunityMaterial.Icon3.cmd_widgets, + title = stringResource(R.string.no_widgets), + subtitle = stringResource(R.string.no_widgets_summary) + ) } } widgetItems( diff --git a/app/src/main/res/drawable/ic_map_marker_path.xml b/app/src/main/res/drawable/ic_map_marker_path.xml new file mode 100644 index 00000000000..4cea920812d --- /dev/null +++ b/app/src/main/res/drawable/ic_map_marker_path.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/xml/preferences_developer.xml b/app/src/main/res/xml/preferences_developer.xml index 0b5a9054f23..fa8beb17284 100644 --- a/app/src/main/res/xml/preferences_developer.xml +++ b/app/src/main/res/xml/preferences_developer.xml @@ -7,9 +7,9 @@ android:summary="@string/show_share_logs_summary" /> + android:summary="@string/location_tracking_summary" /> No entities with locations found Change server Account + Accuracy Reply Activate Unable to send activity intent, please check command format @@ -348,13 +349,17 @@ Some setting(s) do not work. Click to enable location. Disable option Location is disabled + No recent locations + Location updates received by the app will appear here + Location history is off + Turn on location history to see and review all location updates received by the app Zone entered Zone exited Zone dwell Sent Ignored - Inaccurate - Received too fast + Not accurate enough + Too many updates Duplicate Future location Old location @@ -363,6 +368,7 @@ Android set up restrictions for apps which want to use your WiFi, because WiFi can be theoretically used to determine your location.\n\nAlso to ensure the app can access WiFi in background (URL decision making, sensors) you need to allow location access all the time.\n\nTherefore, to use this option location access all the time is needed.\n\nContinue granting location access? Location access Location tracking + View a history of location tracking updates to troubleshoot the device tracker(s) Location disabled Location Use biometric or screen lock credential to unlock app @@ -722,6 +728,7 @@ Sensor The following sensors offer custom settings: %1$s Sensors + Server Server settings Servers & devices Activate @@ -774,6 +781,7 @@ There are no shortcut tiles added yet Shortcuts Show + Show on map Sharing logs with the Home Assistant team will help to solve issues. Please share the logs only if you have been asked to do so by a Home Assistant developer Show and share logs Show entity name From b8c0e0db6ea3e04deceed04a294d175244355b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sat, 23 Sep 2023 11:34:46 +0200 Subject: [PATCH 4/7] Filter by state, improve list speed - Add option to filter by state - Simplify Room configuration and sort by indexed ID to improve list loading speed --- .../location/LocationTrackingFragment.kt | 48 +++++++++++++++++-- .../location/LocationTrackingViewModel.kt | 33 ++++++++++--- .../location/views/LocationTrackingView.kt | 2 +- .../menu/menu_fragment_locationtracking.xml | 31 ++++++++++++ .../database/location/LocationHistoryDao.kt | 11 ++--- .../database/location/LocationHistoryItem.kt | 9 +--- common/src/main/res/values/strings.xml | 4 +- 7 files changed, 110 insertions(+), 28 deletions(-) create mode 100644 app/src/main/res/menu/menu_fragment_locationtracking.xml diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt index 0ee21a615fb..a14017607e6 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt @@ -1,19 +1,27 @@ package io.homeassistant.companion.android.settings.developer.location +import android.content.Intent import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView +import androidx.core.net.toUri +import androidx.core.view.MenuHost +import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle import com.google.accompanist.themeadapter.material.MdcTheme import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.common.R +import io.homeassistant.companion.android.R import io.homeassistant.companion.android.common.data.servers.ServerManager -import io.homeassistant.companion.android.settings.addHelpMenuProvider import io.homeassistant.companion.android.settings.developer.location.views.LocationTrackingView import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint class LocationTrackingFragment : Fragment() { @@ -43,11 +51,43 @@ class LocationTrackingFragment : Fragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - addHelpMenuProvider("https://companion.home-assistant.io/docs/troubleshooting/faqs#device-tracker-is-not-updating-in-android-app") + super.onViewCreated(view, savedInstanceState) + + val menuHost: MenuHost = requireActivity() + menuHost.addMenuProvider( + object : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.menu_fragment_locationtracking, menu) + } + + override fun onPrepareMenu(menu: Menu) { + menu.findItem(R.id.get_help).apply { + intent = Intent(Intent.ACTION_VIEW, "https://companion.home-assistant.io/docs/troubleshooting/faqs#device-tracker-is-not-updating-in-android-app".toUri()) + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { + R.id.history_all, R.id.history_sent, R.id.history_skipped -> { + viewModel.setHistoryFilter( + when (menuItem.itemId) { + R.id.history_sent -> true + R.id.history_skipped -> false + else -> null + } + ) + menuItem.isChecked = true + true + } + else -> false + } + }, + viewLifecycleOwner, + Lifecycle.State.RESUMED + ) } override fun onResume() { super.onResume() - activity?.title = getString(R.string.location_tracking) + activity?.title = getString(commonR.string.location_tracking) } } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt index 850b29dabd2..f28aba7715d 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt @@ -1,6 +1,7 @@ package io.homeassistant.companion.android.settings.developer.location import android.app.Application +import android.util.Log import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -11,6 +12,10 @@ import androidx.paging.PagingConfig import dagger.hilt.android.lifecycle.HiltViewModel import io.homeassistant.companion.android.common.data.prefs.PrefsRepository import io.homeassistant.companion.android.database.location.LocationHistoryDao +import io.homeassistant.companion.android.database.location.LocationHistoryItemResult +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,20 +27,30 @@ class LocationTrackingViewModel @Inject constructor( ) : AndroidViewModel(application) { companion object { - private const val TAG = "LocationTrackingViewModel" + private const val TAG = "LocationTrackingViewMod" private const val PAGE_SIZE = 25 } - val historyPagerFlow = Pager( - PagingConfig(pageSize = PAGE_SIZE, maxSize = PAGE_SIZE * 10) - ) { - locationHistoryDao.getAll() - }.flow - var historyEnabled by mutableStateOf(false) private set + private val historyResultFilter = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + val historyPagerFlow = historyResultFilter.flatMapLatest { filter -> + Pager(PagingConfig(pageSize = PAGE_SIZE, maxSize = PAGE_SIZE * 6)) { + Log.d(TAG, "Returning PagingSource for filter sent only: $filter") + when (filter) { + true -> locationHistoryDao.getAll(listOf(LocationHistoryItemResult.SENT.name)) + false -> locationHistoryDao.getAll( + (LocationHistoryItemResult.values().toMutableList() - LocationHistoryItemResult.SENT).map { it.name } + ) + else -> locationHistoryDao.getAll() + } + }.flow + } + init { viewModelScope.launch { historyEnabled = prefsRepository.isLocationHistoryEnabled() @@ -50,4 +65,8 @@ class LocationTrackingViewModel @Inject constructor( if (!enabled) locationHistoryDao.deleteAll() } } + + fun setHistoryFilter(sentOnly: Boolean?) { + historyResultFilter.value = sentOnly + } } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt index 70dc753d0bb..1d8a2657aed 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt @@ -241,7 +241,7 @@ fun LocationTrackingHistoryRow(item: LocationHistoryItem?, servers: List } IconButton(onClick = { ShareCompat.IntentBuilder(context) - .setText(item?.forSharing(serverName) ?: "") + .setText(item?.toSharingString(serverName) ?: "") .setType("text/plain") .startChooser() }) { diff --git a/app/src/main/res/menu/menu_fragment_locationtracking.xml b/app/src/main/res/menu/menu_fragment_locationtracking.xml new file mode 100644 index 00000000000..d5a6b668f2b --- /dev/null +++ b/app/src/main/res/menu/menu_fragment_locationtracking.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt index af6694cb8b9..77dc06f9a43 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryDao.kt @@ -12,18 +12,15 @@ interface LocationHistoryDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun add(item: LocationHistoryItem): Long - @Query("SELECT * FROM location_history WHERE id = :id") - fun get(id: Int): LocationHistoryItem? - - @Query("SELECT * FROM location_history ORDER BY created DESC") + @Query("SELECT * FROM location_history ORDER BY id DESC") fun getAll(): PagingSource + @Query("SELECT * FROM location_history WHERE result IN (:results) ORDER BY id DESC") + fun getAll(results: List): PagingSource + @Query("DELETE FROM location_history WHERE created < :created") suspend fun deleteBefore(created: Long) - @Query("DELETE FROM location_history WHERE server_id = :serverId") - suspend fun deleteForServer(serverId: Int) - @Query("DELETE FROM location_history") suspend fun deleteAll() } diff --git a/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt index 0aec05a77b7..6850f7fdac9 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt @@ -10,26 +10,19 @@ import java.util.Locale data class LocationHistoryItem( @PrimaryKey(autoGenerate = true) val id: Int = 0, - @ColumnInfo(name = "created") val created: Long = System.currentTimeMillis(), - @ColumnInfo(name = "trigger") val trigger: LocationHistoryItemTrigger, - @ColumnInfo(name = "result") val result: LocationHistoryItemResult, - @ColumnInfo(name = "latitude") val latitude: Double?, - @ColumnInfo(name = "longitude") val longitude: Double?, @ColumnInfo(name = "location_name") val locationName: String?, - @ColumnInfo(name = "accuracy") val accuracy: Int?, - @ColumnInfo(name = "data") val data: String?, @ColumnInfo(name = "server_id") val serverId: Int? ) { - fun forSharing(serverName: String?): String { + fun toSharingString(serverName: String?): String { val createdString = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(created) return "Created: $createdString\n" + diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index c650bee2951..27f715114fd 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -21,6 +21,7 @@ Add widget Advanced All entities + All results Allow Always show first view on app start The first view of the default dashboard is shown as soon as the app is opened @@ -270,6 +271,7 @@ All sensors Enabled sensors Disabled sensors + Filter updates Finish This means you will be unable to receive cloud notifications.\n\nLocal notifications will still be available via persistent connection settings in companion app settings Firebase error @@ -357,7 +359,7 @@ Zone exited Zone dwell Sent - Ignored + Skipped Not accurate enough Too many updates Duplicate From 93349dece22a45ca6d06b33a9bd97d5f2ebcfbfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sat, 23 Sep 2023 11:55:33 +0200 Subject: [PATCH 5/7] Remove duplicate source --- .../companion/android/sensors/LocationSensorManager.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt b/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt index ccd6cc38089..87e15f18a24 100644 --- a/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt +++ b/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt @@ -829,8 +829,7 @@ class LocationSensorManager : BroadcastReceiver(), SensorManager { "Last Location: " + "\nCoords:(${location.latitude}, ${location.longitude})" + "\nAccuracy: ${location.accuracy}" + - "\nBearing: ${location.bearing}" + - "\nProvider: ${location.provider}" + "\nBearing: ${location.bearing}" ) var accuracy = 0 if (location.accuracy.toInt() >= 0) { From f1ae269582e8d7a3c0f211e0dab80859319bddb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sat, 23 Sep 2023 12:48:02 +0200 Subject: [PATCH 6/7] Fix minimal --- .../companion/android/sensors/LocationSensorManager.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/minimal/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt b/app/src/minimal/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt index f80bd4bae9b..b94a84e39ff 100644 --- a/app/src/minimal/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt +++ b/app/src/minimal/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt @@ -1,12 +1,12 @@ package io.homeassistant.companion.android.sensors +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import io.homeassistant.companion.android.common.sensors.LocationSensorManagerBase import io.homeassistant.companion.android.common.sensors.SensorManager import io.homeassistant.companion.android.common.R as commonR -class LocationSensorManager : LocationSensorManagerBase(), SensorManager { +class LocationSensorManager : BroadcastReceiver(), SensorManager { companion object { const val MINIMUM_ACCURACY = 200 From 7ff8f126dc7d380508024472cb0c08aa7c4c986d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Mon, 25 Sep 2023 21:42:11 +0200 Subject: [PATCH 7/7] Split sent into sent and failed to send --- .../android/sensors/LocationSensorManager.kt | 3 +- .../location/LocationTrackingFragment.kt | 10 ++---- .../location/LocationTrackingViewModel.kt | 31 ++++++++++++++----- .../location/views/LocationTrackingView.kt | 20 +++++++----- .../menu/menu_fragment_locationtracking.xml | 3 ++ .../database/location/LocationHistoryItem.kt | 1 + common/src/main/res/values/strings.xml | 1 + 7 files changed, 46 insertions(+), 23 deletions(-) diff --git a/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt b/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt index 87e15f18a24..88df4aba342 100644 --- a/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt +++ b/app/src/full/java/io/homeassistant/companion/android/sensors/LocationSensorManager.kt @@ -915,7 +915,6 @@ class LocationSensorManager : BroadcastReceiver(), SensorManager { } lastLocationSend[serverId] = now lastUpdateLocation[serverId] = updateLocationString - logLocationUpdate(location, updateLocation, serverId, trigger, LocationHistoryItemResult.SENT) val geocodeIncludeLocation = getSetting( latestContext, @@ -929,6 +928,7 @@ class LocationSensorManager : BroadcastReceiver(), SensorManager { try { serverManager(latestContext).integrationRepository(serverId).updateLocation(updateLocation) Log.d(TAG, "Location update sent successfully for $serverId as $updateLocationAs") + logLocationUpdate(location, updateLocation, serverId, trigger, LocationHistoryItemResult.SENT) // Update Geocoded Location Sensor if (geocodeIncludeLocation) { @@ -942,6 +942,7 @@ class LocationSensorManager : BroadcastReceiver(), SensorManager { } } catch (e: Exception) { Log.e(TAG, "Could not update location for $serverId.", e) + logLocationUpdate(location, updateLocation, serverId, trigger, LocationHistoryItemResult.FAILED_SEND) } } } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt index a14017607e6..fa2d63afac8 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingFragment.kt @@ -67,14 +67,8 @@ class LocationTrackingFragment : Fragment() { } override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { - R.id.history_all, R.id.history_sent, R.id.history_skipped -> { - viewModel.setHistoryFilter( - when (menuItem.itemId) { - R.id.history_sent -> true - R.id.history_skipped -> false - else -> null - } - ) + R.id.history_all, R.id.history_sent, R.id.history_skipped, R.id.history_failed -> { + viewModel.setHistoryFilter(menuItem.itemId) menuItem.isChecked = true true } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt index f28aba7715d..bb9bd741859 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/LocationTrackingViewModel.kt @@ -2,6 +2,7 @@ package io.homeassistant.companion.android.settings.developer.location import android.app.Application import android.util.Log +import androidx.annotation.IdRes import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -10,6 +11,7 @@ import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.R import io.homeassistant.companion.android.common.data.prefs.PrefsRepository import io.homeassistant.companion.android.database.location.LocationHistoryDao import io.homeassistant.companion.android.database.location.LocationHistoryItemResult @@ -32,20 +34,35 @@ class LocationTrackingViewModel @Inject constructor( private const val PAGE_SIZE = 25 } + enum class HistoryFilter(@IdRes val menuItemId: Int) { + ALL(R.id.history_all), + SENT(R.id.history_sent), + SKIPPED(R.id.history_skipped), + FAILED(R.id.history_failed); + + companion object { + val menuItemIdToFilter = values().associateBy { it.menuItemId } + } + } + var historyEnabled by mutableStateOf(false) private set - private val historyResultFilter = MutableStateFlow(null) + private val historyResultFilter = MutableStateFlow(HistoryFilter.ALL) @OptIn(ExperimentalCoroutinesApi::class) val historyPagerFlow = historyResultFilter.flatMapLatest { filter -> Pager(PagingConfig(pageSize = PAGE_SIZE, maxSize = PAGE_SIZE * 6)) { - Log.d(TAG, "Returning PagingSource for filter sent only: $filter") + Log.d(TAG, "Returning PagingSource for history filter: $filter") when (filter) { - true -> locationHistoryDao.getAll(listOf(LocationHistoryItemResult.SENT.name)) - false -> locationHistoryDao.getAll( - (LocationHistoryItemResult.values().toMutableList() - LocationHistoryItemResult.SENT).map { it.name } + HistoryFilter.SENT -> + locationHistoryDao.getAll(listOf(LocationHistoryItemResult.SENT.name)) + HistoryFilter.SKIPPED -> locationHistoryDao.getAll( + (LocationHistoryItemResult.values().toMutableList() - LocationHistoryItemResult.SENT - LocationHistoryItemResult.FAILED_SEND) + .map { it.name } ) + HistoryFilter.FAILED -> + locationHistoryDao.getAll(listOf(LocationHistoryItemResult.FAILED_SEND.name)) else -> locationHistoryDao.getAll() } }.flow @@ -66,7 +83,7 @@ class LocationTrackingViewModel @Inject constructor( } } - fun setHistoryFilter(sentOnly: Boolean?) { - historyResultFilter.value = sentOnly + fun setHistoryFilter(@IdRes filterMenuItemId: Int) { + historyResultFilter.value = HistoryFilter.menuItemIdToFilter.getValue(filterMenuItemId) } } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt index 1d8a2657aed..ea747601e92 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/developer/location/views/LocationTrackingView.kt @@ -167,22 +167,27 @@ fun LocationTrackingHistoryRow(item: LocationHistoryItem?, servers: List Row(verticalAlignment = Alignment.CenterVertically) { item?.let { val sent = it.result == LocationHistoryItemResult.SENT + val failed = it.result == LocationHistoryItemResult.FAILED_SEND Text( text = "${stringResource(item.trigger.toStringResource())} • ${stringResource(it.result.toStringResource())}", style = MaterialTheme.typography.body2, modifier = Modifier.padding(end = 4.dp) ) Image( - asset = if (sent) CommunityMaterial.Icon.cmd_check else CommunityMaterial.Icon.cmd_debug_step_over, - contentDescription = if (sent) null else stringResource(commonR.string.location_history_skipped), + asset = when { + sent -> CommunityMaterial.Icon.cmd_check + failed -> CommunityMaterial.Icon.cmd_alert_outline + else -> CommunityMaterial.Icon.cmd_debug_step_over + }, + contentDescription = if (sent || failed) null else stringResource(commonR.string.location_history_skipped), colorFilter = ColorFilter.tint( - if (sent) { - colorResource(commonR.color.colorOnAlertSuccess) - } else { - LocalContentColor.current + when { + sent -> colorResource(commonR.color.colorOnAlertSuccess) + failed -> colorResource(commonR.color.colorOnAlertWarning) + else -> LocalContentColor.current } ), - alpha = if (sent) 1.0f else LocalContentAlpha.current, + alpha = if (sent || failed) 1.0f else LocalContentAlpha.current, modifier = Modifier.size(with(LocalDensity.current) { 16.sp.toDp() }) ) } @@ -301,6 +306,7 @@ private fun LocationHistoryItemResult.toStringResource() = when (this) { LocationHistoryItemResult.SKIPPED_DUPLICATE -> commonR.string.location_history_skipped_duplicate LocationHistoryItemResult.SKIPPED_DEBOUNCE -> commonR.string.location_history_skipped_debounce LocationHistoryItemResult.SKIPPED_OLD -> commonR.string.location_history_skipped_old + LocationHistoryItemResult.FAILED_SEND -> commonR.string.location_history_failed_send LocationHistoryItemResult.SENT -> commonR.string.location_history_sent } diff --git a/app/src/main/res/menu/menu_fragment_locationtracking.xml b/app/src/main/res/menu/menu_fragment_locationtracking.xml index d5a6b668f2b..9c1a4417a86 100644 --- a/app/src/main/res/menu/menu_fragment_locationtracking.xml +++ b/app/src/main/res/menu/menu_fragment_locationtracking.xml @@ -20,6 +20,9 @@ + diff --git a/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt index 6850f7fdac9..505b872fdf6 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/location/LocationHistoryItem.kt @@ -52,5 +52,6 @@ enum class LocationHistoryItemResult { SKIPPED_DUPLICATE, SKIPPED_DEBOUNCE, SKIPPED_OLD, + FAILED_SEND, SENT } diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 27f715114fd..874635ccb35 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -355,6 +355,7 @@ Location updates received by the app will appear here Location history is off Turn on location history to see and review all location updates received by the app + Failed to send Zone entered Zone exited Zone dwell