Skip to content

Refactor WebViewActivity path handling#6447

Draft
Gifford47 wants to merge 16 commits intohome-assistant:mainfrom
Gifford47:main
Draft

Refactor WebViewActivity path handling#6447
Gifford47 wants to merge 16 commits intohome-assistant:mainfrom
Gifford47:main

Conversation

@Gifford47
Copy link
Copy Markdown

@Gifford47 Gifford47 commented Feb 16, 2026

Summary

This PR refactors how paths are handled in the WebView to ensure navigation keeps the current path and exposes it reliably.

Key Changes

  • Path handling refactor in WebViewPresenterImpl.kt, WebViewActivity.kt, WebView.kt.
  • New method: getCurrentPath() → retrieves the active WebView path.
  • Navigation now preserves paths during internal WebView transitions.

Why

  • Fixes inconsistent path retrieval during navigation and deep link handling.

Impact

  • WebView navigation now maintains the correct path.
  • Activity/Presenter logic decoupled and cleaner.
  • getCurrentPath() can be used wherever current WebView state is needed.

Checklist

  • New or updated tests have been added to cover the changes following the testing guidelines.
  • The code follows the project's code style and best_practices.
  • The changes have been thoroughly tested, and edge cases have been considered.
  • Changes are backward compatible whenever feasible. Any breaking changes are documented in the changelog for users and/or in the code for developers depending on the relevance.

Screenshots

Link to pull request in documentation repositories

User Documentation: home-assistant/companion.home-assistant#

Developer Documentation: home-assistant/developers.home-assistant#

Any other notes

See issue #4983

@Gifford47
Copy link
Copy Markdown
Author

@TimoPtr can you please have a look on it? you have additional webview PRs ... maybe there's conflict between the PRs ...

Copy link
Copy Markdown
Member

@TimoPtr TimoPtr left a comment

Choose a reason for hiding this comment

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

Thanks for your work, did you test it in multiple conditions? Like changing quickly the URL multiple times?

Also yes I'm currently working on making a new version of this WebViewActivity, it is going to take some time but you can already look at how it looks #6386

@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Feb 17, 2026

Also check what is happening when having arguments in the url like some filtered on the history page.

Copilot AI review requested due to automatic review settings February 19, 2026 15:36
Copy link
Copy Markdown

@home-assistant home-assistant bot left a comment

Choose a reason for hiding this comment

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

Hello @Gifford47,

When attempting to inspect the commits of your pull request for CLA signature status among all authors we encountered commit(s) which were not linked to a GitHub account, thus not allowing us to determine their status(es).

The commits that are missing a linked GitHub account are the following:

Unfortunately, we are unable to accept this pull request until this situation is corrected.

Here are your options:

  1. If you had an email address set for the commit that simply wasn't linked to your GitHub account you can link that email now and it will retroactively apply to your commits. The simplest way to do this is to click the link to one of the above commits and look for a blue question mark in a blue circle in the top left. Hovering over that bubble will show you what email address you used. Clicking on that button will take you to your email address settings on GitHub. Just add the email address on that page and you're all set. GitHub has more information about this option in their help center.

  2. If you didn't use an email address at all, it was an invalid email, or it's one you can't link to your GitHub, you will need to change the authorship information of the commit and your global Git settings so this doesn't happen again going forward. GitHub provides some great instructions on how to change your authorship information in their help center.

    • If you only made a single commit you should be able to run
      git commit --amend --author="Author Name <email@address.com>"
      
      (substituting "Author Name" and "email@address.com" for your actual information) to set the authorship information.
    • If you made more than one commit and the commit with the missing authorship information is not the most recent one you have two options:
      1. You can re-create all commits missing authorship information. This is going to be the easiest solution for developers that aren't extremely confident in their Git and command line skills.
      2. You can use this script that GitHub provides to rewrite history. Please note: this should be used only if you are very confident in your abilities and understand its impacts.
    • Whichever method you choose, I will come by to re-check the pull request once you push the fixes to this branch.

We apologize for this inconvenience, especially since it usually bites new contributors to Home Assistant. We hope you understand the need for us to protect ourselves and the great community we all have built legally. The best thing to come out of this is that you only need to fix this once and it benefits the entire Home Assistant and GitHub community.

