Skip to content
Merged
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 @@ -8,6 +8,7 @@ import android.webkit.CookieManager
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
Expand Down Expand Up @@ -38,8 +39,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
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.compose.collectAsStateWithLifecycle
import io.homeassistant.companion.android.common.R as commonR
Expand Down Expand Up @@ -79,6 +82,10 @@ import timber.log.Timber
/** Minimum swipe velocity (pixels/second) to trigger a gesture action. */
private const val MINIMUM_GESTURE_VELOCITY = 75f

/** Test tag applied to the WebView custom view fullscreen overlay. */
@VisibleForTesting
internal const val CUSTOM_VIEW_OVERLAY_TAG = "custom_view_overlay"

/**
* Frontend screen that renders based on the ViewModel's current view state.
*
Expand Down Expand Up @@ -114,6 +121,16 @@ internal fun FrontendScreen(
val pendingDialog by viewModel.pendingDialog.collectAsStateWithLifecycle()
val pendingFileChooser by viewModel.pendingFileChooser.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.
var customView by remember { mutableStateOf<View?>(null) }
val webChromeClient = remember(viewModel) {
viewModel.createWebChromeClient(
onShowCustomView = { customView = it },
onHideCustomView = { customView = null },
)
}

// Create SecurityLevel ViewModel only when needed
val securityLevelViewModel: LocationForSecureConnectionViewModel? =
if (viewState is FrontendViewState.SecurityLevelRequired) {
Expand All @@ -129,7 +146,8 @@ internal fun FrontendScreen(
viewState = viewState,
errorStateProvider = viewModel as FrontendConnectionErrorStateProvider,
webViewClient = viewModel.webViewClient,
webChromeClient = viewModel.webChromeClient,
webChromeClient = webChromeClient,
customView = customView,
frontendJsCallback = viewModel.frontendJsCallback,
pendingPermissionRequest = pendingPermissionRequest,
pendingDialog = pendingDialog,
Expand Down Expand Up @@ -172,6 +190,7 @@ internal fun FrontendScreenContent(
onShowSnackbar: suspend (message: String, action: String?) -> Boolean,
onWebViewCreationFailed: (Throwable) -> Unit,
modifier: Modifier = Modifier,
customView: View? = null,
pendingPermissionRequest: PermissionRequest? = null,
pendingDialog: FrontendDialog? = null,
pendingFileChooser: FileChooserRequest? = null,
Expand Down Expand Up @@ -222,6 +241,8 @@ internal fun FrontendScreenContent(
onFullscreenChanged = onExoPlayerFullscreenChanged,
)

CustomViewOverlay(customView = customView)

StateOverlay(
viewState = viewState,
errorStateProvider = errorStateProvider,
Expand Down Expand Up @@ -596,6 +617,17 @@ private fun WebViewEffects(
}
}

@Composable
private fun CustomViewOverlay(customView: View?) {
val view: View = customView ?: return
AndroidView(
factory = { view },
modifier = Modifier
.fillMaxSize()
.testTag(CUSTOM_VIEW_OVERLAY_TAG),
)
}

@Composable
private fun ExoPlayerOverlay(contentState: FrontendViewState.Content?, onFullscreenChanged: (Boolean) -> Unit) {
val exoState = contentState?.exoPlayerState ?: return
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.homeassistant.companion.android.frontend

import android.view.View
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
Expand Down Expand Up @@ -218,26 +219,6 @@ internal class FrontendViewModel @VisibleForTesting constructor(
/** The current pending file chooser request from the WebView, or null if none. */
val pendingFileChooser: StateFlow<FileChooserRequest?> = fileChooserManager.pendingFileChooser

val webChromeClient: HAWebChromeClient = HAWebChromeClient(
onPermissionRequest = { request ->
viewModelScope.launch {
permissionManager.onWebViewPermissionRequest(request)
}
},
onJsConfirm = { message, jsResult ->
viewModelScope.launch {
if (dialogManager.showJsConfirm(message)) jsResult.confirm() else jsResult.cancel()
}
true
},
onShowFileChooser = { filePathCallback, fileChooserParams ->
viewModelScope.launch {
filePathCallback.onReceiveValue(fileChooserManager.pickFiles(fileChooserParams))
}
true
},
)

/** The current pending permission request that needs user approval, or null if none. */
val pendingPermissionRequest = permissionManager.pendingPermissionRequest

Expand Down Expand Up @@ -306,6 +287,46 @@ internal class FrontendViewModel @VisibleForTesting constructor(
}
}

/**
* Builds an [HAWebChromeClient] wired to this ViewModel for permission/JS handling, while
* delegating WebView fullscreen view ownership to the caller.
*
* The fullscreen [android.view.View] handed over by `onShowCustomView` is bound to the
* WebView's Activity context. Holding it in ViewModel state would leak that Activity across
* configuration changes, so the caller (a Composable) keeps the View in screen-scoped state
* and supplies setters via [onShowCustomView] and [onHideCustomView]. The ViewModel still
* owns the system-fullscreen request and emits [FrontendEvent.RequestFullscreen] on the
* caller's behalf.
*/
fun createWebChromeClient(onShowCustomView: (View) -> Unit, onHideCustomView: () -> Unit): HAWebChromeClient =
HAWebChromeClient(
onPermissionRequest = { request ->
viewModelScope.launch {
permissionManager.onWebViewPermissionRequest(request)
}
},
onJsConfirm = { message, jsResult ->
viewModelScope.launch {
if (dialogManager.showJsConfirm(message)) jsResult.confirm() else jsResult.cancel()
}
true
},
onShowFileChooser = { filePathCallback, fileChooserParams ->
viewModelScope.launch {
filePathCallback.onReceiveValue(fileChooserManager.pickFiles(fileChooserParams))
}
true
},
onShowCustomView = { view ->
onShowCustomView(view)
_events.tryEmit(FrontendEvent.RequestFullscreen(fullscreen = true))
},
onHideCustomView = {
onHideCustomView()
_events.tryEmit(FrontendEvent.RequestFullscreen(fullscreen = false))
},
)

