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..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 @@ -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 @@ -175,6 +176,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=" @@ -270,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 @@ -317,7 +331,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 +576,9 @@ class WebViewActivity : Timber.e("onRenderProcessGone: webView crashed") view?.let { reload() - webViewAddJavascriptInterface() + lifecycleScope.launch { + webViewAddJavascriptInterface() + } } return true @@ -730,8 +745,6 @@ class WebViewActivity : super.onHideCustomView() } } - - webViewAddJavascriptInterface() } val cookieManager = CookieManager.getInstance() @@ -797,14 +810,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 +857,325 @@ 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. + * + * Safe to call multiple times: each path removes the previously + * registered interface before adding the new one. + */ + @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(payload: String) { + handleRevokeExternalAuth(payload = payload.toJsonObjectOrNull()) + } + + @JavascriptInterface + fun externalBus(message: String) { + 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: ${sensitive(sourceOrigin.toString())}") + 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 } + 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)") + } + } - @JavascriptInterface - fun revokeExternalAuth(callback: String) { - presenter.onRevokeExternalAuth(callback.toJsonObjectOrNull()?.getStringOrNull("callback") ?: "") - isRelaunching = true // Prevent auth errors from showing + /** + * 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) { + 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" + if (isConnected) { + alertDialog?.cancel() + presenter.checkSecurityVersion() + } + } + + "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 = messageId, + 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 = externalBusCallback("{type:'onHomeAssistantSetTheme'}") + webView.evaluateJavascript( + "document.addEventListener('settheme', $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 = messageId ?: 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 `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 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() + private fun addEntityTo(json: JsonObject) { val payload = json["payload"]?.jsonObjectOrNull() val entityId = payload?.getStringOrNull("entity_id") @@ -1544,8 +1689,15 @@ class WebViewActivity : if (shouldLoadUrl) { clearHistory = !keepHistory loadedUrl = url - webView.loadUrl(url.toString()) - waitForConnection() + + 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") } @@ -1559,6 +1711,12 @@ class WebViewActivity : } } + /** + * 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 +2159,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,21 +2170,22 @@ class WebViewActivity : } val safeUrl = JSONObject.quote(url) val safeFallbackFilename = JSONObject.quote(fallbackFilename) + val blobCallback = externalBusCallback( + 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); - }; + 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"