From 65cbdce68509b01fb44df3cf3eeede87ae21b69f Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:51:13 +0100 Subject: [PATCH 01/20] Update WebViewPresenterImpl.kt --- .../android/webview/WebViewPresenterImpl.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index 92e221a2766..400949b0f68 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -135,13 +135,18 @@ class WebViewPresenterImpl @Inject constructor( } serverManager.connectionStateProvider(serverId).urlFlow(isInternalOverride).collect { urlState -> - val shouldConsumePath = !pathConsumed && path != null - if (shouldConsumePath) pathConsumed = true + val effectivePath = if (!pathConsumed && path != null) { + pathConsumed = true + path + } else { + // On internal/external URL switches, preserve the current WebView path + withContext(Dispatchers.Main) { view.getCurrentWebViewPath() } + } handleUrlState( urlState = urlState, - path = path, - shouldConsumePath = shouldConsumePath, + path = effectivePath, + shouldConsumePath = effectivePath != null, isNewServer = isNewServer, ) } From 065eab56800c88b197cb1ab0af691fdcb21411f1 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:56:04 +0100 Subject: [PATCH 02/20] Update WebViewActivity.kt --- .../android/webview/WebViewActivity.kt | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) 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 f330f7ad09e..08b9bd4b56b 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 @@ -1424,19 +1424,15 @@ class WebViewActivity : if (hasFocus && !isFinishing) { lifecycleScope.launch { unlockAppIfNeeded() - - if (presenter.isFullScreen() || isVideoFullScreen) { - hideSystemUI() - } else { - showSystemUI() - } - - var path = intent.getStringExtra(EXTRA_PATH) - if (path?.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 intentPath = intent.getStringExtra(EXTRA_PATH) + // When no explicit navigation path is set (e.g. from a notification), + // preserve the current WebView path so the user's dashboard view + // survives internal/external URL switches. See #4983. + var path = intentPath ?: getCurrentWebViewPath() + if (intentPath?.startsWith("entityId:") == true) { + moreInfoEntity = intentPath.substringAfter("entityId:") val pattern = "(?<=^entityId:)((?!.+__)(?!_)[\\da-z_]+(? Date: Mon, 16 Feb 2026 14:57:06 +0100 Subject: [PATCH 03/20] Update WebView.kt --- .../io/homeassistant/companion/android/webview/WebView.kt | 2 ++ 1 file changed, 2 insertions(+) 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..bfd75765ab1 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,8 @@ interface WebView { */ fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean, serverHandleInsets: Boolean) + fun getCurrentWebViewPath(): String? + fun setStatusBarAndBackgroundColor(statusBarColor: Int, backgroundColor: Int) fun setExternalAuth(script: String) From 46a54a376cc5b6ade3c060cdcc53b06ecd6987df Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:32:33 +0100 Subject: [PATCH 04/20] Address TimoPtr's review feedback on WebView path handling - Remove duplicate getCurrentWebViewPath() call from Activity (Presenter handles fallback) - Move intent.removeExtra(EXTRA_PATH) before presenter.load() - Fix moreInfoEntity: only set for JS dispatch path, clear for URL path approach - Simplify getCurrentWebViewPath() using takeIf, remove unnecessary try-catch - Use full GitHub issue URL in comment Co-Authored-By: Claude Opus 4.6 --- .../companion/android/webview/WebView.kt | 3 ++- .../android/webview/WebViewActivity.kt | 19 ++++++------------- .../android/webview/WebViewPresenterImpl.kt | 2 +- 3 files changed, 9 insertions(+), 15 deletions(-) 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 bfd75765ab1..27fa30a0d31 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,8 +35,9 @@ interface WebView { */ fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean, serverHandleInsets: Boolean) + /** Returns the path component of the currently loaded WebView URL, or `null` if unavailable. */ fun getCurrentWebViewPath(): 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 08b9bd4b56b..f04c1f44d64 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 @@ -1425,12 +1425,11 @@ class WebViewActivity : lifecycleScope.launch { unlockAppIfNeeded() val intentPath = intent.getStringExtra(EXTRA_PATH) - // When no explicit navigation path is set (e.g. from a notification), - // preserve the current WebView path so the user's dashboard view - // survives internal/external URL switches. See #4983. - var path = intentPath ?: getCurrentWebViewPath() + intent.removeExtra(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) { - moreInfoEntity = intentPath.substringAfter("entityId:") val pattern = "(?<=^entityId:)((?!.+__)(?!_)[\\da-z_]+(? 1 } } override suspend fun unlockAppIfNeeded() { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index 400949b0f68..861a27bbf4f 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -567,7 +567,7 @@ class WebViewPresenterImpl @Inject constructor( is ThreadManager.SyncResult.NoneHaveCredentials, is ThreadManager.SyncResult.OnlyOnServer, - -> { + -> { mutableMatterThreadStep.tryEmit(MatterThreadStep.THREAD_NONE) } From f8b1965c713c6cb69aa08308fe91daaa574131d3 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:41:05 +0100 Subject: [PATCH 05/20] Preserve full relative URL on internal/external switches Instead of only preserving the path when the connection switches between internal and external URLs, preserve the complete relative URL including query parameters and fragment. This ensures filtered views (e.g. history page with date ranges) survive URL switches seamlessly. - Rename getCurrentWebViewPath() to getCurrentWebViewRelativeUrl() - Extract path + query params + fragment from the current WebView URL - Strip 'external_auth' param to avoid duplication (presenter re-adds it) - Update presenter to use the new method with descriptive variable names Co-Authored-By: Claude Opus 4.6 --- .../companion/android/webview/WebView.kt | 9 +++++++-- .../companion/android/webview/WebViewActivity.kt | 16 ++++++++++++++-- .../android/webview/WebViewPresenterImpl.kt | 12 +++++++----- 3 files changed, 28 insertions(+), 9 deletions(-) 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 27fa30a0d31..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,8 +35,13 @@ interface WebView { */ fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean, serverHandleInsets: Boolean) - /** Returns the path component of the currently loaded WebView URL, or `null` if unavailable. */ - fun getCurrentWebViewPath(): String? + /** + * 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) 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 f04c1f44d64..2ab4f36fa26 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 @@ -1453,8 +1453,20 @@ class WebViewActivity : } } - override fun getCurrentWebViewPath(): String? { - return webView.url?.toUri()?.path?.takeIf { it.length > 1 } + override fun getCurrentWebViewRelativeUrl(): String? { + val uri = webView.url?.toUri() ?: return null + val path = uri.encodedPath?.takeIf { it.length > 1 } ?: return null + // Strip 'external_auth' since the presenter re-adds it on every load + val query = uri.encodedQuery + ?.split("&") + ?.filter { !it.startsWith("external_auth=") } + ?.joinToString("&") + ?.takeIf { it.isNotEmpty() } + return buildString { + append(path) + query?.let { append("?$it") } + uri.encodedFragment?.let { append("#$it") } + } } override suspend fun unlockAppIfNeeded() { diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index 861a27bbf4f..0909af78f90 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -135,18 +135,20 @@ class WebViewPresenterImpl @Inject constructor( } serverManager.connectionStateProvider(serverId).urlFlow(isInternalOverride).collect { urlState -> - val effectivePath = if (!pathConsumed && path != null) { + val effectiveRelativeUrl = if (!pathConsumed && path != null) { pathConsumed = true path } else { - // On internal/external URL switches, preserve the current WebView path - withContext(Dispatchers.Main) { view.getCurrentWebViewPath() } + // On internal/external URL switches, 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. + withContext(Dispatchers.Main) { view.getCurrentWebViewRelativeUrl() } } handleUrlState( urlState = urlState, - path = effectivePath, - shouldConsumePath = effectivePath != null, + path = effectiveRelativeUrl, + shouldConsumePath = effectiveRelativeUrl != null, isNewServer = isNewServer, ) } From ce033b42ccfae4b6c3a7887eecc0d28c0c4e0483 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:39:44 +0100 Subject: [PATCH 06/20] Clear WebView history on internal/external URL switches When the base URL changes (e.g. switching from internal Wi-Fi to external mobile data), old URLs in the back stack become unreachable on the new network. Pressing back would attempt to load those old URLs, causing timeouts and error messages. Track the last base URL in collectUrlStateChanges and set isNewServer=true when a change is detected, which triggers keepHistory=false and clears the navigation history after loading the new URL. Co-Authored-By: Claude Opus 4.6 --- .../companion/android/webview/WebViewPresenterImpl.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index 0909af78f90..4e57a040fe8 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -129,12 +129,17 @@ class WebViewPresenterImpl @Inject constructor( isNewServer: Boolean, ) { var pathConsumed = false + var lastBaseUrl: URL? = null if (isInternalOverride != null) { Timber.d("Using isInternalOverride to get URL") } serverManager.connectionStateProvider(serverId).urlFlow(isInternalOverride).collect { urlState -> + val currentBaseUrl = (urlState as? UrlState.HasUrl)?.url + val baseUrlChanged = lastBaseUrl != null && currentBaseUrl != null && lastBaseUrl != currentBaseUrl + if (currentBaseUrl != null) lastBaseUrl = currentBaseUrl + val effectiveRelativeUrl = if (!pathConsumed && path != null) { pathConsumed = true path @@ -149,7 +154,9 @@ class WebViewPresenterImpl @Inject constructor( urlState = urlState, path = effectiveRelativeUrl, shouldConsumePath = effectiveRelativeUrl != null, - isNewServer = isNewServer, + // 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. + isNewServer = isNewServer || baseUrlChanged, ) } } From 0bcc194a44abcd06a0ce753014b0f07f28bb1dab Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:35:12 +0100 Subject: [PATCH 07/20] Handle back navigation after internal/external URL switches After switching between internal and external URLs, stale history entries from the old connection may remain in the WebView. This change validates that the previous history entry has the same origin as the current URL before navigating back. If the origin differs, it navigates to the base URL instead of attempting to load an unreachable page. Also keeps the back callback enabled when on a non-root path so pressing back navigates to root before exiting. Co-Authored-By: Claude Opus 4.6 --- .../android/webview/WebViewActivity.kt | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) 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 2ab4f36fa26..13b3f980750 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 @@ -441,9 +441,46 @@ class WebViewActivity : decor = window.decorView as FrameLayout - val onBackPressed = object : OnBackPressedCallback(webView.canGoBack()) { + val onBackPressed = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (webView.canGoBack()) webView.goBack() + if (webView.canGoBack()) { + // Check if the previous history entry has the same origin as + // the current URL. After an internal/external URL switch, + // stale entries from the old connection may remain in history. + val backForwardList = webView.copyBackForwardList() + val currentIndex = backForwardList.currentIndex + if (currentIndex > 0) { + val previousUrl = backForwardList.getItemAtIndex(currentIndex - 1).url.toUri() + val currentBase = loadedUrl + if (currentBase != null && previousUrl.hasSameOrigin(currentBase)) { + webView.goBack() + return + } + } else { + webView.goBack() + return + } + } + // History is empty or previous entry has a different origin + // (stale entry from old connection). Navigate to base URL + // instead of going back to an unreachable page. + val currentUrl = loadedUrl + if (currentUrl != null && currentUrl.hasNonRootPath()) { + val baseUrl = currentUrl.buildUpon() + .path("/") + .clearQuery() + .appendQueryParameter("external_auth", "1") + .fragment(null) + .build() + clearHistory = true + loadedUrl = baseUrl + webView.loadUrl(baseUrl.toString()) + } else { + // Already on root — let the system handle back (exit app) + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } } } @@ -632,7 +669,10 @@ class WebViewActivity : override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { super.doUpdateVisitedHistory(view, url, isReload) - onBackPressed.isEnabled = canGoBack() + // Keep the callback enabled when there's history OR when the current + // URL has a non-root path (so pressing back navigates to root first). + onBackPressed.isEnabled = canGoBack() || + url?.toUri()?.hasNonRootPath() == true presenter.stopScanningForImprov(false) } } From eecbd932b23df8334817bd2ddc7806f2748f72f6 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Sun, 22 Feb 2026 17:35:19 +0100 Subject: [PATCH 08/20] Add missing getCurrentWebViewRelativeUrl to FakeWebViewContext The WebView interface gained a new getCurrentWebViewRelativeUrl() method but the FakeWebViewContext test wrapper was not updated, causing a compilation failure in unit tests. Co-Authored-By: Claude Opus 4.6 --- .../companion/android/webview/WebViewPresenterImplTest.kt | 4 ++++ 1 file changed, 4 insertions(+) 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..5444075a214 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) From edb1c27fedaab473fe8494df84f4c69449f5b685 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:44:51 +0100 Subject: [PATCH 09/20] Fix relaxed mock returning empty string for getCurrentWebViewRelativeUrl A relaxed MockK mock returns "" instead of null for String? return types. This caused UrlUtil.handle to normalize the URL with a trailing slash, breaking the exact string assertion in the "previous load in progress" test. Explicitly mock getCurrentWebViewRelativeUrl to return null, matching the real behavior when no WebView page is loaded. Co-Authored-By: Claude Opus 4.6 --- .../companion/android/webview/WebViewPresenterImplTest.kt | 1 + 1 file changed, 1 insertion(+) 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 5444075a214..65ae494db07 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 @@ -127,6 +127,7 @@ class WebViewPresenterImplTest { fun setUp() { mockkStatic(Uri::class) mockUriParse() + every { webView.getCurrentWebViewRelativeUrl() } returns null fakeContext = FakeWebViewContext(mockk(), webView) lifecycleOwner = object : LifecycleOwner { From 6df7cded7b413072fce8b3c6ff4b0e5829ac4f08 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:33:54 +0100 Subject: [PATCH 10/20] Address TimoPtr's latest review feedback - Guard back navigation against about:blank and other non-HTTP history entries that may appear before the first real page has loaded. - Use Uri.Builder in getCurrentWebViewRelativeUrl instead of manual string concatenation for safer URL construction. Co-Authored-By: Claude Opus 4.6 --- .../android/webview/WebViewActivity.kt | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) 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 13b3f980750..7d09356d0b0 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 @@ -452,7 +452,12 @@ class WebViewActivity : if (currentIndex > 0) { val previousUrl = backForwardList.getItemAtIndex(currentIndex - 1).url.toUri() val currentBase = loadedUrl - if (currentBase != null && previousUrl.hasSameOrigin(currentBase)) { + // Skip about:blank and other non-HTTP entries that may appear + // before the first real page has loaded. + if (currentBase != null && + previousUrl.scheme?.startsWith("http") == true && + previousUrl.hasSameOrigin(currentBase) + ) { webView.goBack() return } @@ -1496,17 +1501,19 @@ class WebViewActivity : override fun getCurrentWebViewRelativeUrl(): String? { val uri = webView.url?.toUri() ?: return null val path = uri.encodedPath?.takeIf { it.length > 1 } ?: return null - // Strip 'external_auth' since the presenter re-adds it on every load - val query = uri.encodedQuery + // Strip 'external_auth' since the presenter re-adds it on every load. + // Use Uri.Builder to safely construct the relative URL from components. + val filteredQuery = uri.encodedQuery ?.split("&") ?.filter { !it.startsWith("external_auth=") } ?.joinToString("&") ?.takeIf { it.isNotEmpty() } - return buildString { - append(path) - query?.let { append("?$it") } - uri.encodedFragment?.let { append("#$it") } - } + return Uri.Builder() + .encodedPath(path) + .encodedQuery(filteredQuery) + .encodedFragment(uri.encodedFragment) + .build() + .toString() } override suspend fun unlockAppIfNeeded() { From e6f229a4a003f0a08e755872d990ba0182bccd0a Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:08:13 +0100 Subject: [PATCH 11/20] Extract Uri.toRelativeUrl() extension and add unit tests - Extract URL relative-path logic into a reusable Uri.toRelativeUrl() extension in UrlUtil.kt, using queryParameterNames API instead of string splitting (as suggested by TimoPtr) - Add KDoc documentation to the new extension - Simplify getCurrentWebViewRelativeUrl() to a single-line delegation - Add comments explaining the callback disable/enable pattern and the about:blank edge case in back navigation - Add 10 unit tests for toRelativeUrl() in UriExtensionsTest.kt --- .../android/webview/WebViewActivity.kt | 29 ++++---- .../companion/android/util/UrlUtil.kt | 34 +++++++++ .../android/util/UriExtensionsTest.kt | 72 +++++++++++++++++++ 3 files changed, 119 insertions(+), 16 deletions(-) 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 7d09356d0b0..334ce31fdd7 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 @@ -142,6 +142,7 @@ 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 @@ -469,6 +470,11 @@ class WebViewActivity : // History is empty or previous entry has a different origin // (stale entry from old connection). Navigate to base URL // instead of going back to an unreachable page. + // + // Note: loadedUrl is always the HTTP(S) URL set by the presenter + // — never about:blank or another non-HTTP scheme. If the WebView + // shows about:blank (before the first page loads), loadedUrl is + // null and we fall through to the system back handler below. val currentUrl = loadedUrl if (currentUrl != null && currentUrl.hasNonRootPath()) { val baseUrl = currentUrl.buildUpon() @@ -481,7 +487,12 @@ class WebViewActivity : loadedUrl = baseUrl webView.loadUrl(baseUrl.toString()) } else { - // Already on root — let the system handle back (exit app) + // 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 @@ -1499,21 +1510,7 @@ class WebViewActivity : } override fun getCurrentWebViewRelativeUrl(): String? { - val uri = webView.url?.toUri() ?: return null - val path = uri.encodedPath?.takeIf { it.length > 1 } ?: return null - // Strip 'external_auth' since the presenter re-adds it on every load. - // Use Uri.Builder to safely construct the relative URL from components. - val filteredQuery = uri.encodedQuery - ?.split("&") - ?.filter { !it.startsWith("external_auth=") } - ?.joinToString("&") - ?.takeIf { it.isNotEmpty() } - return Uri.Builder() - .encodedPath(path) - .encodedQuery(filteredQuery) - .encodedFragment(uri.encodedFragment) - .build() - .toString() + return webView.url?.toUri()?.toRelativeUrl(excludeParams = setOf("external_auth")) } override suspend fun unlockAppIfNeeded() { 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 67678712864..2b9a5d47017 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 @@ -212,3 +212,37 @@ 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]. + * + * Used to preserve the user's current page when the base URL changes, for example when + * switching between internal and external connections. Certain query parameters (like + * `external_auth`) can be excluded because the presenter re-adds them on every load. + * + * 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 { + for (name in queryParameterNames) { + if (name in excludeParams) continue + for (value in getQueryParameters(name)) { + 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..566d6b87bd4 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 returns null when only excluded params remain`() { + 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()) + } } From 47491906f06a6968c8d8aa5f2dc72906eadc0d9b Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Fri, 27 Feb 2026 19:33:30 +0100 Subject: [PATCH 12/20] Move intent.removeExtra(EXTRA_PATH) back before presenter.load() Restore original placement to keep git history clean, as requested by TimoPtr. --- .../homeassistant/companion/android/webview/WebViewActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 334ce31fdd7..dbffb919c9b 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 @@ -1481,7 +1481,6 @@ class WebViewActivity : lifecycleScope.launch { unlockAppIfNeeded() val intentPath = intent.getStringExtra(EXTRA_PATH) - intent.removeExtra(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 @@ -1498,6 +1497,7 @@ class WebViewActivity : moreInfoEntity = entity } } + intent.removeExtra(EXTRA_PATH) presenter.load(lifecycle, path, isInternalOverride) if (presenter.isFullScreen() || isVideoFullScreen) { From f5a7621a69308f7a6cff3b5491e0827b69475f42 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:57:30 +0100 Subject: [PATCH 13/20] Address TimoPtr's latest review: restore UI block order, functional style - Move hideSystemUI/showSystemUI block back before path processing to preserve original code order and keep git history clean - Refactor toRelativeUrl() query parameter loop to functional style using filterNot/flatMap/forEach as suggested by TimoPtr --- .../companion/android/webview/WebViewActivity.kt | 13 +++++++------ .../homeassistant/companion/android/util/UrlUtil.kt | 10 ++++------ 2 files changed, 11 insertions(+), 12 deletions(-) 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 dbffb919c9b..31d66878ce3 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 @@ -1480,6 +1480,13 @@ class WebViewActivity : if (hasFocus && !isFinishing) { lifecycleScope.launch { unlockAppIfNeeded() + + if (presenter.isFullScreen() || isVideoFullScreen) { + hideSystemUI() + } else { + showSystemUI() + } + 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 @@ -1499,12 +1506,6 @@ class WebViewActivity : } intent.removeExtra(EXTRA_PATH) presenter.load(lifecycle, path, isInternalOverride) - - if (presenter.isFullScreen() || isVideoFullScreen) { - hideSystemUI() - } else { - showSystemUI() - } } } } 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 2b9a5d47017..b14779e0159 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 @@ -233,12 +233,10 @@ fun Uri.toRelativeUrl(excludeParams: Set = emptySet()): String? { val relativeUrl = Uri.Builder() .encodedPath(path) .apply { - for (name in queryParameterNames) { - if (name in excludeParams) continue - for (value in getQueryParameters(name)) { - appendQueryParameter(name, value) - } - } + queryParameterNames + .filterNot { it in excludeParams } + .flatMap { name -> getQueryParameters(name).map { name to it } } + .forEach { (name, value) -> appendQueryParameter(name, value) } } .encodedFragment(encodedFragment) .build() From da0d124ee0a6f66b68a7e5c74b6ad0c8b2f1a658 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Mon, 23 Mar 2026 08:54:20 +0100 Subject: [PATCH 14/20] Address jpelgrom's review feedback - Don't preserve path on server switch to avoid leaking info and broken pages; only preserve on internal/external URL changes - Restore OnBackPressedCallback(webView.canGoBack()) to keep predictive back animations on Android 14+ - Restore entityId comment with core.py link - Make toRelativeUrl KDoc more generic (it's a util function) - Fix misleading test name that suggested null return --- .../companion/android/webview/WebViewActivity.kt | 10 +++++++--- .../companion/android/webview/WebViewPresenterImpl.kt | 7 ++++++- .../io/homeassistant/companion/android/util/UrlUtil.kt | 4 ---- .../companion/android/util/UriExtensionsTest.kt | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) 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 31d66878ce3..0ac8ca2fc77 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 @@ -442,7 +442,7 @@ class WebViewActivity : decor = window.decorView as FrameLayout - val onBackPressed = object : OnBackPressedCallback(true) { + val onBackPressed = object : OnBackPressedCallback(webView.canGoBack()) { override fun handleOnBackPressed() { if (webView.canGoBack()) { // Check if the previous history entry has the same origin as @@ -685,8 +685,10 @@ class WebViewActivity : override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { super.doUpdateVisitedHistory(view, url, isReload) - // Keep the callback enabled when there's history OR when the current - // URL has a non-root path (so pressing back navigates to root first). + // Enable the callback when there's browser history OR when the + // current URL has a non-root path, so pressing back navigates to + // root before exiting. This keeps predictive back animations working + // correctly on Android 14+. onBackPressed.isEnabled = canGoBack() || url?.toUri()?.hasNonRootPath() == true presenter.stopScanningForImprov(false) @@ -1492,6 +1494,8 @@ class WebViewActivity : // 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_]+(? Date: Wed, 25 Mar 2026 20:10:39 +0100 Subject: [PATCH 15/20] Extract back navigation into shared resolveBackAction() with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move cross-origin check + navigate-to-root logic into WebViewBackNavigation.kt so it can be reused by both WebViewActivity and FrontendScreen. - Rename isNewServer → clearHistory in handleUrlState/loadUrl for clarity (requested by TimoPtr). - Fix ktlint indentation in WebViewPresenterImpl.kt:584. --- .../compose/webview/WebViewBackNavigation.kt | 74 +++++++++++ .../android/webview/WebViewActivity.kt | 70 +++-------- .../android/webview/WebViewPresenterImpl.kt | 14 +-- .../webview/WebViewBackNavigationTest.kt | 119 ++++++++++++++++++ 4 files changed, 219 insertions(+), 58 deletions(-) create mode 100644 app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt create mode 100644 app/src/test/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigationTest.kt diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt new file mode 100644 index 00000000000..3c083076c91 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt @@ -0,0 +1,74 @@ +package io.homeassistant.companion.android.util.compose.webview + +import android.net.Uri +import android.webkit.WebView +import io.homeassistant.companion.android.util.hasNonRootPath +import io.homeassistant.companion.android.util.hasSameOrigin + +/** + * Determines the appropriate back action for a WebView based on its history and current URL. + * + * The resolution logic: + * 1. If the WebView has valid back history with a same-origin previous entry, + * returns [BackAction.GoBack] so the user can navigate back normally. + * 2. If there is no valid back history (empty, cross-origin, or non-HTTP entries) + * 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.Exit] so the caller can finish the activity or + * pop the navigation stack. + * + * @param webView the WebView whose history 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) + * @return the [BackAction] that the caller should execute + */ +fun resolveBackAction(webView: WebView, loadedUrl: Uri?): BackAction { + if (webView.canGoBack()) { + val backForwardList = webView.copyBackForwardList() + val currentIndex = backForwardList.currentIndex + if (currentIndex > 0) { + val previousUrl = Uri.parse( + backForwardList.getItemAtIndex(currentIndex - 1).url, + ) + // Skip about:blank and other non-HTTP entries that may appear + // before the first real page has loaded. + if (loadedUrl != null && + previousUrl.scheme?.startsWith("http") == true && + previousUrl.hasSameOrigin(loadedUrl) + ) { + return BackAction.GoBack + } + } else { + return BackAction.GoBack + } + } + + // History is empty or previous entry has a different origin + // (stale entry from old connection). Navigate to root URL + // before exiting the screen. + if (loadedUrl != null && loadedUrl.hasNonRootPath()) { + val rootUrl = loadedUrl.buildUpon() + .path("/") + .clearQuery() + .appendQueryParameter("external_auth", "1") + .fragment(null) + .build() + return BackAction.NavigateToRoot(rootUrl) + } + + return BackAction.Exit +} + +/** + * 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 — exit the screen. */ + data object Exit : BackAction +} 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 0ac8ca2fc77..497deeee6af 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 @@ -138,6 +138,8 @@ import io.homeassistant.companion.android.util.LifecycleHandler 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.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 @@ -444,58 +446,24 @@ class WebViewActivity : val onBackPressed = object : OnBackPressedCallback(webView.canGoBack()) { override fun handleOnBackPressed() { - if (webView.canGoBack()) { - // Check if the previous history entry has the same origin as - // the current URL. After an internal/external URL switch, - // stale entries from the old connection may remain in history. - val backForwardList = webView.copyBackForwardList() - val currentIndex = backForwardList.currentIndex - if (currentIndex > 0) { - val previousUrl = backForwardList.getItemAtIndex(currentIndex - 1).url.toUri() - val currentBase = loadedUrl - // Skip about:blank and other non-HTTP entries that may appear - // before the first real page has loaded. - if (currentBase != null && - previousUrl.scheme?.startsWith("http") == true && - previousUrl.hasSameOrigin(currentBase) - ) { - webView.goBack() - return - } - } else { - webView.goBack() - return + when (val action = resolveBackAction(webView, loadedUrl)) { + BackAction.GoBack -> webView.goBack() + is BackAction.NavigateToRoot -> { + clearHistory = true + loadedUrl = action.rootUrl + webView.loadUrl(action.rootUrl.toString()) + } + BackAction.Exit -> { + // 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 } - } - // History is empty or previous entry has a different origin - // (stale entry from old connection). Navigate to base URL - // instead of going back to an unreachable page. - // - // Note: loadedUrl is always the HTTP(S) URL set by the presenter - // — never about:blank or another non-HTTP scheme. If the WebView - // shows about:blank (before the first page loads), loadedUrl is - // null and we fall through to the system back handler below. - val currentUrl = loadedUrl - if (currentUrl != null && currentUrl.hasNonRootPath()) { - val baseUrl = currentUrl.buildUpon() - .path("/") - .clearQuery() - .appendQueryParameter("external_auth", "1") - .fragment(null) - .build() - clearHistory = true - loadedUrl = baseUrl - webView.loadUrl(baseUrl.toString()) - } else { - // 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 } } } diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index d360db0e985..065a5afbcf4 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -161,7 +161,7 @@ class WebViewPresenterImpl @Inject constructor( 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. - isNewServer = isNewServer || baseUrlChanged, + clearHistory = isNewServer || baseUrlChanged, ) } } @@ -198,13 +198,13 @@ class WebViewPresenterImpl @Inject constructor( urlState: UrlState, path: String?, shouldConsumePath: Boolean, - isNewServer: Boolean, + clearHistory: Boolean, ) { when (urlState) { is UrlState.HasUrl -> loadUrl( baseUrl = urlState.url, path = if (shouldConsumePath) path else null, - isNewServer = isNewServer, + clearHistory = clearHistory, ) UrlState.InsecureState -> view.showBlockInsecure(serverId = serverId) @@ -219,9 +219,9 @@ 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 clearHistory whether to clear WebView history after loading (e.g. on server or connection switch) */ - private suspend fun loadUrl(baseUrl: URL?, path: String?, isNewServer: Boolean) { + private suspend fun loadUrl(baseUrl: URL?, path: String?, clearHistory: Boolean) { val urlToLoad = if (path != null && !path.startsWith("entityId:")) { UrlUtil.handle(baseUrl, path) } else { @@ -244,7 +244,7 @@ class WebViewPresenterImpl @Inject constructor( } else { view.loadUrl( url = urlWithAuth, - keepHistory = !isNewServer, + keepHistory = !clearHistory, 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, @@ -581,7 +581,7 @@ class WebViewPresenterImpl @Inject constructor( is ThreadManager.SyncResult.NoneHaveCredentials, is ThreadManager.SyncResult.OnlyOnServer, - -> { + -> { mutableMatterThreadStep.tryEmit(MatterThreadStep.THREAD_NONE) } 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..4b28da75cfd --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigationTest.kt @@ -0,0 +1,119 @@ +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 org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class WebViewBackNavigationTest { + + private fun mockWebView(canGoBack: Boolean = false): WebView = mockk { + every { this@mockk.canGoBack() } returns canGoBack + } + + @Test + fun `resolveBackAction returns Exit when no history and root URL`() { + val webView = mockWebView(canGoBack = false) + val loadedUrl = Uri.parse("https://ha.local:8123/?external_auth=1") + + val action = resolveBackAction(webView, loadedUrl) + + assertEquals(BackAction.Exit, action) + } + + @Test + fun `resolveBackAction returns Exit when loadedUrl is null`() { + val webView = mockWebView(canGoBack = false) + + val action = resolveBackAction(webView, null) + + assertEquals(BackAction.Exit, action) + } + + @Test + fun `resolveBackAction returns NavigateToRoot when on sub-path with no history`() { + val webView = mockWebView(canGoBack = false) + val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") + + val action = resolveBackAction(webView, loadedUrl) + + assertTrue(action is BackAction.NavigateToRoot) + val rootUrl = (action as BackAction.NavigateToRoot).rootUrl + assertEquals("/", rootUrl.path) + assertEquals("1", rootUrl.getQueryParameter("external_auth")) + assertEquals("ha.local", rootUrl.host) + } + + @Test + fun `resolveBackAction NavigateToRoot strips query params and fragment`() { + val webView = mockWebView(canGoBack = false) + val loadedUrl = Uri.parse("https://ha.local:8123/history?start_date=2026-01-01&external_auth=1#tab") + + val action = resolveBackAction(webView, loadedUrl) + + assertTrue(action is BackAction.NavigateToRoot) + val rootUrl = (action as BackAction.NavigateToRoot).rootUrl + assertEquals("/", rootUrl.path) + assertEquals("1", rootUrl.getQueryParameter("external_auth")) + assertEquals(null, rootUrl.getQueryParameter("start_date")) + assertEquals(null, rootUrl.fragment) + } + + @Test + fun `resolveBackAction returns Exit when on root path`() { + val webView = mockWebView(canGoBack = false) + val loadedUrl = Uri.parse("https://ha.local:8123/?external_auth=1") + + val action = resolveBackAction(webView, loadedUrl) + + assertEquals(BackAction.Exit, action) + } + + @Test + fun `resolveBackAction returns GoBack when history has same-origin previous entry`() { + val previousItem = mockk { + every { url } returns "https://ha.local:8123/lovelace/0" + } + val backForwardList = mockk { + every { currentIndex } returns 1 + every { getItemAtIndex(0) } returns previousItem + } + val webView = mockk { + every { canGoBack() } returns true + every { copyBackForwardList() } returns backForwardList + } + val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") + + val action = resolveBackAction(webView, loadedUrl) + + assertEquals(BackAction.GoBack, action) + } + + @Test + fun `resolveBackAction returns NavigateToRoot when history has cross-origin previous entry`() { + val previousItem = mockk { + every { url } returns "https://other.server:8123/lovelace/0" + } + val backForwardList = mockk { + every { currentIndex } returns 1 + every { getItemAtIndex(0) } returns previousItem + } + val webView = mockk { + every { canGoBack() } returns true + every { copyBackForwardList() } returns backForwardList + } + val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") + + val action = resolveBackAction(webView, loadedUrl) + + assertTrue(action is BackAction.NavigateToRoot) + } +} From dee94e5c5ad86ee95e8607f16fd91168143f08ab Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:59:24 +0200 Subject: [PATCH 16/20] Preserve current path when switching between internal and external URL Track the last base URL in collectUrlStateChanges so a connection-type switch reuses the current WebView relative URL instead of reloading the root. Tighten resolveBackAction tests to call the pure function directly. --- .../compose/webview/WebViewBackNavigation.kt | 71 +++++++++-------- .../android/webview/WebViewActivity.kt | 2 +- .../android/webview/WebViewPresenterImpl.kt | 13 ++-- .../webview/WebViewBackNavigationTest.kt | 78 ++++++------------- 4 files changed, 68 insertions(+), 96 deletions(-) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt index 3c083076c91..67186f43600 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt @@ -2,50 +2,55 @@ package io.homeassistant.companion.android.util.compose.webview import android.net.Uri import android.webkit.WebView +import androidx.annotation.VisibleForTesting +import androidx.core.net.toUri import io.homeassistant.companion.android.util.hasNonRootPath import io.homeassistant.companion.android.util.hasSameOrigin /** - * Determines the appropriate back action for a WebView based on its history and current URL. + * Convenience overload that extracts the previous URL from the [WebView]'s + * back/forward list and delegates to the pure [resolveBackAction] function. + */ +fun resolveBackAction(webView: WebView, loadedUrl: Uri?): BackAction { + val previousUrl = if (webView.canGoBack()) { + val backForwardList = webView.copyBackForwardList() + backForwardList.currentIndex + .takeIf { it > 0 } + ?.let { backForwardList.getItemAtIndex(it - 1).url } + ?.toUri() + } else { + null + } + return resolveBackAction(previousUrl, loadedUrl) +} + +/** + * Determines the appropriate back action based on the previous and current URL. * * The resolution logic: - * 1. If the WebView has valid back history with a same-origin previous entry, + * 1. If [previousUrl] is a same-origin HTTP entry relative to [loadedUrl], * returns [BackAction.GoBack] so the user can navigate back normally. - * 2. If there is no valid back history (empty, cross-origin, or non-HTTP entries) - * 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.Exit] so the caller can finish the activity or - * pop the navigation stack. + * 2. If there is no valid previous URL 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 or pop the navigation stack). * - * @param webView the WebView whose history is inspected + * @param previousUrl the URL of the previous entry in the WebView's back stack, + * or `null` if the history is empty or the WebView cannot go back * @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) * @return the [BackAction] that the caller should execute */ -fun resolveBackAction(webView: WebView, loadedUrl: Uri?): BackAction { - if (webView.canGoBack()) { - val backForwardList = webView.copyBackForwardList() - val currentIndex = backForwardList.currentIndex - if (currentIndex > 0) { - val previousUrl = Uri.parse( - backForwardList.getItemAtIndex(currentIndex - 1).url, - ) - // Skip about:blank and other non-HTTP entries that may appear - // before the first real page has loaded. - if (loadedUrl != null && - previousUrl.scheme?.startsWith("http") == true && - previousUrl.hasSameOrigin(loadedUrl) - ) { - return BackAction.GoBack - } - } else { - return BackAction.GoBack - } +@VisibleForTesting +internal fun resolveBackAction(previousUrl: Uri?, loadedUrl: Uri?): BackAction { + if (previousUrl != null && + loadedUrl != null && + previousUrl.scheme?.startsWith("http") == true && + previousUrl.hasSameOrigin(loadedUrl) + ) { + return BackAction.GoBack } - // History is empty or previous entry has a different origin - // (stale entry from old connection). Navigate to root URL - // before exiting the screen. if (loadedUrl != null && loadedUrl.hasNonRootPath()) { val rootUrl = loadedUrl.buildUpon() .path("/") @@ -56,7 +61,7 @@ fun resolveBackAction(webView: WebView, loadedUrl: Uri?): BackAction { return BackAction.NavigateToRoot(rootUrl) } - return BackAction.Exit + return BackAction.None } /** @@ -69,6 +74,6 @@ sealed interface 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 — exit the screen. */ - data object Exit : 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/webview/WebViewActivity.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt index c5c646b7e91..21d9647b2ca 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 @@ -454,7 +454,7 @@ class WebViewActivity : loadedUrl = action.rootUrl webView.loadUrl(action.rootUrl.toString()) } - BackAction.Exit -> { + 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 diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index eba75a653f1..a9e99f61550 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -161,7 +161,7 @@ class WebViewPresenterImpl @Inject constructor( 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. - clearHistory = isNewServer || baseUrlChanged, + keepHistory = !(isNewServer || baseUrlChanged), ) } } @@ -198,13 +198,13 @@ class WebViewPresenterImpl @Inject constructor( urlState: UrlState, path: String?, shouldConsumePath: Boolean, - clearHistory: Boolean, + keepHistory: Boolean, ) { when (urlState) { is UrlState.HasUrl -> loadUrl( baseUrl = urlState.url, path = if (shouldConsumePath) path else null, - clearHistory = clearHistory, + keepHistory = keepHistory, ) UrlState.InsecureState -> view.showBlockInsecure(serverId = serverId) @@ -219,9 +219,10 @@ class WebViewPresenterImpl @Inject constructor( * * @param baseUrl the base server URL * @param path optional path to append (ignored if starts with "entityId:") - * @param clearHistory whether to clear WebView history after loading (e.g. on server or connection switch) + * @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?, clearHistory: Boolean) { + private suspend fun loadUrl(baseUrl: URL?, path: String?, keepHistory: Boolean) { val urlToLoad = if (path != null && !path.startsWith("entityId:")) { UrlUtil.handle(baseUrl, path) } else { @@ -244,7 +245,7 @@ class WebViewPresenterImpl @Inject constructor( } else { view.loadUrl( url = urlWithAuth, - keepHistory = !clearHistory, + 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/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 index 4b28da75cfd..4a24f07f6e0 100644 --- 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 @@ -1,11 +1,6 @@ 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 org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -15,35 +10,27 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class WebViewBackNavigationTest { - private fun mockWebView(canGoBack: Boolean = false): WebView = mockk { - every { this@mockk.canGoBack() } returns canGoBack - } - @Test - fun `resolveBackAction returns Exit when no history and root URL`() { - val webView = mockWebView(canGoBack = false) + fun `given no previous url and root loaded url, when resolving back action, then returns None`() { val loadedUrl = Uri.parse("https://ha.local:8123/?external_auth=1") - val action = resolveBackAction(webView, loadedUrl) + val action = resolveBackAction(previousUrl = null, loadedUrl = loadedUrl) - assertEquals(BackAction.Exit, action) + assertEquals(BackAction.None, action) } @Test - fun `resolveBackAction returns Exit when loadedUrl is null`() { - val webView = mockWebView(canGoBack = false) + fun `given no previous url and null loaded url, when resolving back action, then returns None`() { + val action = resolveBackAction(previousUrl = null, loadedUrl = null) - val action = resolveBackAction(webView, null) - - assertEquals(BackAction.Exit, action) + assertEquals(BackAction.None, action) } @Test - fun `resolveBackAction returns NavigateToRoot when on sub-path with no history`() { - val webView = mockWebView(canGoBack = false) + fun `given no previous url and sub-path loaded url, when resolving back action, then returns NavigateToRoot`() { val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") - val action = resolveBackAction(webView, loadedUrl) + val action = resolveBackAction(previousUrl = null, loadedUrl = loadedUrl) assertTrue(action is BackAction.NavigateToRoot) val rootUrl = (action as BackAction.NavigateToRoot).rootUrl @@ -53,11 +40,10 @@ class WebViewBackNavigationTest { } @Test - fun `resolveBackAction NavigateToRoot strips query params and fragment`() { - val webView = mockWebView(canGoBack = false) + fun `given sub-path loaded url with extra params, when resolving back action, then NavigateToRoot strips query and fragment`() { val loadedUrl = Uri.parse("https://ha.local:8123/history?start_date=2026-01-01&external_auth=1#tab") - val action = resolveBackAction(webView, loadedUrl) + val action = resolveBackAction(previousUrl = null, loadedUrl = loadedUrl) assertTrue(action is BackAction.NavigateToRoot) val rootUrl = (action as BackAction.NavigateToRoot).rootUrl @@ -68,51 +54,31 @@ class WebViewBackNavigationTest { } @Test - fun `resolveBackAction returns Exit when on root path`() { - val webView = mockWebView(canGoBack = false) - val loadedUrl = Uri.parse("https://ha.local:8123/?external_auth=1") + fun `given same-origin previous url, when resolving back action, then returns GoBack`() { + val previousUrl = Uri.parse("https://ha.local:8123/lovelace/0") + val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") - val action = resolveBackAction(webView, loadedUrl) + val action = resolveBackAction(previousUrl = previousUrl, loadedUrl = loadedUrl) - assertEquals(BackAction.Exit, action) + assertEquals(BackAction.GoBack, action) } @Test - fun `resolveBackAction returns GoBack when history has same-origin previous entry`() { - val previousItem = mockk { - every { url } returns "https://ha.local:8123/lovelace/0" - } - val backForwardList = mockk { - every { currentIndex } returns 1 - every { getItemAtIndex(0) } returns previousItem - } - val webView = mockk { - every { canGoBack() } returns true - every { copyBackForwardList() } returns backForwardList - } + fun `given cross-origin previous url and sub-path loaded url, when resolving back action, then returns NavigateToRoot`() { + val previousUrl = Uri.parse("https://other.server:8123/lovelace/0") val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") - val action = resolveBackAction(webView, loadedUrl) + val action = resolveBackAction(previousUrl = previousUrl, loadedUrl = loadedUrl) - assertEquals(BackAction.GoBack, action) + assertTrue(action is BackAction.NavigateToRoot) } @Test - fun `resolveBackAction returns NavigateToRoot when history has cross-origin previous entry`() { - val previousItem = mockk { - every { url } returns "https://other.server:8123/lovelace/0" - } - val backForwardList = mockk { - every { currentIndex } returns 1 - every { getItemAtIndex(0) } returns previousItem - } - val webView = mockk { - every { canGoBack() } returns true - every { copyBackForwardList() } returns backForwardList - } + fun `given non-http previous url, when resolving back action, then does not go back`() { + val previousUrl = Uri.parse("about:blank") val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") - val action = resolveBackAction(webView, loadedUrl) + val action = resolveBackAction(previousUrl = previousUrl, loadedUrl = loadedUrl) assertTrue(action is BackAction.NavigateToRoot) } From 369bb2822be165da893516e3623fccab7650f126 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:49:15 +0200 Subject: [PATCH 17/20] Address review comments from PR #6447 - BackAction: inline previousUrl lookup (TimoPtr suggestion) - WebViewPresenterImpl: simplify keepHistory expression with De Morgan - WebViewPresenterImpl: add unit test for base URL change path preservation - WebViewActivity: expand doUpdateVisitedHistory comment with predictive back rationale - WebViewBackNavigationTest: capitalize Given to match project convention - ktlint: rename WebViewBackNavigation.kt -> BackAction.kt, fix import order --- ...WebViewBackNavigation.kt => BackAction.kt} | 5 +-- .../android/webview/WebViewActivity.kt | 10 +++-- .../android/webview/WebViewPresenterImpl.kt | 2 +- .../webview/WebViewBackNavigationTest.kt | 14 +++---- .../webview/WebViewPresenterImplTest.kt | 41 +++++++++++++++++++ 5 files changed, 56 insertions(+), 16 deletions(-) rename app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/{WebViewBackNavigation.kt => BackAction.kt} (94%) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/BackAction.kt similarity index 94% rename from app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt rename to app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/BackAction.kt index 67186f43600..e04277e2b3c 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigation.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/BackAction.kt @@ -14,10 +14,7 @@ import io.homeassistant.companion.android.util.hasSameOrigin fun resolveBackAction(webView: WebView, loadedUrl: Uri?): BackAction { val previousUrl = if (webView.canGoBack()) { val backForwardList = webView.copyBackForwardList() - backForwardList.currentIndex - .takeIf { it > 0 } - ?.let { backForwardList.getItemAtIndex(it - 1).url } - ?.toUri() + backForwardList.getItemAtIndex(backForwardList.currentIndex - 1).url.toUri() } else { null } 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 8c61b94a515..69914730176 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 @@ -137,8 +137,8 @@ import io.homeassistant.companion.android.util.LifecycleHandler 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.BackAction 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 @@ -668,9 +668,11 @@ class WebViewActivity : override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { super.doUpdateVisitedHistory(view, url, isReload) // Enable the callback when there's browser history OR when the - // current URL has a non-root path, so pressing back navigates to - // root before exiting. This keeps predictive back animations working - // correctly on Android 14+. + // current URL has a non-root path. Without the non-root check, + // pressing back on e.g. /history with empty history would skip + // the NavigateToRoot step and exit the app directly. + // It also keeps predictive back animations working on Android 14+, + // since the system needs to know upfront that we'll handle the gesture. onBackPressed.isEnabled = canGoBack() || url?.toUri()?.hasNonRootPath() == true presenter.stopScanningForImprov(false) diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index a9e99f61550..3a8fab7cf04 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -161,7 +161,7 @@ class WebViewPresenterImpl @Inject constructor( 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), + keepHistory = !isNewServer && !baseUrlChanged, ) } } 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 index 4a24f07f6e0..7773b140e04 100644 --- 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 @@ -11,7 +11,7 @@ import org.robolectric.RobolectricTestRunner class WebViewBackNavigationTest { @Test - fun `given no previous url and root loaded url, when resolving back action, then returns None`() { + fun `Given no previous url and root loaded url when resolving back action then returns None`() { val loadedUrl = Uri.parse("https://ha.local:8123/?external_auth=1") val action = resolveBackAction(previousUrl = null, loadedUrl = loadedUrl) @@ -20,14 +20,14 @@ class WebViewBackNavigationTest { } @Test - fun `given no previous url and null loaded url, when resolving back action, then returns None`() { + fun `Given no previous url and null loaded url when resolving back action then returns None`() { val action = resolveBackAction(previousUrl = null, loadedUrl = null) assertEquals(BackAction.None, action) } @Test - fun `given no previous url and sub-path loaded url, when resolving back action, then returns NavigateToRoot`() { + fun `Given no previous url and sub-path loaded url when resolving back action then returns NavigateToRoot`() { val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") val action = resolveBackAction(previousUrl = null, loadedUrl = loadedUrl) @@ -40,7 +40,7 @@ class WebViewBackNavigationTest { } @Test - fun `given sub-path loaded url with extra params, when resolving back action, then NavigateToRoot strips query and fragment`() { + fun `Given sub-path loaded url with extra params when resolving back action then NavigateToRoot strips query and fragment`() { val loadedUrl = Uri.parse("https://ha.local:8123/history?start_date=2026-01-01&external_auth=1#tab") val action = resolveBackAction(previousUrl = null, loadedUrl = loadedUrl) @@ -54,7 +54,7 @@ class WebViewBackNavigationTest { } @Test - fun `given same-origin previous url, when resolving back action, then returns GoBack`() { + fun `Given same-origin previous url when resolving back action then returns GoBack`() { val previousUrl = Uri.parse("https://ha.local:8123/lovelace/0") val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") @@ -64,7 +64,7 @@ class WebViewBackNavigationTest { } @Test - fun `given cross-origin previous url and sub-path loaded url, when resolving back action, then returns NavigateToRoot`() { + fun `Given cross-origin previous url and sub-path loaded url when resolving back action then returns NavigateToRoot`() { val previousUrl = Uri.parse("https://other.server:8123/lovelace/0") val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") @@ -74,7 +74,7 @@ class WebViewBackNavigationTest { } @Test - fun `given non-http previous url, when resolving back action, then does not go back`() { + fun `Given non-http previous url when resolving back action then returns NavigateToRoot`() { val previousUrl = Uri.parse("about:blank") val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") 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 65ae494db07..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 @@ -415,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) From e30d005e740a66e69021bbc4ac60a3136f765463 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:41:59 +0200 Subject: [PATCH 18/20] Address additional review comments from PR #6447 - BackAction: make inner resolveBackAction private, move resolution KDoc to public function so it shows at call sites - WebViewPresenterImpl: gate baseUrlChanged path preservation with !isNewServer (Copilot bug: prevents leaking path across server switches) - WebViewPresenterImpl: simplify baseUrlChanged using lastBaseUrl nullability - WebViewBackNavigationTest: drop Robolectric in favor of mockk-based WebView mocks, exercise logic through the public resolveBackAction overload --- .../util/compose/webview/BackAction.kt | 37 ++-- .../android/webview/WebViewPresenterImpl.kt | 17 +- .../webview/WebViewBackNavigationTest.kt | 185 +++++++++++++++--- 3 files changed, 181 insertions(+), 58 deletions(-) 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 index e04277e2b3c..b053183f854 100644 --- 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 @@ -2,14 +2,25 @@ package io.homeassistant.companion.android.util.compose.webview import android.net.Uri import android.webkit.WebView -import androidx.annotation.VisibleForTesting import androidx.core.net.toUri import io.homeassistant.companion.android.util.hasNonRootPath import io.homeassistant.companion.android.util.hasSameOrigin /** - * Convenience overload that extracts the previous URL from the [WebView]'s - * back/forward list and delegates to the pure [resolveBackAction] function. + * 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 no valid previous URL 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 or pop the navigation stack). + * + * @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()) { @@ -21,25 +32,7 @@ fun resolveBackAction(webView: WebView, loadedUrl: Uri?): BackAction { return resolveBackAction(previousUrl, loadedUrl) } -/** - * Determines the appropriate back action based on the previous and current URL. - * - * The resolution logic: - * 1. If [previousUrl] is a same-origin HTTP entry relative to [loadedUrl], - * returns [BackAction.GoBack] so the user can navigate back normally. - * 2. If there is no valid previous URL 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 or pop the navigation stack). - * - * @param previousUrl the URL of the previous entry in the WebView's back stack, - * or `null` if the history is empty or the WebView cannot go back - * @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) - * @return the [BackAction] that the caller should execute - */ -@VisibleForTesting -internal fun resolveBackAction(previousUrl: Uri?, loadedUrl: Uri?): BackAction { +private fun resolveBackAction(previousUrl: Uri?, loadedUrl: Uri?): BackAction { if (previousUrl != null && loadedUrl != null && previousUrl.scheme?.startsWith("http") == true && diff --git a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt index 3a8fab7cf04..5787f7da06f 100644 --- a/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt +++ b/app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewPresenterImpl.kt @@ -137,19 +137,20 @@ class WebViewPresenterImpl @Inject constructor( serverManager.connectionStateProvider(serverId).urlFlow(isInternalOverride).collect { urlState -> val currentBaseUrl = (urlState as? UrlState.HasUrl)?.url - val baseUrlChanged = lastBaseUrl != null && currentBaseUrl != null && lastBaseUrl != currentBaseUrl + // 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) { - // On internal/external URL switches, 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. - // Only do this for connection type changes on the same server, not - // for server switches where the path may not exist and would leak - // information about the previous server. + } 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 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 index 7773b140e04..a08e9913516 100644 --- 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 @@ -1,36 +1,52 @@ package io.homeassistant.companion.android.util.compose.webview import android.net.Uri -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner +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 -@RunWith(RobolectricTestRunner::class) class WebViewBackNavigationTest { - @Test - fun `Given no previous url and root loaded url when resolving back action then returns None`() { - val loadedUrl = Uri.parse("https://ha.local:8123/?external_auth=1") + @BeforeEach + fun setUp() { + mockkStatic(Uri::class) + every { Uri.parse(any()) } answers { createMockUri(firstArg()) } + } - val action = resolveBackAction(previousUrl = null, loadedUrl = loadedUrl) + @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 previous url and null loaded url when resolving back action then returns None`() { - val action = resolveBackAction(previousUrl = null, loadedUrl = null) + 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 previous url and sub-path loaded url when resolving back action then returns NavigateToRoot`() { - val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") - - val action = resolveBackAction(previousUrl = null, loadedUrl = loadedUrl) + fun `Given no history and sub-path loaded url when resolving back action then returns NavigateToRoot`() { + val action = resolveBackAction( + webViewWithoutHistory(), + Uri.parse("https://ha.local:8123/history?external_auth=1"), + ) assertTrue(action is BackAction.NavigateToRoot) val rootUrl = (action as BackAction.NavigateToRoot).rootUrl @@ -41,9 +57,10 @@ class WebViewBackNavigationTest { @Test fun `Given sub-path loaded url with extra params when resolving back action then NavigateToRoot strips query and fragment`() { - val loadedUrl = Uri.parse("https://ha.local:8123/history?start_date=2026-01-01&external_auth=1#tab") - - val action = resolveBackAction(previousUrl = null, loadedUrl = loadedUrl) + val action = resolveBackAction( + webViewWithoutHistory(), + 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 @@ -55,31 +72,143 @@ class WebViewBackNavigationTest { @Test fun `Given same-origin previous url when resolving back action then returns GoBack`() { - val previousUrl = Uri.parse("https://ha.local:8123/lovelace/0") - val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") + val webView = webViewWithHistory("https://ha.local:8123/lovelace/0") - val action = resolveBackAction(previousUrl = previousUrl, loadedUrl = loadedUrl) + 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 previousUrl = Uri.parse("https://other.server:8123/lovelace/0") - val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") + val webView = webViewWithHistory("https://other.server:8123/lovelace/0") - val action = resolveBackAction(previousUrl = previousUrl, loadedUrl = loadedUrl) + val action = resolveBackAction(webView, Uri.parse("https://ha.local:8123/history?external_auth=1")) assertTrue(action is BackAction.NavigateToRoot) } @Test fun `Given non-http previous url when resolving back action then returns NavigateToRoot`() { - val previousUrl = Uri.parse("about:blank") - val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1") + val webView = webViewWithHistory("about:blank") - val action = resolveBackAction(previousUrl = previousUrl, loadedUrl = loadedUrl) + 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) + } + } + } } From 1f2e7ed3f8cd78434181af37efa5b34037a3427a Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Mon, 20 Apr 2026 20:47:28 +0200 Subject: [PATCH 19/20] Address additional review comments from PR #6447 - BackAction: guard against currentIndex == 0 in previousUrl lookup - WebViewActivity: drop redundant moreInfoEntity = "" reset in the URL-path branch; moreInfoEntity is never set in this branch, so clearing it is unnecessary --- .../companion/android/util/compose/webview/BackAction.kt | 7 ++++++- .../companion/android/webview/WebViewActivity.kt | 1 - 2 files changed, 6 insertions(+), 2 deletions(-) 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 index b053183f854..90759da9b6d 100644 --- 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 @@ -25,7 +25,12 @@ import io.homeassistant.companion.android.util.hasSameOrigin fun resolveBackAction(webView: WebView, loadedUrl: Uri?): BackAction { val previousUrl = if (webView.canGoBack()) { val backForwardList = webView.copyBackForwardList() - backForwardList.getItemAtIndex(backForwardList.currentIndex - 1).url.toUri() + val previousIndex = backForwardList.currentIndex - 1 + if (previousIndex >= 0) { + backForwardList.getItemAtIndex(previousIndex).url.toUri() + } else { + null + } } else { null } 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 69914730176..254621a81fc 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 @@ -1601,7 +1601,6 @@ class WebViewActivity : serverManager.getServer(presenter.getActiveServer())?.version?.isAtLeast(2025, 6, 0) == true ) { path = "/?more-info-entity-id=$entity" - moreInfoEntity = "" } else { moreInfoEntity = entity } From 3e47e4cbb99a42c251955271e7eecdfeb250bdb7 Mon Sep 17 00:00:00 2001 From: Gifford47 <49484063+Gifford47@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:19:06 +0200 Subject: [PATCH 20/20] =?UTF-8?q?Address=20TimoPtr=20review=20on=20PR=20#6?= =?UTF-8?q?447=20=E2=80=94=20BackAction=20NavigateToRoot=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Require previousUrl != null for BackAction.NavigateToRoot so that pressing back with an empty back-stack (e.g. opened via deeplink to /history) returns None instead of synthesizing a navigation to root. This aligns resolveBackAction with jpelgrom's concern about losing Android's predictive-back peek animation. --- .../util/compose/webview/BackAction.kt | 10 +++-- .../webview/WebViewBackNavigationTest.kt | 37 ++++++++++++------- 2 files changed, 29 insertions(+), 18 deletions(-) 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 index 90759da9b6d..b4cf99375c8 100644 --- 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 @@ -13,10 +13,12 @@ import io.homeassistant.companion.android.util.hasSameOrigin * 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 no valid previous URL and the current URL has a non-root path, - * returns [BackAction.NavigateToRoot] so the user is taken to the home page first. + * 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 or pop the navigation stack). + * (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, @@ -46,7 +48,7 @@ private fun resolveBackAction(previousUrl: Uri?, loadedUrl: Uri?): BackAction { return BackAction.GoBack } - if (loadedUrl != null && loadedUrl.hasNonRootPath()) { + if (previousUrl != null && loadedUrl != null && loadedUrl.hasNonRootPath()) { val rootUrl = loadedUrl.buildUpon() .path("/") .clearQuery() 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 index a08e9913516..677944fcc01 100644 --- 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 @@ -42,32 +42,23 @@ class WebViewBackNavigationTest { } @Test - fun `Given no history and sub-path loaded url when resolving back action then returns NavigateToRoot`() { + 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"), ) - assertTrue(action is BackAction.NavigateToRoot) - val rootUrl = (action as BackAction.NavigateToRoot).rootUrl - assertEquals("/", rootUrl.path) - assertEquals("1", rootUrl.getQueryParameter("external_auth")) - assertEquals("ha.local", rootUrl.host) + assertEquals(BackAction.None, action) } @Test - fun `Given sub-path loaded url with extra params when resolving back action then NavigateToRoot strips query and fragment`() { + 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"), ) - assertTrue(action is BackAction.NavigateToRoot) - val rootUrl = (action as BackAction.NavigateToRoot).rootUrl - assertEquals("/", rootUrl.path) - assertEquals("1", rootUrl.getQueryParameter("external_auth")) - assertEquals(null, rootUrl.getQueryParameter("start_date")) - assertEquals(null, rootUrl.fragment) + assertEquals(BackAction.None, action) } @Test @@ -83,9 +74,27 @@ class WebViewBackNavigationTest { 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?external_auth=1")) + 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