Thanks, I look forward to checking this PR again soon! ❤️

@home-assistant home-assistant bot marked this pull request as draft February 19, 2026 15:36
@home-assistant
Copy link
Copy Markdown

Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍

Learn more about our pull request process.

@Gifford47
Copy link
Copy Markdown
Author

Gifford47 commented Feb 19, 2026

Thanks for the thorough review @TimoPtr! I've addressed your feedback:

Duplicate path retrieval: Removed getCurrentWebViewPath() from the Activity — the Presenter already handles this fallback in collectUrlStateChanges(), so the Activity now only passes the intentPath (or null).

intent.removeExtra(EXTRA_PATH): Moved before presenter.load() so the extra is consumed immediately.

moreInfoEntity assignment: Fixed the behavioral change — removed the unconditional raw assignment. Now only sets moreInfoEntity to the regex-validated entity for older servers (JS dispatch path), and explicitly clears it when using the URL path approach (>= 2025.6).

Exception handling in getCurrentWebViewPath(): Removed the broad catch (Exception) — Uri.parse() is lenient and doesn't throw, so the try-catch was unnecessary.

Path extraction simplification: Replaced with webView.url?.toUri()?.path?.takeIf { it.length > 1 } as suggested.

Comment reference: Updated to use the full GitHub issue URL.

Regarding URL arguments (e.g. filters on the history page): Uri.path only returns the path component and excludes query parameters, so filtered views like /history?entity_id=sensor.foo will correctly preserve just /history as the path — the frontend will reload with its default state for that view, which is the expected behavior when switching between internal/external URLs.

@Gifford47 Gifford47 marked this pull request as ready for review February 19, 2026 15:40
@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Feb 19, 2026

Regarding URL arguments (e.g. filters on the history page): Uri.path only returns the path component and excludes query parameters, so filtered views like /history?entity_id=sensor.foo will correctly preserve just /history as the path — the frontend will reload with its default state for that view, which is the expected behavior when switching between internal/external URLs.

Actually it might be nice to keep the whole URI and just change the host/port imagine you are on
http://192.168.15.6:8123/history?start_date=2026-02-19T11%3A00%3A00.000Z&end_date=2026-02-19T14%3A00%3A00.000Z

It would be nice to only change the host/port 192.168.15.6:8123 to the other URL to keep exactly where you are.

@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Feb 19, 2026

The issue would be that most probably you are going to loose the history and if it is not the case I wonder what happens. Did you try to play with the app to see how it behaves for the history?

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown

@home-assistant home-assistant bot left a comment

Choose a reason for hiding this comment

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

Hello @Gifford47,

When attempting to inspect the commits of your pull request for CLA signature status among all authors we encountered commit(s) which were not linked to a GitHub account, thus not allowing us to determine their status(es).

The commits that are missing a linked GitHub account are the following:

Unfortunately, we are unable to accept this pull request until this situation is corrected.

Here are your options:

  1. If you had an email address set for the commit that simply wasn't linked to your GitHub account you can link that email now and it will retroactively apply to your commits. The simplest way to do this is to click the link to one of the above commits and look for a blue question mark in a blue circle in the top left. Hovering over that bubble will show you what email address you used. Clicking on that button will take you to your email address settings on GitHub. Just add the email address on that page and you're all set. GitHub has more information about this option in their help center.

  2. If you didn't use an email address at all, it was an invalid email, or it's one you can't link to your GitHub, you will need to change the authorship information of the commit and your global Git settings so this doesn't happen again going forward. GitHub provides some great instructions on how to change your authorship information in their help center.

    • If you only made a single commit you should be able to run
      git commit --amend --author="Author Name <email@address.com>"
      
      (substituting "Author Name" and "email@address.com" for your actual information) to set the authorship information.
    • If you made more than one commit and the commit with the missing authorship information is not the most recent one you have two options:
      1. You can re-create all commits missing authorship information. This is going to be the easiest solution for developers that aren't extremely confident in their Git and command line skills.
      2. You can use this script that GitHub provides to rewrite history. Please note: this should be used only if you are very confident in your abilities and understand its impacts.
    • Whichever method you choose, I will come by to re-check the pull request once you push the fixes to this branch.

