diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index c60358c184..1183bbbb38 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -28,6 +28,19 @@ kotlin { // Without this, Res.readBytes() throws MissingResourceException at runtime. android { androidResources.enable = true } + // Library module: bare wasmJs(), no browser() (that's for the eventual webApp executable). No custom + // hierarchy group is needed for MAIN, same shape as core:common/core:model/core:repository/core:service: + // every one of this module's own dependencies (core/*, coil, jetbrains-markdown, qrcode-kotlin, every + // compose-multiplatform-*/jetbrains-* artifact) is confirmed to publish a wasmJs variant, and the only + // three commonMain files with `expect`/`actual` (PlatformUtils.kt, ClipboardUtils.kt, HtmlUtils.kt) plus + // three more found while verifying this pass (ScreenUtils.kt, DynamicColorScheme.kt, + // DropDownPreference.kt's enumEntriesOf/isDeprecatedEnumEntry) all gained real wasmJsMain actuals below — + // none of them needed a native-only dependency hidden from wasmJs, so no `applyHierarchyTemplate` call is + // needed here (calling it a second time, after `meshtastic.kmp.jvm.android`'s own, would fail — Gradle + // only allows one per project — which is why core:common/core:model keep the plugin and skip the call too). + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs() + sourceSets { commonMain.dependencies { implementation(projects.core.common) @@ -73,11 +86,44 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.activity.compose) } - commonTest.dependencies { - implementation(projects.core.testing) - implementation(libs.junit) - implementation(libs.compose.multiplatform.ui.test) + // `rememberOpenUrl`'s wasmJs actual (`window.open`) is the only wasmJsMain file needing a JS-facing + // dependency; everything else in wasmJsMain is either a pure Kotlin no-op or (createClipEntry, + // KeepScreenOn) uses only Compose Multiplatform's own API / a hand-declared `external` (matching + // core:ble's `WebBluetoothApi.kt` idiom for the Screen Wake Lock API kotlinx-browser doesn't bind). + wasmJsMain.dependencies { implementation(libs.kotlinx.browser) } + + commonTest.dependencies { implementation(libs.compose.multiplatform.ui.test) } + + // TEST only: core:testing has no wasmJs target (same gap core:ble/core:database/core:network/ + // core:data/core:service all hit) — confirmed via grep that exactly 3 of this module's 27 commonTest + // files import org.meshtastic.core.testing (ConnectionsViewModelTest.kt, ProtoExtensionsTest.kt, + // SharedContactViewModelTest.kt); those 3 move to nonWebTest, the other 24 (including every + // runComposeUiTest-based UI test — compose-multiplatform-ui-test itself does publish a wasmJs variant) + // stay in commonTest. `libs.junit` also moves here: grep found zero `org.junit.*` usage anywhere in + // this module's tests (vestigial, presumably pre-dating the migration to `kotlin.test`), and it is a + // plain non-KMP jar with no wasmJs metadata at all, so leaving it in commonTest risked being a second, + // indistinguishable cause of any wasmJs test-resolution failure. + // + // No applyHierarchyTemplate call happens in this script (see the MAIN comment above), so the default + // template's shared intermediate test source sets are not synchronously available by name this early + // (confirmed: core:ui has no `android { withHostTest {} }` call at all, so `androidHostTest` doesn't + // even exist — unlike core:service/core:data, which all call `withHostTest {}` and could `getByName` + // it directly). `matching{}.configureEach{}` degrades to a harmless no-op for whichever of these leaf + // test source sets isn't registered, instead of throwing. + val nonWebTest by creating { + dependsOn(commonTest.get()) + dependencies { + implementation(projects.core.testing) + implementation(libs.junit) + } } + matching { + it.name == "jvmTest" || + it.name == "androidHostTest" || + it.name == "iosArm64Test" || + it.name == "iosSimulatorArm64Test" + } + .configureEach { dependsOn(nonWebTest) } jvmTest.dependencies { implementation(compose.desktop.currentOs) } } diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt b/core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt similarity index 100% rename from core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt rename to core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/share/SharedContactViewModelTest.kt diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt b/core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt similarity index 100% rename from core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt rename to core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/util/ProtoExtensionsTest.kt diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt b/core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt similarity index 100% rename from core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt rename to core/ui/src/nonWebTest/kotlin/org/meshtastic/core/ui/viewmodel/ConnectionsViewModelTest.kt diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/component/EnumReflection.wasmJs.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/component/EnumReflection.wasmJs.kt new file mode 100644 index 0000000000..04717112a7 --- /dev/null +++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/component/EnumReflection.wasmJs.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.component + +// jvmAndroidMain's actuals use `java.lang.Class` reflection (`declaringJavaClass.enumConstants`, `getField(...)`) to +// enumerate an enum's entries and check for `@Deprecated` from just an instance, with no reified type parameter to +// call `enumValues()`/`enumEntries()` instead. Kotlin/Wasm has no `java.lang.Class`-equivalent reflection +// surface at all (same gap iOS's Kotlin/Native actual has, which is why it takes the identical fallback below) — and +// changing `DropDownPreference`'s public signature to take a reified type parameter instead of an instance is a +// commonMain API change well outside this pass's scope. Matches iOS exactly: `DropDownPreference`'s enum-instance +// overload renders with no selectable items on this target, same as on iOS today — a pre-existing gap this pass +// inherits rather than introduces. Callers who need reliable enum options in a dropdown can use the `List>` or `List>` overloads instead, which don't call through this expect at all. +internal actual fun > enumEntriesOf(selectedItem: T): List = emptyList() + +internal actual fun Enum<*>.isDeprecatedEnumEntry(): Boolean = false diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/theme/DynamicColorScheme.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/theme/DynamicColorScheme.kt new file mode 100644 index 0000000000..b99c1fb0b7 --- /dev/null +++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/theme/DynamicColorScheme.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.runtime.Composable + +/** Web — dynamic (Material You / wallpaper-derived) color is an Android System-only concept. Matches the JVM actual. */ +@Composable actual fun dynamicColorScheme(darkTheme: Boolean): ColorScheme? = null diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt new file mode 100644 index 0000000000..7e1b50bf7e --- /dev/null +++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ClipboardUtils.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.platform.ClipEntry + +// Web has no native clip-data object comparable to Android's `ClipData`/AWT's `StringSelection` — there is no +// `ClipEntry(nativeType)` constructor overload for wasmJs at all (confirmed against Compose Multiplatform's own +// wasmJs `ui` klib: only the common `ClipEntry.withPlainText` factory is present in that target's metadata). That +// factory is real, not a fallback: writing the returned `ClipEntry` to the system clipboard still goes through the +// genuine `navigator.clipboard` Async Clipboard API — that's handled inside Compose Multiplatform's own +// `Clipboard.setClipEntry()` implementation for this target, which every call site here already goes through (see +// `CopyIconButton.kt`, `QrDialog.kt`, etc.) — so no extra `navigator.clipboard.writeText` interop is needed here. +// `label`/`sensitive` have no browser equivalent (there is no OS-level "don't show a paste-preview toast" concept, nor +// a "mark as secret" clipboard flag in the Clipboard API), so — like the JVM/AWT actual's `sensitive` — both are +// accepted and ignored. +@OptIn(ExperimentalComposeUiApi::class) +actual fun createClipEntry(text: String, label: String, sensitive: Boolean): ClipEntry = ClipEntry.withPlainText(text) diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/HtmlUtils.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/HtmlUtils.kt new file mode 100644 index 0000000000..442077580d --- /dev/null +++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/HtmlUtils.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLinkStyles + +// androidMain's actual (`AnnotatedString.fromHtml`) is a genuinely Android-specific real HTML renderer — it isn't a +// common Compose Multiplatform API, so it isn't available here. The desktop JVM actual, despite running on a full +// desktop JVM with far more capability than a browser sandbox, *also* takes the plain-text shortcut below rather than +// pulling in an HTML parser — that's the established bar for "good enough" fidelity on every non-Android platform in +// this codebase today, so wasmJs matches it rather than reaching for `org.jetbrains:markdown`-style HTML parsing +// (this module already depends on that library for actual Markdown, but it doesn't parse HTML) or hand-rolling a +// `dangerouslySetInnerHTML`-style DOM injection, which would also sidestep Compose's own text layout/selection. +/** Web stub — returns the raw HTML as plain text (no HTML rendering), matching the JVM/Desktop and iOS actuals. */ +actual fun annotatedStringFromHtml(html: String, linkStyles: TextLinkStyles?): AnnotatedString = AnnotatedString(html) diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt new file mode 100644 index 0000000000..b315bac263 --- /dev/null +++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("TooManyFunctions") + +package org.meshtastic.core.ui.util + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import kotlinx.browser.window +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.await +import kotlinx.coroutines.awaitCancellation +import org.jetbrains.compose.resources.StringResource +import org.meshtastic.core.common.util.CommonUri +import kotlin.js.Promise + +// Everything below except `rememberOpenUrl` and `KeepScreenOn` mirrors iosMain/NoopStubs.kt's exact choices — same +// reasoning as iOS: these are Android-system-settings concepts (NFC/Bluetooth/Wi-Fi/location/app settings, ahead-of- +// time permission-state checks) with no browser equivalent, or platform features (toasts, native file/document +// pickers) whose nearest browser analog (a File System Access API picker, gated behind a user gesture and Chromium- +// only) isn't a drop-in replacement for the contract these expects describe. `bleScanRequiresLocationServices` and the +// four `is*Disabled`/`is*Unavailable` checks are `false` for the same reason they are on iOS: the concept of an ahead- +// of-time "is Bluetooth/GPS/Wi-Fi disabled at the system level" query doesn't exist for a sandboxed browser tab. + +@Composable actual fun rememberOpenNfcSettings(): () -> Unit = {} + +@Composable actual fun rememberShowToast(): suspend (String) -> Unit = { _ -> } + +@Composable actual fun rememberShowToastResource(): suspend (StringResource) -> Unit = { _ -> } + +@Composable actual fun rememberOpenMap(): (latitude: Double, longitude: Double, label: String) -> Unit = { _, _, _ -> } + +/** Web — opens the URL in a new browser tab via `window.open`, so the running app's own tab/state is left intact. */ +@Composable actual fun rememberOpenUrl(): (url: String) -> Unit = { url -> window.open(url, "_blank", "") } + +@Composable +actual fun rememberSaveFileLauncher( + onUriReceived: (CommonUri) -> Unit, +): (defaultFilename: String, mimeType: String) -> Unit = { _, _ -> } + +@Composable +actual fun rememberOpenFileLauncher(onUriReceived: (CommonUri?) -> Unit): (mimeType: String) -> Unit = { _ -> } + +@Composable actual fun rememberOpenDocumentTreeLauncher(onTreeUriSelect: (CommonUri?) -> Unit): () -> Unit = {} + +@Composable actual fun rememberReadTextFromUri(): suspend (uri: CommonUri, maxChars: Int) -> String? = { _, _ -> null } + +/** + * Web — backed by the real, documented Screen Wake Lock API + * (https://www.w3.org/TR/screen-wake-lock/#the-wakelock-interface). `kotlinx-browser` has no binding for it (checked: + * absent from its generated `org.w3c.dom` sources), so it's declared fresh below, following the same `external + * interface : JsAny` + private `external val navigator` idiom `core:ble`'s `WebBluetoothApi.kt` established for + * `navigator.bluetooth`. + * + * Requesting the lock requires a secure context (HTTPS) and a visible document; a request that is rejected for any + * reason (unsupported browser, insecure origin, tab not visible yet) is swallowed here — the screen simply behaves as + * if `KeepScreenOn` had never been called, the same no-op fallback posture every platform gap in this file takes. + * + * Known gap, not fixed here: per spec the browser releases the lock automatically the instant the document is hidden + * (tab backgrounded/minimized), and this does not re-request it when the tab regains visibility — a deliberate scope + * cut for this pass (re-acquiring correctly needs a `visibilitychange` listener, which `kotlinx-browser` also doesn't + * bind), not an oversight. Android's own `view.keepScreenOn` is similarly revocable by the OS outside this code's + * control, so this is a difference of degree, not of kind. + */ +@Suppress("TooGenericExceptionCaught") +@Composable +actual fun KeepScreenOn(enabled: Boolean) { + LaunchedEffect(enabled) { + if (!enabled) return@LaunchedEffect + // The request and the eventual release live in the same coroutine (rather than a DisposableEffect firing a + // separate `launch` for the request) so a fast dispose can never race ahead of an in-flight `request()` and + // leak the lock — `awaitCancellation()` blocks this coroutine open until the effect leaves composition or + // `enabled` flips, and `finally` then runs release synchronously on that same cancellation, never orphaned. + val sentinel = + try { + navigator.wakeLock?.request("screen")?.await() + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // See kdoc: unsupported/insecure-context/not-yet-visible rejection is an expected, silent no-op. + null + } + try { + awaitCancellation() + } finally { + sentinel?.release() + } + } +} + +@Composable actual fun rememberOpenLocationSettings(): () -> Unit = {} + +@Composable actual fun rememberOpenBluetoothSettings(): () -> Unit = {} + +@Composable actual fun rememberOpenWifiSettings(): () -> Unit = {} + +actual val bleScanRequiresLocationServices: Boolean = false + +@Composable actual fun isGpsDisabled(): Boolean = false + +@Composable actual fun isBluetoothDisabled(): Boolean = false + +@Composable actual fun isWifiUnavailable(): Boolean = false + +@Composable actual fun rememberOpenAppSettings(): () -> Unit = {} + +@Composable actual fun rememberLocationPermissionState(): PermissionUiState = grantedPermissionUiState() + +@Composable actual fun rememberBluetoothPermissionState(): PermissionUiState = grantedPermissionUiState() + +@Composable actual fun rememberNotificationPermissionState(): PermissionUiState = grantedPermissionUiState() + +@Composable actual fun rememberLocalNetworkPermissionState(): PermissionUiState = grantedPermissionUiState() + +@Composable actual fun rememberCameraPermissionState(): PermissionUiState = grantedPermissionUiState() + +private external interface JsWakeLockSentinel : JsAny { + fun release(): Promise +} + +private external interface JsWakeLock : JsAny { + fun request(type: String): Promise +} + +private external interface JsNavigatorWakeLock : JsAny { + val wakeLock: JsWakeLock? +} + +/** Access to `navigator.wakeLock`, isolated here — see the `KeepScreenOn` kdoc above. */ +private external val navigator: JsNavigatorWakeLock diff --git a/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt new file mode 100644 index 0000000000..2677f00bd7 --- /dev/null +++ b/core/ui/src/wasmJsMain/kotlin/org/meshtastic/core/ui/util/ScreenUtils.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.util + +import androidx.compose.runtime.Composable + +/** + * Web no-op — unlike Screen Wake Lock, there is no browser API to read or set the physical display's brightness at all + * (a page can only fake it by dimming its own content with an overlay, which is a UI concern, not a platform primitive + * this function should reach for). Matches the JVM/Desktop actual. + */ +@Composable +actual fun SetScreenBrightness(brightness: Float) { + // No-op on Web — no browser API for physical display brightness exists. +}