fun onRetry() {
_viewState.update {
FrontendViewState.LoadServer(serverId = it.serverId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.homeassistant.companion.android.util

import android.net.Uri
import android.view.View
import android.webkit.JsResult
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
Expand All @@ -15,6 +16,9 @@ import android.webkit.WebView
* Return `true` to indicate the dialog is handled.
* @param onShowFileChooser Callback when the page requests a file upload.
* Return `true` to indicate the file chooser is handled.
* @param onShowCustomView Callback when the page enters fullscreen (e.g. HTML5 video).
* Receives the fullscreen [View] handed over by the WebView.
* @param onHideCustomView Callback when the page leaves fullscreen.
*/
class HAWebChromeClient(
private val onPermissionRequest: (PermissionRequest) -> Unit = {},
Expand All @@ -23,6 +27,8 @@ class HAWebChromeClient(
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: FileChooserParams,
) -> Boolean = { _, _ -> false },
private val onShowCustomView: (View) -> Unit = {},
private val onHideCustomView: () -> Unit = {},
) : WebChromeClient() {

override fun onPermissionRequest(request: PermissionRequest?) {
Expand All @@ -46,4 +52,13 @@ class HAWebChromeClient(
}
return super.onShowFileChooser(webView, filePathCallback, fileChooserParams)
}

override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
Comment thread
jpelgrom marked this conversation as resolved.
view?.let { onShowCustomView.invoke(it) }
}

override fun onHideCustomView() {
onHideCustomView.invoke()
super.onHideCustomView()
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package io.homeassistant.companion.android.frontend

import android.annotation.SuppressLint
import android.graphics.Color as AndroidColor
import android.view.View
import android.webkit.WebChromeClient
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.android.tools.screenshot.PreviewTest
import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.compose.theme.HAThemeForPreview
Expand Down Expand Up @@ -315,4 +318,35 @@ class FrontendScreenScreenshotTest {
)
}
}

@PreviewTest
@HAPreviews
@Composable
fun `FrontendScreen Content state with custom view overlay`() {
HAThemeForPreview {
val context = LocalContext.current
val view = View(context).apply { setBackgroundColor(AndroidColor.RED) }
FrontendScreenContent(
onBackClick = {},
viewState = FrontendViewState.Content(
serverId = 1,
url = "https://example.com",
),
customView = view,
webViewClient = WebViewClient(),
webChromeClient = WebChromeClient(),
frontendJsCallback = FrontendJsBridge.noOp,
onBlockInsecureRetry = {},
onOpenExternalLink = {},
onBlockInsecureHelpClick = {},
onOpenSettings = {},
onChangeSecurityLevel = {},
onOpenLocationSettings = {},
onConfigureHomeNetwork = { _ -> },
onSecurityLevelHelpClick = {},
onShowSnackbar = { _, _ -> false },
onWebViewCreationFailed = {},
)
}
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.homeassistant.companion.android.frontend

import android.Manifest
import android.view.View
import android.webkit.PermissionRequest as WebViewPermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebViewClient
Expand All @@ -9,6 +10,7 @@ import androidx.activity.result.ActivityResultRegistry
import androidx.activity.result.ActivityResultRegistryOwner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
Expand Down Expand Up @@ -482,6 +484,49 @@ class FrontendScreenTest {
}
}

@Test
fun `Given Content state with customView then overlay is displayed`() {
composeTestRule.setContent {
val context = LocalContext.current
FrontendScreenContent(
onBackClick = {},
viewState = FrontendViewState.Content(
serverId = 1,
url = "https://example.com",
),
customView = View(context),
webViewClient = WebViewClient(),
webChromeClient = WebChromeClient(),
frontendJsCallback = FrontendJsBridge.noOp,
onBlockInsecureRetry = {},
onOpenExternalLink = {},
onBlockInsecureHelpClick = {},
onOpenSettings = {},
onChangeSecurityLevel = {},
onOpenLocationSettings = {},
onConfigureHomeNetwork = { _ -> },
onSecurityLevelHelpClick = {},
onShowSnackbar = { _, _ -> true },
onWebViewCreationFailed = {},
)
}

composeTestRule.onNodeWithTag(CUSTOM_VIEW_OVERLAY_TAG).assertIsDisplayed()
}

@Test
fun `Given Content state without customView then overlay is not displayed`() {
composeTestRule.apply {
setFrontendScreen(
viewState = FrontendViewState.Content(
serverId = 1,
url = "https://example.com",
),
)
onNodeWithTag(CUSTOM_VIEW_OVERLAY_TAG).assertDoesNotExist()
}
}

private fun AndroidComposeTestRule<ActivityScenarioRule<HiltComponentActivity>, HiltComponentActivity>.assertIsLoading(show: Boolean) {
val node = onNodeWithContentDescription(stringResource(commonR.string.loading_content_description))
if (show) {
Expand Down
Loading
Loading