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