Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
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,76 @@
package io.homeassistant.companion.android.util.compose.webview
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

import android.net.Uri
import android.webkit.WebView
import androidx.core.net.toUri
import io.homeassistant.companion.android.util.hasNonRootPath
import io.homeassistant.companion.android.util.hasSameOrigin

/**
* Determines the appropriate back action based on the [WebView]'s back/forward list
* and the current loaded URL.
*
* The resolution logic:
* 1. If the previous back-stack entry is a same-origin HTTP URL, returns
* [BackAction.GoBack] so the user can navigate back normally.
* 2. If there is a previous back-stack entry that is not same-origin HTTP and
* the current URL has a non-root path, returns [BackAction.NavigateToRoot]
* so the user is taken to the home page first.
* 3. Otherwise returns [BackAction.None] — the caller decides what to do
* (e.g. exit the activity, pop the navigation stack, or let the system
* handle back to show the predictive-back animation).
*
* @param webView the WebView whose back/forward list is inspected
* @param loadedUrl the current URL shown in the WebView (as tracked by the caller,
* not necessarily [WebView.getUrl] which can be `about:blank` during loads)
*/
fun resolveBackAction(webView: WebView, loadedUrl: Uri?): BackAction {
val previousUrl = if (webView.canGoBack()) {
val backForwardList = webView.copyBackForwardList()
val previousIndex = backForwardList.currentIndex - 1
if (previousIndex >= 0) {
backForwardList.getItemAtIndex(previousIndex).url.toUri()
} else {
null
}
} else {
null
}
return resolveBackAction(previousUrl, loadedUrl)
}

private fun resolveBackAction(previousUrl: Uri?, loadedUrl: Uri?): BackAction {
if (previousUrl != null &&
loadedUrl != null &&
previousUrl.scheme?.startsWith("http") == true &&
previousUrl.hasSameOrigin(loadedUrl)
) {
return BackAction.GoBack
}

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

return BackAction.None
}

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

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

/** No more back navigation possible — the caller decides what to do. */
data object None : BackAction
}
Comment thread
Gifford47 marked this conversation as resolved.
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 @@ -138,10 +138,13 @@ import io.homeassistant.companion.android.util.OnSwipeListener
import io.homeassistant.companion.android.util.TLSWebViewClient
import io.homeassistant.companion.android.util.applyInsets
import io.homeassistant.companion.android.util.compose.webview.BLANK_URL
import io.homeassistant.companion.android.util.compose.webview.BackAction
import io.homeassistant.companion.android.util.compose.webview.resolveBackAction
import io.homeassistant.companion.android.util.hasNonRootPath
import io.homeassistant.companion.android.util.hasSameOrigin
import io.homeassistant.companion.android.util.isStarted
import io.homeassistant.companion.android.util.sensitive
import io.homeassistant.companion.android.util.toRelativeUrl
import io.homeassistant.companion.android.websocket.WebsocketManager
import io.homeassistant.companion.android.webview.WebView.ErrorType
import io.homeassistant.companion.android.webview.addto.EntityAddToHandler
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 +464 to +466
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,14 @@ 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. 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
Comment thread
Gifford47 marked this conversation as resolved.
presenter.stopScanningForImprov(false)
}
}
Expand Down Expand Up @@ -1559,12 +1587,15 @@ class WebViewActivity :
showSystemUI()
}

var path = intent.getStringExtra(EXTRA_PATH)
if (path?.startsWith("entityId:") == true) {
val intentPath = intent.getStringExtra(EXTRA_PATH)
// Let the presenter handle falling back to the current WebView path
// when no explicit navigation path is set. See https://github.com/home-assistant/android/issues/4983
var path: String? = intentPath
if (intentPath?.startsWith("entityId:") == true) {
// Get the entity ID from a string formatted "entityId:domain.entity"
// https://github.com/home-assistant/core/blob/dev/homeassistant/core.py#L159
val pattern = "(?<=^entityId:)((?!.+__)(?!_)[\\da-z_]+(?<!_)\\.(?!_)[\\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
Expand All @@ -1580,6 +1611,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,40 @@ 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
// baseUrlChanged is only true from the second emission onwards; the first emission
// establishes lastBaseUrl and is therefore not considered a change.
val baseUrlChanged = currentBaseUrl != null && lastBaseUrl?.let { it != currentBaseUrl } == true
if (currentBaseUrl != null) lastBaseUrl = currentBaseUrl

val effectiveRelativeUrl = if (!pathConsumed && path != null) {
pathConsumed = true
path
} else if (baseUrlChanged && !isNewServer) {
// On internal/external URL switches on the same server, preserve the full
// relative URL (path + query params + fragment) so the user stays on the exact
// same page, including filtered views like history with date ranges.
// Skipped for server switches where the path may not exist and would leak
// navigation context from the previous server.
withContext(Dispatchers.Main) { view.getCurrentWebViewRelativeUrl() }
} else {
null
}
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 +199,13 @@ class WebViewPresenterImpl @Inject constructor(
urlState: UrlState,
path: String?,
shouldConsumePath: Boolean,
isNewServer: Boolean,
keepHistory: Boolean,
) {
when (urlState) {
is UrlState.HasUrl -> loadUrl(
baseUrl = urlState.url,
path = if (shouldConsumePath) path else null,
isNewServer = isNewServer,
keepHistory = keepHistory,
)

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