Skip to content

Refactor WebViewActivity path handling#6447

Open
Gifford47 wants to merge 22 commits intohome-assistant:mainfrom
Gifford47:main
Open

Refactor WebViewActivity path handling#6447
Gifford47 wants to merge 22 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

Comment thread app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt Outdated
Comment thread app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt Outdated
Comment thread app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt Outdated
Comment thread app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt Outdated
Comment thread app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt Outdated
Comment thread app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt Outdated
@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
home-assistant[bot]

This comment was marked as outdated.

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

home-assistant[bot]

This comment was marked as outdated.

@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?

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

Yes, I used the agent to thoroughly analyze the logic and review the code. However, the new logic was developed by me and has been tested by me as well. In fact, I spent the past two weeks carefully re-testing everything to ensure it works reliably:

Pure function extraction - Split resolveBackAction into a WebView convenience overload that extracts the previousUrl from the back/forward list, and a pure VisibleForTesting internal function that takes (previousUrl: Uri?, loadedUrl: Uri?). This makes the logic testable without mocking WebView. All tests now call the pure function directly - no more MockK for WebView/WebBackForwardList.

Uri.parse() -> .toUri() - Replaced with the Kotlin extension as suggested.

Exit -> None - Good call, renamed to BackAction.None. The caller decides what to do - the function just says "I can't handle this". Updated all callsites.

clearHistory -> keepHistory - Reverted the rename, now using keepHistory directly to avoid the inverted !clearHistory logic.

presenter.load() vs webView.loadUrl() for NavigateToRoot - I intentionally use webView.loadUrl() here because presenter.load() does a full reconnect cycle (resolve URL, auth check, security level). For NavigateToRoot we're staying on the same server and just changing the path to /, so going through the presenter would add unnecessary overhead and could trigger unwanted side effects like the security level dialog.

@jpelgrom's question about hasNonRootPath in doUpdateVisitedHistory - Sorry I missed this one. The OnBackPressedCallback needs to be enabled not just when canGoBack() is true, but also when the user is on a sub-path like /history. Without this, pressing back on a sub-path with empty history would trigger the system back handler (exit app) instead of first navigating to root. This also keeps the predictive back animation working correctly on Android 14+ - the system needs to know in advance that we'll handle the gesture.

Test naming - Updated all test names to Given/When/Then convention.

Duplicate test - Removed the duplicate "Exit when on root path" test (was identical to "no previous url and root loaded url").

Also added a new test for the about:blank edge case - verifying that a non-HTTP previousUrl doesn't trigger GoBack.

