Skip to content

Commit 3d39c1c

Browse files
committed
Introduce Improv onboarding in FrontendScreen
1 parent 1fab22d commit 3d39c1c

80 files changed

Lines changed: 1886 additions & 67 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendScreen.kt

Lines changed: 80 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ import androidx.compose.ui.res.stringResource
4545
import androidx.compose.ui.unit.LayoutDirection
4646
import androidx.compose.ui.viewinterop.AndroidView
4747
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
48+
import androidx.lifecycle.Lifecycle
49+
import androidx.lifecycle.compose.LocalLifecycleOwner
4850
import androidx.lifecycle.compose.collectAsStateWithLifecycle
51+
import androidx.lifecycle.repeatOnLifecycle
4952
import io.homeassistant.companion.android.common.R as commonR
5053
import io.homeassistant.companion.android.common.compose.composable.HAAccentButton
5154
import io.homeassistant.companion.android.common.compose.composable.HAPlainButton
@@ -60,12 +63,11 @@ import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
6063
import io.homeassistant.companion.android.frontend.error.FrontendConnectionErrorStateProvider
6164
import io.homeassistant.companion.android.frontend.filechooser.FileChooserEffect
6265
import io.homeassistant.companion.android.frontend.filechooser.FileChooserRequest
66+
import io.homeassistant.companion.android.frontend.improv.ui.ImprovOverlay
6367
import io.homeassistant.companion.android.frontend.js.FrontendJsBridge
6468
import io.homeassistant.companion.android.frontend.js.FrontendJsCallback
65-
import io.homeassistant.companion.android.frontend.permissions.MultiplePermissionsEffect
66-
import io.homeassistant.companion.android.frontend.permissions.NotificationPermissionPrompt
69+
import io.homeassistant.companion.android.frontend.permissions.PendingPermissionHandler
6770
import io.homeassistant.companion.android.frontend.permissions.PermissionRequest
68-
import io.homeassistant.companion.android.frontend.permissions.SinglePermissionEffect
6971
import io.homeassistant.companion.android.launch.PipReadiness
7072
import io.homeassistant.companion.android.loading.LoadingScreen
7173
import io.homeassistant.companion.android.onboarding.locationforsecureconnection.LocationForSecureConnectionScreen
@@ -124,6 +126,7 @@ internal fun FrontendScreen(
124126
val pendingDialog by viewModel.pendingDialog.collectAsStateWithLifecycle()
125127
val pendingFileChooser by viewModel.pendingFileChooser.collectAsStateWithLifecycle()
126128
val autoPlayVideoEnabled by viewModel.autoPlayVideoEnabled.collectAsStateWithLifecycle()
129+
val improvScanRequested by viewModel.improvScanRequested.collectAsStateWithLifecycle()
127130

128131
// The fullscreen View handed over by the WebView is Activity-scoped. Keep it in screen
129132
// state so it does not leak across configuration changes via the ViewModel.
@@ -172,8 +175,13 @@ internal fun FrontendScreen(
172175
webViewActions = viewModel.webViewActions,
173176
onGesture = viewModel::onGesture,
174177
onExoPlayerFullscreenChanged = viewModel::onExoPlayerFullscreenChanged,
178+
onImprovConnectDevice = viewModel::onImprovConnectDevice,
179+
onImprovRestart = viewModel::onImprovRestart,
180+
onImprovDismiss = viewModel::onImprovSheetDismissed,
175181
autoPlayVideoEnabled = autoPlayVideoEnabled,
176182
onPipReadinessChanged = onPipReadinessChanged,
183+
improvScanRequested = improvScanRequested,
184+
processImprovScanRequests = viewModel::processImprovScanRequests,
177185
modifier = modifier,
178186
)
179187
}
@@ -209,28 +217,26 @@ internal fun FrontendScreenContent(
209217
onGesture: (GestureDirection, Int) -> Unit = { _, _ -> },
210218
onExoPlayerFullscreenChanged: (Boolean) -> Unit = {},
211219
onPipReadinessChanged: (PipReadiness?) -> Unit = {},
220+
onImprovConnectDevice: (ssid: String, password: String) -> Unit = { _, _ -> },
221+
onImprovRestart: () -> Unit = {},
222+
onImprovDismiss: () -> Unit = {},
223+
improvScanRequested: Boolean = false,
224+
processImprovScanRequests: suspend () -> Unit = {},
212225
) {
213226
var webView by remember { mutableStateOf<WebView?>(null) }
214227

215-
WebViewEffects(
228+
FrontendScreenEffects(
216229
webView = webView,
217230
url = viewState.url,
218231
frontendJsCallback = frontendJsCallback,
219232
webViewActions = webViewActions,
233+
pendingFileChooser = pendingFileChooser,
220234
autoPlayVideoEnabled = autoPlayVideoEnabled,
235+
improvScanRequested = improvScanRequested,
236+
processImprovScanRequests = processImprovScanRequests,
221237
)
222238

223-
PendingPermissionHandler(
224-
pendingRequest = pendingPermissionRequest,
225-
)
226-
227-
PendingDialogHandler(
228-
pendingDialog = pendingDialog,
229-
)
230-
231-
FileChooserEffect(
232-
pendingRequest = pendingFileChooser,
233-
)
239+
FrontendScreenHandlers(pendingPermissionRequest = pendingPermissionRequest, pendingDialog = pendingDialog)
234240

235241
Box(modifier = modifier.fillMaxSize()) {
236242
// Always render WebView at base layer
@@ -253,6 +259,13 @@ internal fun FrontendScreenContent(
253259
onPipReadinessChanged = onPipReadinessChanged,
254260
)
255261

262+
ImprovOverlay(
263+
state = (viewState as? FrontendViewState.Content)?.improvUiState,
264+
onConnectDevice = onImprovConnectDevice,
265+
onRestart = onImprovRestart,
266+
onDismiss = onImprovDismiss,
267+
)
268+
256269
StateOverlay(
257270
viewState = viewState,
258271
errorStateProvider = errorStateProvider,
@@ -271,6 +284,46 @@ internal fun FrontendScreenContent(
271284
}
272285
}
273286

287+
@Composable
288+
private fun FrontendScreenHandlers(pendingPermissionRequest: PermissionRequest?, pendingDialog: FrontendDialog?) {
289+
PendingPermissionHandler(
290+
pendingRequest = pendingPermissionRequest,
291+
)
292+
293+
PendingDialogHandler(
294+
pendingDialog = pendingDialog,
295+
)
296+
}
297+
298+
@Composable
299+
private fun FrontendScreenEffects(
300+
webView: WebView?,
301+
url: String,
302+
frontendJsCallback: FrontendJsCallback,
303+
webViewActions: Flow<WebViewAction>,
304+
pendingFileChooser: FileChooserRequest?,
305+
autoPlayVideoEnabled: Boolean,
306+
improvScanRequested: Boolean,
307+
processImprovScanRequests: suspend () -> Unit,
308+
) {
309+
ImprovScanLifecycleEffect(
310+
scanRequested = improvScanRequested,
311+
processImprovScanRequests = processImprovScanRequests,
312+
)
313+
314+
WebViewEffects(
315+
webView = webView,
316+
url = url,
317+
frontendJsCallback = frontendJsCallback,
318+
webViewActions = webViewActions,
319+
autoPlayVideoEnabled = autoPlayVideoEnabled,
320+
)
321+
322+
FileChooserEffect(
323+
pendingRequest = pendingFileChooser,
324+
)
325+
}
326+
274327
/**
275328
* Renders the appropriate overlay based on the current view state.
276329
*/
@@ -566,48 +619,6 @@ private fun WebView.configureForFrontend(
566619
)
567620
}
568621

569-
/**
570-
* Routes a [PermissionRequest] to the appropriate UI and delivers the result back through the
571-
* request's own callback. The slot is freed automatically by the manager once the callback is
572-
* invoked, so this composable doesn't have to clear anything itself.
573-
*
574-
* Types with custom UI (e.g. [PermissionRequest.Notification] bottom sheet) are matched first.
575-
* Remaining types fall through to the system dialog based on their category:
576-
* [PermissionRequest.MultiplePermissions] or [PermissionRequest.SinglePermission].
577-
*
578-
* Adding a new permission type that uses the system dialog requires no changes here.
579-
*/
580-
@Composable
581-
private fun PendingPermissionHandler(pendingRequest: PermissionRequest?) {
582-
when (pendingRequest) {
583-
is PermissionRequest.Notification -> {
584-
@SuppressLint("InlinedApi")
585-
NotificationPermissionPrompt(
586-
onPermissionResult = pendingRequest.onResult,
587-
onDismiss = pendingRequest.onDismiss,
588-
)
589-
}
590-
591-
is PermissionRequest.MultiplePermissions -> {
592-
MultiplePermissionsEffect(
593-
pendingRequest = pendingRequest,
594-
onPermissionResult = pendingRequest.onResult,
595-
)
596-
}
597-
598-
is PermissionRequest.SinglePermission -> {
599-
SinglePermissionEffect(
600-
pendingRequest = pendingRequest,
601-
onPermissionResult = pendingRequest.onResult,
602-
)
603-
}
604-
605-
null -> {
606-
/* No pending permission */
607-
}
608-
}
609-
}
610-
611622
/**
612623
* Handles WebView side effects: URL loading, [WebViewAction] dispatch, and reapplying the
613624
* "Autoplay video" preference (which requires a [WebView.reload] to take effect on the loaded page).
@@ -645,6 +656,18 @@ private fun WebViewEffects(
645656
}
646657
}
647658

659+
@Composable
660+
private fun ImprovScanLifecycleEffect(scanRequested: Boolean, processImprovScanRequests: suspend () -> Unit) {
661+
if (scanRequested) {
662+
val lifecycle = LocalLifecycleOwner.current.lifecycle
663+
LaunchedEffect(lifecycle) {
664+
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
665+
processImprovScanRequests()
666+
}
667+
}
668+
}
669+
}
670+
648671
/**
649672
* Renders PiP-eligible overlays and reports their combined [PipReadiness] to the host.
650673
*/

app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import io.homeassistant.companion.android.frontend.gesture.FrontendGestureHandle
2828
import io.homeassistant.companion.android.frontend.gesture.GestureResult
2929
import io.homeassistant.companion.android.frontend.handler.FrontendBusObserver
3030
import io.homeassistant.companion.android.frontend.handler.FrontendHandlerEvent
31+
import io.homeassistant.companion.android.frontend.improv.FrontendImprovOrchestrator
3132
import io.homeassistant.companion.android.frontend.js.BridgeState
3233
import io.homeassistant.companion.android.frontend.js.FrontendJsBridgeFactory
3334
import io.homeassistant.companion.android.frontend.js.FrontendJsCallback
@@ -93,6 +94,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
9394
private val fileChooserManager: FileChooserManager,
9495
private val httpAuthManager: HttpAuthManager,
9596
private val exoPlayerManager: FrontendExoPlayerManager,
97+
private val improvOrchestrator: FrontendImprovOrchestrator,
9698
) : ViewModel(),
9799
FrontendConnectionErrorStateProvider {
98100

@@ -113,6 +115,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
113115
fileChooserManager: FileChooserManager,
114116
httpAuthManager: HttpAuthManager,
115117
exoPlayerManager: FrontendExoPlayerManager,
118+
improvOrchestrator: FrontendImprovOrchestrator,
116119
) : this(
117120
initialServerId = savedStateHandle.toRoute<FrontendRoute>().serverId,
118121
initialPath = savedStateHandle.toRoute<FrontendRoute>().path,
@@ -130,6 +133,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
130133
fileChooserManager = fileChooserManager,
131134
httpAuthManager = httpAuthManager,
132135
exoPlayerManager = exoPlayerManager,
136+
improvOrchestrator = improvOrchestrator,
133137
)
134138

135139
/**
@@ -248,6 +252,14 @@ internal class FrontendViewModel @VisibleForTesting constructor(
248252
emitAll(prefsRepository.autoPlayVideoFlow())
249253
}.stateIn(viewModelScope, SharingStarted.Eagerly, initialValue = false)
250254

255+
/**
256+
* Whether the frontend currently wants the improv BLE scan running. Observed by
257+
* [io.homeassistant.companion.android.frontend.FrontendScreen] to drive a lifecycle-bound
258+
* collect that keeps the scan alive while the screen is RESUMED and tears it down on
259+
* navigation or pause.
260+
*/
261+
val improvScanRequested: StateFlow<Boolean> = improvOrchestrator.scanRequested
262+
251263
init {
252264
viewModelScope.launch {
253265
_viewState.collectLatest { state ->
@@ -280,6 +292,30 @@ internal class FrontendViewModel @VisibleForTesting constructor(
280292
}
281293
}
282294

295+
viewModelScope.launch {
296+
improvOrchestrator.uiState.collect { improvUiState ->
297+
_viewState.update { currentState ->
298+
if (currentState is FrontendViewState.Content) {
299+
currentState.copy(improvUiState = improvUiState)
300+
} else {
301+
currentState
302+
}
303+
}
304+
}
305+
}
306+
307+
viewModelScope.launch {
308+
improvOrchestrator.events.collect { event ->
309+
when (event) {
310+
is FrontendImprovOrchestrator.Event.ReloadAtPath -> {
311+
_viewState.update {
312+
FrontendViewState.LoadServer(serverId = event.serverId, path = event.path)
313+
}
314+
}
315+
}
316+
}
317+
}
318+
283319
loadServer()
284320
}
285321

@@ -589,14 +625,10 @@ internal class FrontendViewModel @VisibleForTesting constructor(
589625
exoPlayerManager.handle(result)
590626
}
591627

592-
is FrontendHandlerEvent.StartImprovScan,
593-
is FrontendHandlerEvent.ConfigureImprovDevice,
594-
-> {
595-
// Improv handling lands in a follow-up PR; the messages are already typed and
596-
// canSetupImprov will be `false` on devices without BLE so the frontend should
597-
// not be sending these yet on hardware that lacks Bluetooth LE.
598-
Timber.d("Improv event received but not yet handled: $result")
599-
}
628+
is FrontendHandlerEvent.StartImprovScan -> improvOrchestrator.onStartImprovScan()
629+
630+
is FrontendHandlerEvent.ConfigureImprovDevice ->
631+
improvOrchestrator.onConfigureImprovDevice(result.deviceName)
600632

601633
is FrontendHandlerEvent.ConfigSent,
602634
is FrontendHandlerEvent.UnknownMessage,
@@ -606,6 +638,34 @@ internal class FrontendViewModel @VisibleForTesting constructor(
606638
}
607639
}
608640

641+
/**
642+
* Forwards user-entered Wi-Fi credentials to the device on the orchestrator's current
643+
* [io.homeassistant.companion.android.frontend.improv.ImprovUIState.ConfiguringDevice] —
644+
* no-ops if no improv session is active or the BLE address hasn't been resolved yet.
645+
*/
646+
fun onImprovConnectDevice(ssid: String, password: String) {
647+
viewModelScope.launch {
648+
improvOrchestrator.onConnectDevice(scope = viewModelScope, ssid = ssid, password = password)
649+
}
650+
}
651+
652+
/** Re-arms scanning after an improv error — wired to the sheet's "Try again" button. */
653+
fun onImprovRestart() {
654+
viewModelScope.launch { improvOrchestrator.onRestart() }
655+
}
656+
657+
/** Closes the improv bottom sheet and, if successful, navigates the frontend to the matching config flow. */
658+
fun onImprovSheetDismissed() {
659+
viewModelScope.launch { improvOrchestrator.onDismissed(serverId = _viewState.value.serverId) }
660+
}
661+
662+
/**
663+
* Hosts the discovered-device forwarder on the caller's coroutine — suspends until cancelled.
664+
* Intended to be invoked from `FrontendScreen` inside a `repeatOnLifecycle(RESUMED)` block so
665+
* the BLE scan's lifetime is bound to the route's visibility.
666+
*/
667+
suspend fun processImprovScanRequests() = improvOrchestrator.processImprovScanRequests()
668+
609669
/**
610670
* Handles URL load results from the URL manager.
611671
*

app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewState.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.compose.ui.graphics.Color
44
import io.homeassistant.companion.android.common.data.prefs.NightModeTheme
55
import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
66
import io.homeassistant.companion.android.frontend.exoplayer.ExoPlayerUiState
7+
import io.homeassistant.companion.android.frontend.improv.ImprovUIState
78
import io.homeassistant.companion.android.util.compose.webview.BLANK_URL
89

910
/**
@@ -56,6 +57,7 @@ sealed interface FrontendViewState {
5657
val statusBarColor: Color? = null,
5758
val backgroundColor: Color? = null,
5859
val exoPlayerState: ExoPlayerUiState? = null,
60+
val improvUiState: ImprovUIState? = null,
5961
) : FrontendViewState
6062

6163
/**

0 commit comments

Comments
 (0)