Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,15 @@ internal class FrontendViewModel @VisibleForTesting constructor(
exoPlayerManager.handle(result)
}

is FrontendHandlerEvent.StartImprovScan,
is FrontendHandlerEvent.ConfigureImprovDevice,
-> {
// Improv handling lands in a follow-up PR; the messages are already typed and
// canSetupImprov will be `false` on devices without BLE so the frontend should
// not be sending these yet on hardware that lacks Bluetooth LE.
Timber.d("Improv event received but not yet handled: $result")
}

is FrontendHandlerEvent.ConfigSent,
is FrontendHandlerEvent.UnknownMessage,
-> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,36 @@ data class ExoPlayerResizePayload(
val right: Double = 0.0,
val bottom: Double = 0.0,
)

/**
* Message requesting the app to start scanning for nearby BLE devices that advertise the
* Improv Wi-Fi service.
*
* The frontend sends this once when the user opens its "Add device" flow. The app responds
* out-of-band with a stream of [io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDiscoveredDeviceMessage]
* commands as devices are discovered. No `result`-shaped response is expected.
*
* Will not be sent by the frontend when the device reports
* [io.homeassistant.companion.android.frontend.externalbus.outgoing.ConfigResult.canSetupImprov] = `false`.
*/
@Serializable
@SerialName("improv/scan")
data class ImprovScanMessage(override val id: Int? = null) : IncomingExternalBusMessage

/**
* Message requesting the app to begin Wi-Fi onboarding for the named improv device the user
* picked from the discovery list.
*
* The app should open the credentials dialog for [ImprovConfigureDevicePayload.name], submit
* the entered Wi-Fi credentials over BLE, and finally — once the device reports it has been
* provisioned — emit
* [io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDeviceSetupDoneMessage]
* and navigate the frontend to the matching `config_flow_start` URL.
*/
@Serializable
@SerialName("improv/configure_device")
data class ImprovConfigureDeviceMessage(override val id: Int? = null, val payload: ImprovConfigureDevicePayload) :
IncomingExternalBusMessage

