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 new file mode 100644 index 00000000000..b4cf99375c8 --- /dev/null +++ b/app/src/main/kotlin/io/homeassistant/companion/android/util/compose/webview/BackAction.kt @@ -0,0 +1,76 @@ +package io.homeassistant.companion.android.util.compose.webview + +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 +} 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..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,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) 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 0ab5f02cde8..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 @@ -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 @@ -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()) + } + 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 + } + } } } @@ -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 presenter.stopScanningForImprov(false) } } @@ -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_]+(? - 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 + } 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, ) } } @@ -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) @@ -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 { @@ -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, 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..677944fcc01 --- /dev/null +++ b/app/src/test/kotlin/io/homeassistant/companion/android/util/compose/webview/WebViewBackNavigationTest.kt @@ -0,0 +1,223 @@ +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 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 + +class WebViewBackNavigationTest { + + @BeforeEach + fun setUp() { + mockkStatic(Uri::class) + every { Uri.parse(any()) } answers { createMockUri(firstArg()) } + } + + @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 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 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"), + ) + + assertEquals(BackAction.None, action) + } + + @Test + 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"), + ) + + assertEquals(BackAction.None, action) + } + + @Test + fun `Given same-origin previous url when resolving back action then returns GoBack`() { + val webView = webViewWithHistory("https://ha.local:8123/lovelace/0") + + 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 webView = webViewWithHistory("https://other.server:8123/lovelace/0") + + 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 + fun `Given non-http previous url when resolving back action then returns NavigateToRoot`() { + val webView = webViewWithHistory("about:blank") + + 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) + } + } + } +} 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..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 @@ -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) @@ -123,6 +127,7 @@ class WebViewPresenterImplTest { fun setUp() { mockkStatic(Uri::class) mockUriParse() + every { webView.getCurrentWebViewRelativeUrl() } returns null fakeContext = FakeWebViewContext(mockk(), webView) lifecycleOwner = object : LifecycleOwner { @@ -410,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) 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 6769c2b9b1a..bbe034da2a7 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 @@ -220,3 +220,31 @@ 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]. + * + * 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 { + queryParameterNames + .filterNot { it in excludeParams } + .flatMap { name -> getQueryParameters(name).map { name to it } } + .forEach { (name, value) -> 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..7237afe9ca8 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 strips excluded params leaving path only`() { + 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()) + } }