diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt index c577b569636..ce5d69d9c42 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt @@ -45,7 +45,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.compose.composable.HAAccentButton import io.homeassistant.companion.android.common.compose.composable.HAPlainButton @@ -60,12 +63,11 @@ import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.error.FrontendConnectionErrorStateProvider import io.homeassistant.companion.android.frontend.filechooser.FileChooserEffect import io.homeassistant.companion.android.frontend.filechooser.FileChooserRequest +import io.homeassistant.companion.android.frontend.improv.ui.ImprovOverlay import io.homeassistant.companion.android.frontend.js.FrontendJsBridge import io.homeassistant.companion.android.frontend.js.FrontendJsCallback -import io.homeassistant.companion.android.frontend.permissions.MultiplePermissionsEffect -import io.homeassistant.companion.android.frontend.permissions.NotificationPermissionPrompt +import io.homeassistant.companion.android.frontend.permissions.PendingPermissionHandler import io.homeassistant.companion.android.frontend.permissions.PermissionRequest -import io.homeassistant.companion.android.frontend.permissions.SinglePermissionEffect import io.homeassistant.companion.android.launch.PipReadiness import io.homeassistant.companion.android.loading.LoadingScreen import io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionScreen @@ -124,6 +126,7 @@ internal fun FrontendScreen( val pendingDialog by viewModel.pendingDialog.collectAsStateWithLifecycle() val pendingFileChooser by viewModel.pendingFileChooser.collectAsStateWithLifecycle() val autoPlayVideoEnabled by viewModel.autoPlayVideoEnabled.collectAsStateWithLifecycle() + val improvScanRequested by viewModel.improvScanRequested.collectAsStateWithLifecycle() // The fullscreen View handed over by the WebView is Activity-scoped. Keep it in screen // state so it does not leak across configuration changes via the ViewModel. @@ -172,8 +175,13 @@ internal fun FrontendScreen( webViewActions = viewModel.webViewActions, onGesture = viewModel::onGesture, onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged, + onImprovConnectDevice = viewModel::onImprovConnectDevice, + onImprovRestart = viewModel::onImprovRestart, + onImprovDismiss = viewModel::onImprovSheetDismissed, autoPlayVideoEnabled = autoPlayVideoEnabled, onPipReadinessChanged = onPipReadinessChanged, + improvScanRequested = improvScanRequested, + processImprovScanRequests = viewModel::processImprovScanRequests, modifier = modifier, ) } @@ -209,28 +217,26 @@ internal fun FrontendScreenContent( onGesture: (GestureDirection, Int) -> Unit = { _, _ -> }, onExoPlayerFullscreenChanged: (Boolean) -> Unit = {}, onPipReadinessChanged: (PipReadiness?) -> Unit = {}, + onImprovConnectDevice: (ssid: String, password: String) -> Unit = { _, _ -> }, + onImprovRestart: () -> Unit = {}, + onImprovDismiss: () -> Unit = {}, + improvScanRequested: Boolean = false, + processImprovScanRequests: suspend () -> Unit = {}, ) { var webView by remember { mutableStateOf(null) } - WebViewEffects( + FrontendScreenEffects( webView = webView, url = viewState.url, frontendJsCallback = frontendJsCallback, webViewActions = webViewActions, + pendingFileChooser = pendingFileChooser, autoPlayVideoEnabled = autoPlayVideoEnabled, + improvScanRequested = improvScanRequested, + processImprovScanRequests = processImprovScanRequests, ) - PendingPermissionHandler( - pendingRequest = pendingPermissionRequest, - ) - - PendingDialogHandler( - pendingDialog = pendingDialog, - ) - - FileChooserEffect( - pendingRequest = pendingFileChooser, - ) + FrontendScreenHandlers(pendingPermissionRequest = pendingPermissionRequest, pendingDialog = pendingDialog) Box(modifier = modifier.fillMaxSize()) { // Always render WebView at base layer @@ -253,6 +259,13 @@ internal fun FrontendScreenContent( onPipReadinessChanged = onPipReadinessChanged, ) + ImprovOverlay( + state = (viewState as? FrontendViewState.Content)?.improvUiState, + onConnectDevice = onImprovConnectDevice, + onRestart = onImprovRestart, + onDismiss = onImprovDismiss, + ) + StateOverlay( viewState = viewState, errorStateProvider = errorStateProvider, @@ -271,6 +284,46 @@ internal fun FrontendScreenContent( } } +@Composable +private fun FrontendScreenHandlers(pendingPermissionRequest: PermissionRequest?, pendingDialog: FrontendDialog?) { + PendingPermissionHandler( + pendingRequest = pendingPermissionRequest, + ) + + PendingDialogHandler( + pendingDialog = pendingDialog, + ) +} + +@Composable +private fun FrontendScreenEffects( + webView: WebView?, + url: String, + frontendJsCallback: FrontendJsCallback, + webViewActions: Flow, + pendingFileChooser: FileChooserRequest?, + autoPlayVideoEnabled: Boolean, + improvScanRequested: Boolean, + processImprovScanRequests: suspend () -> Unit, +) { + ImprovScanLifecycleEffect( + scanRequested = improvScanRequested, + processImprovScanRequests = processImprovScanRequests, + ) + + WebViewEffects( + webView = webView, + url = url, + frontendJsCallback = frontendJsCallback, + webViewActions = webViewActions, + autoPlayVideoEnabled = autoPlayVideoEnabled, + ) + + FileChooserEffect( + pendingRequest = pendingFileChooser, + ) +} + /** * Renders the appropriate overlay based on the current view state. */ @@ -566,48 +619,6 @@ private fun WebView.configureForFrontend( ) } -/** - * Routes a [PermissionRequest] to the appropriate UI and delivers the result back through the - * request's own callback. The slot is freed automatically by the manager once the callback is - * invoked, so this composable doesn't have to clear anything itself. - * - * Types with custom UI (e.g. [PermissionRequest.Notification] bottom sheet) are matched first. - * Remaining types fall through to the system dialog based on their category: - * [PermissionRequest.MultiplePermissions] or [PermissionRequest.SinglePermission]. - * - * Adding a new permission type that uses the system dialog requires no changes here. - */ -@Composable -private fun PendingPermissionHandler(pendingRequest: PermissionRequest?) { - when (pendingRequest) { - is PermissionRequest.Notification -> { - @SuppressLint("InlinedApi") - NotificationPermissionPrompt( - onPermissionResult = pendingRequest.onResult, - onDismiss = pendingRequest.onDismiss, - ) - } - - is PermissionRequest.MultiplePermissions -> { - MultiplePermissionsEffect( - pendingRequest = pendingRequest, - onPermissionResult = pendingRequest.onResult, - ) - } - - is PermissionRequest.SinglePermission -> { - SinglePermissionEffect( - pendingRequest = pendingRequest, - onPermissionResult = pendingRequest.onResult, - ) - } - - null -> { - /* No pending permission */ - } - } -} - /** * Handles WebView side effects: URL loading, [WebViewAction] dispatch, and reapplying the * "Autoplay video" preference (which requires a [WebView.reload] to take effect on the loaded page). @@ -645,6 +656,18 @@ private fun WebViewEffects( } } +@Composable +private fun ImprovScanLifecycleEffect(scanRequested: Boolean, processImprovScanRequests: suspend () -> Unit) { + if (scanRequested) { + val lifecycle = LocalLifecycleOwner.current.lifecycle + LaunchedEffect(lifecycle) { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + processImprovScanRequests() + } + } + } +} + /** * Renders PiP-eligible overlays and reports their combined [PipReadiness] to the host. */ diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt index 8d67d4b67ab..244ebb566c2 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt @@ -28,6 +28,7 @@ import io.homeassistant.companion.android.frontend.gesture.FrontendGestureHandle import io.homeassistant.companion.android.frontend.gesture.GestureResult import io.homeassistant.companion.android.frontend.handler.FrontendBusObserver import io.homeassistant.companion.android.frontend.handler.FrontendHandlerEvent +import io.homeassistant.companion.android.frontend.improv.FrontendImprovOrchestrator import io.homeassistant.companion.android.frontend.js.BridgeState import io.homeassistant.companion.android.frontend.js.FrontendJsBridgeFactory import io.homeassistant.companion.android.frontend.js.FrontendJsCallback @@ -93,6 +94,7 @@ internal class FrontendViewModel @VisibleForTesting constructor( private val fileChooserManager: FileChooserManager, private val httpAuthManager: HttpAuthManager, private val exoPlayerManager: FrontendExoPlayerManager, + private val improvOrchestrator: FrontendImprovOrchestrator, ) : ViewModel(), FrontendConnectionErrorStateProvider { @@ -113,6 +115,7 @@ internal class FrontendViewModel @VisibleForTesting constructor( fileChooserManager: FileChooserManager, httpAuthManager: HttpAuthManager, exoPlayerManager: FrontendExoPlayerManager, + improvOrchestrator: FrontendImprovOrchestrator, ) : this( initialServerId = savedStateHandle.toRoute().serverId, initialPath = savedStateHandle.toRoute().path, @@ -130,6 +133,7 @@ internal class FrontendViewModel @VisibleForTesting constructor( fileChooserManager = fileChooserManager, httpAuthManager = httpAuthManager, exoPlayerManager = exoPlayerManager, + improvOrchestrator = improvOrchestrator, ) /** @@ -248,6 +252,14 @@ internal class FrontendViewModel @VisibleForTesting constructor( emitAll(prefsRepository.autoPlayVideoFlow()) }.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = false) + /** + * Whether the frontend currently wants the improv BLE scan running. Observed by + * [io.homeassistant.companion.android.frontend.FrontendScreen] to drive a lifecycle-bound + * collect that keeps the scan alive while the screen is RESUMED and tears it down on + * navigation or pause. + */ + val improvScanRequested: StateFlow = improvOrchestrator.scanRequested + init { viewModelScope.launch { _viewState.collectLatest { state -> @@ -280,6 +292,30 @@ internal class FrontendViewModel @VisibleForTesting constructor( } } + viewModelScope.launch { + improvOrchestrator.uiState.collect { improvUiState -> + _viewState.update { currentState -> + if (currentState is FrontendViewState.Content) { + currentState.copy(improvUiState = improvUiState) + } else { + currentState + } + } + } + } + + viewModelScope.launch { + improvOrchestrator.events.collect { event -> + when (event) { + is FrontendImprovOrchestrator.Event.ReloadAtPath -> { + _viewState.update { + FrontendViewState.LoadServer(serverId = event.serverId, path = event.path) + } + } + } + } + } + loadServer() } @@ -589,14 +625,10 @@ 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.StartImprovScan -> improvOrchestrator.onStartImprovScan() + + is FrontendHandlerEvent.ConfigureImprovDevice -> + improvOrchestrator.onConfigureImprovDevice(result.deviceName) is FrontendHandlerEvent.ConfigSent, is FrontendHandlerEvent.UnknownMessage, @@ -606,6 +638,34 @@ internal class FrontendViewModel @VisibleForTesting constructor( } } + /** + * Forwards user-entered Wi-Fi credentials to the device on the orchestrator's current + * [io.homeassistant.companion.android.frontend.improv.ImprovUIState.ConfiguringDevice] — + * no-ops if no improv session is active or the BLE address hasn't been resolved yet. + */ + fun onImprovConnectDevice(ssid: String, password: String) { + viewModelScope.launch { + improvOrchestrator.onConnectDevice(scope = viewModelScope, ssid = ssid, password = password) + } + } + + /** Re-arms scanning after an improv error — wired to the sheet's "Try again" button. */ + fun onImprovRestart() { + viewModelScope.launch { improvOrchestrator.onRestart() } + } + + /** Closes the improv bottom sheet and, if successful, navigates the frontend to the matching config flow. */ + fun onImprovSheetDismissed() { + viewModelScope.launch { improvOrchestrator.onDismissed(serverId = _viewState.value.serverId) } + } + + /** + * Hosts the discovered-device forwarder on the caller's coroutine — suspends until cancelled. + * Intended to be invoked from `FrontendScreen` inside a `repeatOnLifecycle(RESUMED)` block so + * the BLE scan's lifetime is bound to the route's visibility. + */ + suspend fun processImprovScanRequests() = improvOrchestrator.processImprovScanRequests() + /** * Handles URL load results from the URL manager. * diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt index b9dcceb3e96..ff6f86e847d 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.graphics.Color import io.homeassistant.companion.android.common.data.prefs.NightModeTheme import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.exoplayer.ExoPlayerUiState +import io.homeassistant.companion.android.frontend.improv.ImprovUIState import io.homeassistant.companion.android.util.compose.webview.BLANK_URL /** @@ -56,6 +57,7 @@ sealed interface FrontendViewState { val statusBarColor: Color? = null, val backgroundColor: Color? = null, val exoPlayerState: ExoPlayerUiState? = null, + val improvUiState: ImprovUIState? = null, ) : FrontendViewState /** diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/improv/FrontendImprovOrchestrator.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/improv/FrontendImprovOrchestrator.kt new file mode 100644 index 00000000000..98695c0b839 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/improv/FrontendImprovOrchestrator.kt @@ -0,0 +1,387 @@ +package io.homeassistant.companion.android.frontend.improv + +import com.wifi.improv.ImprovDevice +import dagger.hilt.android.scopes.ViewModelScoped +import io.homeassistant.companion.android.common.data.network.WifiHelper +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository +import io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDeviceSetupDoneMessage +import io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDiscoveredDeviceMessage +import io.homeassistant.companion.android.frontend.externalbus.outgoing.NavigateToMessage +import io.homeassistant.companion.android.frontend.permissions.PermissionManager +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber + +/** + * Owns the runtime orchestration of the improv Wi-Fi onboarding flow on top of [ImprovRepository], + * exposing a single [uiState] that drives the UI and reacting to the outcome: + * + * - [scanRequested] is flipped on by `improv/scan` and off on dismiss. While true, the UI layer + * collects [processImprovScanRequests] inside a lifecycle-bound effect — that collect forwards + * each newly-seen device name to the frontend as [ImprovDiscoveredDeviceMessage] and resolves + * the BLE address for the [ImprovUIState.ConfiguringDevice] target as it appears. + * - A provisioning job is launched on [onConnectDevice] and transitions [uiState] through + * [ImprovUIState.Provisioning] → ([ImprovUIState.Provisioned] | [ImprovUIState.Errored]) as the + * [ProvisioningEvent]s arrive, sending [ImprovDeviceSetupDoneMessage] on success and capturing + * the integration `domain` on the terminal [ImprovUIState.Provisioned] for the dismiss-time + * navigation. + * - On dismiss, if a domain was captured, sends [NavigateToMessage] when the HA server is on + * 2025.6+, otherwise emits [Event.ReloadAtPath] for the VM to translate into a server-URL + * reload. + * + * **Concurrency contract.** Entry points are safe to call from any coroutine. Var-backed internal + * state ([provisioningJob], [discoveredDevices], [sentDeviceNames]) is guarded by [mutex]; + * StateFlow-backed state ([scanRequested], [uiState]) is atomic by construction and not + * lock-guarded. The orchestrator does not own a long-lived scope: the caller passes its scope to + * [onConnectDevice] for provisioning, and the UI layer hosts the scan collect through + * [processImprovScanRequests] so navigation away from the frontend route naturally pauses BLE work. + */ +@ViewModelScoped +internal class FrontendImprovOrchestrator @Inject constructor( + private val improvRepository: ImprovRepository, + private val bluetoothCapabilities: BluetoothCapabilities, + private val externalBusRepository: FrontendExternalBusRepository, + private val permissionManager: PermissionManager, + private val serverManager: ServerManager, + private val wifiHelper: WifiHelper, +) { + + private val mutex = Mutex() + private var provisioningJob: Job? = null + + private val _scanRequested = MutableStateFlow(false) + + /** + * Whether the user has an active improv session that wants scanning to keep running. Flipped + * on by [onStartImprovScan] (after permissions) and off by [onDismissed]. The UI layer + * observes this StateFlow and runs [processImprovScanRequests] inside a lifecycle-bound + * effect — that collect is what actually drives the BLE scan; when navigation pauses the + * route, the collect drops and the scan stops. + */ + val scanRequested: StateFlow = _scanRequested.asStateFlow() + + /** + * Mirror of the latest scan emission, written by [forwardDiscoveredDevices]. Read by + * [onConfigureImprovDevice] so a device already surfaced by the scan can land directly in + * [ImprovUIState.ConfiguringDevice] — without this snapshot, that device would have to wait + * for the next scan emission to surface. + * + * Reads and writes go through [mutex]. + */ + private var discoveredDevices: List = emptyList() + + /** + * Device names already forwarded to the frontend during the current session (from + * [onStartImprovScan] until [onDismissed]). Survives `processImprovScanRequests` restarts + * caused by the UI's lifecycle-bound collect tearing down on PAUSE and re-running on RESUME — + * the scan flow replays its current device list to the new collector, and re-sending those + * names would surface duplicates in the frontend's device list. Cleared on [onDismissed]. + * + * Reads and writes go through [mutex]. + */ + private val sentDeviceNames: MutableSet = mutableSetOf() + + private val _uiState = MutableStateFlow(null) + + /** + * Current improv sheet state. `null` while no flow is active; non-null while the UI should be on screen. + */ + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 1) + + /** + * Side-effect events to consume + */ + val events: SharedFlow = _events.asSharedFlow() + + /** + * Handles `improv/scan` from the frontend. + * + * Drops on devices without Bluetooth LE. Drives the rationale + system-permission flow, then + * flips [scanRequested] so the UI layer's lifecycle-bound effect picks up and starts + * [processImprovScanRequests]. + */ + suspend fun onStartImprovScan() { + if (!bluetoothCapabilities.hasBluetoothLe()) { + Timber.d("Improv scan ignored: device has no Bluetooth LE") + return + } + if (!ensurePermissions()) return + + _scanRequested.value = true + } + + /** + * Runs the discovered-device forwarder while [scanRequested] is true — meant to be invoked by + * the UI layer from a lifecycle-bound effect so leaving the frontend route naturally tears + * down the BLE scan. + * + * Suspends indefinitely (until the caller's coroutine is cancelled): observes [scanRequested] + * and, while it's true, runs the forwarder; when it flips back to false the forwarder is + * cancelled but this call keeps observing so a re-flip to true (same composition, + * back-to-back sessions) restarts forwarding. Permissions are re-checked each time the flag + * becomes true because the user may have revoked them while we were paused. + */ + suspend fun processImprovScanRequests() { + _scanRequested.collectLatest { requested -> + if (!requested) return@collectLatest + if (!improvRepository.hasPermissions()) return@collectLatest + forwardDiscoveredDevices() + } + } + + /** + * Handles `improv/configure_device(name)` from the frontend. + * + * Transitions [uiState] to [ImprovUIState.SearchingDevice]; then snapshots + * [discoveredDevices] and, if the target is already known, follows up with an immediate + * [ImprovUIState.ConfiguringDevice] update. Without that snapshot check, a device discovered + * *before* `configure_device` arrived would have to wait for the next scan emission to be + * promoted. Devices discovered *after* `configure_device` are handled by + * [forwardDiscoveredDevices]' promotion logic. + */ + suspend fun onConfigureImprovDevice(deviceName: String) { + mutex.withLock { + // Cancel any prior provisioning attempt from a previous session. + provisioningJob?.cancel() + provisioningJob = null + // Order matters: set SearchingDevice BEFORE snapshotting discoveredDevices. If a scan + // emission lands between the two lines, forwardDiscoveredDevices' promotion path must + // already see SearchingDevice in place — reversing the order would let that emission + // slip through both the snapshot match below and the promotion guard above. + _uiState.value = ImprovUIState.SearchingDevice(deviceName = deviceName) + val matched = discoveredDevices.firstOrNull { it.name == deviceName }?.address + if (matched != null) { + _uiState.value = configuringDeviceFor(deviceName = deviceName, deviceAddress = matched) + } + } + } + + /** + * Forwards user-entered Wi-Fi credentials to the device on the current + * [ImprovUIState.ConfiguringDevice]. Launches a provisioning job that drives the + * connect → authorize → submit-Wi-Fi → provisioned sequence, transitioning [uiState] through + * [ImprovUIState.Provisioning] → ([ImprovUIState.Provisioned] | + * [ImprovUIState.Errored]) as the [ProvisioningEvent]s arrive. Logs a warning and no-ops + * if the current state isn't [ImprovUIState.ConfiguringDevice] — the view never exposes + * the Wi-Fi form before then, so hitting this path indicates a misrouted call. The job is + * tracked in [provisioningJob] so [onRestart] / [onDismissed] can cancel an in-flight session. + */ + suspend fun onConnectDevice(scope: CoroutineScope, ssid: String, password: String) { + mutex.withLock { + val current = _uiState.value + if (current !is ImprovUIState.ConfiguringDevice) { + Timber.w( + "onConnectDevice ignored: expected ConfiguringDevice, got ${current?.let { + it::class.simpleName + } ?: "null"}", + ) + return@withLock + } + provisioningJob?.cancel() + provisioningJob = scope.launch { + runProvisioning( + deviceName = current.deviceName, + deviceAddress = current.deviceAddress, + ssid = ssid, + password = password, + ) + } + } + } + + /** + * Resets per-device provisioning state — wired to the sheet's "Try again" button after an + * error. Reads the target device from the current [ImprovUIState.WithResolvedDevice] and + * reverts to [ImprovUIState.ConfiguringDevice] so the user can re-submit Wi-Fi + * credentials. No-op when the sheet is still searching, terminal + * ([ImprovUIState.Provisioned]) or absent — the "Try again" affordance only ever exists in + * [ImprovUIState.Errored]. + */ + suspend fun onRestart() { + mutex.withLock { + provisioningJob?.cancel() + provisioningJob = null + val current = _uiState.value as? ImprovUIState.WithResolvedDevice ?: return@withLock + _uiState.value = configuringDeviceFor( + deviceName = current.deviceName, + deviceAddress = current.deviceAddress, + ) + } + } + + /** + * UI dismissed. If the device reported a `domain` during provisioning, hands the + * `config_flow_start` redirect to the frontend (or emits [Event.ReloadAtPath] for the legacy + * fallback). Flips [scanRequested] off so the UI's lifecycle-bound effect cancels its + * [processImprovScanRequests] collect, which in turn ends the BLE scan. A follow-up flow + * needs another `improv/scan` from the frontend. + */ + suspend fun onDismissed(serverId: Int) { + val domain: String? + mutex.withLock { + // Read the domain off the terminal state — only [ImprovUIState.Provisioned] carries + // one; every other variant means we landed here without finishing. + domain = (_uiState.value as? ImprovUIState.Provisioned)?.domain + provisioningJob?.cancel() + provisioningJob = null + _scanRequested.value = false + discoveredDevices = emptyList() + sentDeviceNames.clear() + _uiState.value = null + } + if (domain != null) { + navigateToConfigFlow(domain = domain, serverId = serverId) + } + } + + /** + * Builds a [ImprovUIState.ConfiguringDevice] for the given device, sampling the currently + * connected Wi-Fi SSID to pre-fill the credentials form. The SSID is re-sampled on every call + * so flow transitions reflect the network the user is on right now. + */ + private fun configuringDeviceFor(deviceName: String, deviceAddress: String): ImprovUIState.ConfiguringDevice = + ImprovUIState.ConfiguringDevice( + deviceName = deviceName, + deviceAddress = deviceAddress, + activeSsid = wifiHelper.getWifiSsid()?.removeSurrounding("\""), + ) + + /** + * Drives one provisioning attempt for the given device. Sets [uiState] to + * [ImprovUIState.Provisioning], then collects [ImprovRepository.provisionDevice] events + * and resolves to [ImprovUIState.Errored] (on [ProvisioningEvent.ErrorOccurred]) or + * [ImprovUIState.Provisioned] carrying the integration `domain` (on + * [ProvisioningEvent.Provisioned]). Every state transition is guarded against late events + * arriving after the user dismissed or restarted — writes only while still in + * [ImprovUIState.Provisioning], so a cancelled attempt can't resurrect the sheet. + */ + private suspend fun runProvisioning(deviceName: String, deviceAddress: String, ssid: String, password: String) { + _uiState.value = ImprovUIState.Provisioning(deviceName = deviceName, deviceAddress = deviceAddress) + improvRepository.provisionDevice(ImprovDevice(deviceName, deviceAddress), ssid, password).collect { event -> + when (event) { + is ProvisioningEvent.StateChanged -> _uiState.update { current -> + // Stay in Provisioning only — a late state update mustn't overwrite a terminal + // Errored/Provisioned that was set by a previous event. + if (current is ImprovUIState.Provisioning) current.copy(state = event.state) else current + } + is ProvisioningEvent.ErrorOccurred -> _uiState.update { current -> + if (current is ImprovUIState.Provisioning) { + ImprovUIState.Errored(deviceName, deviceAddress, event.error) + } else { + current + } + } + is ProvisioningEvent.Provisioned -> { + var transitioned = false + _uiState.update { current -> + // Guard against a Provisioned event arriving after the user dismissed, + // restarted, or after an Errored event overwriting any of those would + // resurrect the UI or misrepresent the outcome. + if (current is ImprovUIState.Provisioning) { + transitioned = true + ImprovUIState.Provisioned(domain = event.domain) + } else { + transitioned = false + current + } + } + // Gate the frontend signal on the same condition telling the frontend setup + // is done while the UI is errored/closed would be inconsistent. + if (transitioned) { + externalBusRepository.send(ImprovDeviceSetupDoneMessage) + } + } + } + } + } + + /** + * Subscribes to [ImprovRepository.scanDevices] for the lifetime of the call. On each + * emission: mirrors the list into [discoveredDevices], promotes + * [ImprovUIState.SearchingDevice] to [ImprovUIState.ConfiguringDevice] when the target + * appears, and forwards each newly-seen device name to the frontend via + * [ImprovDiscoveredDeviceMessage] — deduped against [sentDeviceNames] so the frontend sees + * each name exactly once per session (across collector restarts). + */ + private suspend fun forwardDiscoveredDevices() { + improvRepository.scanDevices().collect { devices -> + val namesToForward = mutex.withLock { + discoveredDevices = devices + // Compute the diff inside the lock so it's atomic with the [sentDeviceNames] + // update; emit outside the lock so the suspending [send] doesn't hold the mutex. + devices.mapNotNull { it.name }.filter { sentDeviceNames.add(it) } + } + // Promote SearchingDevice → ConfiguringDevice as soon as the scan resolves the BLE + // address. Once we leave SearchingDevice, late scan emissions must not overwrite the + // user-driven transitions (Provisioning, Errored, …). + _uiState.update { current -> + if (current is ImprovUIState.SearchingDevice) { + val matched = devices.firstOrNull { it.name == current.deviceName }?.address + if (matched != null) { + configuringDeviceFor(deviceName = current.deviceName, deviceAddress = matched) + } else { + current + } + } else { + current + } + } + namesToForward.forEach { name -> + externalBusRepository.send(ImprovDiscoveredDeviceMessage(name = name)) + } + } + } + + /** + * Delegates the full permission flow + * to [PermissionManager.checkImprovPermissions]. Returns `true` only when every required + * permission ends up granted. + */ + private suspend fun ensurePermissions(): Boolean { + if (improvRepository.hasPermissions()) return true + return permissionManager.checkImprovPermissions( + requiredPermissions = improvRepository.requiredPermissions, + ) + } + + /** + * HA 2025.6+: `command/navigate` (in-frontend route change), sent directly. + * Older HA: emits [Event.ReloadAtPath] so the VM can transition its loading state. + */ + private suspend fun navigateToConfigFlow(domain: String, serverId: Int) { + val path = "/_my_redirect/config_flow_start?domain=$domain" + val version = serverManager.getServer(serverId)?.version + if (NavigateToMessage.isAvailable(version)) { + externalBusRepository.send(NavigateToMessage(path = path)) + } else { + _events.emit(Event.ReloadAtPath(path = path, serverId = serverId)) + } + } + + /** + * Side-effects the orchestrator can't carry out on its own — the VM has to translate them + * into view-state transitions. + */ + sealed interface Event { + /** + * Reload the WebView at the given [path] for [serverId]. Emitted only when + * [NavigateToMessage] isn't available (HA<2025.6). + */ + data class ReloadAtPath(val path: String, val serverId: Int) : Event + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/improv/ui/ImprovOverlay.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/improv/ui/ImprovOverlay.kt new file mode 100644 index 00000000000..fc0293dc942 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/improv/ui/ImprovOverlay.kt @@ -0,0 +1,39 @@ +package io.homeassistant.companion.android.frontend.improv.ui + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import io.homeassistant.companion.android.common.compose.composable.HAModalBottomSheet +import io.homeassistant.companion.android.common.compose.composable.rememberHAModalBottomSheetState +import io.homeassistant.companion.android.frontend.improv.ImprovUIState + +/** + * Renders the improv Wi-Fi onboarding bottom sheet on top of the WebView when an + * `improv/configure_device` flow is active. + * + * Hidden when [state] is `null`. Forwards swipe-to-dismiss to [onDismiss] so the ViewModel can + * extract the provisioned domain (if any) and trigger the navigate-to-config-flow side effect. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ImprovOverlay( + state: ImprovUIState?, + onConnectDevice: (ssid: String, password: String) -> Unit, + onRestart: () -> Unit, + onDismiss: () -> Unit, +) { + if (state != null) { + val sheetState = rememberHAModalBottomSheetState() + HAModalBottomSheet( + bottomSheetState = sheetState, + onDismissRequest = onDismiss, + dragHandle = {}, + ) { + ImprovSheetView( + screenState = state, + onConnect = onConnectDevice, + onRestart = onRestart, + onDismiss = onDismiss, + ) + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/ImprovPermissionPrompt.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/ImprovPermissionPrompt.kt new file mode 100644 index 00000000000..3f2e6dd1214 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/ImprovPermissionPrompt.kt @@ -0,0 +1,109 @@ +package io.homeassistant.companion.android.frontend.permissions + +import android.annotation.SuppressLint +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.tooling.preview.Preview +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import io.homeassistant.companion.android.common.compose.composable.HAModalBottomSheet +import io.homeassistant.companion.android.common.compose.composable.rememberHAModalBottomSheetState +import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview +import io.homeassistant.companion.android.frontend.improv.ui.ImprovPermissionView +import kotlinx.coroutines.launch + +/** + * Renders the improv permission flow: optional rationale bottom sheet followed by the system + * multi-permission dialog. + * + * Two branches: + * - When [PermissionRequest.Improv.showRationale] is `true`, the bottom sheet is shown. Continue + * asks accompanist's `MultiplePermissionsState` to launch the system dialog; its callback + * surfaces the grant map via [PermissionRequest.Improv.onResult] and closes the sheet. Skip / + * swipe-to-dismiss calls [PermissionRequest.Improv.onDismiss] instead. + * - When `showRationale` is `false`, no UI renders — a one-shot `LaunchedEffect` triggers the + * system dialog directly, and the same accompanist callback delivers the result. + * + * The `isClosed` gate is necessary because Material 3's `ModalBottomSheet` creates a `Dialog` + * window that, even when hidden, can block touch events on top of the system permission dialog + * launched immediately afterwards. Removing the composable entirely (`!isClosed`) tears down the + * Dialog window so the system dialog is fully interactive. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) +@Composable +internal fun ImprovPermissionPrompt(request: PermissionRequest.Improv) { + val sheetState = rememberHAModalBottomSheetState() + val coroutineScope = rememberCoroutineScope() + + // Remove the sheet entirely from composition once dismissed so M3's Dialog window stops + // swallowing touches on top of the system permission dialog launched by accompanist. + var isClosed by remember { mutableStateOf(false) } + + fun closeSheet() { + coroutineScope.launch { + sheetState.hide() + isClosed = true + } + } + + val multiPermissionsState = rememberMultiplePermissionsState( + permissions = request.permissions, + onPermissionsResult = { result -> + request.onResult(result) + closeSheet() + }, + ) + + if (!request.showRationale) { + LaunchedEffect(Unit) { + multiPermissionsState.launchMultiplePermissionRequest() + } + return + } + + if (!isClosed) { + HAModalBottomSheet( + bottomSheetState = sheetState, + onDismissRequest = { + request.onDismiss() + closeSheet() + }, + dragHandle = {}, + ) { + ImprovPermissionView( + needsBluetooth = request.needsBluetooth, + needsLocation = request.needsLocation, + onContinue = { multiPermissionsState.launchMultiplePermissionRequest() }, + onSkip = { + request.onDismiss() + closeSheet() + }, + ) + } + } +} + +@SuppressLint("InlinedApi") +@Preview +@Composable +private fun PreviewImprovPermissionPrompt() { + HAThemeForPreview { + ImprovPermissionPrompt( + PermissionRequest.Improv( + permissions = listOf( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.ACCESS_FINE_LOCATION, + ), + showRationale = true, + onResult = {}, + onDismiss = {}, + ), + ) + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PendingPermissionHandler.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PendingPermissionHandler.kt new file mode 100644 index 00000000000..88c256f30d3 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PendingPermissionHandler.kt @@ -0,0 +1,50 @@ +package io.homeassistant.companion.android.frontend.permissions + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable + +/** + * Routes a [PermissionRequest] to the appropriate UI and delivers the result back through the + * request's own callback. The slot is freed automatically by the manager once the callback is + * invoked, so this composable doesn't have to clear anything itself. + * + * Types with custom UI ([PermissionRequest.Notification], [PermissionRequest.Improv]) are matched + * first. Remaining types fall through to the system dialog based on their category: + * [PermissionRequest.MultiplePermissions] or [PermissionRequest.SinglePermission]. + * + * Adding a new permission type that uses the system dialog requires no changes here. + */ +@Composable +internal fun PendingPermissionHandler(pendingRequest: PermissionRequest?) { + when (pendingRequest) { + is PermissionRequest.Notification -> { + @SuppressLint("InlinedApi") + NotificationPermissionPrompt( + onPermissionResult = pendingRequest.onResult, + onDismiss = pendingRequest.onDismiss, + ) + } + + is PermissionRequest.Improv -> { + ImprovPermissionPrompt(request = pendingRequest) + } + + is PermissionRequest.MultiplePermissions -> { + MultiplePermissionsEffect( + pendingRequest = pendingRequest, + onPermissionResult = pendingRequest.onResult, + ) + } + + is PermissionRequest.SinglePermission -> { + SinglePermissionEffect( + pendingRequest = pendingRequest, + onPermissionResult = pendingRequest.onResult, + ) + } + + null -> { + /* No pending permission */ + } + } +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionManager.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionManager.kt index 48bc4b8e54d..9f718d23a13 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionManager.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionManager.kt @@ -5,6 +5,8 @@ import android.annotation.SuppressLint import android.os.Build import android.webkit.PermissionRequest as WebViewPermissionRequest import androidx.annotation.VisibleForTesting +import dagger.hilt.android.scopes.ViewModelScoped +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.util.NotificationStatusProvider import io.homeassistant.companion.android.common.util.PermissionChecker @@ -28,6 +30,13 @@ private data class WebViewPermissionStatus( val resourcesByPermission: Map, ) +/** + * Caps how many times the improv permission rationale bottom sheet is shown to a user before + * skipping straight to the system multi-permission dialog. + */ +@VisibleForTesting +internal const val IMPROV_RATIONALE_MAX_SHOWS = 2 + /** * Centralises all Android runtime permission request/result for the frontend screen. * @@ -39,12 +48,14 @@ private data class WebViewPermissionStatus( * Concurrent triggers are waiting in FIFO order: a second method call suspends until the * current request is resolved. */ +@ViewModelScoped internal class PermissionManager @VisibleForTesting constructor( private val serverManager: ServerManager, private val settingsDao: SettingsDao, @FcmSupport private val fcmSupport: Boolean, private val notificationStatusProvider: NotificationStatusProvider, private val permissionChecker: PermissionChecker, + private val prefsRepository: PrefsRepository, // Need for testing to avoid the need of Robolectric private val sdkInt: Int, ) { @@ -56,12 +67,14 @@ internal class PermissionManager @VisibleForTesting constructor( @FcmSupport fcmSupport: Boolean, notificationStatusProvider: NotificationStatusProvider, permissionChecker: PermissionChecker, + prefsRepository: PrefsRepository, ) : this( serverManager = serverManager, settingsDao = settingsDao, fcmSupport = fcmSupport, notificationStatusProvider = notificationStatusProvider, permissionChecker = permissionChecker, + prefsRepository = prefsRepository, sdkInt = Build.VERSION.SDK_INT, ) @@ -147,6 +160,45 @@ internal class PermissionManager @VisibleForTesting constructor( } } + /** + * Drives the full permission flow for the improv Wi-Fi onboarding session. + * + * Returns `true` immediately when every permission in [requiredPermissions] is already + * granted. Otherwise enqueues a single [PermissionRequest.Improv] whose `showRationale` flag + * is `true` only while the displayed-count is below [IMPROV_RATIONALE_MAX_SHOWS]. The UI owns + * both stages: the optional rationale bottom sheet and the system multi-permission dialog. + * The call resolves to: + * - `true` after the user answered the system dialog (whether granting or denying — the + * final return value reflects whether everything is granted), + * - `false` when the user skipped the rationale without reaching the system dialog. + * + * @param requiredPermissions The full set of Android runtime permissions improv needs (BLE + + * Location). Variants of the list that depend on SDK level are owned by the caller. + * @return `true` when every permission in [requiredPermissions] is granted after the user + * responded; `false` when the user skipped the rationale or at least one permission is + * still missing. + */ + suspend fun checkImprovPermissions(requiredPermissions: List): Boolean { + val toRequest = requiredPermissions.filterNot { permissionChecker.hasPermission(it) } + if (toRequest.isEmpty()) return true + + val showRationale = prefsRepository.getImprovPermissionDisplayedCount() < IMPROV_RATIONALE_MAX_SHOWS + if (showRationale) { + prefsRepository.addImprovPermissionDisplayedCount() + } + + val responded = queue.awaitResult { resolve -> + PermissionRequest.Improv( + permissions = toRequest, + showRationale = showRationale, + onResult = { _ -> resolve(true) }, + onDismiss = { resolve(false) }, + ) + } + if (!responded) return false + return requiredPermissions.all { permissionChecker.hasPermission(it) } + } + /** * Processes a WebView permission request (camera, microphone). * diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionRequest.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionRequest.kt index 07574060627..eba173e466e 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionRequest.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionRequest.kt @@ -7,8 +7,9 @@ import androidx.annotation.RequiresApi /** * An Android runtime permission request enqueued in [PermissionManager]. * - * Subclasses fall into one of three categories the UI dispatches on: - * - [Notification] — the bottom-sheet prompt, allow/deny + dismiss-without-answering + * Subclasses fall into one of four categories the UI dispatches on: + * - [Notification] — bottom-sheet prompt + (on Allow) system dialog, allow/deny + dismiss + * - [Improv] — optional rationale bottom sheet + system multi-permission dialog * - [SinglePermission] — a single Android permission requested via the system dialog * - [MultiplePermissions] — several Android permissions requested at once via the system dialog * @@ -44,6 +45,37 @@ internal sealed interface PermissionRequest { class WebView(androidPermissions: List, override val onResult: (Map) -> Unit) : MultiplePermissions(permissions = androidPermissions) + /** + * Bluetooth + Location runtime permissions for the improv Wi-Fi onboarding flow. The UI owns + * both stages: when [showRationale] is `true` a rationale UI is shown first and + * Continue triggers the system dialog; when `false` the system dialog launches directly with + * no UI. Either path resolves via [onResult]; only the rationale-skip path uses [onDismiss]. + * + * Modelled as its own top-level category (not [MultiplePermissions]) because of that two-stage + * UI ownership. + * + * @param permissions The Android runtime permissions to request. The [needsBluetooth] / + * [needsLocation] flags shown on the rationale are derived from this list. + * @param showRationale Whether to render the rationale UI before the system dialog. + * [PermissionManager] flips this to `false` once the displayed-count cap is reached. + * @param onResult Called by the UI with the system dialog's grant map after the user + * responded (whether they granted or denied). + * @param onDismiss Called by the UI only when the user skips/dismisses the rationale without + * reaching the system dialog. Never fires when [showRationale] is `false`. + */ + class Improv( + override val permissions: List, + val showRationale: Boolean, + val onResult: (Map) -> Unit, + val onDismiss: () -> Unit, + ) : PermissionRequest { + /** `true` when [permissions] mentions any Bluetooth runtime permission. */ + val needsBluetooth: Boolean = permissions.any { it.contains("BLUETOOTH", ignoreCase = true) } + + /** `true` when [permissions] includes [Manifest.permission.ACCESS_FINE_LOCATION]. */ + val needsLocation: Boolean = permissions.any { it == Manifest.permission.ACCESS_FINE_LOCATION } + } + /** A request for [Manifest.permission.WRITE_EXTERNAL_STORAGE]. */ class ExternalStorage(override val onResult: (Boolean) -> Unit) : SinglePermission(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) diff --git a/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest.kt b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest.kt new file mode 100644 index 00000000000..bcea98c5aca --- /dev/null +++ b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest.kt @@ -0,0 +1,218 @@ +package io.homeassistant.companion.android.frontend + +import android.annotation.SuppressLint +import android.webkit.WebChromeClient +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import com.android.tools.screenshot.PreviewTest +import com.wifi.improv.DeviceState +import com.wifi.improv.ErrorState +import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview +import io.homeassistant.companion.android.frontend.improv.ImprovUIState +import io.homeassistant.companion.android.frontend.js.FrontendJsBridge +import io.homeassistant.companion.android.frontend.permissions.PermissionRequest +import io.homeassistant.companion.android.util.compose.HAPreviews + +/** + * Screenshot coverage for the improv flow as composed inside [FrontendScreenContent]: both the + * onboarding bottom sheet (driven by [ImprovUIState] variants) and the permission rationale + * prompt (driven by [PermissionRequest.Improv] with `showRationale = true`). + */ +@SuppressLint("InlinedApi") +class FrontendScreenImprovScreenshotTest { + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv SearchingDevice`() { + HAThemeForPreview { + FrontendImprovContent( + improvUiState = ImprovUIState.SearchingDevice(deviceName = "Smart Plug"), + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv ConfiguringDevice`() { + HAThemeForPreview { + FrontendImprovContent( + improvUiState = ImprovUIState.ConfiguringDevice( + deviceName = "Smart Plug", + deviceAddress = "A1:B2:C3:D4:E5:F6", + activeSsid = "Home Wi-Fi", + ), + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv Provisioning connecting`() { + HAThemeForPreview { + FrontendImprovContent( + improvUiState = ImprovUIState.Provisioning( + deviceName = "Smart Plug", + deviceAddress = "A1:B2:C3:D4:E5:F6", + state = null, + ), + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv Provisioning authorization required`() { + HAThemeForPreview { + FrontendImprovContent( + improvUiState = ImprovUIState.Provisioning( + deviceName = "Smart Plug", + deviceAddress = "A1:B2:C3:D4:E5:F6", + state = DeviceState.AUTHORIZATION_REQUIRED, + ), + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv Provisioning sending wifi`() { + HAThemeForPreview { + FrontendImprovContent( + improvUiState = ImprovUIState.Provisioning( + deviceName = "Smart Plug", + deviceAddress = "A1:B2:C3:D4:E5:F6", + state = DeviceState.PROVISIONING, + ), + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv Errored unable to connect`() { + HAThemeForPreview { + FrontendImprovContent( + improvUiState = ImprovUIState.Errored( + deviceName = "Smart Plug", + deviceAddress = "A1:B2:C3:D4:E5:F6", + error = ErrorState.UNABLE_TO_CONNECT, + ), + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv Errored not authorized`() { + HAThemeForPreview { + FrontendImprovContent( + improvUiState = ImprovUIState.Errored( + deviceName = "Smart Plug", + deviceAddress = "A1:B2:C3:D4:E5:F6", + error = ErrorState.NOT_AUTHORIZED, + ), + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv Provisioned`() { + HAThemeForPreview { + FrontendImprovContent( + improvUiState = ImprovUIState.Provisioned(domain = "esphome"), + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv permission rationale Bluetooth and Location`() { + HAThemeForPreview { + FrontendImprovContent( + pendingPermissionRequest = PermissionRequest.Improv( + permissions = listOf( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.ACCESS_FINE_LOCATION, + ), + showRationale = true, + onResult = {}, + onDismiss = {}, + ), + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv permission rationale Bluetooth only`() { + HAThemeForPreview { + FrontendImprovContent( + pendingPermissionRequest = PermissionRequest.Improv( + permissions = listOf( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.BLUETOOTH_CONNECT, + ), + showRationale = true, + onResult = {}, + onDismiss = {}, + ), + ) + } + } + + @PreviewTest + @HAPreviews + @Composable + fun `FrontendScreen Improv permission rationale Location only`() { + HAThemeForPreview { + FrontendImprovContent( + pendingPermissionRequest = PermissionRequest.Improv( + permissions = listOf(android.Manifest.permission.ACCESS_FINE_LOCATION), + showRationale = true, + onResult = {}, + onDismiss = {}, + ), + ) + } + } + + @Composable + private fun FrontendImprovContent( + improvUiState: ImprovUIState? = null, + pendingPermissionRequest: PermissionRequest? = null, + ) { + FrontendScreenContent( + onBackClick = {}, + viewState = FrontendViewState.Content( + serverId = 1, + url = "https://example.com", + improvUiState = improvUiState, + ), + pendingPermissionRequest = pendingPermissionRequest, + webViewClient = WebViewClient(), + webChromeClient = WebChromeClient(), + frontendJsCallback = FrontendJsBridge.noOp, + onBlockInsecureRetry = {}, + onOpenExternalLink = {}, + onBlockInsecureHelpClick = {}, + onOpenSettings = {}, + onChangeSecurityLevel = {}, + onOpenLocationSettings = {}, + onConfigureHomeNetwork = { _ -> }, + onSecurityLevelHelpClick = {}, + onShowSnackbar = { _, _ -> true }, + onWebViewCreationFailed = {}, + ) + } +} diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_foldable_c908f502_0.png new file mode 100644 index 00000000000..5d55bb9144d Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_phone_e05166be_0.png new file mode 100644 index 00000000000..b00bd00c3bb Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..77fe8042578 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..fa0cfb5de3f Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..90241845ef7 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..0aa096acb29 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv ConfiguringDevice_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_foldable_c908f502_0.png new file mode 100644 index 00000000000..88076a0114f Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_phone_e05166be_0.png new file mode 100644 index 00000000000..e686628d36d Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..fa875f81fcf Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..d7e3f5beda6 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..30534ff18a4 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..5ffe8538b57 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored not authorized_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_foldable_c908f502_0.png new file mode 100644 index 00000000000..e635e6c5858 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_phone_e05166be_0.png new file mode 100644 index 00000000000..3bf9adffd38 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..53463f9d123 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..38ec9e7f682 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..de7b3d72831 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..36f80f34717 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Errored unable to connect_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_foldable_c908f502_0.png new file mode 100644 index 00000000000..6418624676b Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_phone_e05166be_0.png new file mode 100644 index 00000000000..85832f95685 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..af50ece1f2c Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..b57e518e50c Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..ffb68b21d4e Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..99275ed74fe Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioned_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_foldable_c908f502_0.png new file mode 100644 index 00000000000..e296243f2a9 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_phone_e05166be_0.png new file mode 100644 index 00000000000..434aaf897ac Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..a3716637e0f Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..696a7a5027e Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..90e69897c77 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..1d47efdd153 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning authorization required_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_foldable_c908f502_0.png new file mode 100644 index 00000000000..52ae7f22231 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_phone_e05166be_0.png new file mode 100644 index 00000000000..c52c9ca54e7 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..ec8fd916fd4 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..317c0fa2366 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..4ffb79978c3 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..eedbb1872fc Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning connecting_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_foldable_c908f502_0.png new file mode 100644 index 00000000000..e99c4ce6d15 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_phone_e05166be_0.png new file mode 100644 index 00000000000..c0ede01bb88 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..95a64fea00f Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..21992b2003d Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..8c24c4e8454 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..7ba9f3360a4 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv Provisioning sending wifi_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_foldable_c908f502_0.png new file mode 100644 index 00000000000..52ae7f22231 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_phone_e05166be_0.png new file mode 100644 index 00000000000..c52c9ca54e7 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..ec8fd916fd4 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..317c0fa2366 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..4ffb79978c3 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..eedbb1872fc Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv SearchingDevice_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_foldable_c908f502_0.png new file mode 100644 index 00000000000..c04acb63c71 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_phone_e05166be_0.png new file mode 100644 index 00000000000..0d0f83d5cf0 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..76b5cb94928 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..6596940152d Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..c3f8e9b19a8 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..e399302607d Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth and Location_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_foldable_c908f502_0.png new file mode 100644 index 00000000000..3454a6b90fa Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_phone_e05166be_0.png new file mode 100644 index 00000000000..1ff7ccf4d66 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..76b5cb94928 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..6596940152d Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..4d3c68c0b05 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..295ba2abfb4 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Bluetooth only_tablet_landscape_62cae397_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_foldable_c908f502_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_foldable_c908f502_0.png new file mode 100644 index 00000000000..d296ac51d43 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_foldable_c908f502_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_phone_e05166be_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_phone_e05166be_0.png new file mode 100644 index 00000000000..5e4635f1f21 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_phone_e05166be_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_phone_landscape_9e00b29d_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_phone_landscape_9e00b29d_0.png new file mode 100644 index 00000000000..76b5cb94928 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_phone_landscape_9e00b29d_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_small_phone_66e7bbf2_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_small_phone_66e7bbf2_0.png new file mode 100644 index 00000000000..6596940152d Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_small_phone_66e7bbf2_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_tablet_2f22c4ea_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_tablet_2f22c4ea_0.png new file mode 100644 index 00000000000..4b5debb150d Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_tablet_2f22c4ea_0.png differ diff --git a/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_tablet_landscape_62cae397_0.png b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_tablet_landscape_62cae397_0.png new file mode 100644 index 00000000000..6fe23889193 Binary files /dev/null and b/app/src/screenshotTestFullDebug/reference/io/homeassistant/companion/android/frontend/FrontendScreenImprovScreenshotTest/FrontendScreen Improv permission rationale Location only_tablet_landscape_62cae397_0.png differ diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt index 1596e0a8bd5..c57a64a239a 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenTest.kt @@ -20,7 +20,9 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.wifi.improv.ErrorState import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication @@ -31,6 +33,7 @@ import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.database.settings.SettingsDao import io.homeassistant.companion.android.frontend.error.FrontendConnectionError import io.homeassistant.companion.android.frontend.error.FrontendConnectionErrorStateProvider +import io.homeassistant.companion.android.frontend.improv.ImprovUIState import io.homeassistant.companion.android.frontend.js.FrontendJsBridge import io.homeassistant.companion.android.frontend.permissions.PermissionManager import io.homeassistant.companion.android.frontend.permissions.PermissionRequest @@ -254,6 +257,102 @@ class FrontendScreenTest { } } + @Test + fun `Given Content with null improv state then improv sheet is not displayed`() { + composeTestRule.apply { + setFrontendScreen( + viewState = FrontendViewState.Content(serverId = 1, url = "https://example.com"), + ) + + onNodeWithText(stringResource(commonR.string.improv_wifi_title)).assertDoesNotExist() + onNodeWithText(stringResource(commonR.string.improv_device_provisioned)).assertDoesNotExist() + } + } + + @Test + fun `Given Content with SearchingDevice then improv sheet shows connecting caption`() { + composeTestRule.apply { + setFrontendScreen( + viewState = FrontendViewState.Content( + serverId = 1, + url = "https://example.com", + improvUiState = ImprovUIState.SearchingDevice(deviceName = "Smart Plug"), + ), + ) + + onNodeWithText(stringResource(commonR.string.improv_device_connecting)).assertIsDisplayed() + } + } + + @Test + fun `Given Content with ConfiguringDevice when continue clicked then onImprovConnectDevice is called with credentials`() { + var connectArgs: Pair? = null + composeTestRule.apply { + setFrontendScreen( + viewState = FrontendViewState.Content( + serverId = 1, + url = "https://example.com", + improvUiState = ImprovUIState.ConfiguringDevice( + deviceName = "Smart Plug", + deviceAddress = "AA:BB", + activeSsid = "Home Wi-Fi", + ), + ), + onImprovConnectDevice = { ssid, password -> connectArgs = ssid to password }, + ) + + onNodeWithText(stringResource(commonR.string.improv_wifi_title)).assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.password)).performTextInput("supersecret") + onNodeWithText(stringResource(commonR.string.continue_connect)).performClick() + waitForIdle() + assertEquals("Home Wi-Fi" to "supersecret", connectArgs) + } + } + + @Test + fun `Given Content with Errored when try again clicked then onImprovRestart is called`() { + var restartCalled = false + composeTestRule.apply { + setFrontendScreen( + viewState = FrontendViewState.Content( + serverId = 1, + url = "https://example.com", + improvUiState = ImprovUIState.Errored( + deviceName = "Smart Plug", + deviceAddress = "AA:BB", + error = ErrorState.UNABLE_TO_CONNECT, + ), + ), + onImprovRestart = { restartCalled = true }, + ) + + onNodeWithText(stringResource(commonR.string.improv_error_unable_to_connect)).assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.continue_connect)).performClick() + waitForIdle() + assertTrue("onImprovRestart should be called when try again is clicked", restartCalled) + } + } + + @Test + fun `Given Content with Provisioned when continue clicked then onImprovDismiss is called`() { + var dismissCalled = false + composeTestRule.apply { + setFrontendScreen( + viewState = FrontendViewState.Content( + serverId = 1, + url = "https://example.com", + improvUiState = ImprovUIState.Provisioned(domain = "acme"), + ), + onImprovDismiss = { dismissCalled = true }, + ) + + onNodeWithText(stringResource(commonR.string.improv_device_provisioned)).assertIsDisplayed() + onNodeWithText(stringResource(commonR.string.continue_connect)).performClick() + waitForIdle() + assertTrue("onImprovDismiss should be called when continue is clicked", dismissCalled) + } + } + @Test fun `Given no pending notification permission then notification prompt is not displayed`() { composeTestRule.apply { @@ -383,6 +482,7 @@ class FrontendScreenTest { fcmSupport = false, notificationStatusProvider = mockk(relaxed = true), permissionChecker = { false }, + prefsRepository = mockk(relaxed = true), ) private fun AndroidComposeTestRule, HiltComponentActivity>.setFrontendScreen( @@ -397,6 +497,9 @@ class FrontendScreenTest { onConfigureHomeNetwork: (serverId: Int) -> Unit = { _ -> }, onSecurityLevelHelpClick: suspend () -> Unit = {}, onSecurityLevelDone: () -> Unit = {}, + onImprovConnectDevice: (ssid: String, password: String) -> Unit = { _, _ -> }, + onImprovRestart: () -> Unit = {}, + onImprovDismiss: () -> Unit = {}, registry: ActivityResultRegistry? = null, ) { setContent { @@ -420,6 +523,9 @@ class FrontendScreenTest { onSecurityLevelDone = onSecurityLevelDone, onShowSnackbar = { _, _ -> true }, onWebViewCreationFailed = {}, + onImprovConnectDevice = onImprovConnectDevice, + onImprovRestart = onImprovRestart, + onImprovDismiss = onImprovDismiss, ) } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt index 01abe514276..02154e37459 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModelTest.kt @@ -34,6 +34,8 @@ import io.homeassistant.companion.android.frontend.gesture.FrontendGestureHandle import io.homeassistant.companion.android.frontend.gesture.GestureResult import io.homeassistant.companion.android.frontend.handler.FrontendBusObserver import io.homeassistant.companion.android.frontend.handler.FrontendHandlerEvent +import io.homeassistant.companion.android.frontend.improv.FrontendImprovOrchestrator +import io.homeassistant.companion.android.frontend.improv.ImprovUIState import io.homeassistant.companion.android.frontend.js.FrontendJsBridgeFactory import io.homeassistant.companion.android.frontend.navigation.FrontendEvent import io.homeassistant.companion.android.frontend.permissions.PermissionManager @@ -116,6 +118,15 @@ class FrontendViewModelTest { every { state } returns MutableStateFlow(null) } + private val improvUiStateFlow = MutableStateFlow(null) + private val improvEventsFlow = MutableSharedFlow(extraBufferCapacity = 1) + private val improvScanRequestedFlow = MutableStateFlow(false) + private val improvOrchestrator: FrontendImprovOrchestrator = mockk(relaxed = true) { + every { uiState } returns improvUiStateFlow + every { events } returns improvEventsFlow + every { scanRequested } returns improvScanRequestedFlow + } + private fun createViewModel( serverId: Int = this.serverId, path: String? = null, @@ -126,6 +137,7 @@ class FrontendViewModelTest { clock = FakeClock(), dialogManager = dialogManager, ), + improvOrchestrator: FrontendImprovOrchestrator = this.improvOrchestrator, ): FrontendViewModel { return FrontendViewModel( initialServerId = serverId, @@ -144,6 +156,7 @@ class FrontendViewModelTest { fileChooserManager = fileChooserManager, httpAuthManager = httpAuthManager, exoPlayerManager = exoPlayerManager, + improvOrchestrator = improvOrchestrator, ) } @@ -1739,4 +1752,160 @@ class FrontendViewModelTest { assertEquals(value, viewModel.autoPlayVideoEnabled.value) } } + + @Nested + inner class Improv { + + @Test + fun `Given StartImprovScan event when received then orchestrator is invoked`() = runTest { + val messageFlow = MutableSharedFlow() + every { frontendBusObserver.messageResults() } returns messageFlow + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + messageFlow.emit(FrontendHandlerEvent.StartImprovScan) + advanceTimeBy(1.seconds) + + coVerify { improvOrchestrator.onStartImprovScan() } + } + + @Test + fun `Given ConfigureImprovDevice event when received then orchestrator is invoked with name`() = runTest { + val messageFlow = MutableSharedFlow() + every { frontendBusObserver.messageResults() } returns messageFlow + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + messageFlow.emit(FrontendHandlerEvent.ConfigureImprovDevice(deviceName = "Smart Plug")) + advanceTimeBy(1.seconds) + + coVerify { improvOrchestrator.onConfigureImprovDevice("Smart Plug") } + } + + @Test + fun `Given orchestrator emits uiState when collected then Content improvUiState is updated`() = runTest { + val messageFlow = MutableSharedFlow() + every { frontendBusObserver.messageResults() } returns messageFlow + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + messageFlow.emit(FrontendHandlerEvent.Connected) + advanceTimeBy(1.seconds) + + improvUiStateFlow.value = ImprovUIState.SearchingDevice(deviceName = "Smart Plug") + advanceTimeBy(1.seconds) + + val state = viewModel.viewState.value + assertTrue(state is FrontendViewState.Content) + assertNotNull((state as FrontendViewState.Content).improvUiState) + } + + @Test + fun `Given orchestrator emits ReloadAtPath event when collected then state transitions to LoadServer`() = runTest { + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + improvEventsFlow.emit( + FrontendImprovOrchestrator.Event.ReloadAtPath( + path = "/_my_redirect/config_flow_start?domain=acme", + serverId = serverId, + ), + ) + advanceTimeBy(1.seconds) + + val state = viewModel.viewState.value + assertTrue(state is FrontendViewState.LoadServer) + assertEquals( + "/_my_redirect/config_flow_start?domain=acme", + (state as FrontendViewState.LoadServer).path, + ) + } + + @Test + fun `Given onImprovSheetDismissed when called then orchestrator onDismissed is invoked`() = runTest { + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + viewModel.onImprovSheetDismissed() + advanceTimeBy(1.seconds) + + coVerify { improvOrchestrator.onDismissed(serverId = any()) } + } + + @Test + fun `Given onImprovConnectDevice when called then forwards to orchestrator`() = runTest { + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + viewModel.onImprovConnectDevice("wifi", "pwd") + advanceTimeBy(1.seconds) + + coVerify { improvOrchestrator.onConnectDevice(any(), "wifi", "pwd") } + } + + @Test + fun `Given onImprovRestart when called then forwards to orchestrator`() = runTest { + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + viewModel.onImprovRestart() + advanceTimeBy(1.seconds) + + coVerify { improvOrchestrator.onRestart() } + } + + @Test + fun `Given improvScanRequested exposed when collected then mirrors orchestrator scanRequested`() = runTest { + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + improvScanRequestedFlow.value = true + assertEquals(true, viewModel.improvScanRequested.value) + improvScanRequestedFlow.value = false + assertEquals(false, viewModel.improvScanRequested.value) + } + + @Test + fun `Given processImprovScanRequests when called then forwards to orchestrator`() = runTest { + every { urlManager.serverUrlFlow(any(), any()) } returns flowOf( + UrlLoadResult.Success(url = testUrlWithAuth, serverId = serverId), + ) + + val viewModel = createViewModel() + advanceTimeBy(CONNECTION_TIMEOUT - 1.seconds) + + viewModel.processImprovScanRequests() + + coVerify { improvOrchestrator.processImprovScanRequests() } + } + } } diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/improv/FrontendImprovOrchestratorTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/improv/FrontendImprovOrchestratorTest.kt new file mode 100644 index 00000000000..4ec6af818a3 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/improv/FrontendImprovOrchestratorTest.kt @@ -0,0 +1,464 @@ +package io.homeassistant.companion.android.frontend.improv + +import app.cash.turbine.test +import com.wifi.improv.DeviceState +import com.wifi.improv.ErrorState +import com.wifi.improv.ImprovDevice +import io.homeassistant.companion.android.common.data.HomeAssistantVersion +import io.homeassistant.companion.android.common.data.network.WifiHelper +import io.homeassistant.companion.android.common.data.servers.ServerManager +import io.homeassistant.companion.android.frontend.externalbus.FrontendExternalBusRepository +import io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDeviceSetupDoneMessage +import io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDiscoveredDeviceMessage +import io.homeassistant.companion.android.frontend.externalbus.outgoing.OutgoingExternalBusMessage +import io.homeassistant.companion.android.frontend.permissions.PermissionManager +import io.homeassistant.companion.android.testing.unit.ConsoleLogExtension +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@OptIn(ExperimentalCoroutinesApi::class) +@ExtendWith(ConsoleLogExtension::class) +class FrontendImprovOrchestratorTest { + + /** Backing flow returned by `scanDevices()` — test drives it by emitting. */ + private val scanFlow = MutableStateFlow>(emptyList()) + + /** Backing flow returned by `provisionDevice()` — test drives by emitting ProvisioningEvents. */ + private val provisionFlow = MutableSharedFlow(extraBufferCapacity = 16) + + private val improvRepository: ImprovRepository = mockk(relaxed = true) { + every { scanDevices() } returns scanFlow + every { provisionDevice(any(), any(), any()) } returns provisionFlow + every { hasPermissions() } returns true + every { requiredPermissions } returns listOf( + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.ACCESS_FINE_LOCATION", + ) + } + private val bluetoothCapabilities = BluetoothCapabilities { true } + private val externalBusRepository: FrontendExternalBusRepository = mockk(relaxed = true) + private val permissionManager: PermissionManager = mockk(relaxed = true) { + coEvery { checkImprovPermissions(any()) } returns true + } + private val serverManager: ServerManager = mockk(relaxed = true) + private val wifiHelper: WifiHelper = mockk(relaxed = true) { + every { getWifiSsid() } returns "\"My SSID\"" + } + + private fun createOrchestrator() = FrontendImprovOrchestrator( + improvRepository = improvRepository, + bluetoothCapabilities = bluetoothCapabilities, + externalBusRepository = externalBusRepository, + permissionManager = permissionManager, + serverManager = serverManager, + wifiHelper = wifiHelper, + ) + + /** + * Drives the orchestrator to a ConfiguringDevice variant whose deviceAddress has been + * resolved by an in-flight scan, the realistic precondition for [FrontendImprovOrchestrator.onConnectDevice]. + * Advances the test scheduler at each step so the launched scan collector subscribes before + * we emit, and so the emission is processed before the helper returns. Returns the scan + * collector [Job] so callers can cancel it at the end of the test — the underlying + * `collectLatest` on `_scanRequested` never completes on its own. + */ + private suspend fun TestScope.configureWithResolvedAddress( + orchestrator: FrontendImprovOrchestrator, + deviceName: String = "Smart Plug", + deviceAddress: String = "AA:BB", + ): Job { + orchestrator.onStartImprovScan() + val scanJob = launch { orchestrator.processImprovScanRequests() } + advanceUntilIdle() + orchestrator.onConfigureImprovDevice(deviceName = deviceName) + scanFlow.emit(listOf(ImprovDevice(deviceName, deviceAddress))) + advanceUntilIdle() + return scanJob + } + + @Test + fun `Given device without BLE when onStartImprovScan then scanRequested stays false`() = runTest { + val orchestrator = FrontendImprovOrchestrator( + improvRepository = improvRepository, + bluetoothCapabilities = { false }, + externalBusRepository = externalBusRepository, + permissionManager = permissionManager, + serverManager = serverManager, + wifiHelper = wifiHelper, + ) + + orchestrator.onStartImprovScan() + advanceUntilIdle() + + assertFalse(orchestrator.scanRequested.value) + } + + @Test + fun `Given permissions granted when onStartImprovScan then scanRequested flips true`() = runTest { + val orchestrator = createOrchestrator() + + orchestrator.onStartImprovScan() + + assertTrue(orchestrator.scanRequested.value) + } + + @Test + fun `Given scanRequested true when scan emits then device name is forwarded to frontend`() = runTest { + val orchestrator = createOrchestrator() + orchestrator.onStartImprovScan() + + val job = launch { orchestrator.processImprovScanRequests() } + advanceUntilIdle() + scanFlow.emit(listOf(ImprovDevice("Smart Plug", "AA:BB"))) + advanceUntilIdle() + + val slot = slot() + coVerify { externalBusRepository.send(capture(slot)) } + assertEquals(slot.captured, ImprovDiscoveredDeviceMessage("Smart Plug")) + job.cancel() + } + + @Test + fun `Given scanRequested false when processImprovScanRequests then nothing is sent to frontend`() = runTest { + val orchestrator = createOrchestrator() + + val job = launch { orchestrator.processImprovScanRequests() } + advanceUntilIdle() + // This won't ever happen in reality, but it is the only way to test that we are not subscribing to scanDevices + scanFlow.emit(listOf(ImprovDevice("Smart Plug", "AA:BB"))) + advanceUntilIdle() + + coVerify(exactly = 0) { externalBusRepository.send(any()) } + job.cancel() + } + + @Test + fun `Given missing permissions when onStartImprovScan then delegates to permissionManager`() = runTest { + every { improvRepository.hasPermissions() } returns false + val expectedPermissions = improvRepository.requiredPermissions + + val orchestrator = createOrchestrator() + orchestrator.onStartImprovScan() + advanceUntilIdle() + + coVerify { permissionManager.checkImprovPermissions(expectedPermissions) } + } + + @Test + fun `Given permissionManager denies improv permissions when onStartImprovScan then scanRequested stays false`() = runTest { + every { improvRepository.hasPermissions() } returns false + coEvery { permissionManager.checkImprovPermissions(any()) } returns false + + val orchestrator = createOrchestrator() + orchestrator.onStartImprovScan() + advanceUntilIdle() + + assertFalse(orchestrator.scanRequested.value) + } + + @Test + fun `Given scan is running when devices are discovered then forwards each new name once`() = runTest { + val orchestrator = createOrchestrator() + orchestrator.onStartImprovScan() + val job = launch { orchestrator.processImprovScanRequests() } + advanceUntilIdle() + + scanFlow.emit(listOf(ImprovDevice("Smart Plug", "AA:BB"))) + scanFlow.emit(listOf(ImprovDevice("Smart Plug", "AA:BB"), ImprovDevice("Lamp", "CC:DD"))) + advanceUntilIdle() + + coVerify(exactly = 2) { externalBusRepository.send(any()) } + + job.cancel() + } + + @Test + fun `Given scan collector cancelled and resubscribed while session still active then previously sent device names are not re-sent`() = runTest { + val orchestrator = createOrchestrator() + orchestrator.onStartImprovScan() + + // First lifecycle-bound collect — simulates the foreground RESUMED effect. + val firstJob = launch { orchestrator.processImprovScanRequests() } + advanceUntilIdle() + scanFlow.emit(listOf(ImprovDevice("Smart Plug", "AA:BB"))) + advanceUntilIdle() + coVerify(exactly = 1) { externalBusRepository.send(any()) } + + // Simulate app backgrounding — lifecycle cancels processImprovScanRequests. The session + // is still active (no onDismissed, _scanRequested stays true). + firstJob.cancel() + + // Foreground again: the lifecycle effect re-runs processImprovScanRequests. The repository's + // scanFlow still holds the previously discovered device (replay semantics), so without + // session-level dedup the orchestrator re-sends it to the frontend. + val secondJob = launch { orchestrator.processImprovScanRequests() } + advanceUntilIdle() + + coVerify(exactly = 1) { externalBusRepository.send(any()) } + secondJob.cancel() + } + + @Test + fun `Given onConfigureImprovDevice when handled then uiState is initialised to SearchingDevice`() = runTest { + val orchestrator = createOrchestrator() + + orchestrator.uiState.test { + assertNull(awaitItem()) + orchestrator.onConfigureImprovDevice(deviceName = "Smart Plug") + advanceUntilIdle() + val state = awaitItem() + assertInstanceOf(ImprovUIState.SearchingDevice::class.java, state) + assertEquals("Smart Plug", (state as ImprovUIState.SearchingDevice).deviceName) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `Given scan emits matching device when SearchingDevice then state promotes to ConfiguringDevice`() = runTest { + val orchestrator = createOrchestrator() + orchestrator.onStartImprovScan() + val job = launch { orchestrator.processImprovScanRequests() } + advanceUntilIdle() + orchestrator.onConfigureImprovDevice(deviceName = "Smart Plug") + + scanFlow.emit(listOf(ImprovDevice("Smart Plug", "AA:BB"))) + advanceUntilIdle() + + val state = orchestrator.uiState.value + assertInstanceOf(ImprovUIState.ConfiguringDevice::class.java, state) + val configuring = state as ImprovUIState.ConfiguringDevice + assertEquals("Smart Plug", configuring.deviceName) + assertEquals("AA:BB", configuring.deviceAddress) + job.cancel() + } + + @Test + fun `Given device already discovered before onConfigureImprovDevice then state initialises directly to ConfiguringDevice`() = runTest { + val orchestrator = createOrchestrator() + orchestrator.onStartImprovScan() + val job = launch { orchestrator.processImprovScanRequests() } + advanceUntilIdle() + scanFlow.emit(listOf(ImprovDevice("Smart Plug", "AA:BB"))) + advanceUntilIdle() + + orchestrator.onConfigureImprovDevice(deviceName = "Smart Plug") + + val state = orchestrator.uiState.value + assertInstanceOf(ImprovUIState.ConfiguringDevice::class.java, state) + val configuring = state as ImprovUIState.ConfiguringDevice + assertEquals("Smart Plug", configuring.deviceName) + assertEquals("AA:BB", configuring.deviceAddress) + job.cancel() + } + + @Test + fun `Given onConnectDevice and PROVISIONED event then sends device_setup_done`() = runTest { + val orchestrator = createOrchestrator() + val scanJob = configureWithResolvedAddress(orchestrator) + val provisioningScope = CoroutineScope(coroutineContext + Job()) + + orchestrator.onConnectDevice(scope = provisioningScope, ssid = "wifi", password = "pwd") + advanceUntilIdle() + + provisionFlow.emit(ProvisioningEvent.StateChanged(DeviceState.PROVISIONING)) + provisionFlow.emit(ProvisioningEvent.Provisioned(domain = "acme")) + advanceUntilIdle() + + coVerify(exactly = 1) { externalBusRepository.send(ImprovDeviceSetupDoneMessage) } + scanJob.cancel() + provisioningScope.cancel() + } + + @Test + fun `Given Errored state when late Provisioned event arrives then device_setup_done is not sent`() = runTest { + val orchestrator = createOrchestrator() + val scanJob = configureWithResolvedAddress(orchestrator) + val provisioningScope = CoroutineScope(coroutineContext + Job()) + orchestrator.onConnectDevice(scope = provisioningScope, ssid = "wifi", password = "pwd") + advanceUntilIdle() + + // First an error transitions UI to Errored. + provisionFlow.emit(ProvisioningEvent.ErrorOccurred(ErrorState.UNABLE_TO_CONNECT)) + advanceUntilIdle() + assertInstanceOf(ImprovUIState.Errored::class.java, orchestrator.uiState.value) + + // Then a late Provisioned event from the BLE stack. The UI must stay Errored AND no + // setup-done message should be forwarded — otherwise the frontend would think setup + // succeeded while the sheet still shows the error. + provisionFlow.emit(ProvisioningEvent.Provisioned(domain = "acme")) + advanceUntilIdle() + + assertInstanceOf(ImprovUIState.Errored::class.java, orchestrator.uiState.value) + coVerify(exactly = 0) { externalBusRepository.send(ImprovDeviceSetupDoneMessage) } + scanJob.cancel() + provisioningScope.cancel() + } + + @Test + fun `Given onConnectDevice while still SearchingDevice then provisionDevice is not called`() = runTest { + val orchestrator = createOrchestrator() + orchestrator.onConfigureImprovDevice(deviceName = "Smart Plug") + + orchestrator.onConnectDevice(scope = backgroundScope, ssid = "wifi", password = "pwd") + + verify(exactly = 0) { improvRepository.provisionDevice(any(), any(), any()) } + } + + @Test + fun `Given provisioning emits state changes then uiState mirrors them`() = runTest { + val orchestrator = createOrchestrator() + val scanJob = configureWithResolvedAddress(orchestrator) + val provisioningScope = CoroutineScope(coroutineContext + Job()) + orchestrator.onConnectDevice(scope = provisioningScope, ssid = "wifi", password = "pwd") + advanceUntilIdle() + + provisionFlow.emit(ProvisioningEvent.StateChanged(DeviceState.AUTHORIZATION_REQUIRED)) + advanceUntilIdle() + val provisioning = orchestrator.uiState.value + assertInstanceOf(ImprovUIState.Provisioning::class.java, provisioning) + assertEquals(DeviceState.AUTHORIZATION_REQUIRED, (provisioning as ImprovUIState.Provisioning).state) + + provisionFlow.emit(ProvisioningEvent.ErrorOccurred(ErrorState.UNABLE_TO_CONNECT)) + advanceUntilIdle() + val errored = orchestrator.uiState.value + assertInstanceOf(ImprovUIState.Errored::class.java, errored) + val erroredState = errored as ImprovUIState.Errored + assertEquals(ErrorState.UNABLE_TO_CONNECT, erroredState.error) + assertEquals("Smart Plug", erroredState.deviceName) + assertEquals("AA:BB", erroredState.deviceAddress) + scanJob.cancel() + provisioningScope.cancel() + } + + @Test + fun `Given onDismissed without provisioned domain then clears uiState`() = runTest { + val orchestrator = createOrchestrator() + orchestrator.onConfigureImprovDevice(deviceName = "Smart Plug") + advanceUntilIdle() + + orchestrator.onDismissed(serverId = 1) + advanceUntilIdle() + + assertNull(orchestrator.uiState.value) + } + + @Test + fun `Given onDismissed with provisioned domain on new HA then sends NavigateToMessage`() = runTest { + coEvery { serverManager.getServer(1) } returns mockk(relaxed = true) { + every { version } returns HomeAssistantVersion(year = 2025, month = 6, release = 0) + } + val orchestrator = createOrchestrator() + val scanJob = configureWithResolvedAddress(orchestrator) + val provisioningScope = CoroutineScope(coroutineContext + Job()) + orchestrator.onConnectDevice(scope = provisioningScope, ssid = "wifi", password = "pwd") + advanceUntilIdle() + provisionFlow.emit(ProvisioningEvent.Provisioned(domain = "acme")) + advanceUntilIdle() + + val captured = slot() + coEvery { externalBusRepository.send(capture(captured)) } returns Unit + + orchestrator.onDismissed(serverId = 1) + + assertInstanceOf(OutgoingExternalBusMessage::class.java, captured.captured) + scanJob.cancel() + provisioningScope.cancel() + } + + @Test + fun `Given onDismissed with provisioned domain on old HA then emits ReloadAtPath event`() = runTest { + coEvery { serverManager.getServer(1) } returns mockk(relaxed = true) { + every { version } returns HomeAssistantVersion(year = 2025, month = 5, release = 0) + } + val orchestrator = createOrchestrator() + val scanJob = configureWithResolvedAddress(orchestrator) + val provisioningScope = CoroutineScope(coroutineContext + Job()) + orchestrator.onConnectDevice(scope = provisioningScope, ssid = "wifi", password = "pwd") + advanceUntilIdle() + provisionFlow.emit(ProvisioningEvent.Provisioned(domain = "acme")) + advanceUntilIdle() + + orchestrator.events.test { + orchestrator.onDismissed(serverId = 1) + + val event = awaitItem() + assertInstanceOf(FrontendImprovOrchestrator.Event.ReloadAtPath::class.java, event) + val reload = event as FrontendImprovOrchestrator.Event.ReloadAtPath + assertEquals("/_my_redirect/config_flow_start?domain=acme", reload.path) + assertEquals(1, reload.serverId) + cancelAndIgnoreRemainingEvents() + } + scanJob.cancel() + provisioningScope.cancel() + } + + @Test + fun `Given active scan when onDismissed then scanRequested flips false`() = runTest { + val orchestrator = createOrchestrator() + orchestrator.onStartImprovScan() + val job = launch { orchestrator.processImprovScanRequests() } + advanceUntilIdle() + assertEquals(true, orchestrator.scanRequested.value) + + orchestrator.onDismissed(serverId = 1) + + assertEquals(false, orchestrator.scanRequested.value) + job.cancel() + } + + @Test + fun `Given onRestart from Errored then reverts to ConfiguringDevice with same device`() = runTest { + val orchestrator = createOrchestrator() + val scanJob = configureWithResolvedAddress(orchestrator) + val provisioningScope = CoroutineScope(coroutineContext + Job()) + orchestrator.onConnectDevice(scope = provisioningScope, ssid = "wifi", password = "pwd") + advanceUntilIdle() + provisionFlow.emit(ProvisioningEvent.ErrorOccurred(ErrorState.UNABLE_TO_CONNECT)) + advanceUntilIdle() + + orchestrator.onRestart() + + val state = orchestrator.uiState.value + assertInstanceOf(ImprovUIState.ConfiguringDevice::class.java, state) + val configuring = state as ImprovUIState.ConfiguringDevice + assertEquals("Smart Plug", configuring.deviceName) + assertEquals("AA:BB", configuring.deviceAddress) + scanJob.cancel() + provisioningScope.cancel() + } + + @Test + fun `Given onRestart while still SearchingDevice then state is unchanged`() = runTest { + val orchestrator = createOrchestrator() + orchestrator.onConfigureImprovDevice(deviceName = "Smart Plug") + + orchestrator.onRestart() + + // Try-again is only exposed in Errored; from SearchingDevice it's effectively a no-op. + val state = orchestrator.uiState.value + assertInstanceOf(ImprovUIState.SearchingDevice::class.java, state) + assertEquals("Smart Plug", (state as ImprovUIState.SearchingDevice).deviceName) + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionManagerTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionManagerTest.kt index 9d37e84a9d3..70a0a49229d 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionManagerTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/frontend/permissions/PermissionManagerTest.kt @@ -4,6 +4,7 @@ import android.os.Build import android.webkit.PermissionRequest as WebViewPermissionRequest import app.cash.turbine.turbineScope import io.homeassistant.companion.android.common.data.integration.IntegrationRepository +import io.homeassistant.companion.android.common.data.prefs.PrefsRepository import io.homeassistant.companion.android.common.data.servers.ServerManager import io.homeassistant.companion.android.common.util.NotificationStatusProvider import io.homeassistant.companion.android.common.util.PermissionChecker @@ -46,6 +47,7 @@ class PermissionManagerTest { private val integrationRepository: IntegrationRepository = mockk(relaxed = true) private val notificationStatusProvider: NotificationStatusProvider = mockk() private val permissionChecker: PermissionChecker = mockk() + private val prefsRepository: PrefsRepository = mockk(relaxed = true) private val serverId = 1 @@ -64,6 +66,7 @@ class PermissionManagerTest { fcmSupport = hasFcmPushSupport, notificationStatusProvider = notificationStatusProvider, permissionChecker = permissionChecker, + prefsRepository = prefsRepository, sdkInt = sdkInt, ) } @@ -591,6 +594,111 @@ class PermissionManagerTest { // region Guard against concurrent requests + @Nested + inner class CheckImprovPermissions { + + private val improvPermissions = listOf( + android.Manifest.permission.BLUETOOTH_SCAN, + android.Manifest.permission.BLUETOOTH_CONNECT, + android.Manifest.permission.ACCESS_FINE_LOCATION, + ) + + private fun grantAll() { + improvPermissions.forEach { every { permissionChecker.hasPermission(it) } returns true } + } + + private fun denyAll() { + improvPermissions.forEach { every { permissionChecker.hasPermission(it) } returns false } + } + + @Test + fun `Given all permissions granted then returns true without enqueuing`() = runTest { + grantAll() + val manager = createManager() + + assertTrue(manager.checkImprovPermissions(improvPermissions)) + assertNull(manager.pendingPermissionRequest.value) + } + + @Test + fun `Given rationale below cap when called then enqueues Improv with showRationale true`() = runTest { + denyAll() + coEvery { prefsRepository.getImprovPermissionDisplayedCount() } returns 0 + + val manager = createManager() + val result = async { manager.checkImprovPermissions(improvPermissions) } + advanceUntilIdle() + + val pending = manager.pendingPermissionRequest.value + assertInstanceOf(PermissionRequest.Improv::class.java, pending) + val improv = pending as PermissionRequest.Improv + assertTrue(improv.showRationale) + assertTrue(improv.needsBluetooth) + assertTrue(improv.needsLocation) + assertEquals(improvPermissions, improv.permissions) + coVerify { prefsRepository.addImprovPermissionDisplayedCount() } + + improv.onDismiss() + advanceUntilIdle() + assertFalse(result.await()) + } + + @Test + fun `Given rationale below cap when system dialog grants then returns true`() = runTest { + denyAll() + coEvery { prefsRepository.getImprovPermissionDisplayedCount() } returns 0 + + val manager = createManager() + val result = async { manager.checkImprovPermissions(improvPermissions) } + advanceUntilIdle() + + grantAll() + (manager.pendingPermissionRequest.value as PermissionRequest.Improv) + .onResult(improvPermissions.associateWith { true }) + advanceUntilIdle() + + assertTrue(result.await()) + } + + @Test + fun `Given rationale cap reached when called then enqueues Improv with showRationale false`() = runTest { + denyAll() + coEvery { prefsRepository.getImprovPermissionDisplayedCount() } returns IMPROV_RATIONALE_MAX_SHOWS + + val manager = createManager() + val result = async { manager.checkImprovPermissions(improvPermissions) } + advanceUntilIdle() + + val pending = manager.pendingPermissionRequest.value + assertInstanceOf(PermissionRequest.Improv::class.java, pending) + val improv = pending as PermissionRequest.Improv + assertFalse(improv.showRationale) + coVerify(exactly = 0) { prefsRepository.addImprovPermissionDisplayedCount() } + + improv.onResult(improvPermissions.associateWith { false }) + advanceUntilIdle() + assertFalse(result.await()) + } + + @Test + fun `Given system dialog returns partial grant when called then returns false`() = runTest { + denyAll() + coEvery { prefsRepository.getImprovPermissionDisplayedCount() } returns IMPROV_RATIONALE_MAX_SHOWS + + val manager = createManager() + val result = async { manager.checkImprovPermissions(improvPermissions) } + advanceUntilIdle() + + every { permissionChecker.hasPermission(android.Manifest.permission.BLUETOOTH_SCAN) } returns true + (manager.pendingPermissionRequest.value as PermissionRequest.Improv).onResult( + mapOf(android.Manifest.permission.BLUETOOTH_SCAN to true), + ) + advanceUntilIdle() + + assertFalse(result.await()) + } + } + @Nested inner class ConcurrentRequestQueuing { diff --git a/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt index 41b14423ab0..5a3ba02067b 100644 --- a/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidComposeConventionPlugin.kt @@ -35,7 +35,7 @@ class AndroidComposeConventionPlugin : Plugin { // Screenshot test worker memory grows with test count. Increase as needed. // Tracking: https://issuetracker.google.com/issues/469819154 - val maxHeapSizeScreenshotTesting = "6g" + val maxHeapSizeScreenshotTesting = "7g" tasks.withType().configureEach { maxHeapSize = maxHeapSizeScreenshotTesting