Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
65cbdce
Update WebViewPresenterImpl.kt
Gifford47 Feb 16, 2026
065eab5
Update WebViewActivity.kt
Gifford47 Feb 16, 2026
6bbaf17
Update WebView.kt
Gifford47 Feb 16, 2026
46a54a3
Address TimoPtr's review feedback on WebView path handling
Gifford47 Feb 19, 2026
f8b1965
Preserve full relative URL on internal/external switches
Gifford47 Feb 20, 2026
ce033b4
Clear WebView history on internal/external URL switches
Gifford47 Feb 20, 2026
0bcc194
Handle back navigation after internal/external URL switches
Gifford47 Feb 21, 2026
eecbd93
Add missing getCurrentWebViewRelativeUrl to FakeWebViewContext
Gifford47 Feb 22, 2026
edb1c27
Fix relaxed mock returning empty string for getCurrentWebViewRelativeUrl
Gifford47 Feb 22, 2026
6df7cde
Address TimoPtr's latest review feedback
Gifford47 Feb 25, 2026
e6f229a
Extract Uri.toRelativeUrl() extension and add unit tests
Gifford47 Feb 27, 2026
4749190
Move intent.removeExtra(EXTRA_PATH) back before presenter.load()
Gifford47 Feb 27, 2026
f5a7621
Address TimoPtr's latest review: restore UI block order, functional s…
Gifford47 Mar 2, 2026
da0d124
Address jpelgrom's review feedback
Gifford47 Mar 23, 2026
b764c84
Extract back navigation into shared resolveBackAction() with tests
Gifford47 Mar 25, 2026
9b1b064
Merge origin/main into main
Gifford47 Mar 25, 2026
dee94e5
Preserve current path when switching between internal and external URL
Gifford47 Apr 12, 2026
7529564
Merge branch 'main' into main
Gifford47 Apr 12, 2026
369bb28
Address review comments from PR #6447
Gifford47 Apr 13, 2026
e30d005
Address additional review comments from PR #6447
Gifford47 Apr 13, 2026
1f2e7ed
Address additional review comments from PR #6447
Gifford47 Apr 20, 2026
3e47e4c
Address TimoPtr review on PR #6447 — BackAction NavigateToRoot guard
Gifford47 Apr 24, 2026
d596e23
Enable Android 14+ predictive-back by disabling back callback on root
Gifford47 May 11, 2026
e8baa89
Merge remote-tracking branch 'origin/main'
Gifford47 May 11, 2026
672db56
Preserve WebView back-stack across internal/external URL switches
Gifford47 May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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.
*/
fun resolveBackAction(webView: WebView, loadedUrl: Uri?): BackAction {
Comment thread
Gifford47 marked this conversation as resolved.
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 [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 {
Comment thread
Gifford47 marked this conversation as resolved.
Outdated
if (previousUrl != null &&
loadedUrl != null &&
previousUrl.scheme?.startsWith("http") == true &&
previousUrl.hasSameOrigin(loadedUrl)
) {
return BackAction.GoBack
}

if (loadedUrl != null && loadedUrl.hasNonRootPath()) {
val rootUrl = loadedUrl.buildUpon()
.path("/")
.clearQuery()
.appendQueryParameter("external_auth", "1")
.fragment(null)
.build()
return BackAction.NavigateToRoot(rootUrl)
}

return BackAction.None
}

/**
* Represents the action to take when the user presses back in a WebView.
*/
sealed interface BackAction {
/** Navigate back in the WebView history. */
data object GoBack : BackAction

/** Clear history and navigate to the root URL of the current server. */
data class NavigateToRoot(val rootUrl: Uri) : BackAction

/** No more back navigation possible — the caller decides what to do. */
data object None : BackAction
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ interface WebView {
*/
fun loadUrl(url: Uri, keepHistory: Boolean, openInApp: Boolean, serverHandleInsets: Boolean)

/**
* Returns the relative part (path, query parameters, fragment) of the currently loaded
* WebView URL, or `null` if the URL has no meaningful path (empty or root `/`).
*
* The `external_auth` query parameter is stripped since the presenter re-adds it on every load.
*/
fun getCurrentWebViewRelativeUrl(): String?

fun setStatusBarAndBackgroundColor(statusBarColor: Int, backgroundColor: Int)

fun setExternalAuth(script: String)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,14 @@ 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.resolveBackAction
import io.homeassistant.companion.android.util.hasNonRootPath
import io.homeassistant.companion.android.util.hasSameOrigin
import io.homeassistant.companion.android.util.isStarted
import io.homeassistant.companion.android.util.sensitive
import io.homeassistant.companion.android.util.toRelativeUrl
import io.homeassistant.companion.android.websocket.WebsocketManager
import io.homeassistant.companion.android.webview.WebView.ErrorType
import io.homeassistant.companion.android.webview.addto.EntityAddToHandler
Expand Down Expand Up @@ -455,7 +458,25 @@ class WebViewActivity :

val onBackPressed = object : OnBackPressedCallback(webView.canGoBack()) {
override fun handleOnBackPressed() {
if (webView.canGoBack()) webView.goBack()
when (val action = resolveBackAction(webView, loadedUrl)) {
BackAction.GoBack -> webView.goBack()
is BackAction.NavigateToRoot -> {
clearHistory = true
loadedUrl = action.rootUrl
webView.loadUrl(action.rootUrl.toString())
Comment on lines +465 to +467
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason to not call load of the presenter ? I think it is quite important to go through the presenter logic when we want to load a URL. I have no idea of the implication just thinking, that might avoid having to set the loadedUrl and clearHistory here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

presenter.load() does a full reconnect cycle (URL resolution, auth check, security level dialog). For NavigateToRoot we stay on the same server and only change the path to / -going through the presenter would add overhead and could trigger side effects like the security level prompt.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I think we want this side effect, the overhead is very small here and safer. @jpelgrom wdyt?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into replacing the manual loadUrl call with presenter.load(lifecycle), but that wouldn't give us the behavior we need: collectUrlStateChanges only sets keepHistory = false when baseUrlChanged || isNewServer. For a NavigateToRoot action the base URL stays the same -so presenter.load() would pass keepHistory = true and leave the stale entries in the back stack. That's exactly what we want to avoid here (the user pressed back to get out of a sub-page). Going through the presenter would require adding a new loadRoot() method (or a forceClearHistory parameter), which felt like a bigger API change than this PR should do. the current code sets clearHistory/loadedUrl and calls webView.loadUrl directly, which mirrors what WebViewPresenterImpl.loadUrl does internally for this edge case.

}
BackAction.None -> {
// Already on root — let the system handle back (exit app).
// We must temporarily disable this callback so that the
// dispatcher invokes the next handler in the chain (the
// default Activity handler which finishes the activity).
// Re-enabling afterwards keeps the callback functional in
// case the activity is not destroyed (e.g. multi-window).
isEnabled = false
onBackPressedDispatcher.onBackPressed()
isEnabled = true
}
}
}
}

Expand Down Expand Up @@ -646,7 +667,12 @@ class WebViewActivity :

override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
super.doUpdateVisitedHistory(view, url, isReload)
onBackPressed.isEnabled = canGoBack()
// 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
Comment thread
Gifford47 marked this conversation as resolved.
Outdated
presenter.stopScanningForImprov(false)
}
}
Expand Down Expand Up @@ -1559,17 +1585,21 @@ class WebViewActivity :
showSystemUI()
}

var path = intent.getStringExtra(EXTRA_PATH)
if (path?.startsWith("entityId:") == true) {
val intentPath = intent.getStringExtra(EXTRA_PATH)
// Let the presenter handle falling back to the current WebView path
// when no explicit navigation path is set. See https://github.com/home-assistant/android/issues/4983
var path: String? = intentPath
if (intentPath?.startsWith("entityId:") == true) {
// Get the entity ID from a string formatted "entityId:domain.entity"
// https://github.com/home-assistant/core/blob/dev/homeassistant/core.py#L159
val pattern = "(?<=^entityId:)((?!.+__)(?!_)[\\da-z_]+(?<!_)\\.(?!_)[\\da-z_]+(?<!_)$)".toRegex()
val entity = pattern.find(path)?.value ?: ""
val entity = pattern.find(intentPath)?.value ?: ""
if (
entity.isNotBlank() &&
serverManager.getServer(presenter.getActiveServer())?.version?.isAtLeast(2025, 6, 0) == true
) {
path = "/?more-info-entity-id=$entity"
moreInfoEntity = ""
Comment thread
Gifford47 marked this conversation as resolved.
Outdated
} else {
moreInfoEntity = entity
}
Expand All @@ -1580,6 +1610,10 @@ class WebViewActivity :
}
}

override fun getCurrentWebViewRelativeUrl(): String? {
return webView.url?.toUri()?.toRelativeUrl(excludeParams = setOf("external_auth"))
Comment thread
TimoPtr marked this conversation as resolved.
}
Comment thread
Gifford47 marked this conversation as resolved.

override suspend fun unlockAppIfNeeded() {
appLocked.value = presenter.isAppLocked()
if (appLocked.value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,39 @@ 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 ->
Comment thread
Gifford47 marked this conversation as resolved.
val shouldConsumePath = !pathConsumed && path != null
if (shouldConsumePath) pathConsumed = true
val currentBaseUrl = (urlState as? UrlState.HasUrl)?.url
val baseUrlChanged = lastBaseUrl != null && currentBaseUrl != null && lastBaseUrl != currentBaseUrl
Comment thread
Gifford47 marked this conversation as resolved.
Outdated
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.
withContext(Dispatchers.Main) { view.getCurrentWebViewRelativeUrl() }
Comment thread
jpelgrom marked this conversation as resolved.
Outdated
} else {
null
}
Comment thread
Gifford47 marked this conversation as resolved.

handleUrlState(
urlState = urlState,
path = path,
shouldConsumePath = shouldConsumePath,
isNewServer = isNewServer,
path = effectiveRelativeUrl,
shouldConsumePath = effectiveRelativeUrl != null,
// Clear history when the base URL changes (e.g. internal <-> external)
// because old URLs in the back stack would be unreachable on the new network.
keepHistory = !(isNewServer || baseUrlChanged),
)
Comment thread
Gifford47 marked this conversation as resolved.
}
}
Expand Down Expand Up @@ -179,13 +198,13 @@ class WebViewPresenterImpl @Inject constructor(
urlState: UrlState,
path: String?,
shouldConsumePath: Boolean,
isNewServer: Boolean,
keepHistory: Boolean,
) {
when (urlState) {
is UrlState.HasUrl -> loadUrl(
baseUrl = urlState.url,
path = if (shouldConsumePath) path else null,
isNewServer = isNewServer,
keepHistory = keepHistory,
)

UrlState.InsecureState -> view.showBlockInsecure(serverId = serverId)
Expand All @@ -200,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 isNewServer whether this is a new server (affects history behavior)
* @param keepHistory whether to keep WebView history after loading. False when the
* base URL changes (e.g. server or connection switch) so old entries become unreachable.
*/
private suspend fun loadUrl(baseUrl: URL?, path: String?, isNewServer: Boolean) {
private suspend fun loadUrl(baseUrl: URL?, path: String?, keepHistory: Boolean) {
val urlToLoad = if (path != null && !path.startsWith("entityId:")) {
UrlUtil.handle(baseUrl, path)
} else {
Expand All @@ -225,7 +245,7 @@ class WebViewPresenterImpl @Inject constructor(
} else {
view.loadUrl(
url = urlWithAuth,
keepHistory = !isNewServer,
keepHistory = keepHistory,
openInApp = it.baseIsEqual(baseUrl),
// We need the frontend to notify us of the mode to use for the status bar https://github.com/home-assistant/frontend/issues/29125
serverHandleInsets = false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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

@RunWith(RobolectricTestRunner::class)
class WebViewBackNavigationTest {
Comment thread
Gifford47 marked this conversation as resolved.
Outdated

@Test
fun `given no previous url and root loaded url, when resolving back action, then returns None`() {
Comment thread
Gifford47 marked this conversation as resolved.
Outdated
val loadedUrl = Uri.parse("https://ha.local:8123/?external_auth=1")

val action = resolveBackAction(previousUrl = null, loadedUrl = loadedUrl)

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)

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)

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 `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)

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 `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(previousUrl = previousUrl, loadedUrl = loadedUrl)

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 action = resolveBackAction(previousUrl = previousUrl, loadedUrl = loadedUrl)

assertTrue(action is BackAction.NavigateToRoot)
}

@Test
fun `given non-http previous url, when resolving back action, then does not go back`() {
Comment thread
Gifford47 marked this conversation as resolved.
Outdated
val previousUrl = Uri.parse("about:blank")
val loadedUrl = Uri.parse("https://ha.local:8123/history?external_auth=1")

val action = resolveBackAction(previousUrl = previousUrl, loadedUrl = loadedUrl)

assertTrue(action is BackAction.NavigateToRoot)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -123,6 +127,7 @@ class WebViewPresenterImplTest {
fun setUp() {
mockkStatic(Uri::class)
mockUriParse()
every { webView.getCurrentWebViewRelativeUrl() } returns null
fakeContext = FakeWebViewContext(mockk<Context>(), webView)

lifecycleOwner = object : LifecycleOwner {
Expand Down
Loading
Loading