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 14f1376e664..a1892fa2504 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 @@ -92,7 +92,10 @@ internal const val CUSTOM_VIEW_OVERLAY_TAG = "custom_view_overlay" * The WebView is always rendered at the base layer to prevent it to not load the URL. * Loading indicators, error screens, and blocking screens are overlaid on top. * - * @param onBackClick Callback when user navigates back + * Back navigation is handled inside [HAWebView] when the WebView has back-stack entries; + * otherwise the gesture falls through to the surrounding `NavHost` (predictive-back + * compatible). + * * @param viewModel The ViewModel providing state and handling actions * @param onOpenExternalLink Callback to open external links * @param onBlockInsecureHelpClick Callback when user taps help on the insecure screen @@ -105,7 +108,6 @@ internal const val CUSTOM_VIEW_OVERLAY_TAG = "custom_view_overlay" */ @Composable internal fun FrontendScreen( - onBackClick: () -> Unit, viewModel: FrontendViewModel, onOpenExternalLink: suspend (Uri) -> Unit, onBlockInsecureHelpClick: suspend () -> Unit, @@ -121,6 +123,7 @@ internal fun FrontendScreen( val pendingDialog by viewModel.pendingDialog.collectAsStateWithLifecycle() val pendingFileChooser by viewModel.pendingFileChooser.collectAsStateWithLifecycle() val autoPlayVideoEnabled by viewModel.autoPlayVideoEnabled.collectAsStateWithLifecycle() + val canGoBack by viewModel.canGoBack.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. @@ -143,8 +146,8 @@ internal fun FrontendScreen( } FrontendScreenContent( - onBackClick = onBackClick, viewState = viewState, + canGoBack = canGoBack, errorStateProvider = viewModel as FrontendConnectionErrorStateProvider, webViewClient = viewModel.webViewClient, webChromeClient = webChromeClient, @@ -176,11 +179,11 @@ internal fun FrontendScreen( @Composable internal fun FrontendScreenContent( - onBackClick: () -> Unit, viewState: FrontendViewState, webViewClient: WebViewClient, webChromeClient: WebChromeClient, frontendJsCallback: FrontendJsCallback, + canGoBack: Boolean, onBlockInsecureRetry: () -> Unit, onOpenExternalLink: suspend (Uri) -> Unit, onBlockInsecureHelpClick: suspend () -> Unit, @@ -230,7 +233,7 @@ internal fun FrontendScreenContent( Box(modifier = modifier.fillMaxSize()) { // Always render WebView at base layer SafeHAWebView( - onBackClick = onBackClick, + canGoBack = canGoBack, onWebViewCreated = { webView = it }, webViewClient = webViewClient, webChromeClient = webChromeClient, @@ -421,7 +424,7 @@ private fun ErrorOverlay( */ @Composable private fun SafeHAWebView( - onBackClick: () -> Unit, + canGoBack: Boolean, onWebViewCreated: (WebView) -> Unit, webViewClient: WebViewClient, contentState: FrontendViewState.Content?, @@ -474,7 +477,7 @@ private fun SafeHAWebView( autoPlayVideoEnabled = autoPlayVideoEnabled, ) }, - onBackPressed = onBackClick, + canGoBack = canGoBack, onWebViewCreationFailed = onWebViewCreationFailed, ) @@ -668,7 +671,7 @@ private fun ExoPlayerOverlay(contentState: FrontendViewState.Content?, onFullscr private fun FrontendScreenLoadingPreview() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.Loading( serverId = 1, url = "https://example.com", @@ -695,7 +698,7 @@ private fun FrontendScreenLoadingPreview() { private fun FrontendScreenErrorPreview() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.Error( serverId = 1, url = "https://example.com", @@ -727,7 +730,7 @@ private fun FrontendScreenErrorPreview() { private fun FrontendScreenInsecurePreview() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.Insecure( serverId = 1, missingHomeSetup = true, @@ -755,7 +758,7 @@ private fun FrontendScreenInsecurePreview() { private fun FrontendScreenSecurityLevelRequiredPreview() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.SecurityLevelRequired( serverId = 1, ), 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 d70bfa2903c..a7328fe8f41 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 @@ -202,11 +202,21 @@ internal class FrontendViewModel @VisibleForTesting constructor( stateProvider = { BridgeState(serverId = viewState.value.serverId, url = viewState.value.url) }, ) + private val _canGoBack = MutableStateFlow(false) + + /** + * Whether the WebView has a non-empty back history. Used by the UI layer + * to gate its `BackHandler`; when `false`, the system can handle the back + * gesture and show the Android 14+ predictive-back peek animation. + */ + val canGoBack: StateFlow = _canGoBack.asStateFlow() + val webViewClient: HAWebViewClient = webViewClientFactory.create( currentUrlFlow = urlFlow, onFrontendError = ::onError, onCrash = ::onRetry, onPageFinished = ::onPageFinished, + onCanGoBackChanged = { _canGoBack.value = it }, onReceivedHttpAuthRequest = { handler, host, resource, realm -> viewModelScope.launch { if (httpAuthManager.handleAuthRequest(handler, host = host, resource = resource, realm = realm) == diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt index efed3e71bfb..c5d935fdefa 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/frontend/navigation/FrontendNavigation.kt @@ -104,7 +104,6 @@ internal fun NavGraphBuilder.frontendScreen( ) FrontendScreen( - onBackClick = navController::popBackStack, viewModel = viewModel, onOpenExternalLink = onOpenExternalLink, onBlockInsecureHelpClick = onSecurityLevelHelpClick, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreen.kt b/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreen.kt index a5ebbd49bd9..91675ed3fc6 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreen.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreen.kt @@ -44,12 +44,14 @@ internal fun ConnectionScreen(onBackClick: () -> Unit, viewModel: ConnectionView val url by viewModel.urlFlow.collectAsState() val isLoading by viewModel.isLoadingFlow.collectAsState() val error by viewModel.errorFlow.collectAsState() + val canGoBack by viewModel.canGoBack.collectAsState() val isError = error != null ConnectionScreen( url = url, isLoading = isLoading, isError = isError, + canGoBack = canGoBack, webViewClient = viewModel.webViewClient, onBackClick = onBackClick, onWebViewCreationFailed = viewModel::onWebViewCreationFailed, @@ -62,6 +64,7 @@ internal fun ConnectionScreen( url: String?, isLoading: Boolean, isError: Boolean, + canGoBack: Boolean, webViewClient: WebViewClient, onBackClick: () -> Unit, onWebViewCreationFailed: (Throwable) -> Unit, @@ -86,6 +89,7 @@ internal fun ConnectionScreen( this.webViewClient = webViewClient loadUrl(url) }, + canGoBack = canGoBack, onBackPressed = onBackClick, onWebViewCreationFailed = onWebViewCreationFailed, ) @@ -126,6 +130,7 @@ private fun ConnectionScreenPreview() { url = "https://www.home-assistant.io", isLoading = false, isError = false, + canGoBack = false, webViewClient = WebViewClient(), onBackClick = {}, onWebViewCreationFailed = {}, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModel.kt b/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModel.kt index cf8b5aa0159..1b2818139d0 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModel.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModel.kt @@ -107,11 +107,21 @@ internal class ConnectionViewModel @VisibleForTesting constructor( } } + private val _canGoBack = MutableStateFlow(false) + + /** + * Whether the WebView has a non-empty back history. Used by the UI layer + * to gate its `BackHandler`; when `false`, the system can handle the back + * gesture and show the Android 14+ predictive-back peek animation. + */ + val canGoBack = _canGoBack.asStateFlow() + val webViewClient: HAWebViewClient = webViewClientFactory.create( currentUrlFlow = urlFlow, onFrontendError = ::onError, onUrlIntercepted = ::interceptRedirectIfRequired, onPageFinished = { _isLoadingFlow.update { false } }, + onCanGoBackChanged = { _canGoBack.value = it }, ) init { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt index 454709e0c07..044635b8b21 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/HAWebViewClient.kt @@ -15,6 +15,7 @@ import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository import io.homeassistant.companion.android.common.data.keychain.NamedKeyChain import io.homeassistant.companion.android.frontend.error.FrontendConnectionError +import io.homeassistant.companion.android.util.compose.webview.BLANK_URL import javax.inject.Inject import kotlinx.coroutines.flow.StateFlow import timber.log.Timber @@ -37,6 +38,11 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC * Receives the URI and whether TLS client auth was required. * Return `true` to prevent WebView from loading the URL. * @param onPageFinished Optional callback when a page finishes loading. + * @param onCanGoBackChanged Optional callback invoked whenever the WebView's + * back-stack state changes (page navigations, SPA history updates). + * Receives [WebView.canGoBack]. Callers can hoist this into a state + * holder to drive a back handler's enabled-state for Android 14+ + * predictive-back support. * @param onReceivedHttpAuthRequest Optional callback when the server requests HTTP Basic Auth. * Receives the handler, host, the resource URL that triggered the request, and the realm. */ @@ -46,6 +52,7 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC onCrash: (() -> Unit)? = null, onUrlIntercepted: ((uri: Uri, isTLSClientAuthNeeded: Boolean) -> Boolean)? = null, onPageFinished: (() -> Unit)? = null, + onCanGoBackChanged: ((Boolean) -> Unit)? = null, onReceivedHttpAuthRequest: ( ( handler: HttpAuthHandler, @@ -62,6 +69,7 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC onCrash = onCrash, onUrlIntercepted = onUrlIntercepted, onPageFinished = onPageFinished, + onCanGoBackChanged = onCanGoBackChanged, onReceivedHttpAuthRequest = onReceivedHttpAuthRequest, ) } @@ -80,6 +88,7 @@ class HAWebViewClient internal constructor( private val onCrash: (() -> Unit)?, private val onUrlIntercepted: ((uri: Uri, isTLSClientAuthNeeded: Boolean) -> Boolean)?, private val onPageFinished: (() -> Unit)?, + private val onCanGoBackChanged: ((Boolean) -> Unit)?, private val onReceivedHttpAuthRequest: ( (handler: HttpAuthHandler, host: String, resource: String, realm: String) -> Unit )?, @@ -88,6 +97,12 @@ class HAWebViewClient internal constructor( /** Last resource URL loaded by the WebView, used to identify the resource requesting auth. */ private var lastResourceUrl: String? = null + /** + * Tracks the previously finished page URL so we can detect transitions out + * of the [BLANK_URL] placeholder and drop it from the WebView's back-stack. + */ + private var lastFinishedUrl: String? = null + override fun onLoadResource(view: WebView?, url: String?) { super.onLoadResource(view, url) lastResourceUrl = url @@ -95,7 +110,45 @@ class HAWebViewClient internal constructor( override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) + // Clear the WebView back-stack on transitions to a new origin: out of the + // about:blank placeholder (loading / error / security overlays), across + // internal <-> external URL switches on the same server, and across server + // switches. Without this, back would walk into a stale URL that is no + // longer reachable on the current network. Same-origin in-frontend + // navigation (full page loads between content URLs and SPA pushState, + // which doesn't even fire onPageFinished) is unaffected. Transitions + // INTO about:blank are skipped so the back-stack survives an error state + // and remains usable after recovery. + val previous = lastFinishedUrl + if (previous != null && url != null && url != BLANK_URL && originOf(previous) != originOf(url)) { + view?.clearHistory() + } + lastFinishedUrl = url onPageFinished?.invoke() + notifyCanGoBack(view) + } + + /** + * Returns the `scheme://authority` prefix of [url], or the full string when the + * URL is opaque (e.g. `about:blank`). String-based so it works in plain JUnit + * tests without the Android framework or Robolectric. + */ + private fun originOf(url: String): String { + val schemeEnd = url.indexOf("://") + if (schemeEnd == -1) return url + val authorityStart = schemeEnd + 3 + val pathStart = url.indexOf('/', authorityStart) + return if (pathStart == -1) url else url.substring(0, pathStart) + } + + override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { + super.doUpdateVisitedHistory(view, url, isReload) + // SPA navigations (history.pushState) only surface here, not in onPageFinished. + notifyCanGoBack(view) + } + + private fun notifyCanGoBack(view: WebView?) { + onCanGoBackChanged?.invoke(view?.canGoBack() == true) } override fun onReceivedHttpAuthRequest(view: WebView?, handler: HttpAuthHandler?, host: String?, realm: String?) { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt index 273f9db05b0..8d215d31bac 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/HANavHost.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.fragment.app.FragmentActivity import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost +import androidx.navigation.navOptions import io.homeassistant.companion.android.automotive.navigation.carAppActivity import io.homeassistant.companion.android.automotive.navigation.navigateToCarAppActivity import io.homeassistant.companion.android.common.util.DisabledLocationHandler @@ -68,7 +69,15 @@ internal fun HANavHost( if (isAutomotive) { navController.navigateToCarAppActivity() } else { - navController.navigateToFrontend() + // Frontend is the new root after onboarding completes — clear + // the entire graph back stack so pressing back exits the app + // (and triggers the Android 14+ predictive-back animation) + // instead of returning to the finished onboarding flow. + navController.navigateToFrontend( + navOptions = navOptions { + popUpTo(0) { inclusive = true } + }, + ) } }, urlToOnboard = (startDestination as? OnboardingRoute)?.urlToOnboard, diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/BackAction.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/BackAction.kt new file mode 100644 index 00000000000..b4cf99375c8 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/BackAction.kt @@ -0,0 +1,76 @@ +package io.homeassistant.companion.android.util.compose.webview + +import android.net.Uri +import android.webkit.WebView +import androidx.core.net.toUri +import io.homeassistant.companion.android.util.hasNonRootPath +import io.homeassistant.companion.android.util.hasSameOrigin + +/** + * Determines the appropriate back action based on the [WebView]'s back/forward list + * and the current loaded URL. + * + * The resolution logic: + * 1. If the previous back-stack entry is a same-origin HTTP URL, returns + * [BackAction.GoBack] so the user can navigate back normally. + * 2. If there is a previous back-stack entry that is not same-origin HTTP and + * the current URL has a non-root path, returns [BackAction.NavigateToRoot] + * so the user is taken to the home page first. + * 3. Otherwise returns [BackAction.None] — the caller decides what to do + * (e.g. exit the activity, pop the navigation stack, or let the system + * handle back to show the predictive-back animation). + * + * @param webView the WebView whose back/forward list is inspected + * @param loadedUrl the current URL shown in the WebView (as tracked by the caller, + * not necessarily [WebView.getUrl] which can be `about:blank` during loads) + */ +fun resolveBackAction(webView: WebView, loadedUrl: Uri?): BackAction { + val previousUrl = if (webView.canGoBack()) { + val backForwardList = webView.copyBackForwardList() + val previousIndex = backForwardList.currentIndex - 1 + if (previousIndex >= 0) { + backForwardList.getItemAtIndex(previousIndex).url.toUri() + } else { + null + } + } else { + null + } + return resolveBackAction(previousUrl, loadedUrl) +} + +private fun resolveBackAction(previousUrl: Uri?, loadedUrl: Uri?): BackAction { + if (previousUrl != null && + loadedUrl != null && + previousUrl.scheme?.startsWith("http") == true && + previousUrl.hasSameOrigin(loadedUrl) + ) { + return BackAction.GoBack + } + + if (previousUrl != null && loadedUrl != null && loadedUrl.hasNonRootPath()) { + val rootUrl = loadedUrl.buildUpon() + .path("/") + .clearQuery() + .appendQueryParameter("external_auth", "1") + .fragment(null) + .build() + return BackAction.NavigateToRoot(rootUrl) + } + + return BackAction.None +} + +/** + * Represents the action to take when the user presses back in a WebView. + */ +sealed interface BackAction { + /** Navigate back in the WebView history. */ + data object GoBack : BackAction + + /** Clear history and navigate to the root URL of the current server. */ + data class NavigateToRoot(val rootUrl: Uri) : BackAction + + /** No more back navigation possible — the caller decides what to do. */ + data object None : BackAction +} diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/HAWebView.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/HAWebView.kt index bcd0d257419..1daf0df959b 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/HAWebView.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/HAWebView.kt @@ -48,10 +48,28 @@ const val BLANK_URL = "about:blank" * WebView provider), the [onWebViewCreationFailed] callback is invoked with the exception * and a placeholder view is shown instead. * + * ## Back navigation + * + * Back navigation follows state-hoisting: the caller tracks [canGoBack] (typically from + * its `WebViewClient`'s `doUpdateVisitedHistory`) and passes the current value down. The + * internal `BackHandler` is only enabled when [canGoBack] is `true`, so that when the + * WebView has no back-stack the gesture falls through to the surrounding `NavHost` / + * activity. This lets the system show the Android 14+ predictive-back peek animation + * instead of being silently claimed by an always-enabled handler. + * * @param onWebViewCreationFailed Called when the WebView fails to initialize due to a system-level * issue such as a broken or incompatible WebView provider. * @param modifier The modifier to be applied to this WebView. * @param nightModeTheme current [NightModeTheme] + * @param canGoBack Whether the WebView currently has back-stack entries. When `true` the + * back gesture invokes `webView.goBack()`. Hoist from the WebView's + * `doUpdateVisitedHistory` (see `HAWebViewClient.onCanGoBackChanged`). + * @param onBackPressed Optional fallback invoked when the back gesture fires and the + * WebView has no history (`canGoBack == false`). Provide this when + * the screen needs to claim the gesture itself (e.g. onboarding where + * the back button must pop the nav stack); leave it `null` to let the + * gesture fall through to the surrounding NavHost / activity and + * enable Android 14+ predictive-back peek animations. * @param configure A lambda that allows for customization of the WebView instance. * @param factory A lambda that creates the WebView instance. If this returns null, a new * WebView will be created with the current context. This is useful for providing @@ -63,7 +81,7 @@ fun HAWebView( modifier: Modifier = Modifier, configure: WebView.() -> Unit = {}, factory: () -> WebView? = { null }, - // Only used when the backstack of the webView is empty + canGoBack: Boolean = false, onBackPressed: (() -> Unit)? = null, nightModeTheme: NightModeTheme? = null, ) { @@ -115,11 +133,17 @@ fun HAWebView( ) } - // To avoid checking doUpdateVisitedHistory from the webViewClient we simply delegate the back button handling - // to the webView and when the webview backstack is empty we call the callback given in parameter that should be - // handle by the navHost. - BackHandler(onBackPressed != null) { - webview.takeIf { it?.canGoBack() == true }?.goBack() ?: onBackPressed?.invoke() + // Only claim the back gesture when the WebView has somewhere to go, or when the + // caller supplied an explicit fallback (e.g. onboarding screens that need to pop + // the nav stack themselves). Otherwise the gesture falls through to the + // surrounding NavHost / activity, which enables Android 14+ predictive-back + // peek animations. + BackHandler(enabled = canGoBack || onBackPressed != null) { + if (canGoBack) { + webview?.goBack() + } else { + onBackPressed?.invoke() + } } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebView.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebView.kt index 5d4ac5662fa..dee7e1ecef0 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebView.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebView.kt @@ -35,6 +35,14 @@ interface WebView { */ fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean, serverHandleInsets: Boolean) + /** + * Returns the relative part (path, query parameters, fragment) of the currently loaded + * WebView URL, or `null` if the URL has no meaningful path (empty or root `/`). + * + * The `external_auth` query parameter is stripped since the presenter re-adds it on every load. + */ + fun getCurrentWebViewRelativeUrl(): String? + fun setStatusBarAndBackgroundColor(statusBarColor: Int, backgroundColor: Int) fun setExternalAuth(script: String) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt index 6a1ede4c700..f3a3011bc60 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt @@ -145,10 +145,13 @@ import io.homeassistant.companion.android.util.OnSwipeListener import io.homeassistant.companion.android.util.TLSWebViewClient import io.homeassistant.companion.android.util.applyInsets import io.homeassistant.companion.android.util.compose.webview.BLANK_URL +import io.homeassistant.companion.android.util.compose.webview.BackAction +import io.homeassistant.companion.android.util.compose.webview.resolveBackAction import io.homeassistant.companion.android.util.hasNonRootPath import io.homeassistant.companion.android.util.hasSameOrigin import io.homeassistant.companion.android.util.isStarted import io.homeassistant.companion.android.util.sensitive +import io.homeassistant.companion.android.util.toRelativeUrl import io.homeassistant.companion.android.websocket.WebsocketManager import io.homeassistant.companion.android.webview.WebView.ErrorType import io.homeassistant.companion.android.webview.addto.EntityAddToHandler @@ -456,7 +459,25 @@ class WebViewActivity : val onBackPressed = object : OnBackPressedCallback(webView.canGoBack()) { override fun handleOnBackPressed() { - if (webView.canGoBack()) webView.goBack() + when (val action = resolveBackAction(webView, loadedUrl)) { + BackAction.GoBack -> webView.goBack() + is BackAction.NavigateToRoot -> { + clearHistory = true + loadedUrl = action.rootUrl + webView.loadUrl(action.rootUrl.toString()) + } + BackAction.None -> { + // Already on root — let the system handle back (exit app). + // We must temporarily disable this callback so that the + // dispatcher invokes the next handler in the chain (the + // default Activity handler which finishes the activity). + // Re-enabling afterwards keeps the callback functional in + // case the activity is not destroyed (e.g. multi-window). + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } + } } } @@ -654,6 +675,10 @@ class WebViewActivity : override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { super.doUpdateVisitedHistory(view, url, isReload) + // Enable the callback only when there's browser history. On a fresh + // load (e.g. deeplink to a sub-path) the callback stays disabled so + // the system can handle the gesture and show the Android 14+ + // predictive-back peek animation. onBackPressed.isEnabled = canGoBack() presenter.stopScanningForImprov(false) } @@ -1531,12 +1556,15 @@ class WebViewActivity : showSystemUI() } - var path = intent.getStringExtra(EXTRA_PATH) - if (path?.startsWith("entityId:") == true) { + val intentPath = intent.getStringExtra(EXTRA_PATH) + // Let the presenter handle falling back to the current WebView path + // when no explicit navigation path is set. See https://github.com/home-assistant/android/issues/4983 + var path: String? = intentPath + if (intentPath?.startsWith("entityId:") == true) { // Get the entity ID from a string formatted "entityId:domain.entity" // https://github.com/home-assistant/core/blob/dev/homeassistant/core.py#L159 val pattern = "(?<=^entityId:)((?!.+__)(?!_)[\\da-z_]+(? - val shouldConsumePath = !pathConsumed && path != null - if (shouldConsumePath) pathConsumed = true + val currentBaseUrl = (urlState as? UrlState.HasUrl)?.url + // baseUrlChanged is only true from the second emission onwards; the first emission + // establishes lastBaseUrl and is therefore not considered a change. + val baseUrlChanged = currentBaseUrl != null && lastBaseUrl?.let { it != currentBaseUrl } == true + if (currentBaseUrl != null) lastBaseUrl = currentBaseUrl + + val effectiveRelativeUrl = if (!pathConsumed && path != null) { + pathConsumed = true + path + } else if (baseUrlChanged && !isNewServer) { + // On internal/external URL switches on the same server, preserve the full + // relative URL (path + query params + fragment) so the user stays on the exact + // same page, including filtered views like history with date ranges. + // Skipped for server switches where the path may not exist and would leak + // navigation context from the previous server. + withContext(Dispatchers.Main) { view.getCurrentWebViewRelativeUrl() } + } else { + null + } handleUrlState( urlState = urlState, - path = path, - shouldConsumePath = shouldConsumePath, - isNewServer = isNewServer, + path = effectiveRelativeUrl, + shouldConsumePath = effectiveRelativeUrl != null, + // Clear history when the base URL changes (e.g. internal <-> external) + // because old URLs in the back stack would be unreachable on the new network. + keepHistory = !isNewServer && !baseUrlChanged, ) } } @@ -179,13 +199,13 @@ class WebViewPresenterImpl @Inject constructor( urlState: UrlState, path: String?, shouldConsumePath: Boolean, - isNewServer: Boolean, + keepHistory: Boolean, ) { when (urlState) { is UrlState.HasUrl -> loadUrl( baseUrl = urlState.url, path = if (shouldConsumePath) path else null, - isNewServer = isNewServer, + keepHistory = keepHistory, ) UrlState.InsecureState -> view.showBlockInsecure(serverId = serverId) @@ -200,9 +220,10 @@ class WebViewPresenterImpl @Inject constructor( * * @param baseUrl the base server URL * @param path optional path to append (ignored if starts with "entityId:") - * @param isNewServer whether this is a new server (affects history behavior) + * @param keepHistory whether to keep WebView history after loading. False when the + * base URL changes (e.g. server or connection switch) so old entries become unreachable. */ - private suspend fun loadUrl(baseUrl: URL?, path: String?, isNewServer: Boolean) { + private suspend fun loadUrl(baseUrl: URL?, path: String?, keepHistory: Boolean) { val urlToLoad = if (path != null && !path.startsWith("entityId:")) { UrlUtil.handle(baseUrl, path) } else { @@ -225,7 +246,7 @@ class WebViewPresenterImpl @Inject constructor( } else { view.loadUrl( url = urlWithAuth, - keepHistory = !isNewServer, + keepHistory = keepHistory, openInApp = it.baseIsEqual(baseUrl), // We need the frontend to notify us of the mode to use for the status bar https://github.com/home-assistant/frontend/issues/29125 serverHandleInsets = false, diff --git a/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenScreenshotTest.kt b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenScreenshotTest.kt index c503e8be788..8c5068460c6 100644 --- a/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenScreenshotTest.kt +++ b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/frontend/FrontendScreenScreenshotTest.kt @@ -24,7 +24,7 @@ class FrontendScreenScreenshotTest { fun `FrontendScreen LoadServer state`() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.LoadServer(serverId = 1), webViewClient = WebViewClient(), webChromeClient = WebChromeClient(), @@ -49,7 +49,7 @@ class FrontendScreenScreenshotTest { fun `FrontendScreen Loading state`() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.Loading( serverId = 1, url = "https://example.com", @@ -77,7 +77,7 @@ class FrontendScreenScreenshotTest { fun `FrontendScreen SecurityLevelRequired state`() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.SecurityLevelRequired(serverId = 1), webViewClient = WebViewClient(), webChromeClient = WebChromeClient(), @@ -102,7 +102,7 @@ class FrontendScreenScreenshotTest { fun `FrontendScreen Insecure state`() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.Insecure( serverId = 1, missingHomeSetup = true, @@ -131,7 +131,7 @@ class FrontendScreenScreenshotTest { fun `FrontendScreen Content state`() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.Content( serverId = 1, url = "https://example.com", @@ -160,7 +160,7 @@ class FrontendScreenScreenshotTest { fun `FrontendScreen Content with notification permission prompt`() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.Content( serverId = 1, url = "https://example.com", @@ -222,7 +222,7 @@ class FrontendScreenScreenshotTest { fun `FrontendScreen Error`() { HAThemeForPreview { FrontendScreenContent( - onBackClick = {}, + canGoBack = false, viewState = FrontendViewState.Error( serverId = 1, url = "https://example.com", diff --git a/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreenshotTest.kt b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreenshotTest.kt index 50d4c760374..e2c8fa6a5ea 100644 --- a/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreenshotTest.kt +++ b/app/src/screenshotTest/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreenshotTest.kt @@ -17,6 +17,7 @@ class ConnectionScreenshotTest { url = "https://www.example.com", isLoading = true, isError = false, + canGoBack = false, webViewClient = WebViewClient(), onBackClick = {}, onWebViewCreationFailed = {}, @@ -33,6 +34,7 @@ class ConnectionScreenshotTest { url = "https://www.example.com", isLoading = false, isError = false, + canGoBack = false, webViewClient = WebViewClient(), onBackClick = {}, onWebViewCreationFailed = {}, @@ -49,6 +51,7 @@ class ConnectionScreenshotTest { url = "https://www.example.com", isLoading = false, isError = true, + canGoBack = false, webViewClient = WebViewClient(), onBackClick = {}, onWebViewCreationFailed = {}, 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 2946d4d19f7..fae76afdb8c 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 @@ -398,8 +398,8 @@ class FrontendScreenTest { setContent { val content: @Composable () -> Unit = { FrontendScreenContent( - onBackClick = {}, viewState = viewState, + canGoBack = false, webViewClient = WebViewClient(), webChromeClient = WebChromeClient(), frontendJsCallback = FrontendJsBridge.noOp, @@ -443,12 +443,12 @@ class FrontendScreenTest { composeTestRule.apply { setContent { FrontendScreenContent( - onBackClick = {}, viewState = FrontendViewState.Error( serverId = 1, url = "https://example.com", error = error, ), + canGoBack = false, errorStateProvider = FakeConnectionErrorStateProvider( url = "https://example.com", error = error, @@ -489,7 +489,6 @@ class FrontendScreenTest { composeTestRule.setContent { val context = LocalContext.current FrontendScreenContent( - onBackClick = {}, viewState = FrontendViewState.Content( serverId = 1, url = "https://example.com", @@ -498,6 +497,7 @@ class FrontendScreenTest { webViewClient = WebViewClient(), webChromeClient = WebChromeClient(), frontendJsCallback = FrontendJsBridge.noOp, + canGoBack = false, onBlockInsecureRetry = {}, onOpenExternalLink = {}, onBlockInsecureHelpClick = {}, 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..b5b7153fbb9 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 @@ -920,10 +920,11 @@ class FrontendViewModelTest { onCrash = any(), onUrlIntercepted = any(), onPageFinished = any(), + onCanGoBackChanged = any(), onReceivedHttpAuthRequest = any(), ) } answers { - // onPageFinished is the 5th of the 6 named arguments (zero-based index 4) + // onPageFinished is the 5th of the 7 named arguments (zero-based index 4) capturedPageFinished = arg(4) mockk(relaxed = true) } @@ -1019,6 +1020,7 @@ class FrontendViewModelTest { onCrash = any(), onUrlIntercepted = any(), onPageFinished = any(), + onCanGoBackChanged = any(), onReceivedHttpAuthRequest = any(), ) } answers { diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/WearOnboardingNavigationTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/WearOnboardingNavigationTest.kt index c800259c631..9c70305af97 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/WearOnboardingNavigationTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/WearOnboardingNavigationTest.kt @@ -122,6 +122,7 @@ internal class WearOnboardingNavigationTest { every { navigationEventsFlow } returns connectionNavigationEventFlow every { errorFlow } returns MutableStateFlow(null) every { connectivityCheckState } returns MutableStateFlow(ConnectivityCheckState()) + every { canGoBack } returns MutableStateFlow(false) } private val selectedUri = mockk() diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreenTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreenTest.kt index 1d793d453b3..088ee70618b 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreenTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionScreenTest.kt @@ -42,6 +42,7 @@ class ConnectionScreenTest { onBackClick = {}, isLoading = false, isError = false, + canGoBack = false, url = null, webViewClient = WebViewClient(), onWebViewCreationFailed = {}, @@ -59,6 +60,7 @@ class ConnectionScreenTest { onBackClick = {}, isLoading = true, isError = false, + canGoBack = false, url = "", webViewClient = WebViewClient(), onWebViewCreationFailed = {}, @@ -77,6 +79,7 @@ class ConnectionScreenTest { onBackClick = {}, isLoading = false, isError = false, + canGoBack = false, url = "", webViewClient = WebViewClient(), onWebViewCreationFailed = {}, @@ -95,6 +98,7 @@ class ConnectionScreenTest { onBackClick = {}, isLoading = false, isError = true, + canGoBack = false, url = "", webViewClient = WebViewClient(), onWebViewCreationFailed = {}, @@ -117,6 +121,7 @@ class ConnectionScreenTest { }, isLoading = false, isError = false, + canGoBack = false, url = "", webViewClient = WebViewClient(), onWebViewCreationFailed = {}, diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt index 2462549da7f..5d9a9acf40f 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/connection/ConnectionViewModelTest.kt @@ -61,6 +61,8 @@ class ConnectionViewModelTest { onCrash = any(), onUrlIntercepted = any(), onPageFinished = any(), + onCanGoBackChanged = any(), + onReceivedHttpAuthRequest = any(), ) } answers { HAWebViewClient( @@ -70,7 +72,8 @@ class ConnectionViewModelTest { onCrash = thirdArg(), onUrlIntercepted = arg(3), onPageFinished = arg(4), - onReceivedHttpAuthRequest = arg(5), + onCanGoBackChanged = arg(5), + onReceivedHttpAuthRequest = arg(6), ) } } @@ -103,7 +106,7 @@ class ConnectionViewModelTest { assertEquals(expectedAuthUrl, urlFlow.awaitItem()) - viewModel.webViewClient.onPageFinished(mockk(), null) + viewModel.webViewClient.onPageFinished(mockk(relaxed = true), null) assertFalse(isLoadingFlow.awaitItem()) errorFlow.expectNoEvents() diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/navigation/ServerDiscoveryNavigationTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/navigation/ServerDiscoveryNavigationTest.kt index 0722011dfc1..a9625b6028e 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/navigation/ServerDiscoveryNavigationTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/onboarding/serverdiscovery/navigation/ServerDiscoveryNavigationTest.kt @@ -108,6 +108,7 @@ internal class ServerDiscoveryNavigationTest : BaseOnboardingNavigationTest() { every { navigationEventsFlow } returns connectionNavigationEventFlow every { errorFlow } returns MutableStateFlow(null) every { connectivityCheckState } returns MutableStateFlow(ConnectivityCheckState()) + every { canGoBack } returns MutableStateFlow(false) } @Test diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt index 62cfe208a05..1966efc2cad 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/util/HAWebViewClientTest.kt @@ -36,12 +36,14 @@ class HAWebViewClientTest { private val keyChainRepository: KeyChainRepository = mockk(relaxed = true) private val currentUrlFlow = MutableStateFlow(null) private var capturedError: FrontendConnectionError? = null + private var capturedCanGoBack: Boolean? = null private lateinit var webViewClient: HAWebViewClient @BeforeEach fun setup() { capturedError = null + capturedCanGoBack = null webViewClient = HAWebViewClient( keyChainRepository = keyChainRepository, currentUrlFlow = currentUrlFlow, @@ -49,10 +51,102 @@ class HAWebViewClientTest { onCrash = null, onUrlIntercepted = null, onPageFinished = null, + onCanGoBackChanged = { capturedCanGoBack = it }, onReceivedHttpAuthRequest = null, ) } + @Test + fun `Given onPageFinished when canGoBack is true then notifies onCanGoBackChanged with true`() { + val webView = mockk { + every { canGoBack() } returns true + } + + webViewClient.onPageFinished(webView, "http://homeassistant.local:8123/") + + assertEquals(true, capturedCanGoBack) + } + + @Test + fun `Given onPageFinished when canGoBack is false then notifies onCanGoBackChanged with false`() { + val webView = mockk { + every { canGoBack() } returns false + } + + webViewClient.onPageFinished(webView, "http://homeassistant.local:8123/") + + assertEquals(false, capturedCanGoBack) + } + + @Test + fun `Given doUpdateVisitedHistory when canGoBack is true then notifies onCanGoBackChanged with true`() { + val webView = mockk { + every { canGoBack() } returns true + } + + webViewClient.doUpdateVisitedHistory(webView, "http://homeassistant.local:8123/lovelace/0", false) + + assertEquals(true, capturedCanGoBack) + } + + @Test + fun `Given null WebView when doUpdateVisitedHistory then notifies onCanGoBackChanged with false`() { + webViewClient.doUpdateVisitedHistory(null, "http://homeassistant.local:8123/", false) + + assertEquals(false, capturedCanGoBack) + } + + @Test + fun `Given transition from blank to content url when onPageFinished then clears webView history`() { + val webView = mockk(relaxUnitFun = true) { + every { canGoBack() } returns true + } + + // First load: about:blank + webViewClient.onPageFinished(webView, "about:blank") + // Then transition to real content URL + webViewClient.onPageFinished(webView, "http://homeassistant.local:8123/") + + io.mockk.verify(exactly = 1) { webView.clearHistory() } + } + + @Test + fun `Given content url to content url when onPageFinished then does not clear history`() { + val webView = mockk(relaxUnitFun = true) { + every { canGoBack() } returns true + } + + webViewClient.onPageFinished(webView, "http://homeassistant.local:8123/") + webViewClient.onPageFinished(webView, "http://homeassistant.local:8123/history") + + io.mockk.verify(exactly = 0) { webView.clearHistory() } + } + + @Test + fun `Given content url to blank url when onPageFinished then does not clear history`() { + val webView = mockk(relaxUnitFun = true) { + every { canGoBack() } returns false + } + + webViewClient.onPageFinished(webView, "http://homeassistant.local:8123/") + webViewClient.onPageFinished(webView, "about:blank") + + io.mockk.verify(exactly = 0) { webView.clearHistory() } + } + + @Test + fun `Given transition between different origins when onPageFinished then clears webView history`() { + val webView = mockk(relaxUnitFun = true) { + every { canGoBack() } returns true + } + + // First load on internal URL, then switch to external (e.g. network change) + webViewClient.onPageFinished(webView, "http://192.168.1.10:8123/history?entity_id=foo") + webViewClient.onPageFinished(webView, "https://my.duckdns.org:8123/history?entity_id=foo") + + io.mockk.verify(exactly = 1) { webView.clearHistory() } + } + @Test fun `Given SSL_DATE_INVALID error when onReceivedSslError then emits AuthenticationError`() { testSslError(SslError.SSL_DATE_INVALID, commonR.string.webview_error_SSL_DATE_INVALID) @@ -378,6 +472,7 @@ class HAWebViewClientTest { onCrash = null, onUrlIntercepted = null, onPageFinished = null, + onCanGoBackChanged = null, onReceivedHttpAuthRequest = { handler, host, resource, realm -> capturedHandler = handler capturedHost = host diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigationTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigationTest.kt new file mode 100644 index 00000000000..677944fcc01 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigationTest.kt @@ -0,0 +1,223 @@ +package io.homeassistant.companion.android.util.compose.webview + +import android.net.Uri +import android.webkit.WebBackForwardList +import android.webkit.WebHistoryItem +import android.webkit.WebView +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class WebViewBackNavigationTest { + + @BeforeEach + fun setUp() { + mockkStatic(Uri::class) + every { Uri.parse(any()) } answers { createMockUri(firstArg()) } + } + + @AfterEach + fun tearDown() { + unmockkStatic(Uri::class) + } + + @Test + fun `Given no history and root loaded url when resolving back action then returns None`() { + val action = resolveBackAction(webViewWithoutHistory(), Uri.parse("https://ha.local:8123/?external_auth=1")) + + assertEquals(BackAction.None, action) + } + + @Test + fun `Given no history and null loaded url when resolving back action then returns None`() { + val action = resolveBackAction(webViewWithoutHistory(), loadedUrl = null) + + assertEquals(BackAction.None, action) + } + + @Test + fun `Given no history and sub-path loaded url when resolving back action then returns None`() { + val action = resolveBackAction( + webViewWithoutHistory(), + Uri.parse("https://ha.local:8123/history?external_auth=1"), + ) + + assertEquals(BackAction.None, action) + } + + @Test + fun `Given no history and sub-path loaded url with extra params when resolving back action then returns None`() { + val action = resolveBackAction( + webViewWithoutHistory(), + Uri.parse("https://ha.local:8123/history?start_date=2026-01-01&external_auth=1#tab"), + ) + + assertEquals(BackAction.None, action) + } + + @Test + fun `Given same-origin previous url when resolving back action then returns GoBack`() { + val webView = webViewWithHistory("https://ha.local:8123/lovelace/0") + + val action = resolveBackAction(webView, Uri.parse("https://ha.local:8123/history?external_auth=1")) + + assertEquals(BackAction.GoBack, action) + } + + @Test + fun `Given cross-origin previous url and sub-path loaded url when resolving back action then returns NavigateToRoot`() { + val webView = webViewWithHistory("https://other.server:8123/lovelace/0") + + val action = resolveBackAction( + webView, + Uri.parse("https://ha.local:8123/history?start_date=2026-01-01&external_auth=1#tab"), + ) + + assertTrue(action is BackAction.NavigateToRoot) + val rootUrl = (action as BackAction.NavigateToRoot).rootUrl + assertEquals("/", rootUrl.path) + assertEquals("ha.local", rootUrl.host) + assertEquals("1", rootUrl.getQueryParameter("external_auth")) + assertEquals(null, rootUrl.getQueryParameter("start_date")) + assertEquals(null, rootUrl.fragment) + } + + @Test + fun `Given cross-origin previous url and root loaded url when resolving back action then returns None`() { + val webView = webViewWithHistory("https://other.server:8123/lovelace/0") + + val action = resolveBackAction(webView, Uri.parse("https://ha.local:8123/?external_auth=1")) + + assertEquals(BackAction.None, action) + } + + @Test + fun `Given non-http previous url when resolving back action then returns NavigateToRoot`() { + val webView = webViewWithHistory("about:blank") + + val action = resolveBackAction(webView, Uri.parse("https://ha.local:8123/history?external_auth=1")) + + assertTrue(action is BackAction.NavigateToRoot) + } + + private fun webViewWithoutHistory(): WebView = mockk { + every { canGoBack() } returns false + } + + private fun webViewWithHistory(previousUrl: String): WebView { + val historyItem = mockk { + every { url } returns previousUrl + } + val backForwardList = mockk { + every { currentIndex } returns 1 + every { getItemAtIndex(0) } returns historyItem + } + return mockk { + every { canGoBack() } returns true + every { copyBackForwardList() } returns backForwardList + } + } + + /** + * Builds a mock [Uri] backed by a [FakeUri] data class so that the extension functions + * `hasSameOrigin`/`hasNonRootPath` and the builder chain used in `resolveBackAction` + * return consistent values without a real Android framework. + */ + private fun createMockUri(uriString: String): Uri = mockUriFrom(FakeUri.parse(uriString)) + + private fun mockUriFrom(fake: FakeUri): Uri { + return mockk { + every { scheme } returns fake.scheme + every { host } returns fake.host + every { port } returns fake.port + every { path } returns fake.path + every { fragment } returns fake.fragment + every { getQueryParameter(any()) } answers { fake.queryParams[firstArg()] } + every { this@mockk.toString() } returns fake.toString() + every { buildUpon() } answers { mockUriBuilder(fake.copy()) } + } + } + + private fun mockUriBuilder(state: FakeUri): Uri.Builder = mockk { + every { path(any()) } answers { + state.path = firstArg() + this@mockk + } + every { clearQuery() } answers { + state.queryParams = linkedMapOf() + this@mockk + } + every { appendQueryParameter(any(), any()) } answers { + state.queryParams[firstArg()] = secondArg() + this@mockk + } + every { fragment(any()) } answers { + state.fragment = firstArg() + this@mockk + } + every { build() } answers { mockUriFrom(state.copy(queryParams = LinkedHashMap(state.queryParams))) } + } + + private data class FakeUri( + val scheme: String?, + val host: String?, + val port: Int, + var path: String?, + var queryParams: LinkedHashMap, + var fragment: String?, + ) { + override fun toString(): String { + val hostPart = if (host != null) { + val portPart = if (port != -1) ":$port" else "" + "://$host$portPart" + } else { + ":" + } + val pathPart = path.orEmpty() + val queryPart = if (queryParams.isEmpty()) "" else "?" + queryParams.entries.joinToString("&") { "${it.key}=${it.value}" } + val fragmentPart = fragment?.let { "#$it" } ?: "" + return "$scheme$hostPart$pathPart$queryPart$fragmentPart" + } + + companion object { + fun parse(uri: String): FakeUri { + // Handle opaque URIs (e.g. about:blank) + if (!uri.contains("://")) { + val (scheme, rest) = uri.split(":", limit = 2) + return FakeUri( + scheme = scheme, + host = null, + port = -1, + path = rest.takeIf { it.isNotEmpty() }, + queryParams = linkedMapOf(), + fragment = null, + ) + } + val schemeEnd = uri.indexOf("://") + val scheme = uri.substring(0, schemeEnd) + val afterScheme = uri.substring(schemeEnd + 3) + val fragment = afterScheme.substringAfter('#', "").takeIf { it.isNotEmpty() } + val beforeFragment = afterScheme.substringBefore('#') + val query = beforeFragment.substringAfter('?', "").takeIf { it.isNotEmpty() } + val beforeQuery = beforeFragment.substringBefore('?') + val slashIndex = beforeQuery.indexOf('/') + val authority = if (slashIndex == -1) beforeQuery else beforeQuery.substring(0, slashIndex) + val path = if (slashIndex == -1) "" else beforeQuery.substring(slashIndex) + val host = authority.substringBefore(':') + val port = authority.substringAfter(':', "").toIntOrNull() ?: -1 + val params = linkedMapOf() + query?.split('&')?.forEach { kv -> + val parts = kv.split('=', limit = 2) + if (parts.size == 2) params[parts[0]] = parts[1] + } + return FakeUri(scheme, host, port, path, params, fragment) + } + } + } +} diff --git a/app/src/test/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImplTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImplTest.kt index 1b2d127fd77..d7ef3c9bb18 100644 --- a/app/src/test/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImplTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImplTest.kt @@ -90,6 +90,10 @@ private class FakeWebViewContext( override fun showConnectionSecurityLevel(serverId: Int) { webViewDelegate.showConnectionSecurityLevel(serverId) } + + override fun getCurrentWebViewRelativeUrl(): String? { + return webViewDelegate.getCurrentWebViewRelativeUrl() + } } @OptIn(ExperimentalCoroutinesApi::class) @@ -123,6 +127,7 @@ class WebViewPresenterImplTest { fun setUp() { mockkStatic(Uri::class) mockUriParse() + every { webView.getCurrentWebViewRelativeUrl() } returns null fakeContext = FakeWebViewContext(mockk(), webView) lifecycleOwner = object : LifecycleOwner { @@ -410,6 +415,47 @@ class WebViewPresenterImplTest { assertEquals("https://second-updated.com?external_auth=1", urlSlot[2].toString()) } + @Test + fun `Given base url changes when collecting then preserves current path and clears history`() = runTest(testDispatcher) { + val server = mockk(relaxed = true) + val urlFlow = MutableStateFlow(UrlState.HasUrl(URL("https://internal.example.com"))) + + coEvery { serverManager.getServer(any()) } returns server + coEvery { authenticationRepository.getSessionState() } returns SessionState.CONNECTED + coEvery { connectionStateProvider.urlFlow(any()) } returns urlFlow + every { webView.getCurrentWebViewRelativeUrl() } returns "/history?start_date=2026-01-01" + + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) + lifecycle.handleLifecycleEvent(Lifecycle.Event.ON_START) + + createPresenter() + + backgroundScope.launch { + presenter.load(lifecycle, path = null, isInternalOverride = null) + } + + // Emit a new base URL (e.g. WiFi -> mobile data switch) + urlFlow.emit(UrlState.HasUrl(URL("https://external.example.com"))) + + val urlSlot = mutableListOf() + val keepHistorySlot = mutableListOf() + verify(exactly = 2) { + webView.loadUrl(capture(urlSlot), capture(keepHistorySlot), any(), any()) + } + + // First load: initial internal URL, keeps history (no base URL change yet) + assertTrue(urlSlot[0].toString().startsWith("https://internal.example.com")) + assertTrue(keepHistorySlot[0]) + + // Second load: external URL with preserved path, history cleared + assertTrue(urlSlot[1].toString().startsWith("https://external.example.com")) + assertTrue(urlSlot[1].toString().contains("/history")) + assertTrue(urlSlot[1].toString().contains("start_date=2026-01-01")) + assertFalse(keepHistorySlot[1]) + + verify { webView.getCurrentWebViewRelativeUrl() } + } + @Test fun `Given IllegalStateException when getting session state then does not collect url flow`() = runTest { val server = mockk(relaxed = true) diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/util/UrlUtil.kt b/common/src/main/kotlin/io/homeassistant/companion/android/util/UrlUtil.kt index 6769c2b9b1a..bbe034da2a7 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/util/UrlUtil.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/util/UrlUtil.kt @@ -220,3 +220,31 @@ fun Uri.hasNonRootPath(): Boolean { val path = this.path ?: return false return path.isNotBlank() && path != "/" } + +/** + * Extracts the relative URL (path, filtered query parameters, and fragment) from this [Uri]. + * + * The root path (`/`) is treated as empty since it represents the home page with no + * meaningful relative navigation. + * + * @param excludeParams query parameter names to omit from the result + * @return the relative URL string (e.g. `/history?start_date=2026-01-01#tab`), + * or `null` if the path is root-only or the result would be empty. + */ +fun Uri.toRelativeUrl(excludeParams: Set = emptySet()): String? { + val path = encodedPath?.takeIf { it.length > 1 } ?: return null + + val relativeUrl = Uri.Builder() + .encodedPath(path) + .apply { + queryParameterNames + .filterNot { it in excludeParams } + .flatMap { name -> getQueryParameters(name).map { name to it } } + .forEach { (name, value) -> appendQueryParameter(name, value) } + } + .encodedFragment(encodedFragment) + .build() + .toString() + + return relativeUrl.takeIf { it.isNotEmpty() } +} diff --git a/common/src/test/kotlin/io/homeassistant/companion/android/util/UriExtensionsTest.kt b/common/src/test/kotlin/io/homeassistant/companion/android/util/UriExtensionsTest.kt index 6c8d10ac0d8..7237afe9ca8 100644 --- a/common/src/test/kotlin/io/homeassistant/companion/android/util/UriExtensionsTest.kt +++ b/common/src/test/kotlin/io/homeassistant/companion/android/util/UriExtensionsTest.kt @@ -3,6 +3,7 @@ package io.homeassistant.companion.android.util import android.net.Uri import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -57,4 +58,75 @@ class UriExtensionsTest { val uri = Uri.parse(url) assertEquals("hasNonRootPath($url)", expected, uri.hasNonRootPath()) } + + // ---- toRelativeUrl tests ---- + + @Test + fun `toRelativeUrl returns path for URL with path only`() { + val uri = Uri.parse("https://example.com/lovelace/default") + assertEquals("/lovelace/default", uri.toRelativeUrl()) + } + + @Test + fun `toRelativeUrl returns path and query params`() { + val uri = Uri.parse("https://example.com/history?start_date=2026-01-01&end_date=2026-01-31") + assertEquals("/history?start_date=2026-01-01&end_date=2026-01-31", uri.toRelativeUrl()) + } + + @Test + fun `toRelativeUrl returns path query and fragment`() { + val uri = Uri.parse("https://example.com/history?start_date=2026-01-01#tab") + assertEquals("/history?start_date=2026-01-01#tab", uri.toRelativeUrl()) + } + + @Test + fun `toRelativeUrl excludes specified query params`() { + val uri = Uri.parse("https://example.com/dashboard?external_auth=1&lang=en") + assertEquals("/dashboard?lang=en", uri.toRelativeUrl(excludeParams = setOf("external_auth"))) + } + + @Test + fun `toRelativeUrl strips excluded params leaving path only`() { + val uri = Uri.parse("https://example.com/dashboard?external_auth=1") + assertEquals("/dashboard", uri.toRelativeUrl(excludeParams = setOf("external_auth"))) + } + + @Test + fun `toRelativeUrl returns null for root path`() { + val uri = Uri.parse("https://example.com/") + assertNull(uri.toRelativeUrl()) + } + + @Test + fun `toRelativeUrl returns null for URL without path`() { + val uri = Uri.parse("https://example.com") + assertNull(uri.toRelativeUrl()) + } + + @Test + fun `toRelativeUrl preserves fragment when no query params`() { + val uri = Uri.parse("https://example.com/settings#advanced") + assertEquals("/settings#advanced", uri.toRelativeUrl()) + } + + @Test + fun `toRelativeUrl excludes multiple params`() { + val uri = Uri.parse("https://example.com/view?external_auth=1&token=abc&lang=en") + assertEquals( + "/view?lang=en", + uri.toRelativeUrl(excludeParams = setOf("external_auth", "token")), + ) + } + + @Test + fun `toRelativeUrl preserves all params when no exclusions`() { + val uri = Uri.parse("https://example.com/view?external_auth=1&lang=en") + assertEquals("/view?external_auth=1&lang=en", uri.toRelativeUrl()) + } + + @Test + fun `toRelativeUrl handles deeply nested paths`() { + val uri = Uri.parse("https://example.com/config/devices/device/abc123") + assertEquals("/config/devices/device/abc123", uri.toRelativeUrl()) + } }