We apologize for this inconvenience, especially since it usually bites new contributors to Home Assistant. We hope you understand the need for us to protect ourselves and the great community we all have built legally. The best thing to come out of this is that you only need to fix this once and it benefits the entire Home Assistant and GitHub community.

Thanks, I look forward to checking this PR again soon! ❤️

@home-assistant home-assistant bot marked this pull request as draft February 21, 2026 14:42
@Gifford47 Gifford47 marked this pull request as ready for review February 21, 2026 14:48
@Gifford47
Copy link
Copy Markdown
Author

Changes

  1. Preserve full relative URL on network switches (WebViewPresenterImpl.kt, WebView.kt, WebViewActivity.kt)

Added getCurrentWebViewRelativeUrl() which extracts the full relative URL (path + query parameters + fragment) from the current WebView URL, stripping the external_auth parameter since the presenter re-adds it on every load.
In collectUrlStateChanges, subsequent urlFlow emissions now read the current WebView URL instead of only using the initial path parameter. This ensures the user stays on the exact same page (e.g. /history?entity_id=...&start_date=...) after a network switch.

  1. Clear WebView history on base URL changes (WebViewPresenterImpl.kt)

Added lastBaseUrl tracking in collectUrlStateChanges to detect when the base URL changes (internal ↔ external).
When a base URL change is detected, isNewServer is set to true, which triggers keepHistory = false and clears the WebView's navigation history. This prevents the back button from navigating to unreachable URLs from the old connection.

  1. Safe back navigation after URL switches (WebViewActivity.kt)

After history is cleared, rapid urlFlow emissions can create stale history entries from the old connection. The back button handler now validates the origin of the previous history entry using WebBackForwardList before navigating back.
If the previous entry has a different origin (stale entry), the handler navigates to the base URL instead of attempting to load an unreachable page.
If the user is already on the root path, back exits the app as expected.
doUpdateVisitedHistory keeps the back callback enabled when on a non-root path, so pressing back navigates to root before exiting.

Tested:

WiFi → mobile data switch on Lovelace tabs: URL switches correctly, page preserved
WiFi → mobile data switch on history page with query parameters (/history?entity_id=...&start_date=...): page and filters preserved
Mobile data → WiFi switch: same behavior, page preserved
Back button after switch: navigates to base URL (no "cannot connect" error)
Back button on base URL: exits app correctly
Multiple rapid switches: no infinite loading loops

@Gifford47
Copy link
Copy Markdown
Author

@TimoPtr all tests are successfully done. Do you have any hints?

Gifford47 and others added 9 commits March 2, 2026 20:00
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
- 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
Restore original placement to keep git history clean, as requested
by TimoPtr.
…tyle

- 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
@Gifford47
Copy link
Copy Markdown
Author

Gifford47 commented Mar 7, 2026

I'm using this logic now for quite a while and everything works as expected. Any suggestions @TimoPtr ?

@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Mar 18, 2026

I need to play with it to see how it behaves, I didn't have time to do this yet. @jpelgrom I think this is also ready for you to look at. I'm mostly curious about the back behavior while changing URLs.

We will also need to port this change to the FrontendScreen @Gifford47 that is the new way to show the Frontend, this is only visible in debug.

Copy link
Copy Markdown
Member

@jpelgrom jpelgrom left a comment

Choose a reason for hiding this comment

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

The path is preserved when switching servers, which should not happen as the path may not exist on the other server (resulting in a broken page) and it leaks information about the initial server to the other server.

// 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
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.

Why add this?

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.

You didn't answer.

@home-assistant home-assistant bot marked this pull request as draft March 22, 2026 19:46
@Gifford47
Copy link
Copy Markdown
Author

Thanks for the thorough review @jpelgrom, all great catches!

Path leaking on server switch — I'll guard the getCurrentWebViewRelativeUrl() fallback so it only applies on internal/external URL changes (baseUrlChanged), not on actual server switches where isNewServer is true.

OnBackPressedCallback(true) — You're right, losing predictive back animations isn't ideal. I'll extract a canHandleBack() function that returns canGoBack() || hasNonRootPath() and use that for the initial enabled state + the doUpdateVisitedHistory update. That way we keep the adaptive behavior.

