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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)
}
Expand Down Expand Up @@ -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<WebView?>(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
Expand All @@ -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,
Expand All @@ -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<WebViewAction>,
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.
*/
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {

Expand All @@ -113,6 +115,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
fileChooserManager: FileChooserManager,
httpAuthManager: HttpAuthManager,
exoPlayerManager: FrontendExoPlayerManager,
improvOrchestrator: FrontendImprovOrchestrator,
) : this(
initialServerId = savedStateHandle.toRoute<FrontendRoute>().serverId,
initialPath = savedStateHandle.toRoute<FrontendRoute>().path,
Expand All @@ -130,6 +133,7 @@ internal class FrontendViewModel @VisibleForTesting constructor(
fileChooserManager = fileChooserManager,
httpAuthManager = httpAuthManager,
exoPlayerManager = exoPlayerManager,
improvOrchestrator = improvOrchestrator,
)

/**
Expand Down Expand Up @@ -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<Boolean> = improvOrchestrator.scanRequested

init {
viewModelScope.launch {
_viewState.collectLatest { state ->
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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,
Expand All @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -56,6 +57,7 @@ sealed interface FrontendViewState {
val statusBarColor: Color? = null,
val backgroundColor: Color? = null,
val exoPlayerState: ExoPlayerUiState? = null,
val improvUiState: ImprovUIState? = null,
) : FrontendViewState

/**
Expand Down
Loading
Loading