diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md index eb8f3f13f40..76dd0f1d497 100644 --- a/.github/ISSUE_TEMPLATE/Bug_report.md +++ b/.github/ISSUE_TEMPLATE/Bug_report.md @@ -31,7 +31,7 @@ assignees: '' **Companion App Logs:** diff --git a/Gemfile.lock b/Gemfile.lock index 951496cffed..0e0e0c9fdc9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,13 +3,13 @@ GEM specs: CFPropertyList (3.0.6) rexml - addressable (2.8.4) + addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.2.0) - aws-partitions (1.795.0) - aws-sdk-core (3.180.1) + aws-partitions (1.824.0) + aws-sdk-core (3.181.1) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) @@ -17,8 +17,8 @@ GEM aws-sdk-kms (1.71.0) aws-sdk-core (~> 3, >= 3.177.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.132.0) - aws-sdk-core (~> 3, >= 3.179.0) + aws-sdk-s3 (1.134.0) + aws-sdk-core (~> 3, >= 3.181.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) aws-sigv4 (1.6.0) @@ -36,7 +36,7 @@ GEM unf (>= 0.0.5, < 1.0.0) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.100.0) + excon (0.103.0) faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -66,7 +66,7 @@ GEM faraday_middleware (1.2.0) faraday (~> 1.0) fastimage (2.2.7) - fastlane (2.214.0) + fastlane (2.215.1) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -87,6 +87,7 @@ GEM google-apis-playcustomapp_v1 (~> 0.1) google-cloud-storage (~> 1.31) highline (~> 2.0) + http-cookie (~> 1.0.5) json (< 3.0.0) jwt (>= 2.1.0, < 3) mini_magick (>= 4.9.4, < 5.0.0) @@ -98,7 +99,7 @@ GEM security (= 0.1.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) + terminal-table (~> 3) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -107,7 +108,7 @@ GEM xcpretty-travis-formatter (>= 0.0.3) fastlane-plugin-amazon_app_submission (0.4.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.46.0) + google-apis-androidpublisher_v3 (0.49.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-core (0.11.1) addressable (~> 2.5, >= 2.5.1) @@ -138,10 +139,9 @@ GEM google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (1.7.0) + googleauth (1.8.0) faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) - memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) @@ -152,9 +152,8 @@ GEM jmespath (1.6.2) json (2.6.3) jwt (2.7.1) - memoist (0.16.2) mini_magick (4.12.0) - mini_mime (1.1.2) + mini_mime (1.1.5) multi_json (1.15.0) multipart-post (2.3.0) nanaimo (0.3.0) @@ -174,7 +173,7 @@ GEM ruby2_keywords (0.0.5) rubyzip (2.3.2) security (0.1.3) - signet (0.17.0) + signet (0.18.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) @@ -183,8 +182,8 @@ GEM CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) trailblazer-option (0.1.2) tty-cursor (0.7.1) tty-screen (0.8.1) @@ -194,7 +193,7 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.8.2) - unicode-display_width (1.8.0) + unicode-display_width (2.4.2) webrick (1.8.1) word_wrap (1.0.0) xcodeproj (1.22.0) diff --git a/app/src/full/java/io/homeassistant/companion/android/notifications/FirebaseCloudMessagingService.kt b/app/src/full/java/io/homeassistant/companion/android/notifications/FirebaseCloudMessagingService.kt index f19950fc149..1c01ff42a57 100644 --- a/app/src/full/java/io/homeassistant/companion/android/notifications/FirebaseCloudMessagingService.kt +++ b/app/src/full/java/io/homeassistant/companion/android/notifications/FirebaseCloudMessagingService.kt @@ -49,9 +49,8 @@ class FirebaseCloudMessagingService : FirebaseMessagingService() { launch { try { serverManager.integrationRepository(it.id).updateRegistration( - DeviceRegistration( - pushToken = token - ) + deviceRegistration = DeviceRegistration(pushToken = token), + allowReregistration = false ) } catch (e: Exception) { Log.e(TAG, "Issue updating token", e) diff --git a/app/src/full/java/io/homeassistant/companion/android/sensors/ActivitySensorManager.kt b/app/src/full/java/io/homeassistant/companion/android/sensors/ActivitySensorManager.kt index f6ef6d79574..bea7a9d621c 100644 --- a/app/src/full/java/io/homeassistant/companion/android/sensors/ActivitySensorManager.kt +++ b/app/src/full/java/io/homeassistant/companion/android/sensors/ActivitySensorManager.kt @@ -17,6 +17,7 @@ import com.google.android.gms.location.SleepSegmentRequest import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.common.sensors.SensorManager import io.homeassistant.companion.android.common.sensors.SensorReceiverBase +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import java.util.concurrent.TimeUnit import io.homeassistant.companion.android.common.R as commonR @@ -168,8 +169,8 @@ class ActivitySensorManager : BroadcastReceiver(), SensorManager { DetectedActivity.STILL -> "still" DetectedActivity.TILTING -> "tilting" DetectedActivity.WALKING -> "walking" - DetectedActivity.UNKNOWN -> "unknown" - else -> "unknown" + DetectedActivity.UNKNOWN -> STATE_UNKNOWN + else -> STATE_UNKNOWN } } @@ -184,7 +185,7 @@ class ActivitySensorManager : BroadcastReceiver(), SensorManager { SleepSegmentEvent.STATUS_SUCCESSFUL -> "successful" SleepSegmentEvent.STATUS_MISSING_DATA -> "missing data" SleepSegmentEvent.STATUS_NOT_DETECTED -> "not detected" - else -> "unknown" + else -> STATE_UNKNOWN } } diff --git a/app/src/full/java/io/homeassistant/companion/android/sensors/GeocodeSensorManager.kt b/app/src/full/java/io/homeassistant/companion/android/sensors/GeocodeSensorManager.kt index 25430c1bdfe..027730f2aa1 100644 --- a/app/src/full/java/io/homeassistant/companion/android/sensors/GeocodeSensorManager.kt +++ b/app/src/full/java/io/homeassistant/companion/android/sensors/GeocodeSensorManager.kt @@ -9,6 +9,7 @@ import android.os.Build.VERSION.SDK_INT import android.util.Log import com.google.android.gms.location.LocationServices import io.homeassistant.companion.android.common.sensors.SensorManager +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.database.AppDatabase import io.homeassistant.companion.android.database.sensor.SensorSetting import io.homeassistant.companion.android.database.sensor.SensorSettingType @@ -132,7 +133,7 @@ class GeocodeSensorManager : SensorManager { onSensorUpdated( context, geocodedLocation, - if (!prettyAddress.isNullOrEmpty()) prettyAddress else "Unknown", + if (!prettyAddress.isNullOrEmpty()) prettyAddress else STATE_UNKNOWN, geocodedLocation.statelessIcon, attributes ) diff --git a/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt b/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt index 380ad18c585..0e5ff2e8c61 100644 --- a/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt +++ b/app/src/full/java/io/homeassistant/companion/android/settings/wear/views/SettingsWearTemplateTile.kt @@ -87,7 +87,7 @@ fun SettingsWearTemplateTile( expanded = dropdownExpanded, onDismissRequest = { dropdownExpanded = false } ) { - val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60) + val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 2 * 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60) for (option in options) { DropdownMenuItem(onClick = { onRefreshIntervalChanged(option) diff --git a/app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt b/app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt index cf8ead01d61..88b3ca55dac 100644 --- a/app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt +++ b/app/src/full/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt @@ -28,7 +28,7 @@ class ThreadManagerImpl @Inject constructor( companion object { private const val TAG = "ThreadManagerImpl" - // ID is a placeholder while we wait for Google to remove the requirement to provide one + // ID is a placeholder used in previous app versions / for older Home Assistant versions private const val BORDER_AGENT_ID = "0000000000000001" } @@ -54,6 +54,8 @@ class ThreadManagerImpl @Inject constructor( if (!appSupportsThread()) return ThreadManager.SyncResult.AppUnsupported if (!coreSupportsThread(serverId)) return ThreadManager.SyncResult.ServerUnsupported + deleteOrphanedThreadCredentials(context, serverId) + val getDeviceDataset = scope.async { getPreferredDatasetFromDevice(context) } val getCoreDatasets = scope.async { getDatasetsFromServer(serverId) } val deviceThreadIntent = getDeviceDataset.await() @@ -62,7 +64,10 @@ class ThreadManagerImpl @Inject constructor( return if (deviceThreadIntent == null && coreThreadDataset != null) { try { - importDatasetFromServer(context, coreThreadDataset.datasetId, serverId) + importDatasetFromServer(context, coreThreadDataset.datasetId, coreThreadDataset.preferredBorderAgentId, serverId) + coreThreadDataset.preferredBorderAgentId?.let { + serverManager.integrationRepository(serverId).setThreadBorderAgentIds(listOf(it)) + } // else added using placeholder, will be removed when core is updated Log.d(TAG, "Thread import to device completed") ThreadManager.SyncResult.OnlyOnServer(imported = true) } catch (e: Exception) { @@ -83,16 +88,48 @@ class ThreadManagerImpl @Inject constructor( var updated: Boolean? = null if (!coreIsDevicePreferred) { if (appIsDevicePreferred) { - // Update or remove the device preferred credential to match core state + // Update or remove the device preferred credential to match core state. + // The device credential store currently doesn't allow the user to choose + // which credential should be used. To prevent unexpected behavior, HA only + // contributes one credential at a time, which is for _this_ server. try { + val localIds = serverManager.defaultServers.flatMap { + serverManager.integrationRepository(it.id).getThreadBorderAgentIds() + } updated = if (coreThreadDataset.source != "Google") { // Credential from HA, update - importDatasetFromServer(context, coreThreadDataset.datasetId, serverId) + localIds.filter { it != coreThreadDataset.preferredBorderAgentId }.forEach { baId -> + try { + deleteThreadCredential(context, baId) + } catch (e: Exception) { + Log.e(TAG, "Unable to delete credential for border agent ID $baId", e) + } + } + importDatasetFromServer(context, coreThreadDataset.datasetId, coreThreadDataset.preferredBorderAgentId, serverId) + serverManager.defaultServers.forEach { + serverManager.integrationRepository(it.id).setThreadBorderAgentIds( + if (it.id == serverId && coreThreadDataset.preferredBorderAgentId != null) { + listOf(coreThreadDataset.preferredBorderAgentId!!) + } else { + emptyList() + } + ) + } + Log.d(TAG, "Thread update device completed: deleted ${localIds.size} datasets, updated 1") true - } else { // Imported from another app, so this shouldn't be managed by HA - deleteThreadCredential(context) + } else { // Core prefers imported from other app, this shouldn't be managed by HA + localIds.forEach { baId -> + try { + deleteThreadCredential(context, baId) + } catch (e: Exception) { + Log.e(TAG, "Unable to delete credential for border agent ID $baId", e) + } + } + serverManager.defaultServers.forEach { + serverManager.integrationRepository(it.id).setThreadBorderAgentIds(emptyList()) + } + Log.d(TAG, "Thread update device completed: deleted ${localIds.size} datasets") false } - Log.d(TAG, "Thread update device completed") } catch (e: Exception) { Log.e(TAG, "Thread update device failed", e) } @@ -120,10 +157,21 @@ class ThreadManagerImpl @Inject constructor( override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? = getDatasetsFromServer(serverId)?.firstOrNull { it.preferred } - override suspend fun importDatasetFromServer(context: Context, datasetId: String, serverId: Int) { + @OptIn(ExperimentalStdlibApi::class) + override suspend fun importDatasetFromServer( + context: Context, + datasetId: String, + preferredBorderAgentId: String?, + serverId: Int + ) { val tlv = serverManager.webSocketRepository(serverId).getThreadDatasetTlv(datasetId)?.tlvAsByteArray if (tlv != null) { - val threadBorderAgent = ThreadBorderAgent.newBuilder(BORDER_AGENT_ID.toByteArray()).build() + val borderAgentId = preferredBorderAgentId ?: run { + Log.w(TAG, "Adding dataset with placeholder border agent ID") + BORDER_AGENT_ID + } + val idAsBytes = borderAgentId.let { if (it.length == 16) it.toByteArray() else it.hexToByteArray() } + val threadBorderAgent = ThreadBorderAgent.newBuilder(idAsBytes).build() val threadNetworkCredentials = ThreadNetworkCredentials.fromActiveOperationalDataset(tlv) suspendCoroutine { cont -> ThreadNetwork.getClient(context).addCredentials(threadBorderAgent, threadNetworkCredentials) @@ -162,7 +210,13 @@ class ThreadManagerImpl @Inject constructor( .addOnFailureListener { cont.resume(null) } } return try { - appCredentials?.any { isPreferredCredentials(context, it) } ?: false + appCredentials?.any { + val isPreferred = isPreferredCredentials(context, it) + if (isPreferred) { + Log.d(TAG, "Thread device prefers app added dataset: ${it.networkName} (PAN ${it.panId}, EXTPAN ${String(it.extendedPanId)})") + } + isPreferred + } ?: false } catch (e: Exception) { Log.e(TAG, "Thread app added credentials preferred check failed", e) false @@ -189,9 +243,32 @@ class ThreadManagerImpl @Inject constructor( return null } - private suspend fun deleteThreadCredential(context: Context) = suspendCoroutine { cont -> - // This only works because we currently always use the same border agent ID - val threadBorderAgent = ThreadBorderAgent.newBuilder(BORDER_AGENT_ID.toByteArray()).build() + private suspend fun deleteOrphanedThreadCredentials(context: Context, serverId: Int) { + if (serverManager.defaultServers.all { it.version?.isAtLeast(2023, 9) == true }) { + try { + deleteThreadCredential(context, BORDER_AGENT_ID) + } catch (e: Exception) { + // Expected, it may not exist + } + } + + val orphanedCredentials = serverManager.integrationRepository(serverId).getOrphanedThreadBorderAgentIds() + if (orphanedCredentials.isEmpty()) return + + orphanedCredentials.forEach { + try { + deleteThreadCredential(context, it) + } catch (e: Exception) { + Log.w(TAG, "Unable to delete credential for border agent ID $it", e) + } + } + serverManager.integrationRepository(serverId).clearOrphanedThreadBorderAgentIds() + } + + @OptIn(ExperimentalStdlibApi::class) + private suspend fun deleteThreadCredential(context: Context, borderAgentId: String) = suspendCoroutine { cont -> + val idAsBytes = borderAgentId.let { if (it.length == 16) it.toByteArray() else it.hexToByteArray() } + val threadBorderAgent = ThreadBorderAgent.newBuilder(idAsBytes).build() ThreadNetwork.getClient(context) .removeCredentials(threadBorderAgent) .addOnSuccessListener { cont.resume(true) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6c50bbc07c4..d46d758baa9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -242,11 +242,21 @@ + + + context.getString(commonR.string.state_idle) - "recording" -> context.getString(commonR.string.state_recording) - "streaming" -> context.getString(commonR.string.state_streaming) - else -> entity.state.capitalize(Locale.getDefault()) - } - ) - val image = if (baseUrl != null && (entity.attributes["entity_picture"] as? String)?.isNotBlank() == true) { getThumbnail(baseUrl + entity.attributes["entity_picture"] as String) } else { @@ -58,7 +48,7 @@ object CameraControl : HaControl { control.setControlTemplate( ThumbnailTemplate( entity.entityId, - entity.state != "unavailable" && image != null, + entity.state != STATE_UNAVAILABLE && image != null, icon, context.getString(commonR.string.widget_camera_contentdescription) ) diff --git a/app/src/main/java/io/homeassistant/companion/android/controls/ClimateControl.kt b/app/src/main/java/io/homeassistant/companion/android/controls/ClimateControl.kt index 564d99e528d..a62e4f40b37 100644 --- a/app/src/main/java/io/homeassistant/companion/android/controls/ClimateControl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/controls/ClimateControl.kt @@ -39,19 +39,6 @@ object ClimateControl : HaControl { area: AreaRegistryResponse?, baseUrl: String? ): Control.StatefulBuilder { - control.setStatusText( - when (entity.state) { - "auto" -> context.getString(commonR.string.state_auto) - "cool" -> context.getString(commonR.string.state_cool) - "dry" -> context.getString(commonR.string.state_dry) - "fan_only" -> context.getString(commonR.string.state_fan_only) - "heat" -> context.getString(commonR.string.state_heat) - "heat_cool" -> context.getString(commonR.string.state_heat_cool) - "off" -> context.getString(commonR.string.state_off) - "unavailable" -> context.getString(commonR.string.state_unavailable) - else -> entity.state - } - ) val minValue = (entity.attributes["min_temp"] as? Number)?.toFloat() ?: 0f val maxValue = (entity.attributes["max_temp"] as? Number)?.toFloat() ?: 100f var currentValue = (entity.attributes["temperature"] as? Number)?.toFloat() ?: ( diff --git a/app/src/main/java/io/homeassistant/companion/android/controls/CoverControl.kt b/app/src/main/java/io/homeassistant/companion/android/controls/CoverControl.kt index e405151834a..52ea1289bb2 100644 --- a/app/src/main/java/io/homeassistant/companion/android/controls/CoverControl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/controls/CoverControl.kt @@ -15,6 +15,7 @@ import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.data.integration.getCoverPosition +import io.homeassistant.companion.android.common.data.integration.isActive import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse import io.homeassistant.companion.android.common.R as commonR @@ -28,22 +29,12 @@ object CoverControl : HaControl { area: AreaRegistryResponse?, baseUrl: String? ): Control.StatefulBuilder { - control.setStatusText( - when (entity.state) { - "closed" -> context.getString(commonR.string.state_closed) - "closing" -> context.getString(commonR.string.state_closing) - "open" -> context.getString(commonR.string.state_open) - "opening" -> context.getString(commonR.string.state_opening) - "unavailable" -> context.getString(commonR.string.state_unavailable) - else -> entity.state - } - ) val position = entity.getCoverPosition() control.setControlTemplate( if ((entity.attributes["supported_features"] as Int) and SUPPORT_SET_POSITION == SUPPORT_SET_POSITION) { ToggleRangeTemplate( entity.entityId, - entity.state in listOf("open", "opening"), + entity.isActive(), "", RangeTemplate( entity.entityId, @@ -58,7 +49,7 @@ object CoverControl : HaControl { ToggleTemplate( entity.entityId, ControlButton( - entity.state in listOf("open", "opening"), + entity.isActive(), "Description" ) ) diff --git a/app/src/main/java/io/homeassistant/companion/android/controls/DefaultSwitchControl.kt b/app/src/main/java/io/homeassistant/companion/android/controls/DefaultSwitchControl.kt index f1953ce6158..a4873828c5d 100644 --- a/app/src/main/java/io/homeassistant/companion/android/controls/DefaultSwitchControl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/controls/DefaultSwitchControl.kt @@ -12,6 +12,7 @@ import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.data.integration.domain +import io.homeassistant.companion.android.common.data.integration.isActive import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse import io.homeassistant.companion.android.common.util.capitalize import java.util.Locale @@ -30,7 +31,7 @@ object DefaultSwitchControl : HaControl { ToggleTemplate( entity.entityId, ControlButton( - entity.state == "on", + entity.isActive(), "Description" ) ) diff --git a/app/src/main/java/io/homeassistant/companion/android/controls/FanControl.kt b/app/src/main/java/io/homeassistant/companion/android/controls/FanControl.kt index 89c673541d5..a7821a972eb 100644 --- a/app/src/main/java/io/homeassistant/companion/android/controls/FanControl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/controls/FanControl.kt @@ -15,6 +15,7 @@ import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.data.integration.getFanSpeed +import io.homeassistant.companion.android.common.data.integration.isActive import io.homeassistant.companion.android.common.data.integration.supportsFanSetSpeed import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse import io.homeassistant.companion.android.common.R as commonR @@ -33,7 +34,7 @@ object FanControl : HaControl { control.setControlTemplate( ToggleRangeTemplate( entity.entityId, - entity.state == "on", + entity.isActive(), "", RangeTemplate( entity.entityId, @@ -50,7 +51,7 @@ object FanControl : HaControl { ToggleTemplate( entity.entityId, ControlButton( - entity.state == "on", + entity.isActive(), "" ) ) diff --git a/app/src/main/java/io/homeassistant/companion/android/controls/HaControl.kt b/app/src/main/java/io/homeassistant/companion/android/controls/HaControl.kt index 3dc188656ba..eee7394a999 100644 --- a/app/src/main/java/io/homeassistant/companion/android/controls/HaControl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/controls/HaControl.kt @@ -16,6 +16,7 @@ import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.data.integration.domain +import io.homeassistant.companion.android.common.data.integration.friendlyState import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse import io.homeassistant.companion.android.webview.WebViewActivity @@ -46,14 +47,7 @@ interface HaControl { (info.area?.name ?: getDomainString(context, entity)) ) control.setStatus(Control.STATUS_OK) - control.setStatusText( - when (entity.state) { - "off" -> context.getString(R.string.state_off) - "on" -> context.getString(R.string.state_on) - "unavailable" -> context.getString(R.string.state_unavailable) - else -> context.getString(R.string.state_unknown) - } - ) + control.setStatusText(entity.friendlyState(context)) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { control.setAuthRequired(info.authRequired) } diff --git a/app/src/main/java/io/homeassistant/companion/android/controls/HaControlsPanelActivity.kt b/app/src/main/java/io/homeassistant/companion/android/controls/HaControlsPanelActivity.kt new file mode 100644 index 00000000000..c13b7261949 --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/controls/HaControlsPanelActivity.kt @@ -0,0 +1,57 @@ +package io.homeassistant.companion.android.controls + +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.webview.WebViewActivity +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class HaControlsPanelActivity : AppCompatActivity() { + + @Inject + lateinit var serverManager: ServerManager + + @Inject + lateinit var prefsRepository: PrefsRepository + + private var launched = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!serverManager.isRegistered()) { + finish() + return + } + + lifecycleScope.launch { + val serverId = prefsRepository.getControlsPanelServer() ?: serverManager.getServer()?.id + val path = prefsRepository.getControlsPanelPath() + Log.d("HaControlsPanel", "Launching WebView…") + startActivity( + WebViewActivity.newInstance( + context = this@HaControlsPanelActivity, + path = path, + serverId = serverId + ).apply { + putExtra(WebViewActivity.EXTRA_SHOW_WHEN_LOCKED, true) + } + ) + overridePendingTransition(0, 0) // Disable activity start/stop animation + + // The device controls panel can flicker if this activity finishes to quickly, so handle + // it in onPause instead to reduce this + launched = true + } + } + + override fun onPause() { + super.onPause() + if (launched) finish() + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/controls/LightControl.kt b/app/src/main/java/io/homeassistant/companion/android/controls/LightControl.kt index 7e1777804e1..9202a5416e6 100644 --- a/app/src/main/java/io/homeassistant/companion/android/controls/LightControl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/controls/LightControl.kt @@ -15,6 +15,7 @@ import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.data.integration.getLightBrightness +import io.homeassistant.companion.android.common.data.integration.isActive import io.homeassistant.companion.android.common.data.integration.supportsLightBrightness import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse import io.homeassistant.companion.android.common.R as commonR @@ -33,7 +34,7 @@ object LightControl : HaControl { if (entity.supportsLightBrightness()) { ToggleRangeTemplate( entity.entityId, - entity.state == "on", + entity.isActive(), "", RangeTemplate( entity.entityId, @@ -48,7 +49,7 @@ object LightControl : HaControl { ToggleTemplate( entity.entityId, ControlButton( - entity.state == "on", + entity.isActive(), "Description" ) ) diff --git a/app/src/main/java/io/homeassistant/companion/android/controls/LockControl.kt b/app/src/main/java/io/homeassistant/companion/android/controls/LockControl.kt index d295cede69a..c33058ad124 100644 --- a/app/src/main/java/io/homeassistant/companion/android/controls/LockControl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/controls/LockControl.kt @@ -11,6 +11,7 @@ import android.service.controls.templates.ToggleTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.integration.isActive import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse import io.homeassistant.companion.android.common.R as commonR @@ -23,22 +24,11 @@ object LockControl : HaControl { area: AreaRegistryResponse?, baseUrl: String? ): Control.StatefulBuilder { - control.setStatusText( - when (entity.state) { - "jammed" -> context.getString(commonR.string.state_jammed) - "locked" -> context.getString(commonR.string.state_locked) - "locking" -> context.getString(commonR.string.state_locking) - "unlocked" -> context.getString(commonR.string.state_unlocked) - "unlocking" -> context.getString(commonR.string.state_unlocking) - "unavailable" -> context.getString(commonR.string.state_unavailable) - else -> context.getString(commonR.string.state_unknown) - } - ) control.setControlTemplate( ToggleTemplate( entity.entityId, ControlButton( - entity.state == "locked", + entity.isActive(), "Description" ) ) diff --git a/app/src/main/java/io/homeassistant/companion/android/controls/VacuumControl.kt b/app/src/main/java/io/homeassistant/companion/android/controls/VacuumControl.kt index 6dcc583880a..582bb2a5373 100644 --- a/app/src/main/java/io/homeassistant/companion/android/controls/VacuumControl.kt +++ b/app/src/main/java/io/homeassistant/companion/android/controls/VacuumControl.kt @@ -11,6 +11,7 @@ import android.service.controls.templates.ToggleTemplate import androidx.annotation.RequiresApi import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.integration.isActive import io.homeassistant.companion.android.common.data.websocket.impl.entities.AreaRegistryResponse import io.homeassistant.companion.android.common.R as commonR @@ -27,29 +28,11 @@ object VacuumControl : HaControl { baseUrl: String? ): Control.StatefulBuilder { entitySupportedFeatures = entity.attributes["supported_features"] as Int - if (entitySupportedFeatures and SUPPORT_TURN_ON != SUPPORT_TURN_ON) { - control.setStatusText( - when (entity.state) { - "cleaning" -> context.getString(commonR.string.state_cleaning) - "docked" -> context.getString(commonR.string.state_docked) - "error" -> context.getString(commonR.string.state_error) - "idle" -> context.getString(commonR.string.state_idle) - "paused" -> context.getString(commonR.string.state_paused) - "returning" -> context.getString(commonR.string.state_returning) - "unavailable" -> context.getString(commonR.string.state_unavailable) - else -> context.getString(commonR.string.state_unknown) - } - ) - } control.setControlTemplate( ToggleTemplate( entity.entityId, ControlButton( - if (entitySupportedFeatures and SUPPORT_TURN_ON == SUPPORT_TURN_ON) { - entity.state == "on" - } else { - entity.state == "cleaning" - }, + entity.isActive(), "Description" ) ) diff --git a/app/src/main/java/io/homeassistant/companion/android/launch/LaunchActivity.kt b/app/src/main/java/io/homeassistant/companion/android/launch/LaunchActivity.kt index 266f864b54d..0f2d2f3515e 100644 --- a/app/src/main/java/io/homeassistant/companion/android/launch/LaunchActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/launch/LaunchActivity.kt @@ -29,6 +29,7 @@ import io.homeassistant.companion.android.onboarding.OnboardApp import io.homeassistant.companion.android.onboarding.getMessagingToken import io.homeassistant.companion.android.sensors.LocationSensorManager import io.homeassistant.companion.android.settings.SettingViewModel +import io.homeassistant.companion.android.settings.server.ServerChooserFragment import io.homeassistant.companion.android.util.UrlUtil import io.homeassistant.companion.android.webview.WebViewActivity import kotlinx.coroutines.CoroutineScope @@ -89,6 +90,20 @@ class LaunchActivity : AppCompatActivity(), LaunchView { Class.forName("androidx.car.app.activity.CarAppActivity") ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(carIntent) + } else if (presenter.hasMultipleServers() && intent.data?.path?.isNotBlank() == true) { + supportFragmentManager.setFragmentResultListener(ServerChooserFragment.RESULT_KEY, this) { _, bundle -> + val serverId = if (bundle.containsKey(ServerChooserFragment.RESULT_SERVER)) { + bundle.getInt(ServerChooserFragment.RESULT_SERVER) + } else { + null + } + supportFragmentManager.clearFragmentResultListener(ServerChooserFragment.RESULT_KEY) + startActivity(WebViewActivity.newInstance(this, intent.data?.path, serverId)) + finish() + overridePendingTransition(0, 0) // Disable activity start/stop animation + } + ServerChooserFragment().show(supportFragmentManager, ServerChooserFragment.TAG) + return } else { startActivity(WebViewActivity.newInstance(this, intent.data?.path)) } diff --git a/app/src/main/java/io/homeassistant/companion/android/launch/LaunchPresenter.kt b/app/src/main/java/io/homeassistant/companion/android/launch/LaunchPresenter.kt index 7931e013ddc..a6b95a2828b 100644 --- a/app/src/main/java/io/homeassistant/companion/android/launch/LaunchPresenter.kt +++ b/app/src/main/java/io/homeassistant/companion/android/launch/LaunchPresenter.kt @@ -6,5 +6,7 @@ interface LaunchPresenter { fun setSessionExpireMillis(value: Long) + fun hasMultipleServers(): Boolean + fun onFinish() } diff --git a/app/src/main/java/io/homeassistant/companion/android/launch/LaunchPresenterBase.kt b/app/src/main/java/io/homeassistant/companion/android/launch/LaunchPresenterBase.kt index 63ef764f31d..0e913b8e312 100644 --- a/app/src/main/java/io/homeassistant/companion/android/launch/LaunchPresenterBase.kt +++ b/app/src/main/java/io/homeassistant/companion/android/launch/LaunchPresenterBase.kt @@ -49,6 +49,8 @@ abstract class LaunchPresenterBase( } } + override fun hasMultipleServers(): Boolean = serverManager.defaultServers.size > 1 + override fun onFinish() { mainScope.cancel() } diff --git a/app/src/main/java/io/homeassistant/companion/android/launch/my/MyActivity.kt b/app/src/main/java/io/homeassistant/companion/android/launch/my/MyActivity.kt index ac2e47a2686..1a33d1e2778 100644 --- a/app/src/main/java/io/homeassistant/companion/android/launch/my/MyActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/launch/my/MyActivity.kt @@ -8,11 +8,16 @@ import android.os.Bundle import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient +import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.BaseActivity import io.homeassistant.companion.android.BuildConfig +import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.databinding.ActivityMyBinding +import io.homeassistant.companion.android.settings.server.ServerChooserFragment import io.homeassistant.companion.android.webview.WebViewActivity +import javax.inject.Inject +@AndroidEntryPoint class MyActivity : BaseActivity() { companion object { @@ -25,15 +30,28 @@ class MyActivity : BaseActivity() { } } + @Inject + lateinit var serverManager: ServerManager + @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (!serverManager.isRegistered()) { + finish() + return + } + val binding = ActivityMyBinding.inflate(layoutInflater) setContentView(binding.root) - if (Intent.ACTION_VIEW == intent?.action && intent.data != null) { - if (intent.data?.getQueryParameter("mobile")?.equals("1") == true) { + if (intent?.action == Intent.ACTION_VIEW && intent.data != null) { + if ( + intent.data?.scheme != "https" || + intent.data?.host != "my.home-assistant.io" || + intent.data?.path?.startsWith("/redirect/") != true || + intent.data?.getQueryParameter("mobile")?.equals("1") == true + ) { finish() return } @@ -52,8 +70,7 @@ class MyActivity : BaseActivity() { ): Boolean { val url = request?.url.toString() if (url.startsWith("homeassistant://navigate/")) { - startActivity(WebViewActivity.newInstance(context, url.removePrefix("homeassistant://navigate/"))) - finish() + navigateTo(url.removePrefix("homeassistant://navigate/")) return true } return false @@ -63,4 +80,26 @@ class MyActivity : BaseActivity() { binding.webview.loadUrl(newUri.toString()) } } + + private fun navigateTo(path: String) { + if (serverManager.defaultServers.size > 1) { + supportFragmentManager.setFragmentResultListener(ServerChooserFragment.RESULT_KEY, this) { _, bundle -> + if (bundle.containsKey(ServerChooserFragment.RESULT_SERVER)) { + startActivity( + WebViewActivity.newInstance( + context = this, + path = path, + serverId = bundle.getInt(ServerChooserFragment.RESULT_SERVER) + ) + ) + finish() + } + supportFragmentManager.clearFragmentResultListener(ServerChooserFragment.RESULT_KEY) + } + ServerChooserFragment().show(supportFragmentManager, ServerChooserFragment.TAG) + } else { + startActivity(WebViewActivity.newInstance(context = this, path = path)) + finish() + } + } } diff --git a/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt b/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt index e75e25c7643..1da92365431 100644 --- a/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/onboarding/authentication/AuthenticationFragment.kt @@ -33,6 +33,7 @@ import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegr import io.homeassistant.companion.android.themes.ThemesManager import io.homeassistant.companion.android.util.TLSWebViewClient import io.homeassistant.companion.android.util.isStarted +import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import javax.inject.Inject import io.homeassistant.companion.android.common.R as commonR @@ -166,8 +167,16 @@ class AuthenticationFragment : Fragment() { private fun buildAuthUrl(base: String): String { return try { - base.toHttpUrl() - .newBuilder() + val url = base.toHttpUrl() + val builder = if (url.host.endsWith("ui.nabu.casa", true)) { + HttpUrl.Builder() + .scheme(url.scheme) + .host(url.host) + .port(url.port) + } else { + url.newBuilder() + } + builder .addPathSegments("auth/authorize") .addEncodedQueryParameter("response_type", "code") .addEncodedQueryParameter("client_id", AuthenticationService.CLIENT_ID) diff --git a/app/src/main/java/io/homeassistant/companion/android/qs/TileExtensions.kt b/app/src/main/java/io/homeassistant/companion/android/qs/TileExtensions.kt index b04377a04fe..658585e1c77 100755 --- a/app/src/main/java/io/homeassistant/companion/android/qs/TileExtensions.kt +++ b/app/src/main/java/io/homeassistant/companion/android/qs/TileExtensions.kt @@ -24,6 +24,7 @@ import dagger.hilt.components.SingletonComponent import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.EntityExt import io.homeassistant.companion.android.common.data.integration.getIcon +import io.homeassistant.companion.android.common.data.integration.isActive import io.homeassistant.companion.android.common.data.integration.onEntityPressedWithoutState import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.database.qs.TileDao @@ -111,7 +112,7 @@ abstract class TileExtensions : TileService() { ) { serverManager.integrationRepository(tileData.serverId).getEntityUpdates(listOf(tileData.entityId))?.collect { tile.state = - if (it.state in validActiveStates) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + if (it.isActive()) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE getTileIcon(tileData.iconName, it, applicationContext)?.let { icon -> tile.icon = Icon.createWithBitmap(icon) } @@ -161,8 +162,8 @@ abstract class TileExtensions : TileService() { } if (tileData.entityId.split('.')[0] in toggleDomainsWithLock) { tile.state = when { - state?.state in validActiveStates -> Tile.STATE_ACTIVE - state?.state != null && state.state !in validActiveStates -> Tile.STATE_INACTIVE + state?.isActive() == true -> Tile.STATE_ACTIVE + state?.state != null && !state.isActive() -> Tile.STATE_INACTIVE else -> Tile.STATE_UNAVAILABLE } } else { @@ -332,7 +333,6 @@ abstract class TileExtensions : TileService() { companion object { private const val TAG = "TileExtensions" private val toggleDomainsWithLock = EntityExt.DOMAINS_TOGGLE - private val validActiveStates = listOf("on", "open", "locked") } private fun handleInject() { diff --git a/app/src/main/java/io/homeassistant/companion/android/sensors/CarSensorManager.kt b/app/src/main/java/io/homeassistant/companion/android/sensors/CarSensorManager.kt index 79bbaff4401..c64ffa2e6ee 100644 --- a/app/src/main/java/io/homeassistant/companion/android/sensors/CarSensorManager.kt +++ b/app/src/main/java/io/homeassistant/companion/android/sensors/CarSensorManager.kt @@ -16,6 +16,8 @@ import androidx.lifecycle.DefaultLifecycleObserver import io.homeassistant.companion.android.BuildConfig import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.common.sensors.SensorManager +import io.homeassistant.companion.android.common.util.STATE_UNAVAILABLE +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.vehicle.HaCarAppService @RequiresApi(Build.VERSION_CODES.O) @@ -173,7 +175,7 @@ class CarSensorManager : onSensorUpdated( context, it, - "unavailable", + STATE_UNAVAILABLE, it.statelessIcon, mapOf() ) @@ -257,7 +259,7 @@ class CarSensorManager : onSensorUpdated( context, fuelLevel, - if (fuelStatus == "success") data.fuelPercent.value!! else "unknown", + if (fuelStatus == "success") data.fuelPercent.value!! else STATE_UNKNOWN, fuelLevel.statelessIcon, mapOf( "status" to fuelStatus @@ -270,7 +272,7 @@ class CarSensorManager : onSensorUpdated( context, batteryLevel, - if (batteryStatus == "success") data.batteryPercent.value!! else "unknown", + if (batteryStatus == "success") data.batteryPercent.value!! else STATE_UNKNOWN, batteryLevel.statelessIcon, mapOf( "status" to batteryStatus @@ -288,7 +290,7 @@ class CarSensorManager : onSensorUpdated( context, carName, - if (status == "success") data.name.value!! else "unknown", + if (status == "success") data.name.value!! else STATE_UNKNOWN, carName.statelessIcon, mapOf( "car_manufacturer" to data.manufacturer.value, @@ -309,7 +311,7 @@ class CarSensorManager : onSensorUpdated( context, carStatus, - if (status == "success") (data.evChargePortConnected.value == true) else "unknown", + if (status == "success") (data.evChargePortConnected.value == true) else STATE_UNKNOWN, carStatus.statelessIcon, mapOf( "car_charge_port_open" to (data.evChargePortOpen.value == true), @@ -329,7 +331,7 @@ class CarSensorManager : onSensorUpdated( context, odometerValue, - if (status == "success") data.odometerMeters.value!! else "unknown", + if (status == "success") data.odometerMeters.value!! else STATE_UNKNOWN, odometerValue.statelessIcon, mapOf( "status" to status @@ -348,7 +350,7 @@ class CarSensorManager : onSensorUpdated( context, fuelType, - if (fuelTypeStatus == "success") getFuelType(data.fuelTypes.value!!) else "unknown", + if (fuelTypeStatus == "success") getFuelType(data.fuelTypes.value!!) else STATE_UNKNOWN, fuelType.statelessIcon, mapOf( "status" to fuelTypeStatus @@ -360,7 +362,7 @@ class CarSensorManager : onSensorUpdated( context, evConnector, - if (evConnectorTypeStatus == "success") getEvConnectorType(data.evConnectorTypes.value!!) else "unknown", + if (evConnectorTypeStatus == "success") getEvConnectorType(data.evConnectorTypes.value!!) else STATE_UNKNOWN, evConnector.statelessIcon, mapOf( "status" to evConnectorTypeStatus @@ -373,8 +375,8 @@ class CarSensorManager : private fun carValueStatus(value: Int): String? { return when (value) { CarValue.STATUS_SUCCESS -> "success" - CarValue.STATUS_UNAVAILABLE -> "unavailable" - CarValue.STATUS_UNKNOWN -> "unknown" + CarValue.STATUS_UNAVAILABLE -> STATE_UNAVAILABLE + CarValue.STATUS_UNKNOWN -> STATE_UNKNOWN CarValue.STATUS_UNIMPLEMENTED -> "unimplemented" else -> null } @@ -395,9 +397,9 @@ class CarSensorManager : EnergyProfile.FUEL_TYPE_LNG -> "Liquified natural gas" EnergyProfile.FUEL_TYPE_LPG -> "Liquified petroleum gas" EnergyProfile.FUEL_TYPE_OTHER -> "Other" - EnergyProfile.FUEL_TYPE_UNKNOWN -> "unknown" + EnergyProfile.FUEL_TYPE_UNKNOWN -> STATE_UNKNOWN EnergyProfile.FUEL_TYPE_UNLEADED -> "Unleaded gasoline" - else -> "unknown" + else -> STATE_UNKNOWN } } return fuelTypeList.toString() @@ -419,8 +421,8 @@ class CarSensorManager : EnergyProfile.EVCONNECTOR_TYPE_TESLA_HPWC -> "High Power Wall Charger of Tesla" EnergyProfile.EVCONNECTOR_TYPE_TESLA_ROADSTER -> "Connector of Tesla Roadster" EnergyProfile.EVCONNECTOR_TYPE_TESLA_SUPERCHARGER -> "Supercharger of Tesla" - EnergyProfile.EVCONNECTOR_TYPE_UNKNOWN -> "unknown" - else -> "unknown" + EnergyProfile.EVCONNECTOR_TYPE_UNKNOWN -> STATE_UNKNOWN + else -> STATE_UNKNOWN } } return evConnectorList.toString() diff --git a/app/src/main/java/io/homeassistant/companion/android/sensors/LastAppSensorManager.kt b/app/src/main/java/io/homeassistant/companion/android/sensors/LastAppSensorManager.kt index 9d910b812fe..25dc60af22b 100755 --- a/app/src/main/java/io/homeassistant/companion/android/sensors/LastAppSensorManager.kt +++ b/app/src/main/java/io/homeassistant/companion/android/sensors/LastAppSensorManager.kt @@ -9,6 +9,7 @@ import android.util.Log import androidx.annotation.RequiresApi import androidx.core.content.getSystemService import io.homeassistant.companion.android.common.sensors.SensorManager +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.common.R as commonR class LastAppSensorManager : SensorManager { @@ -64,7 +65,7 @@ class LastAppSensorManager : SensorManager { val current = System.currentTimeMillis() val lastApp = usageStats.queryUsageStats(UsageStatsManager.INTERVAL_DAILY, current - 1000 * 1000, current).maxByOrNull { it.lastTimeUsed }?.packageName ?: "none" - var appLabel = "unknown" + var appLabel = STATE_UNKNOWN try { val pm = context.packageManager diff --git a/app/src/main/java/io/homeassistant/companion/android/sensors/NotificationSensorManager.kt b/app/src/main/java/io/homeassistant/companion/android/sensors/NotificationSensorManager.kt index 675182cb974..90be7da956a 100644 --- a/app/src/main/java/io/homeassistant/companion/android/sensors/NotificationSensorManager.kt +++ b/app/src/main/java/io/homeassistant/companion/android/sensors/NotificationSensorManager.kt @@ -17,6 +17,8 @@ import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.core.content.getSystemService import io.homeassistant.companion.android.common.sensors.SensorManager +import io.homeassistant.companion.android.common.util.STATE_UNAVAILABLE +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.database.sensor.SensorSettingType import io.homeassistant.companion.android.common.R as commonR @@ -272,7 +274,7 @@ class NotificationSensorManager : NotificationListenerService(), SensorManager { val mediaSessionManager = context.getSystemService()!! val mediaList = mediaSessionManager.getActiveSessions(ComponentName(context, NotificationSensorManager::class.java)) val sessionCount = mediaList.size - val primaryPlaybackState = if (sessionCount > 0) getPlaybackState(mediaList[0].playbackState?.state) else "Unavailable" + val primaryPlaybackState = if (sessionCount > 0) getPlaybackState(mediaList[0].playbackState?.state) else STATE_UNAVAILABLE val attr: MutableMap = mutableMapOf() if (mediaList.size > 0) { for (item in mediaList) { @@ -312,7 +314,7 @@ class NotificationSensorManager : NotificationListenerService(), SensorManager { PlaybackState.STATE_SKIPPING_TO_NEXT -> "Skip to Next" PlaybackState.STATE_SKIPPING_TO_PREVIOUS -> "Skip to Previous" PlaybackState.STATE_SKIPPING_TO_QUEUE_ITEM -> "Skip to Queue Item" - else -> "Unknown" + else -> STATE_UNKNOWN } } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/SettingsFragment.kt b/app/src/main/java/io/homeassistant/companion/android/settings/SettingsFragment.kt index fd7f4d8b23e..3324cd594be 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/SettingsFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/SettingsFragment.kt @@ -201,6 +201,9 @@ class SettingsFragment( return@setOnPreferenceClickListener true } + val isAutomotive = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && requireContext().packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE) + if (Build.MODEL != "Quest") { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { findPreference("shortcuts")?.let { @@ -228,7 +231,7 @@ class SettingsFragment( } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!isAutomotive && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { findPreference("device_controls")?.let { it.isVisible = true } @@ -355,7 +358,6 @@ class SettingsFragment( return@setOnPreferenceClickListener true } - val isAutomotive = requireContext().packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE) findPreference("android_auto")?.let { it.isVisible = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && (BuildConfig.FLAVOR == "full" || isAutomotive) diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/controls/ManageControlsSettingsFragment.kt b/app/src/main/java/io/homeassistant/companion/android/settings/controls/ManageControlsSettingsFragment.kt index 08cb24682c6..829759267ee 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/controls/ManageControlsSettingsFragment.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/controls/ManageControlsSettingsFragment.kt @@ -36,15 +36,19 @@ class ManageControlsSettingsFragment : Fragment() { setContent { MdcTheme { ManageControlsView( + panelEnabled = viewModel.panelEnabled, authSetting = viewModel.authRequired, authRequiredList = viewModel.authRequiredList, entitiesLoaded = viewModel.entitiesLoaded, entitiesList = viewModel.entitiesList, + panelSetting = viewModel.panelSetting, serversList = serverManager.defaultServers, defaultServer = serverManager.getServer()?.id ?: 0, + onSetPanelEnabled = viewModel::enablePanelForControls, onSelectAll = { viewModel.setAuthSetting(ControlsAuthRequiredSetting.NONE) }, onSelectNone = { viewModel.setAuthSetting(ControlsAuthRequiredSetting.ALL) }, - onSelectEntity = { entityId, serverId -> viewModel.toggleAuthForEntity(entityId, serverId) } + onSelectEntity = { entityId, serverId -> viewModel.toggleAuthForEntity(entityId, serverId) }, + onSetPanelSetting = { path, serverId -> viewModel.setPanelConfig(path, serverId) } ) } } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/controls/ManageControlsViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/controls/ManageControlsViewModel.kt index 02ea9b7cb07..687ef4ec5d1 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/controls/ManageControlsViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/controls/ManageControlsViewModel.kt @@ -1,6 +1,8 @@ package io.homeassistant.companion.android.settings.controls import android.app.Application +import android.content.ComponentName +import android.content.pm.PackageManager import android.os.Build import androidx.annotation.RequiresApi import androidx.compose.runtime.getValue @@ -16,6 +18,7 @@ import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.domain import io.homeassistant.companion.android.common.data.prefs.PrefsRepository import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.controls.HaControlsPanelActivity import io.homeassistant.companion.android.controls.HaControlsProviderService import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -27,9 +30,12 @@ import javax.inject.Inject class ManageControlsViewModel @Inject constructor( private val serverManager: ServerManager, private val prefsRepository: PrefsRepository, - application: Application + private val application: Application ) : AndroidViewModel(application) { + var panelEnabled by mutableStateOf(false) + private set + var authRequired by mutableStateOf(ControlsAuthRequiredSetting.NONE) private set @@ -40,8 +46,26 @@ class ManageControlsViewModel @Inject constructor( val entitiesList = mutableStateMapOf>>() + var panelSetting by mutableStateOf?>(null) + private set + init { viewModelScope.launch { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + panelEnabled = + application.packageManager.getComponentEnabledSetting( + ComponentName(application, HaControlsPanelActivity::class.java) + ) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED + + val panelServer = prefsRepository.getControlsPanelServer() + val panelPath = prefsRepository.getControlsPanelPath() + panelSetting = if (panelServer != null) { + Pair(panelPath, panelServer) + } else { + null + } + } + authRequired = prefsRepository.getControlsAuthRequired() authRequiredList.addAll(prefsRepository.getControlsAuthEntities()) @@ -118,4 +142,29 @@ class ManageControlsViewModel @Inject constructor( prefsRepository.setControlsAuthEntities(authRequiredList.toList()) } } + + fun enablePanelForControls(enabled: Boolean) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return + + application.packageManager.setComponentEnabledSetting( + ComponentName(application, HaControlsPanelActivity::class.java), + if (enabled) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DEFAULT // Default is disabled + }, + PackageManager.DONT_KILL_APP + ) + panelEnabled = enabled + if (panelSetting?.second == null) { + serverManager.getServer()?.id?.let { setPanelConfig("", it) } + } + } + + fun setPanelConfig(path: String, serverId: Int) = viewModelScope.launch { + val cleanedPath = path.trim().takeIf { it.isNotBlank() } + prefsRepository.setControlsPanelServer(serverId) + prefsRepository.setControlsPanelPath(cleanedPath) + panelSetting = Pair(cleanedPath, serverId) + } } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/controls/views/ManageControlsView.kt b/app/src/main/java/io/homeassistant/companion/android/settings/controls/views/ManageControlsView.kt index 37a4ebec285..d7d469ce07b 100644 --- a/app/src/main/java/io/homeassistant/companion/android/settings/controls/views/ManageControlsView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/controls/views/ManageControlsView.kt @@ -1,124 +1,257 @@ package io.homeassistant.companion.android.settings.controls.views +import android.os.Build import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight 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.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Button import androidx.compose.material.Checkbox import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.ContentAlpha +import androidx.compose.material.Divider import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton +import androidx.compose.material.RadioButton import androidx.compose.material.Text +import androidx.compose.material.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf 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.res.stringResource import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.mikepenz.iconics.compose.Image +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.common.data.integration.ControlsAuthRequiredSetting import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.domain +import io.homeassistant.companion.android.common.data.integration.friendlyName import io.homeassistant.companion.android.database.server.Server +import io.homeassistant.companion.android.util.compose.HaAlertWarning import io.homeassistant.companion.android.util.compose.ServerExposedDropdownMenu import io.homeassistant.companion.android.util.compose.getEntityDomainString import io.homeassistant.companion.android.common.R as commonR @Composable fun ManageControlsView( + panelEnabled: Boolean, authSetting: ControlsAuthRequiredSetting, authRequiredList: List, entitiesLoaded: Boolean, entitiesList: Map>>, + panelSetting: Pair?, serversList: List, defaultServer: Int, + onSetPanelEnabled: (Boolean) -> Unit, onSelectAll: () -> Unit, onSelectNone: () -> Unit, - onSelectEntity: (String, Int) -> Unit + onSelectEntity: (String, Int) -> Unit, + onSetPanelSetting: (String, Int) -> Unit ) { - var selectedServer by remember { mutableStateOf(defaultServer) } + var selectedServer by remember { mutableIntStateOf(defaultServer) } + val initialPanelEnabled by rememberSaveable { mutableStateOf(panelEnabled) } + var panelServer by remember(panelSetting?.second) { mutableIntStateOf(panelSetting?.second ?: defaultServer) } + var panelPath by remember(panelSetting?.first) { mutableStateOf(panelSetting?.first ?: "") } + LazyColumn(contentPadding = PaddingValues(vertical = 16.dp)) { - item { - Text( - text = stringResource(commonR.string.controls_setting_choose_setting), - modifier = Modifier.padding(horizontal = 16.dp) - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + item { + Text( + text = stringResource(commonR.string.controls_setting_panel), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + item { + Row( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 48.dp) + .height(IntrinsicSize.Min) + ) { + ManageControlsModeButton( + isPanel = false, + selected = !panelEnabled, + onClick = { onSetPanelEnabled(false) }, + modifier = Modifier.weight(0.5f) + ) + Divider( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + ) + ManageControlsModeButton( + isPanel = true, + selected = panelEnabled, + onClick = { onSetPanelEnabled(true) }, + modifier = Modifier.weight(0.5f) + ) + } + } } - if (entitiesLoaded) { - if (entitiesList.isNotEmpty()) { - item { - Row(modifier = Modifier.padding(all = 16.dp)) { - OutlinedButton( - onClick = onSelectAll, - enabled = authSetting !== ControlsAuthRequiredSetting.NONE, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(commonR.string.controls_setting_choose_all)) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || !panelEnabled) { + item { + Text( + text = stringResource(commonR.string.controls_setting_choose_setting), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + if (entitiesLoaded) { + if (entitiesList.isNotEmpty()) { + item { + Row(modifier = Modifier.padding(all = 16.dp)) { + OutlinedButton( + onClick = onSelectAll, + enabled = authSetting !== ControlsAuthRequiredSetting.NONE, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(commonR.string.controls_setting_choose_all)) + } + Spacer(modifier = Modifier.width(16.dp)) + OutlinedButton( + onClick = onSelectNone, + enabled = authSetting !== ControlsAuthRequiredSetting.ALL, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(commonR.string.controls_setting_choose_none)) + } } - Spacer(modifier = Modifier.width(16.dp)) - OutlinedButton( - onClick = onSelectNone, - enabled = authSetting !== ControlsAuthRequiredSetting.ALL, - modifier = Modifier.weight(1f) - ) { - Text(stringResource(commonR.string.controls_setting_choose_none)) + } + if (serversList.size > 1) { + item { + ServerExposedDropdownMenu( + servers = serversList, + current = selectedServer, + onSelected = { selectedServer = it }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) } } - } - if (serversList.size > 1) { + items(entitiesList[selectedServer]?.size ?: 0, key = { "$selectedServer.${entitiesList[selectedServer]?.get(it)?.entityId}" }) { index -> + val entity = entitiesList[selectedServer]?.get(index) ?: return@items + ManageControlsEntity( + entityName = entity.friendlyName, + entityDomain = entity.domain, + selected = ( + authSetting == ControlsAuthRequiredSetting.NONE || + ( + authSetting == ControlsAuthRequiredSetting.SELECTION && + !authRequiredList.contains("$selectedServer.${entity.entityId}") + ) + ), + onClick = { onSelectEntity(entity.entityId, selectedServer) } + ) + } + } else { item { - ServerExposedDropdownMenu( - servers = serversList, - current = selectedServer, - onSelected = { selectedServer = it }, - modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + Text( + text = stringResource(commonR.string.controls_setting_choose_empty), + modifier = Modifier.padding(all = 16.dp), + fontStyle = FontStyle.Italic ) } } - items(entitiesList[selectedServer]?.size ?: 0, key = { "$selectedServer.${entitiesList[selectedServer]?.get(it)?.entityId}" }) { index -> - val entity = entitiesList[selectedServer]?.get(index) as Entity> - ManageControlsEntity( - entityName = ( - entity.attributes["friendly_name"] - ?: entity.entityId - ) as String, - entityDomain = entity.domain, - selected = ( - authSetting == ControlsAuthRequiredSetting.NONE || - ( - authSetting == ControlsAuthRequiredSetting.SELECTION && - !authRequiredList.contains("$selectedServer.${entity.entityId}") - ) - ), - onClick = { onSelectEntity(entity.entityId, selectedServer) } - ) - } } else { item { - Text( - text = stringResource(commonR.string.controls_setting_choose_empty), - modifier = Modifier.padding(all = 16.dp), - fontStyle = FontStyle.Italic - ) + Column(modifier = Modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.height(24.dp)) + CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } } } } else { + if (!initialPanelEnabled) { + item { + Box( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + HaAlertWarning( + message = stringResource(commonR.string.controls_setting_alert), + action = null, + onActionClicked = {} + ) + } + } + } + item { + Text( + text = stringResource(commonR.string.controls_setting_dashboard_setting), + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + if (serversList.size > 1) { + item { + ServerExposedDropdownMenu( + servers = serversList, + current = panelServer, + onSelected = { panelServer = it }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 16.dp) + ) + } + } + item { + TextField( + value = panelPath, + onValueChange = { panelPath = it }, + label = { Text(stringResource(id = R.string.lovelace_view_dashboard)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, autoCorrect = false, keyboardType = KeyboardType.Uri), + modifier = Modifier + .fillMaxWidth() + .padding(all = 16.dp) + ) + } item { - Column(modifier = Modifier.fillMaxWidth()) { - Spacer(modifier = Modifier.height(24.dp)) - CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + Row( + modifier = Modifier.padding(start = 16.dp, bottom = 16.dp) + ) { + Button( + enabled = ( + ( + panelPath != panelSetting?.first && + !(panelPath == "" && panelSetting != null && panelSetting.first == null) + ) || + panelServer != panelSetting.second + ), + onClick = { onSetPanelSetting(panelPath, panelServer) } + ) { + Text(stringResource(commonR.string.save)) + } } } } @@ -157,3 +290,49 @@ fun ManageControlsEntity( } } } + +@Composable +fun ManageControlsModeButton( + isPanel: Boolean, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .height(IntrinsicSize.Max) + .selectable(selected = selected, onClick = onClick) + ) { + Column( + modifier = Modifier.padding(all = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + asset = if (isPanel) { + CommunityMaterial.Icon3.cmd_view_dashboard + } else { + CommunityMaterial.Icon.cmd_dip_switch + }, + contentDescription = null, + modifier = Modifier.size(36.dp), + colorFilter = ColorFilter.tint(LocalContentColor.current) + ) + Text( + text = stringResource(if (isPanel) commonR.string.lovelace else commonR.string.controls_setting_mode_builtin_title), + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "${stringResource(if (isPanel) commonR.string.controls_setting_mode_panel_info else commonR.string.controls_setting_mode_builtin_info)}\n", // Newline for spacing + fontSize = 14.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + RadioButton( + selected = selected, + onClick = null // Handled by parent + ) + } + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/shortcuts/views/ManageShortcutsView.kt b/app/src/main/java/io/homeassistant/companion/android/settings/shortcuts/views/ManageShortcutsView.kt index 84b23a0db36..b54bdc771c9 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/shortcuts/views/ManageShortcutsView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/shortcuts/views/ManageShortcutsView.kt @@ -11,6 +11,7 @@ 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.text.KeyboardOptions import androidx.compose.material.Button import androidx.compose.material.Divider import androidx.compose.material.DropdownMenu @@ -31,6 +32,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.mikepenz.iconics.compose.IconicsPainter @@ -230,6 +233,7 @@ private fun CreateShortcutView( value = viewModel.shortcuts[i].path.value, onValueChange = { viewModel.shortcuts[i].path.value = it }, label = { Text(stringResource(id = R.string.lovelace_view_dashboard)) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, autoCorrect = false, keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) ) } else { diff --git a/app/src/main/java/io/homeassistant/companion/android/thread/ThreadManager.kt b/app/src/main/java/io/homeassistant/companion/android/thread/ThreadManager.kt index cabec682943..7976e890c45 100644 --- a/app/src/main/java/io/homeassistant/companion/android/thread/ThreadManager.kt +++ b/app/src/main/java/io/homeassistant/companion/android/thread/ThreadManager.kt @@ -48,10 +48,16 @@ interface ThreadManager { /** * Import a Thread dataset from the server to this device. * @param datasetId The dataset ID as provided by the server + * @param preferredBorderAgentId The ID for the border agent that provides the dataset * @throws Exception if a preferred dataset exists on the server, but it wasn't possible to * import it */ - suspend fun importDatasetFromServer(context: Context, datasetId: String, serverId: Int) + suspend fun importDatasetFromServer( + context: Context, + datasetId: String, + preferredBorderAgentId: String?, + serverId: Int + ) /** * Start a flow to get the preferred Thread dataset from this device to export to the server. diff --git a/app/src/main/java/io/homeassistant/companion/android/util/vehicle/GridItems.kt b/app/src/main/java/io/homeassistant/companion/android/util/vehicle/GridItems.kt index 053f62770b7..d5a9803b979 100755 --- a/app/src/main/java/io/homeassistant/companion/android/util/vehicle/GridItems.kt +++ b/app/src/main/java/io/homeassistant/companion/android/util/vehicle/GridItems.kt @@ -154,18 +154,16 @@ fun getDomainList( if (!domainIsEmpty) { listBuilder.addItem( GridItem.Builder().apply { - if (icon != null) { - setImage( - CarIcon.Builder( - IconicsDrawable(carContext, icon) - .apply { - sizeDp = 64 - }.toAndroidIconCompat() - ) - .setTint(CarColor.DEFAULT) - .build() + setImage( + CarIcon.Builder( + IconicsDrawable(carContext, icon) + .apply { + sizeDp = 64 + }.toAndroidIconCompat() ) - } + .setTint(CarColor.DEFAULT) + .build() + ) } .setTitle(friendlyDomain) .setOnClickListener { diff --git a/app/src/main/java/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt b/app/src/main/java/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt index d87532c6ce9..cacd6f0eed9 100644 --- a/app/src/main/java/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt +++ b/app/src/main/java/io/homeassistant/companion/android/vehicle/EntityGridVehicleScreen.kt @@ -19,16 +19,17 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.mikepenz.iconics.IconicsDrawable -import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.toAndroidIconCompat import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.EntityExt import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.data.integration.domain import io.homeassistant.companion.android.common.data.integration.friendlyName import io.homeassistant.companion.android.common.data.integration.friendlyState import io.homeassistant.companion.android.common.data.integration.getIcon +import io.homeassistant.companion.android.common.data.integration.isActive import io.homeassistant.companion.android.common.data.integration.isExecuting import io.homeassistant.companion.android.common.data.integration.onPressed import io.homeassistant.companion.android.common.data.prefs.PrefsRepository @@ -162,7 +163,7 @@ class EntityGridVehicleScreen( Log.i(TAG, "Grid limit ($gridLimit) reached, not adding more entities (${entities.size}) for $title ") return@forEachIndexed } - val icon = entity.getIcon(carContext) ?: CommunityMaterial.Icon.cmd_cloud_question + val icon = entity.getIcon(carContext) val gridItem = GridItem.Builder() .setLoading(false) @@ -212,7 +213,16 @@ class EntityGridVehicleScreen( sizeDp = 64 }.toAndroidIconCompat() ) - .setTint(CarColor.DEFAULT) + .setTint( + if (entity.isActive() && entity.domain in EntityExt.STATE_COLORED_DOMAINS) { + CarColor.createCustom( + carContext.getColor(R.color.colorYellow), + carContext.getColor(R.color.colorYellow) + ) + } else { + CarColor.DEFAULT + } + ) .build() ) } diff --git a/app/src/main/java/io/homeassistant/companion/android/vehicle/MapVehicleScreen.kt b/app/src/main/java/io/homeassistant/companion/android/vehicle/MapVehicleScreen.kt index 7b0a0731fd9..f5f459bea10 100644 --- a/app/src/main/java/io/homeassistant/companion/android/vehicle/MapVehicleScreen.kt +++ b/app/src/main/java/io/homeassistant/companion/android/vehicle/MapVehicleScreen.kt @@ -19,16 +19,17 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.mikepenz.iconics.IconicsDrawable -import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.toAndroidIconCompat import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.EntityExt import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.data.integration.domain import io.homeassistant.companion.android.common.data.integration.friendlyName import io.homeassistant.companion.android.common.data.integration.friendlyState import io.homeassistant.companion.android.common.data.integration.getIcon +import io.homeassistant.companion.android.common.data.integration.isActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import io.homeassistant.companion.android.common.R as commonR @@ -91,7 +92,7 @@ class MapVehicleScreen( Log.i(TAG, "Grid limit ($gridLimit) reached, not adding any more navigation entities (${entities.size})") return@forEachIndexed } - val icon = pair.first.getIcon(carContext) ?: CommunityMaterial.Icon.cmd_account + val icon = pair.first.getIcon(carContext) gridBuilder.addItem( GridItem.Builder() .setTitle(pair.first.friendlyName) @@ -103,7 +104,16 @@ class MapVehicleScreen( sizeDp = 64 }.toAndroidIconCompat() ) - .setTint(CarColor.DEFAULT) + .setTint( + if (pair.first.isActive() && pair.first.domain in EntityExt.STATE_COLORED_DOMAINS) { + CarColor.createCustom( + carContext.getColor(R.color.colorYellow), + carContext.getColor(R.color.colorYellow) + ) + } else { + CarColor.DEFAULT + } + ) .build() ) .setOnClickListener { diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebView.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebView.kt index faa67e6756f..fc8ada6d6ec 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebView.kt @@ -10,7 +10,7 @@ interface WebView { TIMEOUT } - fun loadUrl(url: String, keepHistory: Boolean) + fun loadUrl(url: String, keepHistory: Boolean, openInApp: Boolean) fun setStatusBarAndNavigationBarColor(statusBarColor: Int, navigationBarColor: Int) diff --git a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt index 6ea65cd7fee..c6c63223dca 100644 --- a/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -126,6 +126,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi companion object { const val EXTRA_PATH = "path" const val EXTRA_SERVER = "server" + const val EXTRA_SHOW_WHEN_LOCKED = "show_when_locked" private const val TAG = "WebviewActivity" private const val APP_PREFIX = "app://" @@ -212,6 +213,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi private var firstAuthTime: Long = 0 private var resourceURL: String = "" private var appLocked = true + private var unlockingApp = false private var exoPlayer: ExoPlayer? = null private var isExoFullScreen = false private var exoTop = 0 // These margins are from the DOM and scaled to screen @@ -230,6 +232,14 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { + if ( + intent.extras?.containsKey(EXTRA_SHOW_WHEN_LOCKED) == true && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1 + ) { + // Allow showing this on the lock screen when using device controls panel + setShowWhenLocked(intent.extras?.getBoolean(EXTRA_SHOW_WHEN_LOCKED) ?: false) + } + super.onCreate(savedInstanceState) binding = ActivityWebviewBinding.inflate(layoutInflater) @@ -1031,6 +1041,7 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi } else -> Log.d(TAG, "Authentication failed, retry attempts allowed") } + unlockingApp = false } override fun onWindowFocusChanged(hasFocus: Boolean) { @@ -1040,7 +1051,10 @@ class WebViewActivity : BaseActivity(), io.homeassistant.companion.android.webvi val path = intent.getStringExtra(EXTRA_PATH) presenter.onViewReady(path) if (path?.startsWith("entityId:") == true) { - moreInfoEntity = path.substringAfter("entityId:") + // Get the entity ID from a string formatted "entityId:domain.entity" + // https://github.com/home-assistant/core/blob/dev/homeassistant/core.py#L159 + val pattern = "(?<=^entityId:)((?!.+__)(?!_)[\\da-z_]+(? } .setPositiveButton(android.R.string.ok) { _, _ -> + if (dynamicFields.any { it.field == binding.widgetTextConfigService.text.toString() }) return@setPositiveButton + + val position = dynamicFields.size dynamicFields.add( + position, ServiceFieldBinder( binding.widgetTextConfigService.text.toString(), fieldKeyInput.text.toString() ) ) - dynamicFieldAdapter.notifyDataSetChanged() + dynamicFieldAdapter.notifyItemInserted(position) } .show() } @@ -134,6 +138,7 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity() { Log.d(TAG, "Fields applicable to this service: $fields") val existingServiceData = mutableMapOf() + val addedFields = mutableListOf() buttonWidgetDao.get(appWidgetId)?.let { buttonWidget -> if ( buttonWidget.serverId != selectedServerId || @@ -146,6 +151,7 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity() { for (item in dbMap) { val value = item.value.toString().replace("[", "").replace("]", "") + if (item.key == "entity_id") ", " else "" existingServiceData[item.key] = value.ifEmpty { null } + addedFields.add(item.key) } } @@ -165,6 +171,10 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity() { dynamicFields.add(ServiceFieldBinder(serviceText, fieldKey, existingServiceData[fieldKey])) } } + addedFields.minus("entity_id").minus(fieldKeys).forEach { extraFieldKey -> + Log.d(TAG, "Creating a text input box for extra $extraFieldKey") + dynamicFields.add(ServiceFieldBinder(serviceText, extraFieldKey, existingServiceData[extraFieldKey])) + } dynamicFieldAdapter.notifyDataSetChanged() } else { diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/common/WidgetDynamicFieldAdapter.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/common/WidgetDynamicFieldAdapter.kt index 1bf7b495219..f65f096454b 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/common/WidgetDynamicFieldAdapter.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/common/WidgetDynamicFieldAdapter.kt @@ -148,6 +148,8 @@ class WidgetDynamicFieldAdapter( // Set text to empty string to prevent a recycled, incorrect value autoCompleteTextView.setText("") } + } else { + autoCompleteTextView.setText("") } // Have the text view store its text for later recall @@ -203,21 +205,9 @@ class WidgetDynamicFieldAdapter( } } - private fun String.toBooleanOrNull(): Boolean? { - // Parse all valid YAML boolean values - return when (this.trim().lowercase(Locale.getDefault())) { - "true" -> true - "on" -> true - "yes" -> true - "y" -> true - - "false" -> false - "off" -> false - "no" -> false - "n" -> false - - // If it's not a valid YAML boolean, return null - else -> null - } + private fun String.toBooleanOrNull(): Boolean? = when (lowercase()) { + "true" -> true + "false" -> false + else -> null } } diff --git a/app/src/main/res/xml/changelog_master.xml b/app/src/main/res/xml/changelog_master.xml index 2c13b5fd68b..f3fcac79f94 100755 --- a/app/src/main/res/xml/changelog_master.xml +++ b/app/src/main/res/xml/changelog_master.xml @@ -1,14 +1,22 @@ - + + <b>Breaking Change:</b> Tiles and Device Controls may show a different highlighted state for some domains, to better match the frontend + <b>Breaking Change:</b> Unavailable is now unavailable and Unknown is now unknown for all sensors that report those states. Phone SIM, Next Alarm, Media Session, Geocoded location Widgets can now toggle on tap or refresh + Android Auto: Color icons to indicate entity is active + Show select server dialog when deep link is used Bug fixes, design improvements and dependency updates - + + <b>Breaking Change:</b> Home screen may show a different highlighted state for some domains, to better match the frontend + <b>Breaking Change:</b> Unavailable is now unavailable and Unknown is now unknown for all sensors that report those states. Phone SIM, Next Alarm Bug fixes, design improvements and dependency updates - + + <b>Breaking Change:</b> Unavailable is now unavailable and Unknown is now unknown for all sensors that report those states. Phone SIM, Next Alarm, Media Session, Geocoded location + Driving Interface: Color icons to indicate entity is active Bug fixes, design improvements and dependency updates diff --git a/app/src/minimal/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt b/app/src/minimal/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt index 4b417b17f0d..875ea499bfe 100644 --- a/app/src/minimal/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt +++ b/app/src/minimal/java/io/homeassistant/companion/android/thread/ThreadManagerImpl.kt @@ -24,7 +24,12 @@ class ThreadManagerImpl @Inject constructor() : ThreadManager { override suspend fun getPreferredDatasetFromServer(serverId: Int): ThreadDatasetResponse? = null - override suspend fun importDatasetFromServer(context: Context, datasetId: String, serverId: Int) { } + override suspend fun importDatasetFromServer( + context: Context, + datasetId: String, + preferredBorderAgentId: String?, + serverId: Int + ) { } override suspend fun getPreferredDatasetFromDevice(context: Context): IntentSender? { throw IllegalStateException("Thread is not supported with the minimal flavor") diff --git a/build.gradle.kts b/build.gradle.kts index 29b70d02ea7..cb0037ab446 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.kotlin.kapt).apply(false) alias(libs.plugins.kotlin.parcelize).apply(false) alias(libs.plugins.firebase.crashlytics).apply(false) + alias(libs.plugins.ksp).apply(false) } allprojects { diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 5bf7a61580a..937cbb34c68 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -2,6 +2,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.ksp) alias(libs.plugins.hilt) } @@ -22,15 +23,8 @@ android { buildConfigField("String", "RATE_LIMIT_URL", "\"$homeAssistantAndroidRateLimitUrl\"") buildConfigField("String", "VERSION_NAME", "\"$versionName-$versionCode\"") - javaCompileOptions { - annotationProcessorOptions { - arguments( - mapOf( - "room.incremental" to "true", - "room.schemaLocation" to "$projectDir/schemas" - ) - ) - } + ksp { + arg("room.schemaLocation", "$projectDir/schemas") } } @@ -67,7 +61,7 @@ dependencies { api(libs.androidx.room.runtime) api(libs.androidx.room.ktx) - kapt(libs.androidx.room.compiler) + ksp(libs.androidx.room.compiler) api(libs.androidx.work.runtime.ktx) diff --git a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/44.json b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/44.json new file mode 100644 index 00000000000..5d95687c3e7 --- /dev/null +++ b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/44.json @@ -0,0 +1,1040 @@ +{ + "formatVersion": 1, + "database": { + "version": 44, + "identityHash": "3201b89ecfb5c51a8600de7386008f21", + "entities": [ + { + "tableName": "sensor_attributes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))", + "fields": [ + { + "fieldPath": "sensorId", + "columnName": "sensor_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "value_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sensor_id", + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "authentication_list", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`host`))", + "fields": [ + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "host" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sensors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL, `registered` INTEGER DEFAULT NULL, `state` TEXT NOT NULL, `last_sent_state` TEXT DEFAULT NULL, `last_sent_icon` TEXT DEFAULT NULL, `state_type` TEXT NOT NULL, `type` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `device_class` TEXT, `unit_of_measurement` TEXT, `state_class` TEXT, `entity_category` TEXT, `core_registration` TEXT, `app_registration` TEXT, PRIMARY KEY(`id`, `server_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registered", + "columnName": "registered", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSentState", + "columnName": "last_sent_state", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastSentIcon", + "columnName": "last_sent_icon", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "stateType", + "columnName": "state_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceClass", + "columnName": "device_class", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unitOfMeasurement", + "columnName": "unit_of_measurement", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "stateClass", + "columnName": "state_class", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "entityCategory", + "columnName": "entity_category", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coreRegistration", + "columnName": "core_registration", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appRegistration", + "columnName": "app_registration", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "server_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sensor_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `entries` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))", + "fields": [ + { + "fieldPath": "sensorId", + "columnName": "sensor_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "value_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entries", + "columnName": "entries", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sensor_id", + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "button_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT NOT NULL, `domain` TEXT NOT NULL, `service` TEXT NOT NULL, `service_data` TEXT NOT NULL, `label` TEXT, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, `require_authentication` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "service", + "columnName": "service", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serviceData", + "columnName": "service_data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "requireAuthentication", + "columnName": "require_authentication", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "camera_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "media_player_controls_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `label` TEXT, `show_skip` INTEGER NOT NULL, `show_seek` INTEGER NOT NULL, `show_volume` INTEGER NOT NULL, `show_source` INTEGER NOT NULL DEFAULT false, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showSkip", + "columnName": "show_skip", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showSeek", + "columnName": "show_seek", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showVolume", + "columnName": "show_volume", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showSource", + "columnName": "show_source", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "static_widget", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` REAL NOT NULL, `state_separator` TEXT NOT NULL, `attribute_separator` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeIds", + "columnName": "attribute_ids", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "stateSeparator", + "columnName": "state_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeSeparator", + "columnName": "attribute_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "template_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `template` TEXT NOT NULL, `text_size` REAL NOT NULL DEFAULT 12.0, `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "template", + "columnName": "template", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true, + "defaultValue": "12.0" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `received` INTEGER NOT NULL, `message` TEXT NOT NULL, `data` TEXT NOT NULL, `source` TEXT NOT NULL, `server_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "received", + "columnName": "received", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "qs_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tile_id` TEXT NOT NULL, `added` INTEGER NOT NULL DEFAULT 1, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT, `entity_id` TEXT NOT NULL, `label` TEXT NOT NULL, `subtitle` TEXT, `should_vibrate` INTEGER NOT NULL DEFAULT 0, `auth_required` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tileId", + "columnName": "tile_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shouldVibrate", + "columnName": "should_vibrate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "authRequired", + "columnName": "auth_required", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `friendly_name` TEXT NOT NULL, `icon` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "friendlyName", + "columnName": "friendly_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "camera_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT, `refresh_interval` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refreshInterval", + "columnName": "refresh_interval", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "entity_state_complications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT NOT NULL, `show_title` INTEGER NOT NULL DEFAULT 1, `show_unit` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "showTitle", + "columnName": "show_title", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "showUnit", + "columnName": "show_unit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `_name` TEXT NOT NULL, `name_override` TEXT, `_version` TEXT, `list_order` INTEGER NOT NULL, `device_name` TEXT, `external_url` TEXT NOT NULL, `internal_url` TEXT, `cloud_url` TEXT, `webhook_id` TEXT, `secret` TEXT, `cloudhook_url` TEXT, `use_cloud` INTEGER NOT NULL, `internal_ssids` TEXT NOT NULL, `prioritize_internal` INTEGER NOT NULL, `access_token` TEXT, `refresh_token` TEXT, `token_expiration` INTEGER, `token_type` TEXT, `install_id` TEXT, `user_id` TEXT, `user_name` TEXT, `user_is_owner` INTEGER, `user_is_admin` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "_name", + "columnName": "_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameOverride", + "columnName": "name_override", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "_version", + "columnName": "_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "device_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.externalUrl", + "columnName": "external_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connection.internalUrl", + "columnName": "internal_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.cloudUrl", + "columnName": "cloud_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.webhookId", + "columnName": "webhook_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.cloudhookUrl", + "columnName": "cloudhook_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.useCloud", + "columnName": "use_cloud", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "connection.internalSsids", + "columnName": "internal_ssids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connection.prioritizeInternal", + "columnName": "prioritize_internal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "session.accessToken", + "columnName": "access_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.refreshToken", + "columnName": "refresh_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.tokenExpiration", + "columnName": "token_expiration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "session.tokenType", + "columnName": "token_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.id", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.name", + "columnName": "user_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.isOwner", + "columnName": "user_is_owner", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "user.isAdmin", + "columnName": "user_is_admin", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websocket_setting` TEXT NOT NULL, `sensor_update_frequency` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "websocketSetting", + "columnName": "websocket_setting", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sensorUpdateFrequency", + "columnName": "sensor_update_frequency", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3201b89ecfb5c51a8600de7386008f21')" + ] + } +} \ No newline at end of file diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/Entity.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/Entity.kt index a8d3e61ca1c..42d4e70958a 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/Entity.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/Entity.kt @@ -53,6 +53,38 @@ object EntityExt { "scene", "script" ) + + val STATE_COLORED_DOMAINS = listOf( + "alarm_control_panel", + "alert", + "automation", + "binary_sensor", + "calendar", + "camera", + "climate", + "cover", + "device_tracker", + "fan", + "group", + "humidifier", + "input_boolean", + "lawn_mower", + "light", + "lock", + "media_player", + "person", + "plant", + "remote", + "schedule", + "script", + "siren", + "sun", + "switch", + "timer", + "update", + "vacuum", + "water_heater" + ) } val Entity.domain: String @@ -181,7 +213,7 @@ fun Entity.getFanSteps(): Int? { ((attributes as Map<*, *>)["percentage_step"] as? Double)?.toDouble() ?: 1.0 ) - 1 } catch (e: Exception) { - Log.e(EntityExt.TAG, "Unable to get getFanSteps") + Log.e(EntityExt.TAG, "Unable to get getFanSteps", e) null } } @@ -195,7 +227,7 @@ fun Entity.supportsLightBrightness(): Boolean { val supportedColorModes = (attributes as Map<*, *>)["supported_color_modes"] as? List val supportsBrightness = - if (supportedColorModes == null) false else (supportedColorModes - EntityExt.LIGHT_MODE_NO_BRIGHTNESS_SUPPORT).isNotEmpty() + if (supportedColorModes == null) false else (supportedColorModes - EntityExt.LIGHT_MODE_NO_BRIGHTNESS_SUPPORT.toSet()).isNotEmpty() val supportedFeatures = attributes["supported_features"] as Int supportsBrightness || (supportedFeatures and EntityExt.LIGHT_SUPPORT_BRIGHTNESS_DEPR == EntityExt.LIGHT_SUPPORT_BRIGHTNESS_DEPR) } catch (e: Exception) { @@ -266,7 +298,7 @@ fun Entity.getLightColor(): Int? { } } -fun Entity.getIcon(context: Context): IIcon? { +fun Entity.getIcon(context: Context): IIcon { val attributes = this.attributes as Map val icon = attributes["icon"] as? String return if (icon?.startsWith("mdi") == true) { @@ -351,6 +383,7 @@ fun Entity.getIcon(context: Context): IIcon? { "input_number" -> CommunityMaterial.Icon3.cmd_ray_vertex "input_select" -> CommunityMaterial.Icon2.cmd_format_list_bulleted "input_text" -> CommunityMaterial.Icon2.cmd_form_textbox + "lawn_mower" -> CommunityMaterial.Icon3.cmd_robot_mower "light" -> CommunityMaterial.Icon2.cmd_lightbulb "lock" -> when (compareState) { "unlocked" -> CommunityMaterial.Icon2.cmd_lock_open @@ -667,30 +700,69 @@ val Entity.friendlyName: String get() = (attributes as? Map<*, *>)?.get("friendly_name")?.toString() ?: entityId fun Entity.friendlyState(context: Context, options: EntityRegistryOptions? = null, appendUnitOfMeasurement: Boolean = false): String { + // https://github.com/mikey0000/frontend/blob/c14d801380f04ac63c4cf9ac2479e3b39ef4db32/src/common/entity/get_states.ts#L5 var friendlyState = when (state) { + "above_horizon" -> context.getString(commonR.string.state_above_horizon) + "active" -> context.getString(commonR.string.state_active) "armed_away" -> context.getString(commonR.string.state_armed_away) "armed_custom_bypass" -> context.getString(commonR.string.state_armed_custom_bypass) "armed_home" -> context.getString(commonR.string.state_armed_home) "armed_night" -> context.getString(commonR.string.state_armed_night) "armed_vacation" -> context.getString(commonR.string.state_armed_vacation) "arming" -> context.getString(commonR.string.state_arming) + "auto" -> context.getString(commonR.string.state_auto) + "below_horizon" -> context.getString(commonR.string.state_below_horizon) + "buffering" -> context.getString(commonR.string.state_buffering) + "cleaning" -> context.getString(commonR.string.state_cleaning) + "clear-night" -> context.getString(commonR.string.state_clear_night) + "cloudy" -> context.getString(commonR.string.state_cloudy) "closed" -> context.getString(commonR.string.state_closed) "closing" -> context.getString(commonR.string.state_closing) + "cool" -> context.getString(commonR.string.state_cool) "disarmed" -> context.getString(commonR.string.state_disarmed) "disarming" -> context.getString(commonR.string.state_disarming) + "docked" -> context.getString(commonR.string.state_docked) + "dry" -> context.getString(commonR.string.state_dry) + "error" -> context.getString(commonR.string.state_error) + "exceptional" -> context.getString(commonR.string.state_exceptional) + "fan_only" -> context.getString(commonR.string.state_fan_only) + "fog" -> context.getString(commonR.string.state_fog) + "hail" -> context.getString(commonR.string.state_hail) + "heat" -> context.getString(commonR.string.state_heat) + "heat_cool" -> context.getString(commonR.string.state_heat_cool) + "home" -> context.getString(commonR.string.state_home) + "idle" -> context.getString(commonR.string.state_idle) "jammed" -> context.getString(commonR.string.state_jammed) + "lightning-raining" -> context.getString(commonR.string.state_lightning_raining) + "lightning" -> context.getString(commonR.string.state_lightning) "locked" -> context.getString(commonR.string.state_locked) "locking" -> context.getString(commonR.string.state_locking) + "mowing" -> context.getString(commonR.string.state_mowing) + "not_home" -> context.getString(commonR.string.state_not_home) "off" -> context.getString(commonR.string.state_off) "on" -> context.getString(commonR.string.state_on) "open" -> context.getString(commonR.string.state_open) "opening" -> context.getString(commonR.string.state_opening) + "partlycloudy" -> context.getString(commonR.string.state_partlycloudy) + "paused" -> context.getString(commonR.string.state_paused) "pending" -> context.getString(commonR.string.state_pending) + "playing" -> context.getString(commonR.string.state_playing) + "problem" -> context.getString(commonR.string.state_problem) + "pouring" -> context.getString(commonR.string.state_pouring) + "rainy" -> context.getString(commonR.string.state_rainy) + "recording" -> context.getString(commonR.string.state_recording) + "returning" -> context.getString(commonR.string.state_returning) + "snowy-rainy" -> context.getString(commonR.string.state_snowy_rainy) + "snowy" -> context.getString(commonR.string.state_snowy) + "standby" -> context.getString(commonR.string.state_standby) + "streaming" -> context.getString(commonR.string.state_streaming) + "sunny" -> context.getString(commonR.string.state_sunny) "triggered" -> context.getString(commonR.string.state_triggered) "unavailable" -> context.getString(commonR.string.state_unavailable) "unlocked" -> context.getString(commonR.string.state_unlocked) "unlocking" -> context.getString(commonR.string.state_unlocking) "unknown" -> context.getString(commonR.string.state_unknown) + "windy", "windy-variant" -> context.getString(commonR.string.state_windy) else -> state } if (friendlyState == state && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -746,3 +818,24 @@ fun Entity.isExecuting() = when (state) { "unlocking" -> true else -> false } + +fun Entity.isActive() = when { + // https://github.com/home-assistant/frontend/blob/dev/src/common/entity/state_active.ts + (domain in listOf("button", "input_button", "event", "scene")) -> state != "unavailable" + (state == "unavailable" || state == "unknown") -> false + (state == "off" && domain != "alert") -> false + (domain == "alarm_control_panel") -> state != "disarmed" + (domain == "alert") -> state != "idle" + (domain == "cover") -> state != "closed" + (domain in listOf("device_tracker", "person")) -> state != "not_home" + (domain == "lawn_mower") -> state in listOf("mowing", "error") + // on Android, contrary to HA Frontend, a lock is considered active when locked + (domain == "lock") -> state == "locked" + (domain == "media_player") -> state != "standby" + (domain == "vacuum") -> state !in listOf("idle", "docked", "paused") + (domain == "plant") -> state == "problem" + (domain == "group") -> state in listOf("on", "home", "open", "locked", "problem") + (domain == "timer") -> state == "active" + (domain == "camera") -> state == "streaming" + else -> true +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt index 43100b1cf13..07c8c458d06 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/IntegrationRepository.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.Flow interface IntegrationRepository { suspend fun registerDevice(deviceRegistration: DeviceRegistration) - suspend fun updateRegistration(deviceRegistration: DeviceRegistration) + suspend fun updateRegistration(deviceRegistration: DeviceRegistration, allowReregistration: Boolean = true) suspend fun getRegistration(): DeviceRegistration suspend fun deletePreferences() @@ -68,6 +68,18 @@ interface IntegrationRepository { suspend fun getLastUsedPipelineSttSupport(): Boolean suspend fun setLastUsedPipeline(pipelineId: String, supportsStt: Boolean) + + /** @return List of border agent IDs added to this device from the server */ + suspend fun getThreadBorderAgentIds(): List + + /** Set the list of border agent IDs added to this device from the server */ + suspend fun setThreadBorderAgentIds(ids: List) + + /** @return List of border agent IDs added to this device from a server that no longer exists */ + suspend fun getOrphanedThreadBorderAgentIds(): List + + /** Clear the list of orphaned border agent IDs, to use after removing them from storage */ + suspend fun clearOrphanedThreadBorderAgentIds() } @AssistedFactory diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt index 99504db3c37..48b704ccaf3 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/integration/impl/IntegrationRepositoryImpl.kt @@ -56,6 +56,7 @@ class IntegrationRepositoryImpl @AssistedInject constructor( private const val PREF_APP_VERSION = "app_version" // Note: _not_ server-specific private const val PREF_PUSH_TOKEN = "push_token" // Note: _not_ server-specific + private const val PREF_ORPHANED_THREAD_BORDER_AGENT_IDS = "orphaned_thread_border_agent_ids" // Note: _not_ server-specific private const val PREF_CHECK_SENSOR_REGISTRATION_NEXT = "sensor_reg_last" private const val PREF_SESSION_TIMEOUT = "session_timeout" @@ -64,6 +65,7 @@ class IntegrationRepositoryImpl @AssistedInject constructor( private const val PREF_SEC_WARNING_NEXT = "sec_warning_last" private const val PREF_LAST_USED_PIPELINE_ID = "last_used_pipeline" private const val PREF_LAST_USED_PIPELINE_STT = "last_used_pipeline_stt" + private const val PREF_THREAD_BORDER_AGENT_IDS = "thread_border_agent_ids" private const val TAG = "IntegrationRepository" private const val RATE_LIMIT_URL = BuildConfig.RATE_LIMIT_URL @@ -115,7 +117,7 @@ class IntegrationRepositoryImpl @AssistedInject constructor( } } - override suspend fun updateRegistration(deviceRegistration: DeviceRegistration) { + override suspend fun updateRegistration(deviceRegistration: DeviceRegistration, allowReregistration: Boolean) { val request = IntegrationRequest( "update_registration", @@ -124,9 +126,20 @@ class IntegrationRepositoryImpl @AssistedInject constructor( var causeException: Exception? = null for (it in server.connection.getApiUrls()) { try { - if (integrationService.callWebhook(it.toHttpUrlOrNull()!!, request).isSuccessful) { - persistDeviceRegistration(deviceRegistration) - return + val response = integrationService.callWebhook(it.toHttpUrlOrNull()!!, request) + // The server should return a body with the registration, but might return: + // 200 with empty body for broken direct webhook + // 404 for broken cloudhook + // 410 for missing config entry + if (response.isSuccessful) { + if (response.code() == 200 && (response.body()?.contentLength() ?: 0) == 0L) { + throw IllegalStateException("update_registration returned empty body") + } else { + persistDeviceRegistration(deviceRegistration) + return + } + } else if (response.code() == 404 || response.code() == 410) { + throw IllegalStateException("update_registration returned code ${response.code()}") } } catch (e: Exception) { if (causeException == null) causeException = e @@ -135,7 +148,16 @@ class IntegrationRepositoryImpl @AssistedInject constructor( } if (causeException != null) { - throw IntegrationException(causeException) + if (allowReregistration && (causeException is IllegalStateException)) { + Log.w(TAG, "Device registration broken, reregistering", causeException) + try { + registerDevice(deviceRegistration) + } catch (e: Exception) { + throw IntegrationException(e) + } + } else { + throw IntegrationException(causeException) + } } else { throw IntegrationException("Error calling integration request update_registration") } @@ -169,6 +191,15 @@ class IntegrationRepositoryImpl @AssistedInject constructor( localStorage.remove("${serverId}_$PREF_SEC_WARNING_NEXT") localStorage.remove("${serverId}_$PREF_LAST_USED_PIPELINE_ID") localStorage.remove("${serverId}_$PREF_LAST_USED_PIPELINE_STT") + + // Thread credentials are managed in the app module and can't be deleted now, so store them + val threadBorderAgentIds = getThreadBorderAgentIds() + if (threadBorderAgentIds.any()) { + val orphanedBorderAgentIds = localStorage.getStringSet(PREF_ORPHANED_THREAD_BORDER_AGENT_IDS).orEmpty() + localStorage.putStringSet(PREF_ORPHANED_THREAD_BORDER_AGENT_IDS, orphanedBorderAgentIds + threadBorderAgentIds.toSet()) + } + localStorage.remove("${serverId}_$PREF_THREAD_BORDER_AGENT_IDS") + // app version and push token is device-specific } @@ -565,6 +596,20 @@ class IntegrationRepositoryImpl @AssistedInject constructor( localStorage.putBoolean("${serverId}_$PREF_LAST_USED_PIPELINE_STT", supportsStt) } + override suspend fun getThreadBorderAgentIds(): List = + localStorage.getStringSet("${serverId}_$PREF_THREAD_BORDER_AGENT_IDS").orEmpty().toList() + + override suspend fun setThreadBorderAgentIds(ids: List) { + localStorage.putStringSet("${serverId}_$PREF_THREAD_BORDER_AGENT_IDS", ids.toSet()) + } + + override suspend fun getOrphanedThreadBorderAgentIds(): List = + localStorage.getStringSet(PREF_ORPHANED_THREAD_BORDER_AGENT_IDS).orEmpty().toList() + + override suspend fun clearOrphanedThreadBorderAgentIds() { + localStorage.remove(PREF_ORPHANED_THREAD_BORDER_AGENT_IDS) + } + override suspend fun getEntities(): List>? { val response = webSocketRepository.getStates() diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt index 5f45950bf0e..effbd918318 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/prefs/PrefsRepository.kt @@ -27,6 +27,14 @@ interface PrefsRepository { suspend fun setControlsAuthEntities(entities: List) + suspend fun getControlsPanelServer(): Int? + + suspend fun setControlsPanelServer(serverId: Int) + + suspend fun getControlsPanelPath(): String? + + suspend fun setControlsPanelPath(path: String?) + suspend fun isFullScreenEnabled(): Boolean suspend fun setFullScreenEnabled(enabled: Boolean) 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 3bd8c40c662..51c25e5fec3 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 @@ -22,6 +22,8 @@ class PrefsRepositoryImpl @Inject constructor( private const val PREF_SCREEN_ORIENTATION = "screen_orientation" private const val PREF_CONTROLS_AUTH_REQUIRED = "controls_auth_required" private const val PREF_CONTROLS_AUTH_ENTITIES = "controls_auth_entities" + private const val CONTROLS_PANEL_SERVER = "controls_panel_server" + private const val CONTROLS_PANEL_PATH = "controls_panel_path" private const val PREF_FULLSCREEN_ENABLED = "fullscreen_enabled" private const val PREF_KEEP_SCREEN_ON_ENABLED = "keep_screen_on_enabled" private const val PREF_PINCH_TO_ZOOM_ENABLED = "pinch_to_zoom_enabled" @@ -127,6 +129,24 @@ class PrefsRepositoryImpl @Inject constructor( localStorage.putStringSet(PREF_CONTROLS_AUTH_ENTITIES, entities.toSet()) } + override suspend fun getControlsPanelServer(): Int? = + localStorage.getInt(CONTROLS_PANEL_SERVER) + + override suspend fun setControlsPanelServer(serverId: Int) { + localStorage.putInt(CONTROLS_PANEL_SERVER, serverId) + } + + override suspend fun getControlsPanelPath(): String? = + localStorage.getString(CONTROLS_PANEL_PATH) + + override suspend fun setControlsPanelPath(path: String?) { + if (path.isNullOrBlank()) { + localStorage.remove(CONTROLS_PANEL_PATH) + } else { + localStorage.putString(CONTROLS_PANEL_PATH, path) + } + } + override suspend fun isFullScreenEnabled(): Boolean { return localStorage.getBoolean(PREF_FULLSCREEN_ENABLED) } @@ -213,5 +233,10 @@ class PrefsRepositoryImpl @Inject constructor( val autoFavorites = getAutoFavorites().filter { it.split("-")[0].toIntOrNull() != serverId } setAutoFavorites(autoFavorites) + + if (getControlsPanelServer() == serverId) { + localStorage.remove(CONTROLS_PANEL_SERVER) + setControlsPanelPath(null) + } } } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetResponse.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetResponse.kt index b2d45a42246..8abc5ea02d0 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetResponse.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/websocket/impl/entities/ThreadDatasetResponse.kt @@ -9,5 +9,6 @@ data class ThreadDatasetResponse( val networkName: String, val panId: String, val preferred: Boolean, + val preferredBorderAgentId: String?, // only on core >= 2023.9, may still be null val source: String ) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/AndroidOsSensorManager.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/AndroidOsSensorManager.kt index c1745baa8aa..98b9b86cc40 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/AndroidOsSensorManager.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/sensors/AndroidOsSensorManager.kt @@ -2,6 +2,7 @@ package io.homeassistant.companion.android.common.sensors import android.content.Context import android.os.Build +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.common.R as commonR class AndroidOsSensorManager : SensorManager { @@ -67,10 +68,10 @@ class AndroidOsSensorManager : SensorManager { osSecurityPatch.id -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Build.VERSION.SECURITY_PATCH } else { - "unknown" + STATE_UNKNOWN } else -> { - "unknown" + STATE_UNKNOWN } }, sensor.statelessIcon, diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/AudioSensorManager.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/AudioSensorManager.kt index eab8e227165..35623af8f13 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/AudioSensorManager.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/sensors/AudioSensorManager.kt @@ -6,6 +6,7 @@ import android.media.AudioManager import android.os.Build import androidx.annotation.RequiresApi import androidx.core.content.getSystemService +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.common.R as commonR class AudioSensorManager : SensorManager { @@ -187,7 +188,7 @@ class AudioSensorManager : SensorManager { AudioManager.RINGER_MODE_NORMAL -> "normal" AudioManager.RINGER_MODE_SILENT -> "silent" AudioManager.RINGER_MODE_VIBRATE -> "vibrate" - else -> "unknown" + else -> STATE_UNKNOWN } val icon = when (audioManager.ringerMode) { @@ -216,7 +217,7 @@ class AudioSensorManager : SensorManager { AudioManager.MODE_IN_CALL -> "in_call" AudioManager.MODE_IN_COMMUNICATION -> "in_communication" AudioManager.MODE_CALL_SCREENING -> "call_screening" - else -> "unknown" + else -> STATE_UNKNOWN } val icon = when (audioManager.mode) { diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/BatterySensorManager.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/BatterySensorManager.kt index fb3ba7faae2..aeeda0a78b6 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/BatterySensorManager.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/sensors/BatterySensorManager.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.BatteryManager +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import java.math.RoundingMode import io.homeassistant.companion.android.common.R as commonR @@ -314,7 +315,7 @@ class BatterySensorManager : SensorManager { BatteryManager.BATTERY_STATUS_CHARGING -> "charging" BatteryManager.BATTERY_STATUS_DISCHARGING -> "discharging" BatteryManager.BATTERY_STATUS_NOT_CHARGING -> "not_charging" - else -> "unknown" + else -> STATE_UNKNOWN } } @@ -326,7 +327,7 @@ class BatterySensorManager : SensorManager { BatteryManager.BATTERY_HEALTH_OVERHEAT -> "overheated" BatteryManager.BATTERY_HEALTH_OVER_VOLTAGE -> "over_voltage" BatteryManager.BATTERY_HEALTH_UNSPECIFIED_FAILURE -> "failed" - else -> "unknown" + else -> STATE_UNKNOWN } } diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/BluetoothSensorManager.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/BluetoothSensorManager.kt index 7d913048813..e7190fb2bf6 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/BluetoothSensorManager.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/sensors/BluetoothSensorManager.kt @@ -14,6 +14,7 @@ import io.homeassistant.companion.android.common.bluetooth.ble.KalmanFilter import io.homeassistant.companion.android.common.bluetooth.ble.MonitoringManager import io.homeassistant.companion.android.common.bluetooth.ble.TransmitterManager import io.homeassistant.companion.android.common.bluetooth.ble.name +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.database.AppDatabase import io.homeassistant.companion.android.database.sensor.SensorSetting import io.homeassistant.companion.android.database.sensor.SensorSettingType @@ -353,7 +354,7 @@ public class BluetoothSensorManager : SensorManager { TransmitterManager.stopTransmitting(bleTransmitterDevice) } - val lastState = AppDatabase.getInstance(context).sensorDao().get(bleTransmitter.id).firstOrNull()?.state ?: "unknown" + val lastState = AppDatabase.getInstance(context).sensorDao().get(bleTransmitter.id).firstOrNull()?.state ?: STATE_UNKNOWN val state = if (isBtOn(context)) bleTransmitterDevice.state else "Bluetooth is turned off" val icon = if (bleTransmitterDevice.transmitting) "mdi:bluetooth" else "mdi:bluetooth-off" onSensorUpdated( diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/DNDSensorManager.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/DNDSensorManager.kt index f0a507d0235..ed18daa7fb6 100755 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/DNDSensorManager.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/sensors/DNDSensorManager.kt @@ -7,6 +7,7 @@ import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi import androidx.core.content.getSystemService +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.common.R as commonR @RequiresApi(Build.VERSION_CODES.M) @@ -67,8 +68,8 @@ class DNDSensorManager : SensorManager { NotificationManager.INTERRUPTION_FILTER_ALL -> "off" NotificationManager.INTERRUPTION_FILTER_NONE -> "total_silence" NotificationManager.INTERRUPTION_FILTER_PRIORITY -> "priority_only" - NotificationManager.INTERRUPTION_FILTER_UNKNOWN -> "unknown" - else -> "unknown" + NotificationManager.INTERRUPTION_FILTER_UNKNOWN -> STATE_UNKNOWN + else -> STATE_UNKNOWN } onSensorUpdated( diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/LastRebootSensorManager.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/LastRebootSensorManager.kt index 00f0cc62d1a..d5563d62782 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/LastRebootSensorManager.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/sensors/LastRebootSensorManager.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.os.SystemClock import android.util.Log +import io.homeassistant.companion.android.common.util.STATE_UNAVAILABLE import io.homeassistant.companion.android.database.AppDatabase import io.homeassistant.companion.android.database.sensor.SensorSetting import io.homeassistant.companion.android.database.sensor.SensorSettingType @@ -62,7 +63,7 @@ class LastRebootSensorManager : SensorManager { var timeInMillis = 0L var local = "" - var utc = "unavailable" + var utc = STATE_UNAVAILABLE val sensorDao = AppDatabase.getInstance(context).sensorDao() val fullSensor = sensorDao.getFull(lastRebootSensor.id).toSensorWithAttributes() diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/NetworkSensorManager.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/NetworkSensorManager.kt index c17b98ebca3..d1abd2ccb6e 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/NetworkSensorManager.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/sensors/NetworkSensorManager.kt @@ -11,6 +11,8 @@ import android.os.Build import android.util.Log import androidx.annotation.RequiresApi import androidx.core.content.getSystemService +import io.homeassistant.companion.android.common.util.STATE_UNAVAILABLE +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.database.AppDatabase import io.homeassistant.companion.android.database.sensor.SensorSetting import io.homeassistant.companion.android.database.sensor.SensorSettingType @@ -188,7 +190,7 @@ class NetworkSensorManager : SensorManager { } var conInfo: WifiInfo? = null - var ssid = "Unknown" + var ssid = STATE_UNKNOWN var connected = false if (checkPermission(context, wifiConnection.id)) { @@ -272,7 +274,7 @@ class NetworkSensorManager : SensorManager { return } - var deviceIp = "Unknown" + var deviceIp = STATE_UNKNOWN if (checkPermission(context, wifiIp.id)) { val conInfo = getWifiConnectionInfo(context) @@ -445,7 +447,7 @@ class NetworkSensorManager : SensorManager { return } - var ip = "unknown" + var ip = STATE_UNKNOWN val client = OkHttpClient() val request = Request.Builder().url("https://api.ipify.org?format=json").build() @@ -485,7 +487,7 @@ class NetworkSensorManager : SensorManager { val activeNetwork = connectivityManager?.activeNetwork val capabilities = connectivityManager?.getNetworkCapabilities(activeNetwork) - var networkCapability = "unavailable" + var networkCapability = STATE_UNAVAILABLE var metered = false if (capabilities != null) { networkCapability = @@ -498,7 +500,7 @@ class NetworkSensorManager : SensorManager { (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) -> "vpn" (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) -> "wifi" (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI_AWARE)) -> "wifi_aware" - else -> "unknown" + else -> STATE_UNKNOWN } metered = !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/NextAlarmManager.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/NextAlarmManager.kt index 5a0c82e796b..48a2edc4f82 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/NextAlarmManager.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/sensors/NextAlarmManager.kt @@ -6,6 +6,8 @@ import android.content.pm.PackageManager import android.os.Build import android.util.Log import androidx.core.content.getSystemService +import io.homeassistant.companion.android.common.util.STATE_UNAVAILABLE +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.database.AppDatabase import io.homeassistant.companion.android.database.sensor.SensorSetting import io.homeassistant.companion.android.database.sensor.SensorSettingType @@ -68,7 +70,7 @@ class NextAlarmManager : SensorManager { var triggerTime = 0L var local = "" - var utc = "unavailable" + var utc = STATE_UNAVAILABLE var pendingIntent = "" val sensorDao = AppDatabase.getInstance(context).sensorDao() @@ -81,7 +83,7 @@ class NextAlarmManager : SensorManager { val alarmClockInfo = alarmManager.nextAlarmClock if (alarmClockInfo != null) { - pendingIntent = alarmClockInfo.showIntent?.creatorPackage ?: "Unknown" + pendingIntent = alarmClockInfo.showIntent?.creatorPackage ?: STATE_UNKNOWN triggerTime = alarmClockInfo.triggerTime Log.d(TAG, "Next alarm is scheduled by $pendingIntent with trigger time $triggerTime") diff --git a/common/src/main/java/io/homeassistant/companion/android/common/sensors/PhoneStateSensorManager.kt b/common/src/main/java/io/homeassistant/companion/android/common/sensors/PhoneStateSensorManager.kt index 98e8726d1c5..1ef032c9b9e 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/sensors/PhoneStateSensorManager.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/sensors/PhoneStateSensorManager.kt @@ -10,6 +10,8 @@ import android.telephony.SubscriptionManager import android.telephony.TelephonyManager import android.util.Log import androidx.core.content.getSystemService +import io.homeassistant.companion.android.common.util.STATE_UNAVAILABLE +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.common.R as commonR class PhoneStateSensorManager : SensorManager { @@ -78,7 +80,7 @@ class PhoneStateSensorManager : SensorManager { @SuppressLint("MissingPermission") private fun checkPhoneState(context: Context) { if (isEnabled(context, phoneState)) { - var currentPhoneState = "unknown" + var currentPhoneState = STATE_UNKNOWN if (checkPermission(context, phoneState.id)) { val telephonyManager = @@ -90,7 +92,7 @@ class PhoneStateSensorManager : SensorManager { TelephonyManager.CALL_STATE_IDLE -> "idle" TelephonyManager.CALL_STATE_RINGING -> "ringing" TelephonyManager.CALL_STATE_OFFHOOK -> "offhook" - else -> "unknown" + else -> STATE_UNKNOWN } } @@ -124,7 +126,7 @@ class PhoneStateSensorManager : SensorManager { return } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - var displayName = "Unavailable" + var displayName = STATE_UNAVAILABLE val attrs = mutableMapOf() if (checkPermission(context, basicSimSensor.id)) { 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 7aac4f4369e..aa0a730bed0 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 @@ -109,15 +109,14 @@ abstract class SensorReceiverBase : BroadcastReceiver() { return } + @Suppress("DEPRECATION") if (isSensorEnabled(LastUpdateManager.lastUpdate.id)) { LastUpdateManager().sendLastUpdate(context, intent.action) val allSettings = sensorDao.getSettings(LastUpdateManager.lastUpdate.id) for (setting in allSettings) { if (setting.value != "" && intent.action == setting.value) { val eventData = intent.extras?.keySet() - ?.associate { - it.toString() to (intent.extras?.getString(it) ?: "") - } + ?.associate { it.toString() to intent.extras?.get(it).toString() } ?.plus("intent" to intent.action.toString()) ?: mapOf("intent" to intent.action.toString()) Log.d(tag, "Event data: $eventData") diff --git a/common/src/main/java/io/homeassistant/companion/android/common/util/States.kt b/common/src/main/java/io/homeassistant/companion/android/common/util/States.kt new file mode 100755 index 00000000000..a269f52ea28 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/util/States.kt @@ -0,0 +1,4 @@ +package io.homeassistant.companion.android.common.util + +const val STATE_UNAVAILABLE = "unavailable" +const val STATE_UNKNOWN = "unknown" diff --git a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt index 450a652626b..cf87bcddb86 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt @@ -50,6 +50,8 @@ import io.homeassistant.companion.android.database.settings.LocalNotificationSet import io.homeassistant.companion.android.database.settings.LocalSensorSettingConverter import io.homeassistant.companion.android.database.settings.Setting import io.homeassistant.companion.android.database.settings.SettingsDao +import io.homeassistant.companion.android.database.wear.CameraTile +import io.homeassistant.companion.android.database.wear.CameraTileDao import io.homeassistant.companion.android.database.wear.EntityStateComplications import io.homeassistant.companion.android.database.wear.EntityStateComplicationsDao import io.homeassistant.companion.android.database.wear.FavoriteCaches @@ -87,11 +89,12 @@ import io.homeassistant.companion.android.common.R as commonR TileEntity::class, Favorites::class, FavoriteCaches::class, + CameraTile::class, EntityStateComplications::class, Server::class, Setting::class ], - version = 43, + version = 44, autoMigrations = [ AutoMigration(from = 24, to = 25), AutoMigration(from = 25, to = 26), @@ -110,7 +113,8 @@ import io.homeassistant.companion.android.common.R as commonR AutoMigration(from = 38, to = 39), AutoMigration(from = 39, to = 40), AutoMigration(from = 41, to = 42), - AutoMigration(from = 42, to = 43) + AutoMigration(from = 42, to = 43), + AutoMigration(from = 43, to = 44) ] ) @TypeConverters( @@ -133,6 +137,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun tileDao(): TileDao abstract fun favoritesDao(): FavoritesDao abstract fun favoriteCachesDao(): FavoriteCachesDao + abstract fun cameraTileDao(): CameraTileDao abstract fun entityStateComplicationsDao(): EntityStateComplicationsDao abstract fun serverDao(): ServerDao abstract fun settingsDao(): SettingsDao diff --git a/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt b/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt index b47c29553ab..99b57baf3cf 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt @@ -12,6 +12,7 @@ import io.homeassistant.companion.android.database.qs.TileDao import io.homeassistant.companion.android.database.sensor.SensorDao import io.homeassistant.companion.android.database.server.ServerDao import io.homeassistant.companion.android.database.settings.SettingsDao +import io.homeassistant.companion.android.database.wear.CameraTileDao import io.homeassistant.companion.android.database.wear.EntityStateComplicationsDao import io.homeassistant.companion.android.database.wear.FavoriteCachesDao import io.homeassistant.companion.android.database.wear.FavoritesDao @@ -72,6 +73,9 @@ object DatabaseModule { @Provides fun provideSettingsDao(database: AppDatabase): SettingsDao = database.settingsDao() + @Provides + fun provideCameraTileDao(database: AppDatabase): CameraTileDao = database.cameraTileDao() + @Provides fun provideEntityStateComplicationsDao(database: AppDatabase): EntityStateComplicationsDao = database.entityStateComplicationsDao() } diff --git a/common/src/main/java/io/homeassistant/companion/android/database/server/ServerConnectionInfo.kt b/common/src/main/java/io/homeassistant/companion/android/database/server/ServerConnectionInfo.kt index f0052649326..1db2732c287 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/server/ServerConnectionInfo.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/server/ServerConnectionInfo.kt @@ -8,7 +8,6 @@ import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import io.homeassistant.companion.android.common.data.wifi.WifiHelper -import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import java.net.URL @@ -43,34 +42,34 @@ data class ServerConnectionInfo( return arrayOf() } - val retVal = ArrayList() + val retVal = mutableListOf() // If we are local then add the local URL in the first position, otherwise no reason to try if (isInternal() || prioritizeInternal) { internalUrl?.let { retVal.add( - it.toHttpUrl().newBuilder() - .addPathSegments("api/webhook/$webhookId") - .build() - .toUrl() + it.toHttpUrlOrNull()?.newBuilder() + ?.addPathSegments("api/webhook/$webhookId") + ?.build() + ?.toUrl() ) } } cloudhookUrl?.let { - retVal.add(it.toHttpUrl().toUrl()) + retVal.add(it.toHttpUrlOrNull()?.toUrl()) } externalUrl.let { retVal.add( - it.toHttpUrl().newBuilder() - .addPathSegments("api/webhook/$webhookId") - .build() - .toUrl() + it.toHttpUrlOrNull()?.newBuilder() + ?.addPathSegments("api/webhook/$webhookId") + ?.build() + ?.toUrl() ) } - return retVal.toTypedArray() + return retVal.filterNotNull().toTypedArray() } fun getUrl(isInternal: Boolean? = null, force: Boolean = false): URL? { diff --git a/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTile.kt b/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTile.kt new file mode 100644 index 00000000000..633bba35a63 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTile.kt @@ -0,0 +1,23 @@ +package io.homeassistant.companion.android.database.wear + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Represents the configuration of a camera tile. + * If the tile was added but not configured, everything except the tile ID will be `null`. + */ +@Entity(tableName = "camera_tiles") +data class CameraTile( + /** The system's tile ID */ + @PrimaryKey + @ColumnInfo(name = "id") + val id: Int, + /** The camera entity ID */ + @ColumnInfo(name = "entity_id") + val entityId: String? = null, + /** The refresh interval of this tile, in seconds */ + @ColumnInfo(name = "refresh_interval") + val refreshInterval: Long? = null +) diff --git a/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTileDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTileDao.kt new file mode 100644 index 00000000000..a2425854076 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/wear/CameraTileDao.kt @@ -0,0 +1,23 @@ +package io.homeassistant.companion.android.database.wear + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface CameraTileDao { + + @Query("SELECT * FROM camera_tiles WHERE id = :id") + suspend fun get(id: Int): CameraTile? + + @Query("SELECT * FROM camera_tiles ORDER BY id ASC") + fun getAllFlow(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun add(tile: CameraTile) + + @Query("DELETE FROM camera_tiles where id = :id") + fun delete(id: Int) +} diff --git a/common/src/main/java/io/homeassistant/companion/android/util/UrlUtil.kt b/common/src/main/java/io/homeassistant/companion/android/util/UrlUtil.kt index cf3fd4e8d1a..a2d55519d81 100644 --- a/common/src/main/java/io/homeassistant/companion/android/util/UrlUtil.kt +++ b/common/src/main/java/io/homeassistant/companion/android/util/UrlUtil.kt @@ -1,11 +1,13 @@ package io.homeassistant.companion.android.util import android.net.Uri +import android.util.Log import io.homeassistant.companion.android.common.data.MalformedHttpUrlException import io.homeassistant.companion.android.common.data.authentication.impl.AuthenticationService import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.net.URI import java.net.URL object UrlUtil { @@ -39,15 +41,26 @@ object UrlUtil { } fun handle(base: URL?, input: String): URL? { + val asURI = try { + URI(input.removePrefix("homeassistant://navigate/")) + } catch (e: Exception) { + Log.w("UrlUtil", "Invalid input, returning base only") + null + } return when { - isAbsoluteUrl(input) -> { - URL(input) + asURI == null -> { + base } - input.startsWith("homeassistant://navigate/") -> { - (base.toString() + input.removePrefix("homeassistant://navigate/")).toHttpUrlOrNull()?.toUrl() + isAbsoluteUrl(input) -> { + asURI.toURL() } - else -> { - (base.toString() + input.removePrefix("/")).toHttpUrlOrNull()?.toUrl() + else -> { // Input is relative to base URL + val builder = base + ?.toHttpUrlOrNull() + ?.newBuilder() + if (!asURI.path.isNullOrBlank()) builder?.addPathSegments(asURI.path.trim().removePrefix("/")) + if (!asURI.query.isNullOrBlank()) builder?.query(asURI.query.trim()) + builder?.build()?.toUrl() } } } @@ -56,6 +69,17 @@ object UrlUtil { return Regex("^https?://").containsMatchIn(it.toString()) } + /** @return `true` if both URLs have the same 'base': an equal protocol, host, port and userinfo */ + fun URL.baseIsEqual(other: URL?): Boolean = + if (other == null) { + false + } else { + host?.lowercase() == other.host?.lowercase() && + port.let { if (it == -1) defaultPort else it } == other.port.let { if (it == -1) defaultPort else it } && + protocol?.lowercase() == other.protocol?.lowercase() && + userInfo == other.userInfo + } + fun splitNfcTagId(it: Uri?): String? { val matches = Regex("^https?://www\\.home-assistant\\.io/tag/(.*)").find( diff --git a/common/src/main/res/values/colors.xml b/common/src/main/res/values/colors.xml index b02635e181d..69f8f10accf 100644 --- a/common/src/main/res/values/colors.xml +++ b/common/src/main/res/values/colors.xml @@ -29,6 +29,7 @@ #1fffa600 #ffa600 #FDD663 + #F6C344 #9AA0A6 #8AB4F8 #FF8B66 diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 2e1b7fbec52..bfba4a55d89 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -120,6 +120,14 @@ Next Call any service Calendar + Camera + Camera tile + See what\'s on your camera + Log in to Home Assistant to add a camera tile + There are no camera tiles added yet - add one from the watch face to set it up + Edit the tile settings and select a camera to show + Camera tile #%d + Camera tiles Camera widgets Displays the latest image from the camera Cancel @@ -153,11 +161,17 @@ Continue Device controls Manage device controls + Choose which device controls mode you would like to use: + Built-in + Easy to use, with advanced features like locked device settings and multiple servers support + Use one of your Home Assistant dashboards to fully customize your controls Choose if quick access device controls can be used when this device is locked Choose which entities you want to be able to control using the built-in device controls option when this device is locked: Select all Select none No entities available + If you previously used built-in device controls, you may need to remove all controls before the dashboard will be shown + Enter the dashboard path you want to use. When left empty, the default dashboard will be used. Covers Help the developers fix bugs and crashes by leaving this enabled. If the application crashes this will automatically generate and send a report. If you notice a crash also create an issue on GitHub! Crash reporting @@ -1147,4 +1161,28 @@ Arming Disarming Pending + Mowing + Above horizon + Active + Below horizon + Buffering + Cloudy + Exceptional + Clear, night + Fog + Hail + Home + Lightning, raining + Lightning + Not home + Partly cloudy + Playing + Problem + Pouring + Rainy + Snowy, rainy + Snowy + Standby + Sunny + Windy diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 9a89eb91463..db6e66e4097 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -60,6 +60,7 @@ platform :android do track: 'beta', track_promote_to: 'production', skip_upload_changelogs: true, + sync_image_upload: true, ) supply( track: 'wear:beta', diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f914bc62bdd..7659b4afbbc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,9 @@ [versions] -accompanist = "0.30.1" +accompanist = "0.32.0" activity-compose = "1.7.2" androidBeaconLibrary = "2.19.6" androidJunit5 = "1.9.3.0" -androidPlugin = "8.1.0" +androidPlugin = "8.1.1" androidSdk-compile = "34" androidSdk-min = "21" androidSdk-target = "33" @@ -16,52 +16,55 @@ blurView = "version-1.6.6" car-versions = "1.3.0-rc01" changeLog = "3.4" community-material-typeface = "7.0.96.0-kotlin" -compose-bom = "2023.06.01" -compose-compiler = "1.4.8" +compose-bom = "2023.09.00" +compose-compiler = "1.5.3" constraintlayout = "2.1.4" converterJackson = "2.9.0" -coreKtx = "1.10.1" +coreKtx = "1.12.0" cronet-embedded = "113.5672.61" emojiJava = "5.1.1" -firebase-bom = "32.2.0" +firebase-bom = "32.2.3" firebaseAppdistributionGradle = "4.0.0" fragment-ktx = "1.6.1" googleServices = "4.3.15" guava = "32.1.1-android" healthServicesClient = "1.0.0-rc01" -hilt = "2.47" +hilt = "2.48" iconics = "5.4.0" jackson-module-kotlin = "2.13.5" javaVersion = "11" kotlinx-coroutines = "1.7.3" -kotlin = "1.8.22" -ktlint = "11.5.1" -lifecycle = "2.6.1" +kotlin = "1.9.10" +ksp = "1.9.10-1.0.13" +ktlint = "11.6.0" +lifecycle = "2.6.2" loggingInterceptor = "4.11.0" material = "1.9.0" -media3 = "1.1.0" -navigation-compose = "2.6.0" +media3 = "1.1.1" +navigation-compose = "2.7.2" okhttp = "4.11.0" picasso = "2.8" play-services-threadnetwork = "16.0.0" play-services-home = "16.0.0" play-services-location = "21.0.1" play-services-wearable = "18.0.0" -preference-ktx = "1.2.0" +preference-ktx = "1.2.1" recyclerview = "1.3.1" reorderable = "0.9.6" retrofit = "2.9.0" room = "2.5.2" -sentry-android = "6.28.0" +sentry-android = "6.29.0" watchfaceComplicationsDataSourceKtx = "1.1.1" -wear = "1.2.0" -wear-compose-foundation = "1.1.2" -wear-tiles = "1.2.0-alpha07" +wear = "1.3.0" +wear-compose-foundation = "1.2.0" +wear-protolayout = "1.0.0" +wear-tiles = "1.2.0" wearPhoneInteractions = "1.0.1" wearInput = "1.2.0-alpha02" -webkit = "1.7.0" +webkit = "1.8.0" wear-remote-interactions = "1.0.0" workRuntimeKtx = "2.8.1" +horologist = "0.5.5" firebase="2.9.6" @@ -74,6 +77,7 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebase" } @@ -120,6 +124,7 @@ constraintlayout = { module = "androidx.constraintlayout:constraintlayout", vers guava = { module = "com.google.guava:guava", version.ref = "guava" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } +horologist-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" } iconics-compose = { module = "com.mikepenz:iconics-compose", version.ref = "iconics" } iconics-core = { module = "com.mikepenz:iconics-core", version.ref = "iconics" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson-module-kotlin" } @@ -153,8 +158,10 @@ wear-compose-material = { module = "androidx.wear.compose:compose-material", ver wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "wear-compose-foundation" } wear-phone-interactions = { module = "androidx.wear:wear-phone-interactions", version.ref = "wearPhoneInteractions" } wear-input = { module = "androidx.wear:wear-input", version.ref = "wearInput" } +wear-protolayout-expression = { module = "androidx.wear.protolayout:protolayout-expression", version.ref = "wear-protolayout" } +wear-protolayout-main = { module = "androidx.wear.protolayout:protolayout", version.ref = "wear-protolayout" } +wear-protolayout-material = { module = "androidx.wear.protolayout:protolayout-material", version.ref = "wear-protolayout" } wear-remote-interactions = { module = "androidx.wear:wear-remote-interactions", version.ref = "wear-remote-interactions" } -wear-tiles-material = { module = "androidx.wear.tiles:tiles-material", version.ref = "wear-tiles" } wear-tiles = { module = "androidx.wear.tiles:tiles", version.ref = "wear-tiles" } webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx" } @@ -162,4 +169,5 @@ analytics = { module = "com.google.firebase:firebase-analytics" } amap = "com.amap.api:location:6.1.0" [bundles] -media3 = ["media3-exoplayer", "media3-exoplayer-hls", "media3-ui"] \ No newline at end of file +media3 = ["media3-exoplayer", "media3-exoplayer-hls", "media3-ui"] +wear-tiles = ["wear-tiles", "wear-protolayout-main", "wear-protolayout-expression", "wear-protolayout-material"] \ No newline at end of file diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index a8c3b1e6921..54f9c6cc4ea 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -19,12 +19,6 @@ android { versionName = project.version.toString() // We add 1 because the app and wear versions need to have different version codes. versionCode = (System.getenv("VERSION_CODE")?.toIntOrNull() ?: 1) + 1 - - javaCompileOptions { - annotationProcessorOptions { - arguments(mapOf("room.incremental" to "true")) - } - } } buildFeatures { @@ -114,9 +108,10 @@ dependencies { implementation(libs.wear.compose.material) implementation(libs.wear.compose.navigation) + implementation(libs.horologist.layout) + implementation(libs.guava) - implementation(libs.wear.tiles) - implementation(libs.wear.tiles.material) + implementation(libs.bundles.wear.tiles) implementation(libs.androidx.watchface.complications.data.source.ktx) diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index aa63f4c4776..7052fc7148e 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -84,7 +84,7 @@ @@ -92,6 +92,11 @@ + + + + + @@ -167,6 +172,27 @@ + + + + + + + + + + + diff --git a/wear/src/main/java/io/homeassistant/companion/android/complications/EntityStateDataSourceService.kt b/wear/src/main/java/io/homeassistant/companion/android/complications/EntityStateDataSourceService.kt index ce669d454be..dc7a8b974d3 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/complications/EntityStateDataSourceService.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/complications/EntityStateDataSourceService.kt @@ -13,7 +13,6 @@ import androidx.wear.watchface.complications.data.ShortTextComplicationData import androidx.wear.watchface.complications.datasource.ComplicationRequest import androidx.wear.watchface.complications.datasource.SuspendingComplicationDataSourceService import com.mikepenz.iconics.IconicsDrawable -import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import com.mikepenz.iconics.utils.colorInt import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.common.R @@ -69,7 +68,7 @@ class EntityStateDataSourceService : SuspendingComplicationDataSourceService() { null } - val icon = entity.getIcon(applicationContext) ?: CommunityMaterial.Icon.cmd_bookmark + val icon = entity.getIcon(applicationContext) val iconBitmap = IconicsDrawable(this, icon).apply { colorInt = Color.WHITE }.toBitmap() diff --git a/wear/src/main/java/io/homeassistant/companion/android/complications/views/ComplicationConfigMainView.kt b/wear/src/main/java/io/homeassistant/companion/android/complications/views/ComplicationConfigMainView.kt index d348c94f649..58753b1320d 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/complications/views/ComplicationConfigMainView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/complications/views/ComplicationConfigMainView.kt @@ -106,7 +106,7 @@ fun MainConfigView( modifier = Modifier.fillMaxWidth(), icon = { Image( - asset = iconBitmap ?: CommunityMaterial.Icon.cmd_bookmark, + asset = iconBitmap, colorFilter = ColorFilter.tint(wearColorPalette.onSurface) ) }, diff --git a/wear/src/main/java/io/homeassistant/companion/android/conversation/views/ConversationView.kt b/wear/src/main/java/io/homeassistant/companion/android/conversation/views/ConversationView.kt index 109ac4412dd..6dda1514421 100755 --- a/wear/src/main/java/io/homeassistant/companion/android/conversation/views/ConversationView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/conversation/views/ConversationView.kt @@ -35,6 +35,9 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Button import androidx.wear.compose.material.ButtonDefaults import androidx.wear.compose.material.Chip @@ -42,10 +45,7 @@ import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.LocalContentColor import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold -import androidx.wear.compose.material.ScalingLazyColumn import androidx.wear.compose.material.Text -import androidx.wear.compose.material.items -import androidx.wear.compose.material.rememberScalingLazyListState import androidx.wear.compose.navigation.SwipeDismissableNavHost import androidx.wear.compose.navigation.composable import androidx.wear.compose.navigation.rememberSwipeDismissableNavController diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt b/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt index 4e618370723..c9ad3f8a01f 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/HomeActivity.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.home.views.DEEPLINK_PREFIX_SET_CAMERA_TILE import io.homeassistant.companion.android.home.views.DEEPLINK_PREFIX_SET_SHORTCUT_TILE import io.homeassistant.companion.android.home.views.LoadHomePage import io.homeassistant.companion.android.onboarding.OnboardingActivity @@ -37,6 +38,16 @@ class HomeActivity : ComponentActivity(), HomeView { return Intent(context, HomeActivity::class.java) } + fun getCameraTileSettingsIntent( + context: Context, + tileId: Int + ) = Intent( + Intent.ACTION_VIEW, + "$DEEPLINK_PREFIX_SET_CAMERA_TILE/$tileId".toUri(), + context, + HomeActivity::class.java + ) + fun getShortcutsTileSettingsIntent( context: Context, tileId: Int diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt b/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt index 9a1341d646f..ed7cfd07160 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/MainViewModel.kt @@ -26,6 +26,8 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.En import io.homeassistant.companion.android.common.sensors.SensorManager import io.homeassistant.companion.android.data.SimplifiedEntity import io.homeassistant.companion.android.database.sensor.SensorDao +import io.homeassistant.companion.android.database.wear.CameraTile +import io.homeassistant.companion.android.database.wear.CameraTileDao import io.homeassistant.companion.android.database.wear.FavoriteCaches import io.homeassistant.companion.android.database.wear.FavoriteCachesDao import io.homeassistant.companion.android.database.wear.FavoritesDao @@ -48,6 +50,7 @@ class MainViewModel @Inject constructor( private val favoritesDao: FavoritesDao, private val favoriteCachesDao: FavoriteCachesDao, private val sensorsDao: SensorDao, + private val cameraTileDao: CameraTileDao, application: Application ) : AndroidViewModel(application) { @@ -88,6 +91,10 @@ class MainViewModel @Inject constructor( val shortcutEntitiesMap = mutableStateMapOf>() + val cameraTiles = cameraTileDao.getAllFlow().collectAsState() + var cameraEntitiesMap = mutableStateMapOf>>() + private set + var areas = mutableListOf() private set @@ -221,6 +228,10 @@ class MainViewModel @Inject constructor( getEntities.await()?.also { entities.clear() it.forEach { state -> updateEntityStates(state) } + + // Special list: camera entities + val cameraEntities = it.filter { entity -> entity.domain == "camera" } + cameraEntitiesMap["camera"] = mutableStateListOf>().apply { addAll(cameraEntities) } } if (!isFavoritesOnly) { updateEntityDomains() @@ -412,6 +423,18 @@ class MainViewModel @Inject constructor( } } + fun setCameraTileEntity(tileId: Int, entityId: String) = viewModelScope.launch { + val current = cameraTileDao.get(tileId) + val updated = current?.copy(entityId = entityId) ?: CameraTile(id = tileId, entityId = entityId) + cameraTileDao.add(updated) + } + + fun setCameraTileRefreshInterval(tileId: Int, interval: Long) = viewModelScope.launch { + val current = cameraTileDao.get(tileId) + val updated = current?.copy(refreshInterval = interval) ?: CameraTile(id = tileId, refreshInterval = interval) + cameraTileDao.add(updated) + } + fun setTileShortcut(tileId: Int?, index: Int, entity: SimplifiedEntity) { viewModelScope.launch { val shortcutEntities = shortcutEntitiesMap[tileId]!! diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/DetailsPanelView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/DetailsPanelView.kt index c2534af4443..70b99c272c3 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/DetailsPanelView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/DetailsPanelView.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Icon import androidx.wear.compose.material.InlineSlider import androidx.wear.compose.material.InlineSliderDefaults @@ -28,7 +29,6 @@ import androidx.wear.compose.material.Text import androidx.wear.compose.material.ToggleButton import androidx.wear.compose.material.ToggleButtonDefaults import androidx.wear.compose.material.ToggleChipDefaults -import androidx.wear.compose.material.rememberScalingLazyListState import com.mikepenz.iconics.compose.Image import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.common.R diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/EntityListView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/EntityListView.kt index c69179901f5..84017340ee0 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/EntityListView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/EntityListView.kt @@ -6,13 +6,13 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold import androidx.wear.compose.material.Text -import androidx.wear.compose.material.items -import androidx.wear.compose.material.rememberScalingLazyListState import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.theme.WearAppTheme import io.homeassistant.companion.android.util.playPreviewEntityScene1 diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/EntityUi.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/EntityUi.kt index f965d04fba9..b5fac00b2ff 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/EntityUi.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/EntityUi.kt @@ -20,12 +20,13 @@ import androidx.wear.compose.material.Text import androidx.wear.compose.material.ToggleChip import androidx.wear.compose.material.ToggleChipDefaults import com.mikepenz.iconics.compose.Image -import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.EntityExt import io.homeassistant.companion.android.common.data.integration.domain import io.homeassistant.companion.android.common.data.integration.getIcon +import io.homeassistant.companion.android.common.data.integration.isActive +import io.homeassistant.companion.android.common.util.STATE_UNAVAILABLE import io.homeassistant.companion.android.theme.wearColorPalette import io.homeassistant.companion.android.util.WearToggleChip import io.homeassistant.companion.android.util.onEntityClickedFeedback @@ -47,7 +48,7 @@ fun EntityUi( val friendlyName = attributes["friendly_name"].toString() if (entity.domain in EntityExt.DOMAINS_TOGGLE) { - val isChecked = entity.state in listOf("on", "locked", "open", "opening") + val isChecked = entity.isActive() ToggleChip( checked = isChecked, onCheckedChange = { @@ -58,7 +59,7 @@ fun EntityUi( .fillMaxWidth(), appIcon = { Image( - asset = iconBitmap ?: CommunityMaterial.Icon.cmd_bookmark, + asset = iconBitmap, colorFilter = ColorFilter.tint(wearColorPalette.onSurface) ) }, @@ -86,7 +87,7 @@ fun EntityUi( } ) }, - enabled = entity.state != "unavailable", + enabled = entity.state != STATE_UNAVAILABLE, toggleControl = { Icon( imageVector = ToggleChipDefaults.switchIcon(isChecked), @@ -105,7 +106,7 @@ fun EntityUi( .fillMaxWidth(), icon = { Image( - asset = iconBitmap ?: CommunityMaterial.Icon.cmd_bookmark, + asset = iconBitmap, colorFilter = ColorFilter.tint(wearColorPalette.onSurface) ) }, @@ -133,7 +134,7 @@ fun EntityUi( } ) }, - enabled = entity.state != "unavailable", + enabled = entity.state != STATE_UNAVAILABLE, onClick = { onEntityClicked(entity.entityId, entity.state) onEntityClickedFeedback(isToastEnabled, isHapticEnabled, context, friendlyName, haptic) diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt index 58126e6f1cc..6f790e8731e 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/HomeView.kt @@ -1,6 +1,9 @@ package io.homeassistant.companion.android.home.views import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.navigation.NavType import androidx.navigation.navArgument @@ -12,11 +15,13 @@ import androidx.wear.tiles.TileService import io.homeassistant.companion.android.common.sensors.id import io.homeassistant.companion.android.home.MainViewModel import io.homeassistant.companion.android.theme.WearAppTheme +import io.homeassistant.companion.android.tiles.CameraTile import io.homeassistant.companion.android.tiles.ShortcutsTile import io.homeassistant.companion.android.tiles.TemplateTile import io.homeassistant.companion.android.views.ChooseEntityView private const val ARG_SCREEN_SENSOR_MANAGER_ID = "sensorManagerId" +private const val ARG_SCREEN_CAMERA_TILE_ID = "cameraTileId" private const val ARG_SCREEN_SHORTCUTS_TILE_ID = "shortcutsTileId" private const val ARG_SCREEN_SHORTCUTS_TILE_ENTITY_INDEX = "shortcutsTileEntityIndex" @@ -27,6 +32,11 @@ private const val SCREEN_MANAGE_SENSORS = "manage_all_sensors" private const val SCREEN_SINGLE_SENSOR_MANAGER = "sensor_manager" private const val SCREEN_SETTINGS = "settings" private const val SCREEN_SET_FAVORITES = "set_favorites" +private const val ROUTE_CAMERA_TILE = "camera_tile" +private const val SCREEN_SELECT_CAMERA_TILE = "select_camera_tile" +private const val SCREEN_SET_CAMERA_TILE = "set_camera_tile" +private const val SCREEN_SET_CAMERA_TILE_ENTITY = "entity" +private const val SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL = "refresh_interval" private const val ROUTE_SHORTCUTS_TILE = "shortcuts_tile" private const val SCREEN_SELECT_SHORTCUTS_TILE = "select_shortcuts_tile" private const val SCREEN_SET_SHORTCUTS_TILE = "set_shortcuts_tile" @@ -35,6 +45,7 @@ private const val SCREEN_SET_TILE_TEMPLATE = "set_tile_template" private const val SCREEN_SET_TILE_TEMPLATE_REFRESH_INTERVAL = "set_tile_template_refresh_interval" const val DEEPLINK_SENSOR_MANAGER = "ha_wear://$SCREEN_SINGLE_SENSOR_MANAGER" +const val DEEPLINK_PREFIX_SET_CAMERA_TILE = "ha_wear://$SCREEN_SET_CAMERA_TILE" const val DEEPLINK_PREFIX_SET_SHORTCUT_TILE = "ha_wear://$SCREEN_SET_SHORTCUTS_TILE" @Composable @@ -147,6 +158,9 @@ fun LoadHomePage( onHapticEnabled = { mainViewModel.setHapticEnabled(it) }, onToastEnabled = { mainViewModel.setToastEnabled(it) }, setFavoritesOnly = { mainViewModel.setWearFavoritesOnly(it) }, + onClickCameraTile = { + swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$SCREEN_SELECT_CAMERA_TILE") + }, onClickTemplateTile = { swipeDismissableNavController.navigate(SCREEN_SET_TILE_TEMPLATE) }, onAssistantAppAllowed = mainViewModel::setAssistantApp ) @@ -163,6 +177,85 @@ fun LoadHomePage( } } } + composable("$ROUTE_CAMERA_TILE/$SCREEN_SELECT_CAMERA_TILE") { + SelectCameraTileView( + tiles = mainViewModel.cameraTiles.value, + onSelectTile = { tileId -> + swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$tileId/$SCREEN_SET_CAMERA_TILE") + } + ) + } + composable( + route = "$ROUTE_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}/$SCREEN_SET_CAMERA_TILE", + arguments = listOf( + navArgument(name = ARG_SCREEN_CAMERA_TILE_ID) { + type = NavType.IntType + } + ), + deepLinks = listOf( + navDeepLink { uriPattern = "$DEEPLINK_PREFIX_SET_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}" } + ) + ) { backStackEntry -> + val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_CAMERA_TILE_ID) + SetCameraTileView( + tile = mainViewModel.cameraTiles.value.firstOrNull { it.id == tileId }, + entities = mainViewModel.cameraEntitiesMap["camera"], + onSelectEntity = { + swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$tileId/$SCREEN_SET_CAMERA_TILE_ENTITY") + }, + onSelectRefreshInterval = { + swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$tileId/$SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL") + } + ) + } + composable( + route = "$ROUTE_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}/$SCREEN_SET_CAMERA_TILE_ENTITY", + arguments = listOf( + navArgument(name = ARG_SCREEN_CAMERA_TILE_ID) { + type = NavType.IntType + } + ) + ) { backStackEntry -> + val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_CAMERA_TILE_ID) + val cameraDomains = remember { mutableStateListOf("camera") } + val cameraFavorites = remember { mutableStateOf(emptyList()) } // There are no camera favorites + ChooseEntityView( + entitiesByDomainOrder = cameraDomains, + entitiesByDomain = mainViewModel.cameraEntitiesMap, + favoriteEntityIds = cameraFavorites, + onNoneClicked = {}, + onEntitySelected = { entity -> + tileId?.let { + mainViewModel.setCameraTileEntity(it, entity.entityId) + TileService.getUpdater(context).requestUpdate(CameraTile::class.java) + } + swipeDismissableNavController.navigateUp() + }, + allowNone = false + ) + } + composable( + route = "$ROUTE_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}/$SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL", + arguments = listOf( + navArgument(name = ARG_SCREEN_CAMERA_TILE_ID) { + type = NavType.IntType + } + ) + ) { backStackEntry -> + val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_CAMERA_TILE_ID) + RefreshIntervalPickerView( + currentInterval = ( + mainViewModel.cameraTiles.value + .firstOrNull { it.id == tileId }?.refreshInterval + ?: CameraTile.DEFAULT_REFRESH_INTERVAL + ).toInt() + ) { interval -> + tileId?.let { + mainViewModel.setCameraTileRefreshInterval(it, interval.toLong()) + } + swipeDismissableNavController.navigateUp() + } + } composable("$ROUTE_SHORTCUTS_TILE/$SCREEN_SELECT_SHORTCUTS_TILE") { SelectShortcutsTileView( shortcutTileEntitiesCountById = mainViewModel.shortcutEntitiesMap.mapValues { (_, entities) -> entities.size }, diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/MainView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/MainView.kt index 64472933b9f..a8ae606f365 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/MainView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/MainView.kt @@ -24,18 +24,18 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.CircularProgressIndicator import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold -import androidx.wear.compose.material.ScalingLazyListState import androidx.wear.compose.material.Text -import androidx.wear.compose.material.rememberScalingLazyListState import com.mikepenz.iconics.compose.Image import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.home.MainViewModel import io.homeassistant.companion.android.theme.WearAppTheme import io.homeassistant.companion.android.theme.wearColorPalette @@ -58,7 +58,7 @@ fun MainView( isHapticEnabled: Boolean, isToastEnabled: Boolean ) { - val scalingLazyListState: ScalingLazyListState = rememberScalingLazyListState() + val scalingLazyListState = rememberScalingLazyListState() var expandedFavorites: Boolean by rememberSaveable { mutableStateOf(true) } @@ -96,7 +96,7 @@ fun MainView( .fillMaxWidth(), icon = { Image( - asset = getIcon(cached?.icon, favoriteEntityID.split(".")[0], context) ?: CommunityMaterial.Icon.cmd_bookmark, + asset = getIcon(cached?.icon, favoriteEntityID.split(".")[0], context), colorFilter = ColorFilter.tint(wearColorPalette.onSurface) ) }, @@ -108,7 +108,7 @@ fun MainView( ) }, onClick = { - onEntityClicked(favoriteEntityID, "unknown") + onEntityClicked(favoriteEntityID, STATE_UNKNOWN) onEntityClickedFeedback(isToastEnabled, isHapticEnabled, context, favoriteEntityID, haptic) }, colors = ChipDefaults.secondaryChipColors() @@ -273,7 +273,7 @@ fun MainView( "", domain, context - )?.let { Image(asset = it) } + ).let { Image(asset = it) } }, label = { Text(text = mainViewModel.stringForDomain(domain)!!) diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/RefreshIntervalPickerView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/RefreshIntervalPickerView.kt index 0a1657b6f5d..8ae4b4e18b3 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/RefreshIntervalPickerView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/RefreshIntervalPickerView.kt @@ -39,7 +39,7 @@ fun RefreshIntervalPickerView( currentInterval: Int, onSelectInterval: (Int) -> Unit ) { - val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60) + val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 2 * 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60) val initialIndex = options.indexOf(currentInterval) val state = rememberPickerState( initialNumberOfOptions = options.size, diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectCameraTileView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectCameraTileView.kt new file mode 100644 index 00000000000..081e685a049 --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectCameraTileView.kt @@ -0,0 +1,86 @@ +package io.homeassistant.companion.android.home.views + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.wear.compose.foundation.lazy.itemsIndexed +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.Text +import io.homeassistant.companion.android.database.wear.CameraTile +import io.homeassistant.companion.android.theme.WearAppTheme +import io.homeassistant.companion.android.views.ListHeader +import io.homeassistant.companion.android.views.ThemeLazyColumn +import io.homeassistant.companion.android.common.R as commonR + +@Composable +fun SelectCameraTileView( + tiles: List, + onSelectTile: (tileId: Int) -> Unit +) { + val scalingLazyListState = rememberScalingLazyListState() + WearAppTheme { + Scaffold( + positionIndicator = { + if (scalingLazyListState.isScrollInProgress) { + PositionIndicator(scalingLazyListState = scalingLazyListState) + } + }, + timeText = { TimeText(scalingLazyListState = scalingLazyListState) } + ) { + ThemeLazyColumn(state = scalingLazyListState) { + item { + ListHeader(id = commonR.string.camera_tiles) + } + if (tiles.isEmpty()) { + item { + Text( + text = stringResource(commonR.string.camera_tile_no_tiles_yet), + textAlign = TextAlign.Center + ) + } + } else { + itemsIndexed(tiles, key = { _, item -> "tile.${item.id}" }) { index, tile -> + Chip( + modifier = Modifier.fillMaxWidth(), + label = { + Text(stringResource(commonR.string.camera_tile_n, index + 1)) + }, + secondaryLabel = if (tile.entityId != null) { + { Text(tile.entityId!!) } + } else { + null + }, + onClick = { onSelectTile(tile.id) }, + colors = ChipDefaults.secondaryChipColors() + ) + } + } + } + } + } +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND) +@Composable +private fun PreviewSelectCameraTileViewOne() { + SelectCameraTileView( + tiles = listOf( + CameraTile(id = 1, entityId = "camera.buienradar", refreshInterval = 300) + ), + onSelectTile = {} + ) +} + +@Preview(device = Devices.WEAR_OS_LARGE_ROUND) +@Composable +private fun PreviewSelectCameraTileViewEmpty() { + SelectCameraTileView(tiles = emptyList(), onSelectTile = {}) +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectShortcutsTileView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectShortcutsTileView.kt index 4f5935c1e31..bd860a034b6 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectShortcutsTileView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SelectShortcutsTileView.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.wear.compose.foundation.lazy.itemsIndexed +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Icon @@ -16,8 +18,6 @@ import androidx.wear.compose.material.Scaffold import androidx.wear.compose.material.Text import androidx.wear.compose.material.ToggleChip import androidx.wear.compose.material.ToggleChipDefaults -import androidx.wear.compose.material.itemsIndexed -import androidx.wear.compose.material.rememberScalingLazyListState import com.mikepenz.iconics.compose.Image import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.theme.WearAppTheme diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SensorManagerUi.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SensorManagerUi.kt index fb450d70d5e..97ac49ac23c 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/SensorManagerUi.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SensorManagerUi.kt @@ -11,11 +11,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.CircularProgressIndicator import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold -import androidx.wear.compose.material.ScalingLazyListState -import androidx.wear.compose.material.rememberScalingLazyListState import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.common.sensors.SensorManager import io.homeassistant.companion.android.database.sensor.Sensor @@ -32,7 +31,7 @@ fun SensorManagerUi( sensorManager: SensorManager, onSensorClicked: (String, Boolean) -> Unit ) { - val scalingLazyListState: ScalingLazyListState = rememberScalingLazyListState() + val scalingLazyListState = rememberScalingLazyListState() WearAppTheme { Scaffold( positionIndicator = { diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SensorsView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SensorsView.kt index d3b66778daa..04664e37c6d 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/SensorsView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SensorsView.kt @@ -9,13 +9,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold -import androidx.wear.compose.material.ScalingLazyListState import androidx.wear.compose.material.Text -import androidx.wear.compose.material.rememberScalingLazyListState import io.homeassistant.companion.android.common.sensors.SensorManager import io.homeassistant.companion.android.sensors.SensorReceiver import io.homeassistant.companion.android.theme.WearAppTheme @@ -27,7 +26,7 @@ import io.homeassistant.companion.android.common.R as commonR fun SensorsView( onClickSensorManager: (SensorManager) -> Unit ) { - val scalingLazyListState: ScalingLazyListState = rememberScalingLazyListState() + val scalingLazyListState = rememberScalingLazyListState() WearAppTheme { Scaffold( diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SetCameraTileView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SetCameraTileView.kt new file mode 100644 index 00000000000..7911dc79c0b --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SetCameraTileView.kt @@ -0,0 +1,103 @@ +package io.homeassistant.companion.android.home.views + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.Text +import com.mikepenz.iconics.compose.Image +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import io.homeassistant.companion.android.common.R +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.friendlyName +import io.homeassistant.companion.android.common.data.integration.getIcon +import io.homeassistant.companion.android.database.wear.CameraTile +import io.homeassistant.companion.android.theme.WearAppTheme +import io.homeassistant.companion.android.theme.wearColorPalette +import io.homeassistant.companion.android.tiles.CameraTile.Companion.DEFAULT_REFRESH_INTERVAL +import io.homeassistant.companion.android.util.intervalToString +import io.homeassistant.companion.android.views.ListHeader +import io.homeassistant.companion.android.views.ThemeLazyColumn +import io.homeassistant.companion.android.common.R as commonR + +@Composable +fun SetCameraTileView( + tile: CameraTile?, + entities: List>?, + onSelectEntity: () -> Unit, + onSelectRefreshInterval: () -> Unit +) { + val scalingLazyListState = rememberScalingLazyListState() + WearAppTheme { + Scaffold( + positionIndicator = { + if (scalingLazyListState.isScrollInProgress) { + PositionIndicator(scalingLazyListState = scalingLazyListState) + } + }, + timeText = { TimeText(scalingLazyListState = scalingLazyListState) } + ) { + ThemeLazyColumn(state = scalingLazyListState) { + item { + ListHeader(commonR.string.camera_tile) + } + item { + val entity = tile?.entityId?.let { tileEntityId -> + entities?.firstOrNull { it.entityId == tileEntityId } + } + val icon = entity?.getIcon(LocalContext.current) ?: CommunityMaterial.Icon3.cmd_video + Chip( + modifier = Modifier.fillMaxWidth(), + icon = { + Image( + asset = icon, + colorFilter = ColorFilter.tint(wearColorPalette.onSurface) + ) + }, + colors = ChipDefaults.secondaryChipColors(), + label = { + Text( + text = stringResource(id = R.string.choose_entity) + ) + }, + secondaryLabel = { + Text(entity?.friendlyName ?: tile?.entityId ?: "") + }, + onClick = onSelectEntity + ) + } + + item { + Chip( + modifier = Modifier.fillMaxWidth(), + icon = { + Image( + asset = CommunityMaterial.Icon3.cmd_timer_cog, + colorFilter = ColorFilter.tint(wearColorPalette.onSurface) + ) + }, + colors = ChipDefaults.secondaryChipColors(), + label = { + Text( + text = stringResource(id = R.string.refresh_interval) + ) + }, + secondaryLabel = { + Text( + intervalToString(LocalContext.current, (tile?.refreshInterval ?: DEFAULT_REFRESH_INTERVAL).toInt()) + ) + }, + onClick = onSelectRefreshInterval + ) + } + } + } + } +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SetFavoriteView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SetFavoriteView.kt index 87dfe9c0a40..3dc8a06440b 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/SetFavoriteView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SetFavoriteView.kt @@ -7,17 +7,15 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.wear.compose.foundation.lazy.items +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Icon import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold -import androidx.wear.compose.material.ScalingLazyListState import androidx.wear.compose.material.Text import androidx.wear.compose.material.ToggleChip import androidx.wear.compose.material.ToggleChipDefaults -import androidx.wear.compose.material.items -import androidx.wear.compose.material.rememberScalingLazyListState import com.mikepenz.iconics.compose.Image -import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.getIcon import io.homeassistant.companion.android.home.MainViewModel @@ -38,7 +36,7 @@ fun SetFavoritesView( // Remember expanded state of each header val expandedStates = rememberExpandedStates(mainViewModel.supportedDomains()) - val scalingLazyListState: ScalingLazyListState = rememberScalingLazyListState() + val scalingLazyListState = rememberScalingLazyListState() WearAppTheme { Scaffold( @@ -101,7 +99,7 @@ private fun FavoriteToggleChip( .fillMaxWidth(), appIcon = { Image( - asset = iconBitmap ?: CommunityMaterial.Icon.cmd_bookmark, + asset = iconBitmap, colorFilter = ColorFilter.tint(wearColorPalette.onSurface) ) }, diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SetShortcutsTileView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SetShortcutsTileView.kt index 373dba0f44b..7c348df5510 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/SetShortcutsTileView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SetShortcutsTileView.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Button import androidx.wear.compose.material.ButtonDefaults import androidx.wear.compose.material.Chip @@ -19,7 +20,6 @@ import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold import androidx.wear.compose.material.Text -import androidx.wear.compose.material.rememberScalingLazyListState import com.mikepenz.iconics.compose.Image import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.data.SimplifiedEntity @@ -62,7 +62,7 @@ fun SetShortcutsTileView( .fillMaxWidth(), icon = { Image( - iconBitmap ?: CommunityMaterial.Icon.cmd_bookmark, + iconBitmap, colorFilter = ColorFilter.tint(Color.White) ) }, diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt index 2aea27ad876..70978bb4612 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/SettingsView.kt @@ -10,16 +10,15 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Icon import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold -import androidx.wear.compose.material.ScalingLazyListState import androidx.wear.compose.material.Text import androidx.wear.compose.material.ToggleChip import androidx.wear.compose.material.ToggleChipDefaults -import androidx.wear.compose.material.rememberScalingLazyListState import com.mikepenz.iconics.compose.Image import com.mikepenz.iconics.typeface.IIcon import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial @@ -74,10 +73,11 @@ fun SettingsView( onHapticEnabled: (Boolean) -> Unit, onToastEnabled: (Boolean) -> Unit, setFavoritesOnly: (Boolean) -> Unit, + onClickCameraTile: () -> Unit, onClickTemplateTile: () -> Unit, onAssistantAppAllowed: (Boolean) -> Unit ) { - val scalingLazyListState: ScalingLazyListState = rememberScalingLazyListState() + val scalingLazyListState = rememberScalingLazyListState() WearAppTheme { Scaffold( @@ -213,6 +213,13 @@ fun SettingsView( id = commonR.string.tile_settings ) } + item { + SecondarySettingsChip( + icon = CommunityMaterial.Icon3.cmd_video_box, + label = stringResource(commonR.string.camera_tiles), + onClick = onClickCameraTile + ) + } item { SecondarySettingsChip( icon = CommunityMaterial.Icon3.cmd_star_circle_outline, @@ -324,6 +331,7 @@ private fun PreviewSettingsView() { onHapticEnabled = {}, onToastEnabled = {}, setFavoritesOnly = {}, + onClickCameraTile = {}, onClickTemplateTile = {}, onAssistantAppAllowed = {} ) diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/TemplateTileSettingsView.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/TemplateTileSettingsView.kt index 60f50aaec94..2776ff0b4a0 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/TemplateTileSettingsView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/TemplateTileSettingsView.kt @@ -10,12 +10,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold import androidx.wear.compose.material.Text -import androidx.wear.compose.material.rememberScalingLazyListState import com.mikepenz.iconics.compose.Image import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.common.R diff --git a/wear/src/main/java/io/homeassistant/companion/android/home/views/TimeText.kt b/wear/src/main/java/io/homeassistant/companion/android/home/views/TimeText.kt index 358f3b4d5c7..cbaac4ebf65 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/home/views/TimeText.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/home/views/TimeText.kt @@ -5,9 +5,9 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview -import androidx.wear.compose.material.ScalingLazyListState +import androidx.wear.compose.foundation.lazy.ScalingLazyListState +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.TimeText -import androidx.wear.compose.material.rememberScalingLazyListState import androidx.wear.compose.material.scrollAway @Composable diff --git a/wear/src/main/java/io/homeassistant/companion/android/notifications/FirebaseCloudMessagingService.kt b/wear/src/main/java/io/homeassistant/companion/android/notifications/FirebaseCloudMessagingService.kt index 9b6ffd95331..e886caaf3dc 100755 --- a/wear/src/main/java/io/homeassistant/companion/android/notifications/FirebaseCloudMessagingService.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/notifications/FirebaseCloudMessagingService.kt @@ -49,10 +49,11 @@ class FirebaseCloudMessagingService : FirebaseMessagingService() { launch { try { serverManager.integrationRepository(it.id).updateRegistration( - DeviceRegistration( + deviceRegistration = DeviceRegistration( pushToken = token, pushWebsocket = false - ) + ), + allowReregistration = false ) } catch (e: Exception) { Log.e(TAG, "Issue updating token", e) diff --git a/wear/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt b/wear/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt index 15c5bcc7d36..bce5de1b1a1 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/onboarding/integration/MobileAppIntegrationPresenterImpl.kt @@ -8,6 +8,7 @@ import io.homeassistant.companion.android.BuildConfig import io.homeassistant.companion.android.common.data.integration.DeviceRegistration import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.onboarding.getMessagingToken +import io.homeassistant.companion.android.tiles.CameraTile import io.homeassistant.companion.android.tiles.ConversationTile import io.homeassistant.companion.android.tiles.ShortcutsTile import io.homeassistant.companion.android.tiles.TemplateTile @@ -60,6 +61,7 @@ class MobileAppIntegrationPresenterImpl @Inject constructor( try { val context = view as Context val updater = TileService.getUpdater(context) + updater.requestUpdate(CameraTile::class.java) updater.requestUpdate(ConversationTile::class.java) updater.requestUpdate(ShortcutsTile::class.java) updater.requestUpdate(TemplateTile::class.java) diff --git a/wear/src/main/java/io/homeassistant/companion/android/onboarding/phoneinstall/PhoneInstallView.kt b/wear/src/main/java/io/homeassistant/companion/android/onboarding/phoneinstall/PhoneInstallView.kt index d7d7386d16a..9a4d398a471 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/onboarding/phoneinstall/PhoneInstallView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/onboarding/phoneinstall/PhoneInstallView.kt @@ -12,13 +12,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState import androidx.wear.compose.material.Button import androidx.wear.compose.material.ButtonDefaults import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold import androidx.wear.compose.material.Text -import androidx.wear.compose.material.rememberScalingLazyListState import io.homeassistant.companion.android.R import io.homeassistant.companion.android.home.views.TimeText import io.homeassistant.companion.android.views.ThemeLazyColumn diff --git a/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt b/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt index 57f7a6a20eb..6ad8bfefc55 100755 --- a/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/phone/PhoneSettingsListener.kt @@ -32,6 +32,7 @@ import io.homeassistant.companion.android.database.wear.replaceAll import io.homeassistant.companion.android.home.HomeActivity import io.homeassistant.companion.android.home.HomePresenterImpl import io.homeassistant.companion.android.onboarding.getMessagingToken +import io.homeassistant.companion.android.tiles.CameraTile import io.homeassistant.companion.android.tiles.ConversationTile import io.homeassistant.companion.android.tiles.ShortcutsTile import io.homeassistant.companion.android.tiles.TemplateTile @@ -223,6 +224,7 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange private fun updateTiles() = mainScope.launch { try { val updater = TileService.getUpdater(applicationContext) + updater.requestUpdate(CameraTile::class.java) updater.requestUpdate(ConversationTile::class.java) updater.requestUpdate(ShortcutsTile::class.java) updater.requestUpdate(TemplateTile::class.java) diff --git a/wear/src/main/java/io/homeassistant/companion/android/sensors/HealthServicesSensorManager.kt b/wear/src/main/java/io/homeassistant/companion/android/sensors/HealthServicesSensorManager.kt index 04c12165c51..ec03ae297c7 100755 --- a/wear/src/main/java/io/homeassistant/companion/android/sensors/HealthServicesSensorManager.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/sensors/HealthServicesSensorManager.kt @@ -20,6 +20,7 @@ import androidx.health.services.client.data.PassiveMonitoringCapabilities import androidx.health.services.client.data.UserActivityInfo import androidx.health.services.client.data.UserActivityState import io.homeassistant.companion.android.common.sensors.SensorManager +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import io.homeassistant.companion.android.database.AppDatabase import kotlinx.coroutines.guava.await import kotlinx.coroutines.runBlocking @@ -199,7 +200,7 @@ class HealthServicesSensorManager : SensorManager { UserActivityState.USER_ACTIVITY_ASLEEP -> "asleep" UserActivityState.USER_ACTIVITY_PASSIVE -> "passive" UserActivityState.USER_ACTIVITY_EXERCISE -> "exercise" - else -> "unknown" + else -> STATE_UNKNOWN }, getActivityIcon(info), mapOf( diff --git a/wear/src/main/java/io/homeassistant/companion/android/sensors/HeartRateSensorManager.kt b/wear/src/main/java/io/homeassistant/companion/android/sensors/HeartRateSensorManager.kt index 019bee9e41f..feef3b67acc 100755 --- a/wear/src/main/java/io/homeassistant/companion/android/sensors/HeartRateSensorManager.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/sensors/HeartRateSensorManager.kt @@ -15,6 +15,7 @@ import android.hardware.SensorManager.SENSOR_STATUS_UNRELIABLE import android.util.Log import androidx.core.content.getSystemService import io.homeassistant.companion.android.common.sensors.SensorManager +import io.homeassistant.companion.android.common.util.STATE_UNKNOWN import kotlin.math.roundToInt import io.homeassistant.companion.android.common.R as commonR @@ -135,7 +136,7 @@ class HeartRateSensorManager : SensorManager, SensorEventListener { SENSOR_STATUS_ACCURACY_LOW -> "low" SENSOR_STATUS_UNRELIABLE -> "unreliable" SENSOR_STATUS_NO_CONTACT -> "no_contact" - else -> "unknown" + else -> STATE_UNKNOWN } } } diff --git a/wear/src/main/java/io/homeassistant/companion/android/theme/Color.kt b/wear/src/main/java/io/homeassistant/companion/android/theme/Color.kt index ce58654482c..b25a3d4f73b 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/theme/Color.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/theme/Color.kt @@ -5,7 +5,7 @@ import androidx.wear.compose.material.Colors val Blue = Color(0xFF03A9F4) val BlueDark = Color(0xFF0288D1) -val Yellow = Color(0xFFFDD835) +val Yellow = Color(0xFFF6C344) val Orange = Color(0xFFFF9800) val Red = Color(0xFFD32F2F) diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/CameraTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/CameraTile.kt new file mode 100644 index 00000000000..8cc0b067190 --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/CameraTile.kt @@ -0,0 +1,235 @@ +package io.homeassistant.companion.android.tiles + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Log +import androidx.wear.protolayout.DimensionBuilders +import androidx.wear.protolayout.LayoutElementBuilders +import androidx.wear.protolayout.LayoutElementBuilders.CONTENT_SCALE_MODE_FIT +import androidx.wear.protolayout.ResourceBuilders +import androidx.wear.protolayout.ResourceBuilders.ImageResource +import androidx.wear.protolayout.ResourceBuilders.InlineImageResource +import androidx.wear.protolayout.ResourceBuilders.Resources +import androidx.wear.protolayout.TimelineBuilders.Timeline +import androidx.wear.tiles.EventBuilders +import androidx.wear.tiles.RequestBuilders +import androidx.wear.tiles.TileBuilders.Tile +import androidx.wear.tiles.TileService +import com.google.common.util.concurrent.ListenableFuture +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.database.AppDatabase +import io.homeassistant.companion.android.database.wear.CameraTile +import io.homeassistant.companion.android.util.UrlUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.guava.future +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.ByteArrayOutputStream +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR + +@AndroidEntryPoint +class CameraTile : TileService() { + + companion object { + private const val TAG = "CameraTile" + + const val DEFAULT_REFRESH_INTERVAL = 3600L // 1 hour, matching phone widget + + private const val RESOURCE_SNAPSHOT = "snapshot" + } + + private val serviceJob = Job() + private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + + @Inject + lateinit var serverManager: ServerManager + + @Inject + lateinit var wearPrefsRepository: WearPrefsRepository + + @Inject + lateinit var okHttpClient: OkHttpClient + + override fun onTileRequest(requestParams: RequestBuilders.TileRequest): ListenableFuture = + serviceScope.future { + val tileId = requestParams.tileId + val tileConfig = AppDatabase.getInstance(this@CameraTile) + .cameraTileDao() + .get(tileId) + + if (requestParams.currentState.lastClickableId == MODIFIER_CLICK_REFRESH) { + if (wearPrefsRepository.getWearHapticFeedback()) hapticClick(applicationContext) + } + + Tile.Builder() + .setResourcesVersion("$TAG$tileId.${System.currentTimeMillis()}") + .setFreshnessIntervalMillis( + TimeUnit.SECONDS.toMillis(tileConfig?.refreshInterval ?: DEFAULT_REFRESH_INTERVAL) + ) + .setTileTimeline( + if (serverManager.isRegistered()) { + timeline( + requestParams.deviceConfiguration.screenWidthDp, + requestParams.deviceConfiguration.screenHeightDp, + tileConfig?.entityId.isNullOrBlank() + ) + } else { + loggedOutTimeline( + this@CameraTile, + requestParams, + commonR.string.camera, + commonR.string.camera_tile_log_in + ) + } + ) + .build() + } + + override fun onTileResourcesRequest(requestParams: RequestBuilders.ResourcesRequest): ListenableFuture = + serviceScope.future { + var imageWidth = 0 + var imageHeight = 0 + val imageData = if (serverManager.isRegistered()) { + val tileId = requestParams.tileId + val tileConfig = AppDatabase.getInstance(this@CameraTile) + .cameraTileDao() + .get(tileId) + + try { + val entity = tileConfig?.entityId?.let { + serverManager.integrationRepository().getEntity(it) + } + val picture = entity?.attributes?.get("entity_picture")?.toString() + val url = UrlUtil.handle(serverManager.getServer()?.connection?.getUrl(), picture ?: "") + if (picture != null && url != null) { + var byteArray: ByteArray? + val maxWidth = requestParams.deviceConfiguration.screenWidthDp * requestParams.deviceConfiguration.screenDensity + val maxHeight = requestParams.deviceConfiguration.screenHeightDp * requestParams.deviceConfiguration.screenDensity + withContext(Dispatchers.IO) { + val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute() + byteArray = response.body?.byteStream()?.readBytes() + byteArray?.let { + var bitmap = BitmapFactory.decodeByteArray(it, 0, it.size) + if (bitmap.width > maxWidth || bitmap.height > maxHeight) { + Log.d(TAG, "Scaling camera snapshot to fit screen (${bitmap.width}x${bitmap.height} to ${maxWidth.toInt()}x${maxHeight.toInt()} max)") + val currentRatio = (bitmap.width.toFloat() / bitmap.height.toFloat()) + val screenRatio = (requestParams.deviceConfiguration.screenWidthDp.toFloat() / requestParams.deviceConfiguration.screenHeightDp.toFloat()) + imageWidth = maxWidth.toInt() + imageHeight = maxHeight.toInt() + if (currentRatio > screenRatio) { + imageWidth = (maxHeight * currentRatio).toInt() + } else { + imageHeight = (maxWidth / currentRatio).toInt() + } + bitmap = Bitmap.createScaledBitmap(bitmap, imageWidth, imageHeight, true) + ByteArrayOutputStream().use { stream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream) + byteArray = stream.toByteArray() + } + } else { + imageWidth = bitmap.width + imageHeight = bitmap.height + } + } + response.close() + } + byteArray + } else { + null + } + } catch (e: Exception) { + Log.e(TAG, "Unable to fetch entity ${tileConfig?.entityId}", e) + null + } + } else { + null + } + + val builder = Resources.Builder() + .setVersion(requestParams.version) + .addIdToImageMapping( + RESOURCE_REFRESH, + ImageResource.Builder() + .setAndroidResourceByResId( + ResourceBuilders.AndroidImageResourceByResId.Builder() + .setResourceId(R.drawable.ic_refresh) + .build() + ).build() + ) + if (imageData != null) { + builder.addIdToImageMapping( + RESOURCE_SNAPSHOT, + ImageResource.Builder() + .setInlineResource( + InlineImageResource.Builder() + .setData(imageData) + .setWidthPx(imageWidth) + .setHeightPx(imageHeight) + .setFormat(ResourceBuilders.IMAGE_FORMAT_UNDEFINED) + .build() + ) + .build() + ) + } + + builder.build() + } + + override fun onTileAddEvent(requestParams: EventBuilders.TileAddEvent) { + serviceScope.launch { + AppDatabase.getInstance(this@CameraTile) + .cameraTileDao() + .add(CameraTile(id = requestParams.tileId)) + } + } + + override fun onTileRemoveEvent(requestParams: EventBuilders.TileRemoveEvent) { + serviceScope.launch { + AppDatabase.getInstance(this@CameraTile) + .cameraTileDao() + .delete(requestParams.tileId) + } + } + + override fun onDestroy() { + super.onDestroy() + serviceScope.cancel() + } + + private fun timeline(width: Int, height: Int, requiresSetup: Boolean): Timeline = Timeline.fromLayoutElement( + LayoutElementBuilders.Box.Builder().apply { + // Camera image + if (requiresSetup) { + addContent( + LayoutElementBuilders.Text.Builder() + .setText(getString(commonR.string.camera_tile_no_entity_yet)) + .setMaxLines(10) + .build() + ) + } else { + addContent( + LayoutElementBuilders.Image.Builder() + .setResourceId(RESOURCE_SNAPSHOT) + .setWidth(DimensionBuilders.dp(width.toFloat())) + .setHeight(DimensionBuilders.dp(height.toFloat())) + .setContentScaleMode(CONTENT_SCALE_MODE_FIT) + .build() + ) + } + // Refresh button + addContent(getRefreshButton()) + // Click: refresh + setModifiers(getRefreshModifiers()) + }.build() + ) +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt index 5c0eb1f6145..5c5481494b5 100755 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/ConversationTile.kt @@ -1,21 +1,21 @@ package io.homeassistant.companion.android.tiles import androidx.core.content.ContextCompat -import androidx.wear.tiles.ActionBuilders -import androidx.wear.tiles.ColorBuilders.argb -import androidx.wear.tiles.DimensionBuilders.dp -import androidx.wear.tiles.DimensionBuilders.sp -import androidx.wear.tiles.LayoutElementBuilders -import androidx.wear.tiles.LayoutElementBuilders.VERTICAL_ALIGN_CENTER -import androidx.wear.tiles.ModifiersBuilders +import androidx.wear.protolayout.ActionBuilders +import androidx.wear.protolayout.ColorBuilders.argb +import androidx.wear.protolayout.DimensionBuilders.dp +import androidx.wear.protolayout.DimensionBuilders.sp +import androidx.wear.protolayout.LayoutElementBuilders +import androidx.wear.protolayout.LayoutElementBuilders.VERTICAL_ALIGN_CENTER +import androidx.wear.protolayout.ModifiersBuilders +import androidx.wear.protolayout.ResourceBuilders +import androidx.wear.protolayout.ResourceBuilders.ImageResource +import androidx.wear.protolayout.ResourceBuilders.Resources +import androidx.wear.protolayout.TimelineBuilders.Timeline import androidx.wear.tiles.RequestBuilders.ResourcesRequest import androidx.wear.tiles.RequestBuilders.TileRequest -import androidx.wear.tiles.ResourceBuilders -import androidx.wear.tiles.ResourceBuilders.ImageResource -import androidx.wear.tiles.ResourceBuilders.Resources import androidx.wear.tiles.TileBuilders.Tile import androidx.wear.tiles.TileService -import androidx.wear.tiles.TimelineBuilders.Timeline import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.common.R @@ -40,7 +40,7 @@ class ConversationTile : TileService() { serviceScope.future { Tile.Builder() .setResourcesVersion("1") - .setTimeline( + .setTileTimeline( if (serverManager.isRegistered()) { Timeline.fromLayoutElement(boxLayout()) } else { @@ -54,7 +54,7 @@ class ConversationTile : TileService() { ).build() } - override fun onResourcesRequest(requestParams: ResourcesRequest): ListenableFuture = + override fun onTileResourcesRequest(requestParams: ResourcesRequest): ListenableFuture = serviceScope.future { Resources.Builder() .setVersion("1") diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/LoggedOutTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/LoggedOutTile.kt deleted file mode 100644 index cf5da41b0c1..00000000000 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/LoggedOutTile.kt +++ /dev/null @@ -1,76 +0,0 @@ -package io.homeassistant.companion.android.tiles - -import android.content.Context -import androidx.annotation.StringRes -import androidx.core.content.ContextCompat -import androidx.wear.tiles.ActionBuilders -import androidx.wear.tiles.ColorBuilders.argb -import androidx.wear.tiles.ModifiersBuilders -import androidx.wear.tiles.RequestBuilders -import androidx.wear.tiles.TimelineBuilders.Timeline -import androidx.wear.tiles.material.ChipColors -import androidx.wear.tiles.material.Colors -import androidx.wear.tiles.material.CompactChip -import androidx.wear.tiles.material.Text -import androidx.wear.tiles.material.Typography -import androidx.wear.tiles.material.layouts.PrimaryLayout -import io.homeassistant.companion.android.R -import io.homeassistant.companion.android.splash.SplashActivity -import io.homeassistant.companion.android.common.R as commonR - -/** - * A [Timeline] with a single entry, asking the user to log in to the app to start using the tile - * with a button to open the app. The tile is using the 'Dialog' style. - */ -fun loggedOutTimeline( - context: Context, - requestParams: RequestBuilders.TileRequest, - @StringRes title: Int, - @StringRes text: Int -): Timeline { - val theme = Colors( - ContextCompat.getColor(context, R.color.colorPrimary), // Primary - ContextCompat.getColor(context, R.color.colorOnPrimary), // On primary - ContextCompat.getColor(context, R.color.colorOverlay), // Surface - ContextCompat.getColor(context, android.R.color.white) // On surface - ) - val chipColors = ChipColors.primaryChipColors(theme) - val chipAction = ModifiersBuilders.Clickable.Builder() - .setId("login") - .setOnClick( - ActionBuilders.LaunchAction.Builder() - .setAndroidActivity( - ActionBuilders.AndroidActivity.Builder() - .setClassName(SplashActivity::class.java.name) - .setPackageName(context.packageName) - .build() - ).build() - ).build() - return Timeline.fromLayoutElement( - PrimaryLayout.Builder(requestParams.deviceParameters!!) - .setPrimaryLabelTextContent( - Text.Builder(context, context.getString(title)) - .setTypography(Typography.TYPOGRAPHY_CAPTION1) - .setColor(argb(theme.primary)) - .build() - ) - .setContent( - Text.Builder(context, context.getString(text)) - .setTypography(Typography.TYPOGRAPHY_BODY1) - .setMaxLines(10) - .setColor(argb(theme.onSurface)) - .build() - ) - .setPrimaryChipContent( - CompactChip.Builder( - context, - context.getString(commonR.string.login), - chipAction, - requestParams.deviceParameters!! - ) - .setChipColors(chipColors) - .build() - ) - .build() - ) -} diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/OpenShortcutTileSettingsActivity.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/OpenTileSettingsActivity.kt similarity index 50% rename from wear/src/main/java/io/homeassistant/companion/android/tiles/OpenShortcutTileSettingsActivity.kt rename to wear/src/main/java/io/homeassistant/companion/android/tiles/OpenTileSettingsActivity.kt index cee60dcbfa8..4ea43bbed98 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/OpenShortcutTileSettingsActivity.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/OpenTileSettingsActivity.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.launch import javax.inject.Inject @AndroidEntryPoint -class OpenShortcutTileSettingsActivity : AppCompatActivity() { +class OpenTileSettingsActivity : AppCompatActivity() { @Inject lateinit var wearPrefsRepository: WearPrefsRepositoryImpl @@ -19,14 +19,25 @@ class OpenShortcutTileSettingsActivity : AppCompatActivity() { super.onCreate(savedInstanceState) val tileId = intent.extras?.getInt("com.google.android.clockwork.EXTRA_PROVIDER_CONFIG_TILE_ID") tileId?.takeIf { it != 0 }?.let { - lifecycleScope.launch { - wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId) + val settingsIntent = when (intent.action) { + "ConfigCameraTile" -> + HomeActivity.getCameraTileSettingsIntent( + context = this, + tileId = it + ) + "ConfigShortcutsTile" -> { + lifecycleScope.launch { + wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId) + } + HomeActivity.getShortcutsTileSettingsIntent( + context = this, + tileId = it + ) + } + else -> null } - val intent = HomeActivity.getShortcutsTileSettingsIntent( - context = this, - tileId = it - ) - startActivity(intent) + + settingsIntent?.let { startActivity(settingsIntent) } } finish() } diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/ShortcutsTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/ShortcutsTile.kt index 1457b2d2500..cdf3423b002 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/ShortcutsTile.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/ShortcutsTile.kt @@ -6,30 +6,29 @@ import android.graphics.Bitmap import android.graphics.Color import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toBitmap -import androidx.wear.tiles.ActionBuilders -import androidx.wear.tiles.ColorBuilders.argb -import androidx.wear.tiles.DimensionBuilders.dp -import androidx.wear.tiles.DimensionBuilders.sp +import androidx.wear.protolayout.ActionBuilders +import androidx.wear.protolayout.ColorBuilders.argb +import androidx.wear.protolayout.DimensionBuilders.dp +import androidx.wear.protolayout.DimensionBuilders.sp +import androidx.wear.protolayout.LayoutElementBuilders +import androidx.wear.protolayout.LayoutElementBuilders.Box +import androidx.wear.protolayout.LayoutElementBuilders.Column +import androidx.wear.protolayout.LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER +import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement +import androidx.wear.protolayout.LayoutElementBuilders.Row +import androidx.wear.protolayout.LayoutElementBuilders.Spacer +import androidx.wear.protolayout.ModifiersBuilders +import androidx.wear.protolayout.ResourceBuilders +import androidx.wear.protolayout.ResourceBuilders.Resources +import androidx.wear.protolayout.TimelineBuilders.Timeline import androidx.wear.tiles.EventBuilders -import androidx.wear.tiles.LayoutElementBuilders -import androidx.wear.tiles.LayoutElementBuilders.Box -import androidx.wear.tiles.LayoutElementBuilders.Column -import androidx.wear.tiles.LayoutElementBuilders.HORIZONTAL_ALIGN_CENTER -import androidx.wear.tiles.LayoutElementBuilders.LayoutElement -import androidx.wear.tiles.LayoutElementBuilders.Row -import androidx.wear.tiles.LayoutElementBuilders.Spacer -import androidx.wear.tiles.ModifiersBuilders import androidx.wear.tiles.RequestBuilders.ResourcesRequest import androidx.wear.tiles.RequestBuilders.TileRequest -import androidx.wear.tiles.ResourceBuilders -import androidx.wear.tiles.ResourceBuilders.Resources import androidx.wear.tiles.TileBuilders.Tile import androidx.wear.tiles.TileService -import androidx.wear.tiles.TimelineBuilders.Timeline import com.google.common.util.concurrent.ListenableFuture import com.mikepenz.iconics.IconicsColor import com.mikepenz.iconics.IconicsDrawable -import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import com.mikepenz.iconics.utils.backgroundColor import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp @@ -71,8 +70,8 @@ class ShortcutsTile : TileService() { override fun onTileRequest(requestParams: TileRequest): ListenableFuture = serviceScope.future { - val state = requestParams.state - if (state != null && state.lastClickableId.isNotEmpty()) { + val state = requestParams.currentState + if (state.lastClickableId.isNotEmpty()) { Intent().also { intent -> intent.action = "io.homeassistant.companion.android.TILE_ACTION" intent.putExtra("entity_id", state.lastClickableId) @@ -86,7 +85,7 @@ class ShortcutsTile : TileService() { Tile.Builder() .setResourcesVersion(entities.toString()) - .setTimeline( + .setTileTimeline( if (serverManager.isRegistered()) { timeline(tileId) } else { @@ -100,11 +99,11 @@ class ShortcutsTile : TileService() { ).build() } - override fun onResourcesRequest(requestParams: ResourcesRequest): ListenableFuture = + override fun onTileResourcesRequest(requestParams: ResourcesRequest): ListenableFuture = serviceScope.future { val showLabels = wearPrefsRepository.getShowShortcutText() val iconSize = if (showLabels) ICON_SIZE_SMALL else ICON_SIZE_FULL - val density = requestParams.deviceParameters!!.screenDensity + val density = requestParams.deviceConfiguration.screenDensity val iconSizePx = (iconSize * density).roundToInt() val entities = getEntities(requestParams.tileId) @@ -117,7 +116,7 @@ class ShortcutsTile : TileService() { entity.icon, entity.domain, this@ShortcutsTile - ) ?: CommunityMaterial.Icon.cmd_bookmark + ) val iconBitmap = IconicsDrawable(this@ShortcutsTile, iconIIcon).apply { colorInt = Color.WHITE sizeDp = iconSize.roundToInt() diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt index 17bc58c9254..e53476f4961 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/TemplateTile.kt @@ -1,10 +1,6 @@ package io.homeassistant.companion.android.tiles import android.graphics.Typeface -import android.os.Build -import android.os.VibrationEffect -import android.os.Vibrator -import android.os.VibratorManager import android.text.style.AbsoluteSizeSpan import android.text.style.CharacterStyle import android.text.style.ForegroundColorSpan @@ -12,25 +8,21 @@ import android.text.style.RelativeSizeSpan import android.text.style.StyleSpan import android.text.style.UnderlineSpan import android.util.Log -import androidx.core.content.getSystemService import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY import androidx.core.text.HtmlCompat.fromHtml -import androidx.wear.tiles.ActionBuilders -import androidx.wear.tiles.ColorBuilders -import androidx.wear.tiles.DimensionBuilders -import androidx.wear.tiles.DimensionBuilders.dp -import androidx.wear.tiles.LayoutElementBuilders -import androidx.wear.tiles.LayoutElementBuilders.Box -import androidx.wear.tiles.LayoutElementBuilders.FONT_WEIGHT_BOLD -import androidx.wear.tiles.LayoutElementBuilders.LayoutElement -import androidx.wear.tiles.ModifiersBuilders +import androidx.wear.protolayout.ColorBuilders +import androidx.wear.protolayout.DimensionBuilders +import androidx.wear.protolayout.LayoutElementBuilders +import androidx.wear.protolayout.LayoutElementBuilders.Box +import androidx.wear.protolayout.LayoutElementBuilders.FONT_WEIGHT_BOLD +import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement +import androidx.wear.protolayout.ResourceBuilders +import androidx.wear.protolayout.ResourceBuilders.Resources +import androidx.wear.protolayout.TimelineBuilders.Timeline import androidx.wear.tiles.RequestBuilders.ResourcesRequest import androidx.wear.tiles.RequestBuilders.TileRequest -import androidx.wear.tiles.ResourceBuilders -import androidx.wear.tiles.ResourceBuilders.Resources import androidx.wear.tiles.TileBuilders.Tile import androidx.wear.tiles.TileService -import androidx.wear.tiles.TimelineBuilders.Timeline import com.fasterxml.jackson.databind.JsonMappingException import com.google.common.util.concurrent.ListenableFuture import dagger.hilt.android.AndroidEntryPoint @@ -57,19 +49,8 @@ class TemplateTile : TileService() { override fun onTileRequest(requestParams: TileRequest): ListenableFuture = serviceScope.future { - val state = requestParams.state - if (state != null && state.lastClickableId == "refresh") { - if (wearPrefsRepository.getWearHapticFeedback()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = applicationContext.getSystemService() - val vibrator = vibratorManager?.defaultVibrator - vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) - } else { - val vibrator = applicationContext.getSystemService() - @Suppress("DEPRECATION") - vibrator?.vibrate(200) - } - } + if (requestParams.currentState.lastClickableId == MODIFIER_CLICK_REFRESH) { + if (wearPrefsRepository.getWearHapticFeedback()) hapticClick(applicationContext) } Tile.Builder() @@ -77,7 +58,7 @@ class TemplateTile : TileService() { .setFreshnessIntervalMillis( wearPrefsRepository.getTemplateTileRefreshInterval().toLong() * 1000 ) - .setTimeline( + .setTileTimeline( if (serverManager.isRegistered()) { timeline() } else { @@ -91,12 +72,12 @@ class TemplateTile : TileService() { ).build() } - override fun onResourcesRequest(requestParams: ResourcesRequest): ListenableFuture = + override fun onTileResourcesRequest(requestParams: ResourcesRequest): ListenableFuture = serviceScope.future { Resources.Builder() .setVersion("1") .addIdToImageMapping( - "refresh", + RESOURCE_REFRESH, ResourceBuilders.ImageResource.Builder() .setAndroidResourceByResId( ResourceBuilders.AndroidImageResourceByResId.Builder() @@ -147,45 +128,11 @@ class TemplateTile : TileService() { parseHtml(renderedText) ) } - addContent( - LayoutElementBuilders.Arc.Builder() - .setAnchorAngle( - DimensionBuilders.DegreesProp.Builder() - .setValue(180f) - .build() - ) - .addContent( - LayoutElementBuilders.ArcAdapter.Builder() - .setContent( - LayoutElementBuilders.Image.Builder() - .setResourceId("refresh") - .setWidth(dp(24f)) - .setHeight(dp(24f)) - .setModifiers(getRefreshModifiers()) - .build() - ) - .setRotateContents(false) - .build() - ) - .build() - ) + addContent(getRefreshButton()) setModifiers(getRefreshModifiers()) } .build() - private fun getRefreshModifiers(): ModifiersBuilders.Modifiers { - return ModifiersBuilders.Modifiers.Builder() - .setClickable( - ModifiersBuilders.Clickable.Builder() - .setOnClick( - ActionBuilders.LoadAction.Builder().build() - ) - .setId("refresh") - .build() - ) - .build() - } - private fun parseHtml(renderedText: String): LayoutElementBuilders.Spannable { // Replace control char \r\n, \r, \n and also \r\n, \r, \n as text literals in strings to
val renderedSpanned = fromHtml(renderedText.replace("(\r\n|\r|\n)|(\\\\r\\\\n|\\\\r|\\\\n)".toRegex(), "
"), FROM_HTML_MODE_LEGACY) @@ -204,9 +151,7 @@ class TemplateTile : TileService() { .build() ) is ForegroundColorSpan -> setColor( - ColorBuilders.ColorProp.Builder() - .setArgb(span.foregroundColor) - .build() + ColorBuilders.ColorProp.Builder(span.foregroundColor).build() ) is RelativeSizeSpan -> { val defaultSize = 16 // https://developer.android.com/training/wearables/design/typography diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/TileActionReceiver.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/TileActionReceiver.kt index 91658e12742..30cf8dc63d6 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/tiles/TileActionReceiver.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/TileActionReceiver.kt @@ -3,12 +3,7 @@ package io.homeassistant.companion.android.tiles import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import android.os.Build -import android.os.VibrationEffect -import android.os.Vibrator -import android.os.VibratorManager import android.util.Log -import androidx.core.content.getSystemService import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.common.data.integration.onEntityPressedWithoutState import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository @@ -34,17 +29,7 @@ class TileActionReceiver : BroadcastReceiver() { if (entityId != null) { runBlocking { - if (wearPrefsRepository.getWearHapticFeedback()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val vibratorManager = context?.getSystemService() - val vibrator = vibratorManager?.defaultVibrator - vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) - } else { - val vibrator = context?.getSystemService() - @Suppress("DEPRECATION") - vibrator?.vibrate(200) - } - } + if (wearPrefsRepository.getWearHapticFeedback() && context != null) hapticClick(context) try { onEntityPressedWithoutState( diff --git a/wear/src/main/java/io/homeassistant/companion/android/tiles/TileViews.kt b/wear/src/main/java/io/homeassistant/companion/android/tiles/TileViews.kt new file mode 100644 index 00000000000..2209efa95e6 --- /dev/null +++ b/wear/src/main/java/io/homeassistant/companion/android/tiles/TileViews.kt @@ -0,0 +1,157 @@ +package io.homeassistant.companion.android.tiles + +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.wear.protolayout.ActionBuilders +import androidx.wear.protolayout.ColorBuilders.argb +import androidx.wear.protolayout.DimensionBuilders +import androidx.wear.protolayout.LayoutElementBuilders +import androidx.wear.protolayout.ModifiersBuilders +import androidx.wear.protolayout.TimelineBuilders.Timeline +import androidx.wear.protolayout.material.ChipColors +import androidx.wear.protolayout.material.Colors +import androidx.wear.protolayout.material.CompactChip +import androidx.wear.protolayout.material.Text +import androidx.wear.protolayout.material.Typography +import androidx.wear.protolayout.material.layouts.PrimaryLayout +import androidx.wear.tiles.RequestBuilders +import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.splash.SplashActivity +import io.homeassistant.companion.android.common.R as commonR + +const val RESOURCE_REFRESH = "refresh" +const val MODIFIER_CLICK_REFRESH = "refresh" + +/** Performs a [VibrationEffect.EFFECT_CLICK] or equivalent on older Android versions */ +fun hapticClick(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService() + val vibrator = vibratorManager?.defaultVibrator + vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK)) + } else { + val vibrator = context.getSystemService() + @Suppress("DEPRECATION") + vibrator?.vibrate(200) + } +} + +/** + * A [Timeline] with a single entry, asking the user to log in to the app to start using the tile + * with a button to open the app. The tile is using the 'Dialog' style. + */ +fun loggedOutTimeline( + context: Context, + requestParams: RequestBuilders.TileRequest, + @StringRes title: Int, + @StringRes text: Int +): Timeline = primaryLayoutTimeline( + context = context, + requestParams = requestParams, + title = title, + text = text, + actionText = commonR.string.login, + action = ActionBuilders.LaunchAction.Builder() + .setAndroidActivity( + ActionBuilders.AndroidActivity.Builder() + .setClassName(SplashActivity::class.java.name) + .setPackageName(context.packageName) + .build() + ).build() +) + +/** + * A [Timeline] with a single entry using the Material `PrimaryLayout`. The title is optional. + */ +fun primaryLayoutTimeline( + context: Context, + requestParams: RequestBuilders.TileRequest, + @StringRes title: Int?, + @StringRes text: Int, + @StringRes actionText: Int, + action: ActionBuilders.Action +): Timeline { + val theme = Colors( + ContextCompat.getColor(context, R.color.colorPrimary), // Primary + ContextCompat.getColor(context, R.color.colorOnPrimary), // On primary + ContextCompat.getColor(context, R.color.colorOverlay), // Surface + ContextCompat.getColor(context, android.R.color.white) // On surface + ) + val chipColors = ChipColors.primaryChipColors(theme) + val chipAction = ModifiersBuilders.Clickable.Builder() + .setId("action") + .setOnClick(action) + .build() + val builder = PrimaryLayout.Builder(requestParams.deviceConfiguration) + if (title != null) { + builder.setPrimaryLabelTextContent( + Text.Builder(context, context.getString(title)) + .setTypography(Typography.TYPOGRAPHY_CAPTION1) + .setColor(argb(theme.primary)) + .build() + ) + } + builder.setContent( + Text.Builder(context, context.getString(text)) + .setTypography(Typography.TYPOGRAPHY_BODY1) + .setMaxLines(10) + .setColor(argb(theme.onSurface)) + .build() + ) + builder.setPrimaryChipContent( + CompactChip.Builder( + context, + context.getString(actionText), + chipAction, + requestParams.deviceConfiguration + ) + .setChipColors(chipColors) + .build() + ) + return Timeline.fromLayoutElement(builder.build()) +} + +/** + * An [LayoutElementBuilders.Arc] with a refresh button at the bottom (centered). When added, it is + * expected that the TileService: + * - handles the refresh action ([MODIFIER_CLICK_REFRESH]) in `onTileRequest`; + * - adds a resource for [RESOURCE_REFRESH] in `onTileResourcesRequest`. + */ +fun getRefreshButton(): LayoutElementBuilders.Arc = + LayoutElementBuilders.Arc.Builder() + .setAnchorAngle( + DimensionBuilders.DegreesProp.Builder(180f).build() + ) + .addContent( + LayoutElementBuilders.ArcAdapter.Builder() + .setContent( + LayoutElementBuilders.Image.Builder() + .setResourceId(RESOURCE_REFRESH) + .setWidth(DimensionBuilders.dp(24f)) + .setHeight(DimensionBuilders.dp(24f)) + .setModifiers(getRefreshModifiers()) + .build() + ) + .setRotateContents(false) + .build() + ) + .build() + +/** @return a modifier for tiles that represents a 'tap to refresh' [ActionBuilders.LoadAction] */ +fun getRefreshModifiers(): ModifiersBuilders.Modifiers { + return ModifiersBuilders.Modifiers.Builder() + .setClickable( + ModifiersBuilders.Clickable.Builder() + .setOnClick( + ActionBuilders.LoadAction.Builder().build() + ) + .setId(MODIFIER_CLICK_REFRESH) + .build() + ) + .build() +} diff --git a/wear/src/main/java/io/homeassistant/companion/android/util/CommonFunctions.kt b/wear/src/main/java/io/homeassistant/companion/android/util/CommonFunctions.kt index 2bca7681f56..ae3ae6174cf 100755 --- a/wear/src/main/java/io/homeassistant/companion/android/util/CommonFunctions.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/util/CommonFunctions.kt @@ -30,7 +30,7 @@ fun stringForDomain(domain: String, context: Context): String? = ) )[domain]?.let { context.getString(it) } -fun getIcon(icon: String?, domain: String, context: Context): IIcon? { +fun getIcon(icon: String?, domain: String, context: Context): IIcon { val simpleEntity = Entity( "$domain.ha_android_placeholder", "", diff --git a/wear/src/main/java/io/homeassistant/companion/android/views/ChooseEntityView.kt b/wear/src/main/java/io/homeassistant/companion/android/views/ChooseEntityView.kt index 1fae9153dd6..610ab0aec77 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/views/ChooseEntityView.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/views/ChooseEntityView.kt @@ -17,10 +17,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.items import androidx.wear.compose.material.Chip import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Text -import androidx.wear.compose.material.items import com.mikepenz.iconics.compose.Image import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import io.homeassistant.companion.android.common.data.integration.Entity @@ -126,7 +126,7 @@ private fun ChooseEntityChip( .fillMaxWidth(), icon = { Image( - asset = iconBitmap ?: CommunityMaterial.Icon.cmd_bookmark, + asset = iconBitmap, colorFilter = ColorFilter.tint(Color.White) ) }, diff --git a/wear/src/main/java/io/homeassistant/companion/android/views/ThemeLazyColumn.kt b/wear/src/main/java/io/homeassistant/companion/android/views/ThemeLazyColumn.kt index 4ebcfd0690f..529fb43edb5 100644 --- a/wear/src/main/java/io/homeassistant/companion/android/views/ThemeLazyColumn.kt +++ b/wear/src/main/java/io/homeassistant/companion/android/views/ThemeLazyColumn.kt @@ -1,7 +1,5 @@ package io.homeassistant.companion.android.views -import androidx.compose.foundation.focusable -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -12,15 +10,15 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.rotary.onRotaryScrollEvent import androidx.compose.ui.unit.dp -import androidx.wear.compose.material.ScalingLazyColumn -import androidx.wear.compose.material.ScalingLazyListScope -import androidx.wear.compose.material.ScalingLazyListState -import androidx.wear.compose.material.rememberScalingLazyListState -import kotlinx.coroutines.launch +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.ScalingLazyListScope +import androidx.wear.compose.foundation.lazy.ScalingLazyListState +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import com.google.android.horologist.annotations.ExperimentalHorologistApi +import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll +@OptIn(ExperimentalHorologistApi::class) @Composable fun ThemeLazyColumn( state: ScalingLazyListState = rememberScalingLazyListState(), @@ -31,14 +29,7 @@ fun ThemeLazyColumn( ScalingLazyColumn( modifier = Modifier .fillMaxSize() - .onRotaryScrollEvent { - coroutineScope.launch { - state.scrollBy(it.verticalScrollPixels) - } - true - } - .focusRequester(focusRequester) - .focusable(), + .rotaryWithScroll(state, focusRequester), contentPadding = PaddingValues( start = 8.dp, end = 8.dp diff --git a/wear/src/main/res/drawable-round/camera_tile_example.png b/wear/src/main/res/drawable-round/camera_tile_example.png new file mode 100644 index 00000000000..114c090618c Binary files /dev/null and b/wear/src/main/res/drawable-round/camera_tile_example.png differ diff --git a/wear/src/main/res/drawable/camera_tile_example.png b/wear/src/main/res/drawable/camera_tile_example.png new file mode 100644 index 00000000000..c519212aa84 Binary files /dev/null and b/wear/src/main/res/drawable/camera_tile_example.png differ diff --git a/wear/src/main/res/layout/view_loading.xml b/wear/src/main/res/layout/view_loading.xml index eaddc499fff..f70494ceafb 100644 --- a/wear/src/main/res/layout/view_loading.xml +++ b/wear/src/main/res/layout/view_loading.xml @@ -4,6 +4,7 @@ android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" + android:background="@android:color/black" android:gravity="center">