@Serializable
data class ImprovConfigureDevicePayload(val name: String)
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,32 @@ object NavigateToMessage {
* @see CommandMessage
*/
val ShowSidebarMessage: OutgoingExternalBusMessage = CommandMessage(command = "sidebar/show")

/**
* Notifies the frontend that an improv-capable BLE device has been discovered while scanning.
*
* Emitted once per device — the frontend deduplicates by [name] when adding the entry to its
* "Add device" list. This is a one-way command; the frontend does not respond.
*
* @see CommandMessage
*/
object ImprovDiscoveredDeviceMessage {
operator fun invoke(name: String): OutgoingExternalBusMessage = CommandMessage(
command = "improv/discovered_device",
payload = frontendExternalBusJson.encodeToJsonElement(DiscoveredDevicePayload(name = name)),
)

@Serializable
private data class DiscoveredDevicePayload(val name: String)
}

/**
* Notifies the frontend that the user-selected improv device has finished its Wi-Fi onboarding
* (BLE state moved to provisioned). The frontend uses this to dismiss its progress UI before
* the app navigates the WebView to the `config_flow_start` URL for the device's domain.
*
* One-way command; no response is expected.
*
* @see CommandMessage
*/
val ImprovDeviceSetupDoneMessage: OutgoingExternalBusMessage = CommandMessage(command = "improv/device_setup_done")
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,14 @@ data class ConfigResult(
canCommissionMatter: Boolean,
canExportThread: Boolean,
hasBarCodeScanner: Int,
canSetupImprov: Boolean,
appVersion: AppVersion,
) = ConfigResult(
canWriteTag = hasNfc,
canCommissionMatter = canCommissionMatter,
canImportThreadCredentials = canExportThread,
hasBarCodeScanner = hasBarCodeScanner,
canSetupImprov = canSetupImprov,
appVersion = appVersion.value,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,26 @@ sealed interface FrontendHandlerEvent {
*/
data class WriteNfcTag(val messageId: Int, val tagId: String?) : FrontendHandlerEvent

/**
* Frontend requested the app to start scanning for improv (Wi-Fi onboarding) BLE devices.
*
* The ViewModel is responsible for the BLE-feature gate, the runtime permission flow
* (Bluetooth + Location), and emitting
* [io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDiscoveredDeviceMessage]
* for each device the scanner reports.
*/
data object StartImprovScan : FrontendHandlerEvent

/**
* User picked an improv device in the frontend's list. The ViewModel should open the
* Wi-Fi-credentials dialog seeded with [deviceName], drive the BLE provisioning flow, and
* emit [io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDeviceSetupDoneMessage]
* once provisioning succeeds.
*
* @param deviceName Advertised name of the BLE device the user selected.
*/
data class ConfigureImprovDevice(val deviceName: String) : FrontendHandlerEvent

sealed interface ExoPlayerAction : FrontendHandlerEvent {

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import io.homeassistant.companion.android.frontend.externalbus.incoming.ExoPlaye
import io.homeassistant.companion.android.frontend.externalbus.incoming.ExoPlayerStopMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.HandleBlobMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.ImprovConfigureDeviceMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.ImprovScanMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.IncomingExternalBusMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenAssistMessage
import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenAssistSettingsMessage
Expand All @@ -25,6 +27,7 @@ import io.homeassistant.companion.android.frontend.externalbus.incoming.ThemeUpd
import io.homeassistant.companion.android.frontend.externalbus.incoming.UnknownIncomingMessage
import io.homeassistant.companion.android.frontend.externalbus.outgoing.ConfigResult
import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage
import io.homeassistant.companion.android.frontend.improv.BluetoothCapabilities
import io.homeassistant.companion.android.frontend.js.FrontendJsHandler
import io.homeassistant.companion.android.frontend.session.AuthPayload
import io.homeassistant.companion.android.frontend.session.ExternalAuthResult
Expand Down Expand Up @@ -64,6 +67,7 @@ class FrontendMessageHandler @Inject constructor(
private val appVersionProvider: AppVersionProvider,
private val sessionManager: ServerSessionManager,
private val downloadManager: FrontendDownloadManager,
private val bluetoothCapabilities: BluetoothCapabilities,
@param:IsAutomotive private val isAutomotive: Boolean,
) : FrontendJsHandler,
FrontendBusObserver {
Expand Down Expand Up @@ -224,6 +228,16 @@ class FrontendMessageHandler @Inject constructor(
FrontendHandlerEvent.DownloadCompleted(result)
}

is ImprovScanMessage -> {
Timber.d("improv/scan received with id: ${message.id}")
FrontendHandlerEvent.StartImprovScan
}

is ImprovConfigureDeviceMessage -> {
Timber.d("improv/configure_device received with id: ${message.id}")
FrontendHandlerEvent.ConfigureImprovDevice(deviceName = message.payload.name)
}

is UnknownIncomingMessage -> {
Timber.d("Unknown message type received: ${message.content}")
FrontendHandlerEvent.UnknownMessage
Expand All @@ -250,6 +264,7 @@ class FrontendMessageHandler @Inject constructor(
canCommissionMatter = canCommissionMatter,
canExportThread = canExportThread,
hasBarCodeScanner = hasBarCodeScanner,
canSetupImprov = bluetoothCapabilities.hasBluetoothLe(),
appVersion = appVersionProvider(),
),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package io.homeassistant.companion.android.frontend.improv

/**
* Reports the device's Bluetooth-related capabilities.
*
* Exposed as an interface so call sites (e.g. the config/get response, the improv scan flow)
* can stay testable without Robolectric.
*/
fun interface BluetoothCapabilities {

/**
* @return `true` when the device declares Bluetooth Low Energy support
* ([android.content.pm.PackageManager.FEATURE_BLUETOOTH_LE]).
*/
fun hasBluetoothLe(): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.homeassistant.companion.android.frontend.improv

import android.content.pm.PackageManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

/**
* Hilt bindings for the frontend's improv (Wi-Fi onboarding for BLE devices) integration.
*/
@Module
@InstallIn(SingletonComponent::class)
object FrontendImprovModule {

@Provides
fun provideBluetoothCapabilities(packageManager: PackageManager): BluetoothCapabilities = BluetoothCapabilities {
packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,38 @@ class IncomingExternalBusMessageTest {
assertEquals(200.5, resize.payload.bottom)
}

@Test
fun `Given improv scan JSON then parses to ImprovScanMessage`() {
val json = """{"type":"improv/scan","id":50}"""

val message = frontendExternalBusJson.decodeFromString<IncomingExternalBusMessage>(json)

assertInstanceOf(ImprovScanMessage::class.java, message)
assertEquals(50, (message as ImprovScanMessage).id)
}

@Test
fun `Given improv scan JSON without id then parses to ImprovScanMessage with null id`() {
val json = """{"type":"improv/scan"}"""

val message = frontendExternalBusJson.decodeFromString<IncomingExternalBusMessage>(json)

assertInstanceOf(ImprovScanMessage::class.java, message)
assertNull((message as ImprovScanMessage).id)
}

@Test
fun `Given improv configure_device JSON then parses to ImprovConfigureDeviceMessage with name`() {
val json = """{"type":"improv/configure_device","id":51,"payload":{"name":"Smart Plug"}}"""

val message = frontendExternalBusJson.decodeFromString<IncomingExternalBusMessage>(json)

assertInstanceOf(ImprovConfigureDeviceMessage::class.java, message)
val configureMessage = message as ImprovConfigureDeviceMessage
assertEquals(51, configureMessage.id)
assertEquals("Smart Plug", configureMessage.payload.name)
}

@Test
fun `Given exoplayer resize JSON without payload then parses with zero defaults`() {
val json = """{"type":"exoplayer/resize","id":25}"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,26 @@ class CommandMessageTest {
json,
)
}

@Test
fun `Given ImprovDiscoveredDeviceMessage when serializing then produces command with name payload`() {
val message = ImprovDiscoveredDeviceMessage(name = "Smart Plug")

val json = frontendExternalBusJson.encodeToString<OutgoingExternalBusMessage>(message)

assertEquals(
"""{"type":"command","id":null,"command":"improv/discovered_device","payload":{"name":"Smart Plug"}}""",
json,
)
}

@Test
fun `Given ImprovDeviceSetupDoneMessage when serializing then produces command without payload`() {
val json = frontendExternalBusJson.encodeToString<OutgoingExternalBusMessage>(ImprovDeviceSetupDoneMessage)

assertEquals(
"""{"type":"command","id":null,"command":"improv/device_setup_done","payload":null}""",
json,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import io.homeassistant.companion.android.common.util.AppVersion
import io.homeassistant.companion.android.frontend.externalbus.frontendExternalBusJson
import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
Expand All @@ -21,6 +22,7 @@ class OutgoingExternalBusMessageTest {
canCommissionMatter = true,
canExportThread = true,
hasBarCodeScanner = 0,
canSetupImprov = true,
appVersion = AppVersion.from("1.0.0 (1)"),
),
),
Expand All @@ -31,13 +33,32 @@ class OutgoingExternalBusMessageTest {
)
}

@Test
fun `Given device without BLE when serializing config then canSetupImprov is false`() {
val json = frontendExternalBusJson.encodeToString<OutgoingExternalBusMessage>(
ResultMessage.config(
id = 2,
config = ConfigResult.create(
hasNfc = false,
canCommissionMatter = false,
canExportThread = false,
hasBarCodeScanner = 0,
canSetupImprov = false,
appVersion = AppVersion.from("1.0.0 (1)"),
),
),
)
assertTrue(json.contains(""""canSetupImprov":false"""))
}

@Test
fun `Given ConfigResult then default values are correct`() {
val config = ConfigResult.create(
hasNfc = false,
canCommissionMatter = false,
canExportThread = false,
hasBarCodeScanner = 0,
canSetupImprov = true,
appVersion = AppVersion.from("1.0.0 (1)"),
)

Expand All @@ -49,4 +70,18 @@ class OutgoingExternalBusMessageTest {
assertTrue(config.hasEntityAddTo)
assertTrue(config.hasAssistSettings)
}

@Test
fun `Given canSetupImprov passed false to create then ConfigResult exposes canSetupImprov false`() {
val config = ConfigResult.create(
hasNfc = false,
canCommissionMatter = false,
canExportThread = false,
hasBarCodeScanner = 0,
canSetupImprov = false,
appVersion = AppVersion.from("1.0.0 (1)"),
)

assertFalse(config.canSetupImprov)
}
}
Loading
Loading