Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
6b1efcc
Enable Android 14+ predictive-back by gating BackHandler on canGoBack
Gifford47 May 11, 2026
7e46150
Clear navigation back-stack after onboarding completes
Gifford47 May 11, 2026
44ecb1f
Clear WebView history after transitioning out of about:blank
Gifford47 May 11, 2026
5b503cd
HAWebView: restore optional onBackPressed fallback for nav-only screens
Gifford47 May 11, 2026
d2769a8
Clear WebView history across origin changes, not just out of about:blank
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
Expand Up @@ -92,7 +92,10 @@ internal const val CUSTOM_VIEW_OVERLAY_TAG = "custom_view_overlay"
* The WebView is always rendered at the base layer to prevent it to not load the URL.
* Loading indicators, error screens, and blocking screens are overlaid on top.
*
* @param onBackClick Callback when user navigates back
* Back navigation is handled inside [HAWebView] when the WebView has back-stack entries;
* otherwise the gesture falls through to the surrounding `NavHost` (predictive-back
* compatible).
*
* @param viewModel The ViewModel providing state and handling actions
* @param onOpenExternalLink Callback to open external links
* @param onBlockInsecureHelpClick Callback when user taps help on the insecure screen
Expand All @@ -105,7 +108,6 @@ internal const val CUSTOM_VIEW_OVERLAY_TAG = "custom_view_overlay"
*/
@Composable
internal fun FrontendScreen(
onBackClick: () -> Unit,
viewModel: FrontendViewModel,
onOpenExternalLink: suspend (Uri) -> Unit,
onBlockInsecureHelpClick: suspend () -> Unit,
Expand All @@ -121,6 +123,7 @@ internal fun FrontendScreen(
val pendingDialog by viewModel.pendingDialog.collectAsStateWithLifecycle()
val pendingFileChooser by viewModel.pendingFileChooser.collectAsStateWithLifecycle()
val autoPlayVideoEnabled by viewModel.autoPlayVideoEnabled.collectAsStateWithLifecycle()
val canGoBack by viewModel.canGoBack.collectAsStateWithLifecycle()

// The fullscreen View handed over by the WebView is Activity-scoped. Keep it in screen
// state so it does not leak across configuration changes via the ViewModel.
Expand All @@ -143,8 +146,8 @@ internal fun FrontendScreen(
}

FrontendScreenContent(
onBackClick = onBackClick,
viewState = viewState,
canGoBack = canGoBack,
errorStateProvider = viewModel as FrontendConnectionErrorStateProvider,
webViewClient = viewModel.webViewClient,
webChromeClient = webChromeClient,
Expand Down Expand Up @@ -176,11 +179,11 @@ internal fun FrontendScreen(

@Composable
internal fun FrontendScreenContent(
onBackClick: () -> Unit,
viewState: FrontendViewState,
webViewClient: WebViewClient,
webChromeClient: WebChromeClient,
frontendJsCallback: FrontendJsCallback,
canGoBack: Boolean,
onBlockInsecureRetry: () -> Unit,
onOpenExternalLink: suspend (Uri) -> Unit,
onBlockInsecureHelpClick: suspend () -> Unit,
Expand Down Expand Up @@ -230,7 +233,7 @@ internal fun FrontendScreenContent(
Box(modifier = modifier.fillMaxSize()) {
// Always render WebView at base layer
SafeHAWebView(
onBackClick = onBackClick,
canGoBack = canGoBack,
onWebViewCreated = { webView = it },
webViewClient = webViewClient,
webChromeClient = webChromeClient,
Expand Down Expand Up @@ -421,7 +424,7 @@ private fun ErrorOverlay(
*/
@Composable
private fun SafeHAWebView(
onBackClick: () -> Unit,
canGoBack: Boolean,
onWebViewCreated: (WebView) -> Unit,
webViewClient: WebViewClient,
contentState: FrontendViewState.Content?,
Expand Down Expand Up @@ -474,7 +477,7 @@ private fun SafeHAWebView(
autoPlayVideoEnabled = autoPlayVideoEnabled,
)
},
onBackPressed = onBackClick,
canGoBack = canGoBack,
onWebViewCreationFailed = onWebViewCreationFailed,
)

Expand Down Expand Up @@ -668,7 +671,7 @@ private fun ExoPlayerOverlay(contentState: FrontendViewState.Content?, onFullscr
private fun FrontendScreenLoadingPreview() {
HAThemeForPreview {
FrontendScreenContent(
onBackClick = {},
canGoBack = false,
viewState = FrontendViewState.Loading(
serverId = 1,
url = "https://example.com",
Expand All @@ -695,7 +698,7 @@ private fun FrontendScreenLoadingPreview() {
private fun FrontendScreenErrorPreview() {
HAThemeForPreview {
FrontendScreenContent(
onBackClick = {},
canGoBack = false,
viewState = FrontendViewState.Error(
serverId = 1,
url = "https://example.com",
Expand Down Expand Up @@ -727,7 +730,7 @@ private fun FrontendScreenErrorPreview() {
private fun FrontendScreenInsecurePreview() {
HAThemeForPreview {
FrontendScreenContent(
onBackClick = {},
canGoBack = false,
viewState = FrontendViewState.Insecure(
serverId = 1,
missingHomeSetup = true,
Expand Down Expand Up @@ -755,7 +758,7 @@ private fun FrontendScreenInsecurePreview() {
private fun FrontendScreenSecurityLevelRequiredPreview() {
HAThemeForPreview {
FrontendScreenContent(
onBackClick = {},
canGoBack = false,
viewState = FrontendViewState.SecurityLevelRequired(
serverId = 1,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,11 +202,21 @@ internal class FrontendViewModel @VisibleForTesting constructor(
stateProvider = { BridgeState(serverId = viewState.value.serverId, url = viewState.value.url) },
)

private val _canGoBack = MutableStateFlow(false)

/**
* Whether the WebView has a non-empty back history. Used by the UI layer
* to gate its `BackHandler`; when `false`, the system can handle the back
* gesture and show the Android 14+ predictive-back peek animation.
*/
val canGoBack: StateFlow<Boolean> = _canGoBack.asStateFlow()

val webViewClient: HAWebViewClient = webViewClientFactory.create(
currentUrlFlow = urlFlow,
onFrontendError = ::onError,
onCrash = ::onRetry,
onPageFinished = ::onPageFinished,
onCanGoBackChanged = { _canGoBack.value = it },
onReceivedHttpAuthRequest = { handler, host, resource, realm ->
viewModelScope.launch {
if (httpAuthManager.handleAuthRequest(handler, host = host, resource = resource, realm = realm) ==
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ internal fun NavGraphBuilder.frontendScreen(
)

FrontendScreen(
onBackClick = navController::popBackStack,
viewModel = viewModel,
onOpenExternalLink = onOpenExternalLink,
onBlockInsecureHelpClick = onSecurityLevelHelpClick,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@ internal fun ConnectionScreen(onBackClick: () -> Unit, viewModel: ConnectionView
val url by viewModel.urlFlow.collectAsState()
val isLoading by viewModel.isLoadingFlow.collectAsState()
val error by viewModel.errorFlow.collectAsState()
val canGoBack by viewModel.canGoBack.collectAsState()
val isError = error != null

ConnectionScreen(
url = url,
isLoading = isLoading,
isError = isError,
canGoBack = canGoBack,
webViewClient = viewModel.webViewClient,
onBackClick = onBackClick,
onWebViewCreationFailed = viewModel::onWebViewCreationFailed,
Expand All @@ -62,6 +64,7 @@ internal fun ConnectionScreen(
url: String?,
isLoading: Boolean,
isError: Boolean,
canGoBack: Boolean,
webViewClient: WebViewClient,
onBackClick: () -> Unit,
onWebViewCreationFailed: (Throwable) -> Unit,
Expand All @@ -86,6 +89,7 @@ internal fun ConnectionScreen(
this.webViewClient = webViewClient
loadUrl(url)
},
canGoBack = canGoBack,
onBackPressed = onBackClick,
onWebViewCreationFailed = onWebViewCreationFailed,
)
Expand Down Expand Up @@ -126,6 +130,7 @@ private fun ConnectionScreenPreview() {
url = "https://www.home-assistant.io",
isLoading = false,
isError = false,
canGoBack = false,
webViewClient = WebViewClient(),
onBackClick = {},
onWebViewCreationFailed = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,21 @@ internal class ConnectionViewModel @VisibleForTesting constructor(
}
}

private val _canGoBack = MutableStateFlow(false)

/**
* Whether the WebView has a non-empty back history. Used by the UI layer
* to gate its `BackHandler`; when `false`, the system can handle the back
* gesture and show the Android 14+ predictive-back peek animation.
*/
val canGoBack = _canGoBack.asStateFlow()

val webViewClient: HAWebViewClient = webViewClientFactory.create(
currentUrlFlow = urlFlow,
onFrontendError = ::onError,
onUrlIntercepted = ::interceptRedirectIfRequired,
onPageFinished = { _isLoadingFlow.update { false } },
onCanGoBackChanged = { _canGoBack.value = it },
)

init {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import io.homeassistant.companion.android.common.R as commonR
import io.homeassistant.companion.android.common.data.keychain.KeyChainRepository
import io.homeassistant.companion.android.common.data.keychain.NamedKeyChain
import io.homeassistant.companion.android.frontend.error.FrontendConnectionError
import io.homeassistant.companion.android.util.compose.webview.BLANK_URL
import javax.inject.Inject
import kotlinx.coroutines.flow.StateFlow
import timber.log.Timber
Expand All @@ -37,6 +38,11 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC
* Receives the URI and whether TLS client auth was required.
* Return `true` to prevent WebView from loading the URL.
* @param onPageFinished Optional callback when a page finishes loading.
* @param onCanGoBackChanged Optional callback invoked whenever the WebView's
* back-stack state changes (page navigations, SPA history updates).
* Receives [WebView.canGoBack]. Callers can hoist this into a state
* holder to drive a back handler's enabled-state for Android 14+
* predictive-back support.
* @param onReceivedHttpAuthRequest Optional callback when the server requests HTTP Basic Auth.
* Receives the handler, host, the resource URL that triggered the request, and the realm.
*/
Expand All @@ -46,6 +52,7 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC
onCrash: (() -> Unit)? = null,
onUrlIntercepted: ((uri: Uri, isTLSClientAuthNeeded: Boolean) -> Boolean)? = null,
onPageFinished: (() -> Unit)? = null,
onCanGoBackChanged: ((Boolean) -> Unit)? = null,
onReceivedHttpAuthRequest: (
(
handler: HttpAuthHandler,
Expand All @@ -62,6 +69,7 @@ class HAWebViewClientFactory @Inject constructor(@NamedKeyChain private val keyC
onCrash = onCrash,
onUrlIntercepted = onUrlIntercepted,
onPageFinished = onPageFinished,
onCanGoBackChanged = onCanGoBackChanged,
onReceivedHttpAuthRequest = onReceivedHttpAuthRequest,
)
}
Expand All @@ -80,6 +88,7 @@ class HAWebViewClient internal constructor(
private val onCrash: (() -> Unit)?,
private val onUrlIntercepted: ((uri: Uri, isTLSClientAuthNeeded: Boolean) -> Boolean)?,
private val onPageFinished: (() -> Unit)?,
private val onCanGoBackChanged: ((Boolean) -> Unit)?,
private val onReceivedHttpAuthRequest: (
(handler: HttpAuthHandler, host: String, resource: String, realm: String) -> Unit
)?,
Expand All @@ -88,14 +97,58 @@ class HAWebViewClient internal constructor(
/** Last resource URL loaded by the WebView, used to identify the resource requesting auth. */
private var lastResourceUrl: String? = null

/**
* Tracks the previously finished page URL so we can detect transitions out
* of the [BLANK_URL] placeholder and drop it from the WebView's back-stack.
*/
private var lastFinishedUrl: String? = null

override fun onLoadResource(view: WebView?, url: String?) {
super.onLoadResource(view, url)
lastResourceUrl = url
}

override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// Clear the WebView back-stack on transitions to a new origin: out of the
// about:blank placeholder (loading / error / security overlays), across
// internal <-> external URL switches on the same server, and across server
// switches. Without this, back would walk into a stale URL that is no
// longer reachable on the current network. Same-origin in-frontend
// navigation (full page loads between content URLs and SPA pushState,
// which doesn't even fire onPageFinished) is unaffected. Transitions
// INTO about:blank are skipped so the back-stack survives an error state
// and remains usable after recovery.
val previous = lastFinishedUrl
if (previous != null && url != null && url != BLANK_URL && originOf(previous) != originOf(url)) {
view?.clearHistory()
}
lastFinishedUrl = url
onPageFinished?.invoke()
notifyCanGoBack(view)
}

/**
* Returns the `scheme://authority` prefix of [url], or the full string when the
* URL is opaque (e.g. `about:blank`). String-based so it works in plain JUnit
* tests without the Android framework or Robolectric.
*/
private fun originOf(url: String): String {
val schemeEnd = url.indexOf("://")
if (schemeEnd == -1) return url
val authorityStart = schemeEnd + 3
val pathStart = url.indexOf('/', authorityStart)
return if (pathStart == -1) url else url.substring(0, pathStart)
}

override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) {
super.doUpdateVisitedHistory(view, url, isReload)
// SPA navigations (history.pushState) only surface here, not in onPageFinished.
notifyCanGoBack(view)
}

private fun notifyCanGoBack(view: WebView?) {
onCanGoBackChanged?.invoke(view?.canGoBack() == true)
}

override fun onReceivedHttpAuthRequest(view: WebView?, handler: HttpAuthHandler?, host: String?, realm: String?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable
import androidx.fragment.app.FragmentActivity
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.navOptions
import io.homeassistant.companion.android.automotive.navigation.carAppActivity
import io.homeassistant.companion.android.automotive.navigation.navigateToCarAppActivity
import io.homeassistant.companion.android.common.util.DisabledLocationHandler
Expand Down Expand Up @@ -68,7 +69,15 @@ internal fun HANavHost(
if (isAutomotive) {
navController.navigateToCarAppActivity()
} else {
navController.navigateToFrontend()
// Frontend is the new root after onboarding completes — clear
// the entire graph back stack so pressing back exits the app
// (and triggers the Android 14+ predictive-back animation)
// instead of returning to the finished onboarding flow.
navController.navigateToFrontend(
navOptions = navOptions {
popUpTo(0) { inclusive = true }
},
)
}
},
urlToOnboard = (startDestination as? OnboardingRoute)?.urlToOnboard,
Expand Down
Loading