@Gifford47 Gifford47 marked this pull request as ready for review April 12, 2026 15:08
@home-assistant home-assistant Bot requested a review from TimoPtr April 12, 2026 15:08
@Gifford47 Gifford47 requested a review from Copilot April 12, 2026 15:08
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.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Comment on lines +234 to +247
fun Uri.toRelativeUrl(excludeParams: Set<String> = emptySet()): String? {
val path = encodedPath?.takeIf { it.length > 1 } ?: return null

val relativeUrl = Uri.Builder()
.encodedPath(path)
.apply {
queryParameterNames
.filterNot { it in excludeParams }
.flatMap { name -> getQueryParameters(name).map { name to it } }
.forEach { (name, value) -> appendQueryParameter(name, value) }
}
.encodedFragment(encodedFragment)
.build()
.toString()
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

queryParameterNames is a Set, so iteration order isn’t guaranteed; this can make toRelativeUrl() output nondeterministic (and can cause flaky tests or inconsistent navigation URLs) when multiple query params exist. Consider preserving the original query order by parsing encodedQuery (filtering out excluded param names while keeping ordering/encoding), or at minimum sorting parameter names before appending to ensure deterministic output.

Copilot uses AI. Check for mistakes.
Comment thread app/src/main/kotlin/io/homeassistant/companion/android/webview/WebViewActivity.kt Outdated
@TimoPtr TimoPtr marked this pull request as draft April 13, 2026 12:42
- 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
- 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
@Gifford47 Gifford47 marked this pull request as ready for review April 19, 2026 14:46
@home-assistant home-assistant Bot requested a review from TimoPtr April 19, 2026 14:46
- 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
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 wait for @jpelgrom final review before merging this. Also I would like to wait that the current release is on the play store before merging this one.

@Gifford47
Copy link
Copy Markdown
Author

Let's wait for @jpelgrom final review before merging this. Also I would like to wait that the current release is on the play store before merging this one.

Did you already test the feature? On my side everything works as expected. I tested it for some days in my productive environment.

@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Apr 21, 2026

Let's wait for @jpelgrom final review before merging this. Also I would like to wait that the current release is on the play store before merging this one.

Did you already test the feature? On my side everything works as expected. I tested it for some days in my productive environment.

I honestly didn't because I don't have a test setup with internal/external URL. I want to test this once it's merged because we have an internal lane on the play store. I focused on the code itself for now.

@jpelgrom
Copy link
Copy Markdown
Member

The switching now mostly works from a technical point of view and preserves the URL when switching between networks on the same server, and doesn't when switching servers.

However I have two issues:

  1. I never seem to get the 'predictive back animations' anymore, both with the very simple app start>dashboard loads>swipe to go back and variations with more navigation and/or switching networks. Did you test this?

  2. I still find the back behavior 'inserting' a root page after switching networks with a non-root path very odd. This also causes a behavior change for shortcuts which deeplink to a specific dashboard: previously that dashboard was the root (which in this case is correct) so pressing back closes the app - now it adds the / page and requires one more back.

Before:

shortcut-pre.mp4

After (this also shows the animation issue):

shortcut-post.mp4

(I'm using energy as an example as I'm not that much into dashboards myself, but you can imagine people having shortcuts to specific dashboards and/or views who get this extra step now.)

…eToRoot guard

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

I've spent some time thinking about a solution for the keepHistory issue.
My proposal is to keep a separate logical back stack alongside the native one, so we don't depend on the WebView history surviving the switch.

Logic:

  • New WebViewNavigationState holder with logicalBack: ArrayDeque<String>
    and currentLogicalPath: String
  • doUpdateVisitedHistory pushes only when the path component changes.
    This filters out the HA frontend replaceState calls (e.g.
    /history?entity_id=...&start_date=...). Cross-origin URLs and reloads
    are skipped, native history handles those.
  • Base URL change: keep the logical stack, load newBaseUrl + currentLogicalPath,
    then clearHistory(). On isNewServer: also clear the logical stack.
  • resolveBackAction order: canGoBack() -> GoBack, else
    logicalBack.isNotEmpty() -> GoBackLogical(path), else None.
    NavigateToRoot goes away.
  • enabled exposed as StateFlow<Boolean> from the holder, the activity
    collects it. State is correct before the gesture starts.

Fixes both points:

  1. Predictive back works again because enabled updates synchronously
    from the flow, not inside doUpdateVisitedHistory after the load.
  2. Deeplink to /lovelace/energy starts with empty logicalBack and
    currentLogicalPath = "/lovelace/energy", so back closes the app
    directly without the extra / step.

Touched files: new WebViewNavigationState.kt, BackAction.kt (drop
NavigateToRoot, add GoBackLogical), WebViewPresenterImpl.kt (hook
collectUrlStateChanges into the holder), WebViewActivity.kt (collect
the flow, route doUpdateVisitedHistory and back callback through the
holder). Holder is plain Kotlin without view dependencies, so the Compose
rewrite in #6386 can reuse it as-is.

If the direction works for you I'll prepare the changes.

@TimoPtr
Copy link
Copy Markdown
Member

TimoPtr commented Apr 29, 2026

  • doUpdateVisitedHistory

I'm not a big fan to try to keep a secondary history. Especially that if you are deep into the app and switch URL each back would reload the app because the origin would be different, so showing the loader every back.

I invite you to join the discord discussion with the product people to decide where to go with this. Since it's mostly a UX question not really technical.

@jpelgrom
Copy link
Copy Markdown
Member

jpelgrom commented May 4, 2026

Even with the latest changes, predictive back doesn't work at all in my testing. Likely because the history doesn't start with / but that shouldn't matter, my initial dashboard isn't at / and even the default is at /home/overview now.

My WebView has decided to start doing native crashes when changing network somewhat regularly, making testing quite inconvenient. It does seem that when it doesn't crash it is never inserting a navigation to the default dashboard anymore though.

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)

5 participants