From 2b59c35e769d1f0acb00b2f402a7f0629829ce3c Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:51:44 +0200 Subject: [PATCH 1/5] Filter out messages not coming from the frontend external bus --- .../android/webview/WebViewActivity.kt | 555 +++++++++++------- 1 file changed, 356 insertions(+), 199 deletions(-) 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 828a5d91af0..b5ee003faab 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 @@ -175,6 +175,18 @@ class WebViewActivity : const val EXTRA_SERVER = "server" const val EXTRA_SHOW_WHEN_LOCKED = "show_when_locked" + private const val EXTERNAL_APP_V1 = "externalApp" + private const val EXTERNAL_APP_V2_LISTENER = "externalAppV2" + + /** + * Expected callback names for external authentication. + * + * The frontend sends a dynamic callback name in its requests, but the app + * only allows these hardcoded values. + */ + private const val GET_AUTH_CALLBACK = "externalAuthSetToken" + private const val REVOKE_AUTH_CALLBACK = "externalAuthRevokeToken" + private const val APP_PREFIX = "app://" private const val INTENT_PREFIX = "intent:" private const val MARKET_PREFIX = "https://play.google.com/store/apps/details?id=" @@ -317,7 +329,6 @@ class WebViewActivity : private var downloadFileUrl = "" private var downloadFileContentDisposition = "" private var downloadFileMimetype = "" - private val javascriptInterface = "externalApp" private var serverHandleInsets = mutableStateOf(false) private val snackbarHostState = SnackbarHostState() @@ -563,7 +574,9 @@ class WebViewActivity : Timber.e("onRenderProcessGone: webView crashed") view?.let { reload() - webViewAddJavascriptInterface() + lifecycleScope.launch { + webViewAddJavascriptInterface() + } } return true @@ -730,8 +743,6 @@ class WebViewActivity : super.onHideCustomView() } } - - webViewAddJavascriptInterface() } val cookieManager = CookieManager.getInstance() @@ -797,14 +808,17 @@ class WebViewActivity : val message = when (it) { MatterThreadStep.ERROR_MATTER_CANCELLED -> commonR.string.matter_commissioning_cancelled + MatterThreadStep.ERROR_MATTER_OTHER -> commonR.string.matter_commissioning_unavailable + MatterThreadStep.ERROR_THREAD_OTHER -> commonR.string.thread_export_unavailable } val uri = when (it) { MatterThreadStep.ERROR_MATTER_CANCELLED -> "https://www.home-assistant.io/integrations/matter#troubleshooting" + MatterThreadStep.ERROR_MATTER_OTHER, MatterThreadStep.ERROR_THREAD_OTHER, -> @@ -841,196 +855,323 @@ class WebViewActivity : } } - private fun webViewAddJavascriptInterface() { - webView.apply { - removeJavascriptInterface(javascriptInterface) - addJavascriptInterface( - object : Any() { - // TODO This feature is deprecated and should be removed after 2022.6 - @JavascriptInterface - fun onHomeAssistantSetTheme() { - // We need to launch the getAndSetStatusBarNavigationBarColors in another thread, because otherwise the evaluateJavascript inside the method - // will not trigger it's callback method :/ - lifecycleScope.launch(Dispatchers.Main) { - getAndSetStatusBarNavigationBarColors() - } + /** + * Registers the appropriate native bridge for the current server. + * + * Queries [isServerSupportingExternalAppV2] to determine whether to use the `externalAppV2` bridge or the legacy + * `externalApp` bridge. V2 also requires [WebViewFeature.WEB_MESSAGE_LISTENER] support; + * falls back to V1 if the feature is unavailable. + * + * Both directions are safe: V1 uses [android.webkit.WebView.removeJavascriptInterface] and + * V2 uses [WebViewCompat.removeWebMessageListener]. + */ + @SuppressLint("RequiresFeature") + private suspend fun webViewAddJavascriptInterface() { + if (isServerSupportingExternalAppV2() && + WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) + ) { + webView.removeJavascriptInterface(EXTERNAL_APP_V1) + registerExternalAppV2() + } else { + if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { + WebViewCompat.removeWebMessageListener(webView, EXTERNAL_APP_V2_LISTENER) + } + registerExternalAppV1() + } + } + + /** + * Registers the legacy `externalApp` bridge using [android.webkit.WebView.addJavascriptInterface]. + * + * The HA frontend detects `window.externalApp` and calls named methods directly. + * Re-registration is safe because the old interface is removed first. + */ + private fun registerExternalAppV1() { + webView.removeJavascriptInterface(EXTERNAL_APP_V1) + webView.addJavascriptInterface( + object : Any() { + @JavascriptInterface + fun getExternalAuth(payload: String) { + handleGetExternalAuth(payload = payload.toJsonObjectOrNull()) + } + + @JavascriptInterface + fun revokeExternalAuth(callback: String) { + handleRevokeExternalAuth(payload = callback.toJsonObjectOrNull()) + } + + @JavascriptInterface + fun externalBus(message: String) { + Timber.d("External bus $message") + webView.post { + val json = message.toJsonObjectOrNull() ?: return@post + handleExternalBusMessage(json) } + } + }, + EXTERNAL_APP_V1, + ) + } - @JavascriptInterface - fun getExternalAuth(payload: String) { - payload.toJsonObjectOrNull().let { - presenter.onGetExternalAuth( - this@WebViewActivity, - it?.getStringOrNull("callback") ?: "", - it?.getBooleanOrNull("force") ?: false, - ) - } + /** + * Registers the `externalAppV2` bridge using [WebViewCompat.addWebMessageListener]. + * + * The HA frontend detects `window.externalAppV2.postMessage` and sends all messages + * through it with a `type` discriminator. Messages are rejected if they come from + * an iframe or from an origin that doesn't match the currently loaded server URL. + */ + @SuppressLint("RequiresFeature") + private fun registerExternalAppV2() { + WebViewCompat.removeWebMessageListener(webView, EXTERNAL_APP_V2_LISTENER) + WebViewCompat.addWebMessageListener( + webView, + EXTERNAL_APP_V2_LISTENER, + setOf("*"), + ) { _, message, sourceOrigin, isMainFrame, _ -> + if (!isMainFrame) { + Timber.w("Ignored message from iframe") + return@addWebMessageListener + } + if (!sourceOrigin.hasSameOrigin(loadedUrl)) { + Timber.w("Ignored message from unexpected origin: $sourceOrigin") + return@addWebMessageListener + } + + val data = message.data ?: return@addWebMessageListener + val json = data.toJsonObjectOrNull() ?: return@addWebMessageListener + val type = json.getStringOrNull("type") ?: return@addWebMessageListener + val payload = json["payload"]?.jsonObjectOrNull() + + when (type) { + "getExternalAuth" -> { + handleGetExternalAuth(payload) + } + + "revokeExternalAuth" -> { + handleRevokeExternalAuth(payload) + } + + "externalBus" -> { + if (payload == null) { + Timber.w("externalBus message missing payload") + return@addWebMessageListener + } + Timber.d("External bus $payload") + webView.post { + handleExternalBusMessage(payload) } + } + + else -> Timber.w("Unknown bridge message type: $type") + } + } + } + + /** + * Validates and handles a `getExternalAuth` request from either V1 or V2 bridge. + * + * Rejects requests whose callback name does not match the expected [GET_AUTH_CALLBACK]. + */ + private fun handleGetExternalAuth(payload: JsonObject?) { + val callback = payload?.getStringOrNull("callback") ?: "" + if (callback == GET_AUTH_CALLBACK) { + presenter.onGetExternalAuth( + this, + callback, + force = payload?.getBooleanOrNull("force") ?: false, + ) + } else { + Timber.w("Aborting getExternalAuth callback is not the one expected ($GET_AUTH_CALLBACK)") + } + } + + /** + * Validates and handles a `revokeExternalAuth` request from either V1 or V2 bridge. + * + * Rejects requests whose callback name does not match the expected [REVOKE_AUTH_CALLBACK]. + */ + private fun handleRevokeExternalAuth(payload: JsonObject?) { + val callback = payload?.getStringOrNull("callback") ?: "" + if (callback == REVOKE_AUTH_CALLBACK) { + presenter.onRevokeExternalAuth(callback) + isRelaunching = true + } else { + Timber.w("Aborting revokeExternalAuth callback is not the one expected ($REVOKE_AUTH_CALLBACK)") + } + } + + /** + * Handles an external bus message received from the frontend via the native bridge. + */ + private fun handleExternalBusMessage(json: JsonObject) { + when (json.getStringOrNull("type")) { + "connection-status" -> { + isConnected = json["payload"]?.jsonObjectOrNull() + ?.getStringOrNull("event") == "connected" + if (isConnected) { + alertDialog?.cancel() + presenter.checkSecurityVersion() + } + } - @JavascriptInterface - fun revokeExternalAuth(callback: String) { - presenter.onRevokeExternalAuth(callback.toJsonObjectOrNull()?.getStringOrNull("callback") ?: "") - isRelaunching = true // Prevent auth errors from showing + "config/get" -> { + val pm: PackageManager = this@WebViewActivity.packageManager + val hasNfc = pm.hasSystemFeature(PackageManager.FEATURE_NFC) + val canCommissionMatter = presenter.appCanCommissionMatterDevice() + val canExportThread = presenter.appCanExportThreadCredentials() + val hasBarCodeScanner = + if ( + pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) && + !isAutomotive() + ) { + 1 + } else { + 0 } + sendExternalBusMessage( + ExternalConfigResponse( + id = json["id"], + hasNfc = hasNfc, + canCommissionMatter = canCommissionMatter, + canExportThread = canExportThread, + hasBarCodeScanner = hasBarCodeScanner, + appVersion = appVersionProvider(), + ), + ) - @JavascriptInterface - fun externalBus(message: String) { - Timber.d("External bus $message") - webView.post { - val json = message.toJsonObjectOrNull() ?: return@post - when (json.getStringOrNull("type")) { - "connection-status" -> { - isConnected = json["payload"]?.jsonObjectOrNull() - ?.getStringOrNull("event") == "connected" - if (isConnected) { - alertDialog?.cancel() - presenter.checkSecurityVersion() - } - } + // TODO This feature is deprecated and should be removed after 2022.6 + getAndSetStatusBarNavigationBarColors() - "config/get" -> { - val pm: PackageManager = context.packageManager - val hasNfc = pm.hasSystemFeature(PackageManager.FEATURE_NFC) - val canCommissionMatter = presenter.appCanCommissionMatterDevice() - val canExportThread = presenter.appCanExportThreadCredentials() - val hasBarCodeScanner = - if ( - pm.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) && - !isAutomotive() - ) { - 1 - } else { - 0 - } - sendExternalBusMessage( - ExternalConfigResponse( - id = json["id"], - hasNfc = hasNfc, - canCommissionMatter = canCommissionMatter, - canExportThread = canExportThread, - hasBarCodeScanner = hasBarCodeScanner, - appVersion = appVersionProvider(), - ), - ) + // TODO This feature is deprecated and should be removed after 2022.6 + // Set event listener for HA theme change + lifecycleScope.launch { + val themeCallback = externalBusCall("{type:'onHomeAssistantSetTheme'}") + webView.evaluateJavascript( + "document.addEventListener('settheme', function (){$themeCallback});", + null, + ) + } + } - // TODO This feature is deprecated and should be removed after 2022.6 - getAndSetStatusBarNavigationBarColors() - - // TODO This feature is deprecated and should be removed after 2022.6 - // Set event lister for HA theme change - webView.evaluateJavascript( - "document.addEventListener('settheme', function ()" + - "{" + - "window.externalApp.onHomeAssistantSetTheme();" + - "});", - null, - ) - } + "assist/show" -> { + val payload = json["payload"]?.jsonObjectOrNull() + startActivity( + AssistActivity.newInstance( + this@WebViewActivity, + serverId = presenter.getActiveServer(), + pipelineId = payload?.getStringOrNull("pipeline_id"), + startListening = payload?.getBooleanOrNull("start_listening") ?: true, + ), + ) + } - "assist/show" -> { - val payload = json["payload"]?.jsonObjectOrNull() - startActivity( - AssistActivity.newInstance( - this@WebViewActivity, - serverId = presenter.getActiveServer(), - pipelineId = payload?.getStringOrNull("pipeline_id"), - startListening = payload?.getBooleanOrNull("start_listening") ?: true, - ), - ) - } + "assist/settings" -> startActivity( + SettingsActivity.newInstance( + this@WebViewActivity, + SettingsActivity.Deeplink.AssistSettings, + ), + ) - "assist/settings" -> startActivity( - SettingsActivity.newInstance( - this@WebViewActivity, - SettingsActivity.Deeplink.AssistSettings, - ), - ) + "config_screen/show" -> + startActivity( + SettingsActivity.newInstance(this@WebViewActivity), + ) - "config_screen/show" -> - startActivity( - SettingsActivity.newInstance(this@WebViewActivity), - ) + "tag/write" -> + writeNfcTag.launch( + WriteNfcTag.Input( + tagId = json["payload"]?.jsonObjectOrNull()?.getStringOrNull("tag"), + messageId = json.getIntOrElse("id", -1), + ), + ) - "tag/write" -> - writeNfcTag.launch( - WriteNfcTag.Input( - tagId = json["payload"]?.jsonObjectOrNull()?.getStringOrNull("tag"), - messageId = json.getIntOrElse("id", -1), - ), - ) + "matter/commission" -> presenter.startCommissioningMatterDevice(this@WebViewActivity) + "thread/import_credentials" -> { + presenter.exportThreadCredentials(this@WebViewActivity) - "matter/commission" -> presenter.startCommissioningMatterDevice(this@WebViewActivity) - "thread/import_credentials" -> { - presenter.exportThreadCredentials(this@WebViewActivity) + alertDialog = AlertDialog.Builder(this@WebViewActivity) + .setMessage(commonR.string.thread_debug_active) + .create() + alertDialog?.show() + } - alertDialog = AlertDialog.Builder(this@WebViewActivity) - .setMessage(commonR.string.thread_debug_active) - .create() - alertDialog?.show() - } + "bar_code/scan" -> { + val payload = json["payload"]?.jsonObjectOrNull() + if (payload?.containsKey("title") != true || + !payload.containsKey("description") + ) { + return + } + startActivity( + BarcodeScannerActivity.newInstance( + this@WebViewActivity, + messageId = json.getIntOrElse("id", 0), + title = payload.getStringOrElse("title", ""), + subtitle = payload.getStringOrElse("description", ""), + action = payload.getStringOrNull("alternative_option_label")?.ifBlank { + null + }, + ), + ) + } - "bar_code/scan" -> { - val payload = json["payload"]?.jsonObjectOrNull() - if (payload?.containsKey("title") != true || - !payload.containsKey("description") - ) { - return@post - } - startActivity( - BarcodeScannerActivity.newInstance( - this@WebViewActivity, - messageId = json.getIntOrElse("id", 0), - title = payload.getStringOrElse("title", ""), - subtitle = payload.getStringOrElse("description", ""), - action = payload.getStringOrNull("alternative_option_label")?.ifBlank { - null - }, - ), - ) - } + "improv/scan" -> scanForImprov() + "improv/configure_device" -> { + val payload = json["payload"]?.jsonObjectOrNull() + val deviceName = payload?.getStringOrNull("name") ?: return + configureImprovDevice(deviceName) + } - "improv/scan" -> scanForImprov() - "improv/configure_device" -> { - val payload = json["payload"]?.jsonObjectOrNull() - val deviceName = payload?.getStringOrNull("name") ?: return@post - configureImprovDevice(deviceName) - } + "exoplayer/play_hls" -> exoPlayHls(json) + "exoplayer/stop" -> exoStopHls() + "exoplayer/resize" -> exoResizeHls(json) + "haptic" -> processHaptic( + json["payload"]?.jsonObjectOrNull()?.getStringOrNull("hapticType") ?: "", + ) - "exoplayer/play_hls" -> exoPlayHls(json) - "exoplayer/stop" -> exoStopHls() - "exoplayer/resize" -> exoResizeHls(json) - "haptic" -> processHaptic( - json["payload"]?.jsonObjectOrNull()?.getStringOrNull("hapticType") ?: "", - ) + "theme-update" -> getAndSetStatusBarNavigationBarColors() + "entity/add_to/get_actions" -> getActions(json) + "entity/add_to" -> addEntityTo(json) - "theme-update" -> getAndSetStatusBarNavigationBarColors() - "entity/add_to/get_actions" -> getActions(json) - "entity/add_to" -> addEntityTo(json) + "onHomeAssistantSetTheme" -> { + lifecycleScope.launch(Dispatchers.Main) { + getAndSetStatusBarNavigationBarColors() + } + } - else -> presenter.onExternalBusMessage(json) - } - } + "handleBlob" -> { + val blobData = json.getStringOrNull("data") + val filename = json.getStringOrNull("filename") + blobData?.let { + lifecycleScope.launch { + DataUriDownloadManager.saveDataUri( + this@WebViewActivity, + url = it, + mimetype = "", + filename = filename, + ) } + } + } - @JavascriptInterface - fun handleBlob(data: String?, filename: String?) { - data?.let { - lifecycleScope.launch { - DataUriDownloadManager.saveDataUri( - this@WebViewActivity, - url = data, - mimetype = "", - filename = filename, - ) - } - } - } - }, - javascriptInterface, - ) + else -> presenter.onExternalBusMessage(json) } } + /** + * Returns a JS snippet that sends [jsonPayload] through the external bus. + * + * For V1: calls `window.externalApp.externalBus(...)` directly. + * For V2: posts a `{type:'externalBus', payload:...}` message via `window.externalAppV2`. + */ + private fun externalBusCall(jsonPayload: String): String = """ + if (typeof window.$EXTERNAL_APP_V2_LISTENER !== 'undefined') { + window.$EXTERNAL_APP_V2_LISTENER.postMessage(JSON.stringify({type:'externalBus',payload:$jsonPayload})); + } else { + window.$EXTERNAL_APP_V1.externalBus(JSON.stringify($jsonPayload)); + } + """.trimIndent() + private fun addEntityTo(json: JsonObject) { val payload = json["payload"]?.jsonObjectOrNull() val entityId = payload?.getStringOrNull("entity_id") @@ -1530,35 +1671,46 @@ class WebViewActivity : } (keepHistory $keepHistory, openInApp $openInApp, serverHandleInsets $serverHandleInsets)", ) this.serverHandleInsets.value = serverHandleInsets - if (openInApp) { - runFragmentTransactionIfStateSafe { - // Remove any displayed fragments (e.g., BlockInsecureFragment, ConnectionSecurityLevelFragment) - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } - supportFragmentManager.clearFragmentResultListener(BlockInsecureFragment.RESULT_KEY) - - val oldUrl = loadedUrl - // It means that if we loaded an URL with a path previously and we try to load the same URL without - // a path we don't do anything. - val shouldLoadUrl = !url.hasSameOrigin(oldUrl) || url.hasNonRootPath() - if (shouldLoadUrl) { - clearHistory = !keepHistory - loadedUrl = url - webView.loadUrl(url.toString()) - waitForConnection() + lifecycleScope.launch { + // Register the native bridge depending on the server and webview capabilities + webViewAddJavascriptInterface() + + if (openInApp) { + runFragmentTransactionIfStateSafe { + // Remove any displayed fragments (e.g., BlockInsecureFragment, ConnectionSecurityLevelFragment) + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } + supportFragmentManager.clearFragmentResultListener(BlockInsecureFragment.RESULT_KEY) + + val oldUrl = loadedUrl + // It means that if we loaded an URL with a path previously and we try to load the same URL without + // a path we don't do anything. + val shouldLoadUrl = !url.hasSameOrigin(oldUrl) || url.hasNonRootPath() + if (shouldLoadUrl) { + clearHistory = !keepHistory + loadedUrl = url + webView.loadUrl(url.toString()) + waitForConnection() + } else { + Timber.d("Same base URL without meaningful path, skipping load") + } } else { - Timber.d("Same base URL without meaningful path, skipping load") - } - } else { - try { - val browserIntent = Intent(Intent.ACTION_VIEW, url) - startActivity(browserIntent) - } catch (e: Exception) { - Timber.e(e, "Unable to view url") + try { + val browserIntent = Intent(Intent.ACTION_VIEW, url) + startActivity(browserIntent) + } catch (e: Exception) { + Timber.e(e, "Unable to view url") + } } } } + /** + * Whether the active server supports the `externalAppV2` bridge (>= 2026.4.2). + */ + private suspend fun isServerSupportingExternalAppV2(): Boolean = + serverManager.getServer(presenter.getActiveServer())?.version?.isAtLeast(2026, 4, 2) == true + override fun showConnectionSecurityLevel(serverId: Int) { // Skip if already showing ConnectionSecurityLevelFragment to avoid blinking if (supportFragmentManager.fragments.any { it is ConnectionSecurityLevelFragment }) { @@ -2001,7 +2153,7 @@ class WebViewActivity : /** * Triggers a blob download by fetching the blob data via XHR and passing it to the native - * [handleBlob] interface as a data URI. Requires the blob URL to still be valid (not yet + * `handleBlob` bridge handler as a data URI. Requires the blob URL to still be valid (not yet * revoked by the frontend). */ private fun triggerBlobDownload(url: String, contentDisposition: String, mimetype: String) { @@ -2012,20 +2164,25 @@ class WebViewActivity : } val safeUrl = JSONObject.quote(url) val safeFallbackFilename = JSONObject.quote(fallbackFilename) + val blobCallback = externalBusCall( + jsonPayload = "{type:'handleBlob',data:reader.result,filename:$safeFallbackFilename}", + ) val jsCode = """ (async function() { try { const response = await fetch($safeUrl); if (!response.ok) { - console.error('Blob download failed: HTTP ' + response.status + ' for ' + ${sensitive( - safeUrl, - )}); + console.error('Blob download failed: HTTP ' + response.status + ' for ' + ${ + sensitive( + safeUrl, + ) + }); return; } const blob = await response.blob(); const reader = new FileReader(); reader.onloadend = function() { - $javascriptInterface.handleBlob(reader.result, $safeFallbackFilename); + $blobCallback }; reader.readAsDataURL(blob); } catch (e) { From 8e39bbdf4ff02db1d37f00b4e170d13968561fcd Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:26:45 +0200 Subject: [PATCH 2/5] Apply Copilot suggestions --- .../companion/android/webview/WebViewActivity.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 b5ee003faab..830b5ba3deb 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 @@ -933,7 +933,7 @@ class WebViewActivity : return@addWebMessageListener } if (!sourceOrigin.hasSameOrigin(loadedUrl)) { - Timber.w("Ignored message from unexpected origin: $sourceOrigin") + Timber.w("Ignored message from unexpected origin: ${sensitive(sourceOrigin.toString())}") return@addWebMessageListener } @@ -1672,10 +1672,10 @@ class WebViewActivity : ) this.serverHandleInsets.value = serverHandleInsets lifecycleScope.launch { - // Register the native bridge depending on the server and webview capabilities - webViewAddJavascriptInterface() - if (openInApp) { + // Register the native bridge depending on the server and webview capabilities + webViewAddJavascriptInterface() + runFragmentTransactionIfStateSafe { // Remove any displayed fragments (e.g., BlockInsecureFragment, ConnectionSecurityLevelFragment) supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) From 2a963d151c36821c567d3415bf614ed670453896 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:48:43 +0200 Subject: [PATCH 3/5] Apply PR comments --- .../android/webview/WebViewActivity.kt | 62 ++++++++++--------- .../companion/android/util/StringUtils.kt | 17 +++++ 2 files changed, 49 insertions(+), 30 deletions(-) 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 830b5ba3deb..ec02703937e 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 @@ -858,12 +858,13 @@ class WebViewActivity : /** * Registers the appropriate native bridge for the current server. * - * Queries [isServerSupportingExternalAppV2] to determine whether to use the `externalAppV2` bridge or the legacy - * `externalApp` bridge. V2 also requires [WebViewFeature.WEB_MESSAGE_LISTENER] support; + * Queries [isServerSupportingExternalAppV2] to determine whether to use the + * `externalAppV2` bridge or the legacy `externalApp` bridge. + * V2 also requires [WebViewFeature.WEB_MESSAGE_LISTENER] support; * falls back to V1 if the feature is unavailable. * - * Both directions are safe: V1 uses [android.webkit.WebView.removeJavascriptInterface] and - * V2 uses [WebViewCompat.removeWebMessageListener]. + * Safe to call multiple times: each path removes the previously + * registered interface before adding the new one. */ @SuppressLint("RequiresFeature") private suspend fun webViewAddJavascriptInterface() { @@ -902,7 +903,6 @@ class WebViewActivity : @JavascriptInterface fun externalBus(message: String) { - Timber.d("External bus $message") webView.post { val json = message.toJsonObjectOrNull() ?: return@post handleExternalBusMessage(json) @@ -943,13 +943,8 @@ class WebViewActivity : val payload = json["payload"]?.jsonObjectOrNull() when (type) { - "getExternalAuth" -> { - handleGetExternalAuth(payload) - } - - "revokeExternalAuth" -> { - handleRevokeExternalAuth(payload) - } + "getExternalAuth" -> handleGetExternalAuth(payload) + "revokeExternalAuth" -> handleRevokeExternalAuth(payload) "externalBus" -> { if (payload == null) { @@ -1004,7 +999,10 @@ class WebViewActivity : * Handles an external bus message received from the frontend via the native bridge. */ private fun handleExternalBusMessage(json: JsonObject) { - when (json.getStringOrNull("type")) { + val type = json.getStringOrNull("type") + val messageId = json.getIntOrNull("id") + Timber.d("External bus id=$messageId type=$type raw=${sensitive{ json.toString() }}") + when (type) { "connection-status" -> { isConnected = json["payload"]?.jsonObjectOrNull() ?.getStringOrNull("event") == "connected" @@ -1030,7 +1028,7 @@ class WebViewActivity : } sendExternalBusMessage( ExternalConfigResponse( - id = json["id"], + id = messageId, hasNfc = hasNfc, canCommissionMatter = canCommissionMatter, canExportThread = canExportThread, @@ -1045,9 +1043,9 @@ class WebViewActivity : // TODO This feature is deprecated and should be removed after 2022.6 // Set event listener for HA theme change lifecycleScope.launch { - val themeCallback = externalBusCall("{type:'onHomeAssistantSetTheme'}") + val themeCallback = externalBusCallback("{type:'onHomeAssistantSetTheme'}") webView.evaluateJavascript( - "document.addEventListener('settheme', function (){$themeCallback});", + "document.addEventListener('settheme', $themeCallback);", null, ) } @@ -1105,7 +1103,7 @@ class WebViewActivity : startActivity( BarcodeScannerActivity.newInstance( this@WebViewActivity, - messageId = json.getIntOrElse("id", 0), + messageId = messageId ?: 0, title = payload.getStringOrElse("title", ""), subtitle = payload.getStringOrElse("description", ""), action = payload.getStringOrNull("alternative_option_label")?.ifBlank { @@ -1159,16 +1157,21 @@ class WebViewActivity : } /** - * Returns a JS snippet that sends [jsonPayload] through the external bus. + * Returns a JS `function()` expression that sends [jsonPayload] through the external bus. + * + * The returned string is a complete `function() { ... }` expression that can be used + * directly as a callback. * * For V1: calls `window.externalApp.externalBus(...)` directly. * For V2: posts a `{type:'externalBus', payload:...}` message via `window.externalAppV2`. */ - private fun externalBusCall(jsonPayload: String): String = """ - if (typeof window.$EXTERNAL_APP_V2_LISTENER !== 'undefined') { - window.$EXTERNAL_APP_V2_LISTENER.postMessage(JSON.stringify({type:'externalBus',payload:$jsonPayload})); - } else { - window.$EXTERNAL_APP_V1.externalBus(JSON.stringify($jsonPayload)); + private fun externalBusCallback(jsonPayload: String): String = """ + function() { + if (typeof window.$EXTERNAL_APP_V2_LISTENER !== 'undefined') { + window.$EXTERNAL_APP_V2_LISTENER.postMessage(JSON.stringify({type:'externalBus',payload:$jsonPayload})); + } else { + window.$EXTERNAL_APP_V1.externalBus(JSON.stringify($jsonPayload)); + } } """.trimIndent() @@ -1673,9 +1676,6 @@ class WebViewActivity : this.serverHandleInsets.value = serverHandleInsets lifecycleScope.launch { if (openInApp) { - // Register the native bridge depending on the server and webview capabilities - webViewAddJavascriptInterface() - runFragmentTransactionIfStateSafe { // Remove any displayed fragments (e.g., BlockInsecureFragment, ConnectionSecurityLevelFragment) supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) @@ -1689,6 +1689,10 @@ class WebViewActivity : if (shouldLoadUrl) { clearHistory = !keepHistory loadedUrl = url + + // Register the native bridge depending on the server and webview capabilities + webViewAddJavascriptInterface() + webView.loadUrl(url.toString()) waitForConnection() } else { @@ -2164,7 +2168,7 @@ class WebViewActivity : } val safeUrl = JSONObject.quote(url) val safeFallbackFilename = JSONObject.quote(fallbackFilename) - val blobCallback = externalBusCall( + val blobCallback = externalBusCallback( jsonPayload = "{type:'handleBlob',data:reader.result,filename:$safeFallbackFilename}", ) val jsCode = """ @@ -2181,9 +2185,7 @@ class WebViewActivity : } const blob = await response.blob(); const reader = new FileReader(); - reader.onloadend = function() { - $blobCallback - }; + reader.onloadend = $blobCallback; reader.readAsDataURL(blob); } catch (e) { console.error('Blob download failed:', e); diff --git a/common/src/main/kotlin/io/homeassistant/companion/android/util/StringUtils.kt b/common/src/main/kotlin/io/homeassistant/companion/android/util/StringUtils.kt index 29f5430fe53..b98b7281946 100644 --- a/common/src/main/kotlin/io/homeassistant/companion/android/util/StringUtils.kt +++ b/common/src/main/kotlin/io/homeassistant/companion/android/util/StringUtils.kt @@ -20,3 +20,20 @@ import io.homeassistant.companion.android.common.BuildConfig * @return the original value when it is secure, "HIDDEN" otherwise */ fun sensitive(sensitive: String): String = sensitive.takeIf { BuildConfig.DEBUG } ?: "HIDDEN" + +/** + * Lazy variant of [sensitive] that avoids evaluating the string when not in a secure environment. + * + * Use this overload when building the sensitive string involves computation (e.g. string + * interpolation, `toString()` on complex objects) that should be skipped entirely in release builds. + * + * ```kotlin + * Timber.d("Payload: ${sensitive { payload.toString() }}") + * // Debug: "Payload: {type:config/get}" + * // Release: "Payload: HIDDEN" + * ``` + * + * @param sensitive lambda producing the value to sanitize, only invoked in a secure environment + * @return the produced value when it is secure, "HIDDEN" otherwise + */ +fun sensitive(sensitive: () -> String): String = sensitive.takeIf { BuildConfig.DEBUG }?.invoke() ?: "HIDDEN" From 6d2b11317e32c0d54b8131c49fc56c5a2fe00d10 Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:11:33 +0200 Subject: [PATCH 4/5] Apply PR suggestion --- .../android/webview/WebViewActivity.kt | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) 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 ec02703937e..fad58e2ccc7 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 @@ -156,6 +156,7 @@ import io.homeassistant.companion.android.webview.insecure.BlockInsecureFragment import javax.inject.Inject import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -282,6 +283,7 @@ class WebViewActivity : private var isShowingError = false private var isRelaunching = false private var alertDialog: AlertDialog? = null + private var loadUrlJob: Job? = null private var isVideoFullScreen = false private var videoHeight = 0 private var firstAuthTime: Long = 0 @@ -1674,37 +1676,38 @@ class WebViewActivity : } (keepHistory $keepHistory, openInApp $openInApp, serverHandleInsets $serverHandleInsets)", ) this.serverHandleInsets.value = serverHandleInsets - lifecycleScope.launch { - if (openInApp) { - runFragmentTransactionIfStateSafe { - // Remove any displayed fragments (e.g., BlockInsecureFragment, ConnectionSecurityLevelFragment) - supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) - } - supportFragmentManager.clearFragmentResultListener(BlockInsecureFragment.RESULT_KEY) - - val oldUrl = loadedUrl - // It means that if we loaded an URL with a path previously and we try to load the same URL without - // a path we don't do anything. - val shouldLoadUrl = !url.hasSameOrigin(oldUrl) || url.hasNonRootPath() - if (shouldLoadUrl) { - clearHistory = !keepHistory - loadedUrl = url - + if (openInApp) { + runFragmentTransactionIfStateSafe { + // Remove any displayed fragments (e.g., BlockInsecureFragment, ConnectionSecurityLevelFragment) + supportFragmentManager.popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + } + supportFragmentManager.clearFragmentResultListener(BlockInsecureFragment.RESULT_KEY) + + val oldUrl = loadedUrl + // It means that if we loaded an URL with a path previously and we try to load the same URL without + // a path we don't do anything. + val shouldLoadUrl = !url.hasSameOrigin(oldUrl) || url.hasNonRootPath() + if (shouldLoadUrl) { + clearHistory = !keepHistory + loadedUrl = url + + loadUrlJob?.cancel() + loadUrlJob = lifecycleScope.launch { // Register the native bridge depending on the server and webview capabilities webViewAddJavascriptInterface() webView.loadUrl(url.toString()) waitForConnection() - } else { - Timber.d("Same base URL without meaningful path, skipping load") } } else { - try { - val browserIntent = Intent(Intent.ACTION_VIEW, url) - startActivity(browserIntent) - } catch (e: Exception) { - Timber.e(e, "Unable to view url") - } + Timber.d("Same base URL without meaningful path, skipping load") + } + } else { + try { + val browserIntent = Intent(Intent.ACTION_VIEW, url) + startActivity(browserIntent) + } catch (e: Exception) { + Timber.e(e, "Unable to view url") } } } From 854155e681588484750ba84a18ec18dd6004526e Mon Sep 17 00:00:00 2001 From: Timothy <6560631+TimoPtr@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:35:01 +0200 Subject: [PATCH 5/5] Apply Copilot suggestions --- .../companion/android/webview/WebViewActivity.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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 fad58e2ccc7..408a2abd8d2 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 @@ -899,8 +899,8 @@ class WebViewActivity : } @JavascriptInterface - fun revokeExternalAuth(callback: String) { - handleRevokeExternalAuth(payload = callback.toJsonObjectOrNull()) + fun revokeExternalAuth(payload: String) { + handleRevokeExternalAuth(payload = payload.toJsonObjectOrNull()) } @JavascriptInterface @@ -953,7 +953,6 @@ class WebViewActivity : Timber.w("externalBus message missing payload") return@addWebMessageListener } - Timber.d("External bus $payload") webView.post { handleExternalBusMessage(payload) } @@ -1003,7 +1002,7 @@ class WebViewActivity : private fun handleExternalBusMessage(json: JsonObject) { val type = json.getStringOrNull("type") val messageId = json.getIntOrNull("id") - Timber.d("External bus id=$messageId type=$type raw=${sensitive{ json.toString() }}") + Timber.d("External bus id=$messageId type=$type raw=${sensitive { json.toString() }}") when (type) { "connection-status" -> { isConnected = json["payload"]?.jsonObjectOrNull() @@ -2179,11 +2178,9 @@ class WebViewActivity : try { const response = await fetch($safeUrl); if (!response.ok) { - console.error('Blob download failed: HTTP ' + response.status + ' for ' + ${ - sensitive( - safeUrl, - ) - }); + console.error('Blob download failed: HTTP ' + response.status + ' for ${ + sensitive(safeUrl) + }'); return; } const blob = await response.blob();