doUpdateVisitedHistory — This is needed so that pressing back navigates to root first even when there's no WebView history. The canHandleBack() extraction should make this clearer.

entityId comment — Will restore the original comment with the core.py link.

KDoc — Agreed, I'll make it more generic since it's a util function.

Test name — You're right, the name is misleading. Will rename to something like toRelativeUrl strips excluded params leaving path only.

- 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
Copy link
Copy Markdown
Member

@TimoPtr TimoPtr left a comment

Choose a reason for hiding this comment

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

Let's check my last comments so that @jpelgrom can take a look after to merge this.

- 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.
Resolve import conflict: keep BackAction/resolveBackAction imports
alongside new BLANK_URL import from upstream.
@Gifford47 Gifford47 marked this pull request as ready for review March 25, 2026 19:21
@home-assistant home-assistant bot requested review from TimoPtr and jpelgrom March 25, 2026 19:22
@@ -0,0 +1,74 @@
package io.homeassistant.companion.android.util.compose.webview

Check failure

Code scanning / ktlint

File 'WebViewBackNavigation.kt' contains a single class and possibly also extension functions for that class and should be named same after that class 'BackAction.kt' Error

File 'WebViewBackNavigation.kt' contains a single class and possibly also extension functions for that class and should be named same after that class 'BackAction.kt'
@@ -0,0 +1,74 @@
package io.homeassistant.companion.android.util.compose.webview

Check failure

Code scanning / ktlint

File 'WebViewBackNavigation.kt' contains a single class and possibly also extension functions for that class and should be named same after that class 'BackAction.kt' Error

File 'WebViewBackNavigation.kt' contains a single class and possibly also extension functions for that class and should be named same after that class 'BackAction.kt'
* 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.
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.

I'm lost ... What you want is not to go back to root no? it is to preserve the path and param no?

I think it's time to drop your AI agent and start to manually look at the logic.

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.

Ok got it ... I was lost after looking at this way too many time already. The preserve path and param is only when changing URL not when going back.

That might be something we could consider actually too. (In another PR)

I still think that to get this final step you should drop your AI agent and do it manually it's quite sensitive.

Copy link
Copy Markdown
Member

@TimoPtr TimoPtr left a comment

Choose a reason for hiding this comment

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

We're almost there

Comment on lines +65 to +74
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
}
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.

I'm not a big fan of the Exit action it seems too precise, and set the action for the screen itself that could decide to do something else. What do you think about None that let the caller decide what to do with it.

Comment on lines +30 to +32
val previousUrl = Uri.parse(
backForwardList.getItemAtIndex(currentIndex - 1).url,
)
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.

Suggested change
val previousUrl = Uri.parse(
backForwardList.getItemAtIndex(currentIndex - 1).url,
)
val previousUrl = backForwardList.getItemAtIndex(currentIndex - 1).url.toUri()

* 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 {
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.

What about not giving the webView to this function directly, so no one actually call webView.goBack() from this function since it is theoretically possible. Something like

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)
}
@VisibleForTesting
internal fun resolveBackAction(previousUrl: Uri?, loadedUrl: Uri?): BackAction {
    if (previousUrl != null) {

This way the logic is in a pure function easy to test even without mocking the WebView.

Comment on lines +453 to +455
clearHistory = true
loadedUrl = action.rootUrl
webView.loadUrl(action.rootUrl.toString())
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.

// 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
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.

You didn't answer.

view.loadUrl(
url = urlWithAuth,
keepHistory = !isNewServer,
keepHistory = !clearHistory,
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.

I made a mistake in my last comment, we could use keepHistory just to avoid having to inverse the logic.

}

@Test
fun `resolveBackAction returns Exit when no history and root URL`() {
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.

Update the titles to fit the other test in the project

}

@Test
fun `resolveBackAction returns Exit when on root path`() {
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.

Same as funresolveBackAction returns Exit when no history and root URL() { no?

@home-assistant home-assistant bot marked this pull request as draft March 26, 2026 10:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Load last showed dashboard view after changing connection (internal/external)

4 participants