diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index daefa2e..2477449 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,14 +1,17 @@ name: Build & Test -# PR-time build/test gate (issue #22). Every pull request and every push to main -# must build all KMP targets and run the JVM + iOS unit tests before it can merge. +# PR-time build/test gate (issue #22). Every pull request and every push to the +# develop integration trunk or main must build all KMP targets and run the JVM + +# iOS unit tests before it can merge. # This complements the other workflows, which guard different things: # - api-check.yml -> public-API surface stability (apiCheck) # - publish.yml -> tag-triggered Maven Central release # Neither builds the code or runs tests on a PR, so this workflow is the actual # correctness gate. A PR that introduces a failing unit test is blocked here: # - a broken common/JVM test fails the `jvm` job's testDebugUnitTest step; -# - a broken iOS test fails the `ios` job's iosSimulatorArm64Test step. +# - a broken iOS test fails the `ios` job's iosSimulatorArm64Test step; +# - a broken instrumented/UI test fails the `android` job's connected +# test step, which runs the device suites on an API 34 emulator. # Docs/site-only changes skip this gate (paths-ignore): they can't affect the # Kotlin/Gradle build or tests, so there's no point spending ~25 min of macOS # runner time linking the iOS frameworks for a README or CSS edit. Any PR that @@ -22,7 +25,7 @@ on: - "site/**" - ".github/workflows/pages.yml" push: - branches: [main] + branches: [main, develop] paths-ignore: - "**.md" - "docs/**" @@ -105,3 +108,53 @@ jobs: # skip) — a deviceId follow-up, not a hidden gap. - name: Run iOS simulator unit tests run: ./gradlew :sharingan:iosSimulatorArm64Test :sharingan-noop:iosSimulatorArm64Test + + # Android instrumented (connected) tests on a Linux-hosted emulator. This is + # the only job that exercises the device suites — :sharingan's Compose UI + # tests and the sample app's notification E2E + locale-leak regression + # (issue #38, LoggerLocaleLeakTest) — so device-only regressions are caught + # at PR time instead of relying on manual emulator runs. API 34 satisfies + # the test's @SdkSuppress(minSdkVersion = 33) gate (LocaleManager is 33+); + # anything lower would silently skip the locale regression. + android: + runs-on: ubuntu-latest + steps: + # Grant the runner access to /dev/kvm — the emulator needs hardware + # acceleration or it fails to boot within the action's timeout. + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - uses: actions/checkout@v6 + + - name: Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + # Boots the emulator and runs every connected suite in one go. A failing + # instrumented test (e.g. the logger leaking its locale into the host) + # blocks the PR here. + - name: Run instrumented tests (API 34 emulator) + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + script: ./gradlew :sharingan:connectedDebugAndroidTest :sample:composeApp:connectedDebugAndroidTest + + # Connected-test HTML/XML reports are only useful on red runs; keep CI + # storage lean by uploading them just on failure. + - name: Upload instrumented test reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: connected-test-reports + path: | + **/build/reports/androidTests/connected/ + **/build/outputs/androidTest-results/connected/ diff --git a/AGENTS.md b/AGENTS.md index 3ac2188..b02d756 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,8 @@ On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, | Coordinate | Use | |---|---| -| `io.github.mibrahimdev:sharingan:0.1.0` | debug builds (capture + UI) | -| `io.github.mibrahimdev:sharingan-noop:0.1.0` | release builds (same API, inert, no UI) | +| `io.github.mibrahimdev:sharingan:0.1.1` | debug builds (capture + UI) | +| `io.github.mibrahimdev:sharingan-noop:0.1.1` | release builds (same API, inert, no UI) | Android: `debugImplementation` / `releaseImplementation` pair. KMP commonMain: choose one via a Gradle property (single dependency list). diff --git a/README.md b/README.md index 5a46980..cc34d27 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,10 @@ then depend on the coordinate for your build type: | Coordinate | Use | |---|---| -| `io.github.mibrahimdev:sharingan:0.1.0` | debug builds (capture + UI) | -| `io.github.mibrahimdev:sharingan-noop:0.1.0` | release builds (same API, inert, no UI) | +| `io.github.mibrahimdev:sharingan:0.1.1` | debug builds (capture + UI) | +| `io.github.mibrahimdev:sharingan-noop:0.1.1` | release builds (same API, inert, no UI) | -**Tested versions:** Sharingan 0.1.0 → Kotlin **2.4.0**, Ktor **3.5.0**, +**Tested versions:** Sharingan 0.1.1 → Kotlin **2.4.0**, Ktor **3.5.0**, Compose Multiplatform **1.11.1**, AGP 8.13.2. Later versions may work but are unverified — Kotlin/Native has no cross-compiler-version binary compatibility guarantee, so match the Kotlin version exactly. @@ -111,8 +111,8 @@ stage-then-manually-release flow — see [`docs/RELEASING.md`](docs/RELEASING.md ```kotlin dependencies { - debugImplementation("io.github.mibrahimdev:sharingan:0.1.0") - releaseImplementation("io.github.mibrahimdev:sharingan-noop:0.1.0") + debugImplementation("io.github.mibrahimdev:sharingan:0.1.1") + releaseImplementation("io.github.mibrahimdev:sharingan-noop:0.1.1") } ``` @@ -130,7 +130,7 @@ won't see Sharingan at all. // shared/build.gradle.kts kotlin { val sharinganArtifact = if (providers.gradleProperty("release").isPresent) - "io.github.mibrahimdev:sharingan-noop:0.1.0" else "io.github.mibrahimdev:sharingan:0.1.0" + "io.github.mibrahimdev:sharingan-noop:0.1.1" else "io.github.mibrahimdev:sharingan:0.1.1" listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> target.binaries.framework { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a5a68e..14e6533 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -sharingan = "0.1.0" +sharingan = "0.1.1" kotlin = "2.4.0" agp = "8.13.2" diff --git a/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt new file mode 100644 index 0000000..2b1b988 --- /dev/null +++ b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt @@ -0,0 +1,108 @@ +package dev.sharingan.sample + +import android.app.LocaleManager +import android.content.Context +import android.os.LocaleList +import android.view.View +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress +import androidx.test.platform.app.InstrumentationRegistry +import dev.sharingan.SharinganActivity +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Locale + +/** + * Regression for issue #38: opening the Sharingan logger from a host app that + * uses per-app locales (Arabic/RTL) must not corrupt the host. The logger is a + * locale-neutral surface — English + LTR while open — and must leave every + * process-global locale knob exactly as the host set it. + * + * ``` + * ./gradlew :sample:composeApp:connectedDebugAndroidTest + * ``` + * + * We stand in for the AppCompat host by driving the framework per-app locale + * (`LocaleManager`, what `AppCompatDelegate.setApplicationLocales` calls under + * the hood on API 33+) directly in the process, since a test-APK host Activity + * can't be launched into the app-under-test's process and the leak we assert is + * process-global anyway. Requires API 33+; CI's `android` job (build.yml) runs + * the connected suites on an API 34 emulator, so this regression gates PRs. + */ +@RunWith(AndroidJUnit4::class) +@SdkSuppress(minSdkVersion = 33) // LocaleManager is API 33+; skip (don't crash) below. +class LoggerLocaleLeakTest { + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context: Context get() = instrumentation.targetContext + private val localeManager get() = context.getSystemService(LocaleManager::class.java) + + private fun setAppLocale(tags: String) = instrumentation.runOnMainSync { + localeManager.applicationLocales = LocaleList.forLanguageTags(tags) + } + + @Before + fun pinHostToArabic() { + setAppLocale("ar") + // LocaleManager applies asynchronously; wait until the host is Arabic. + val deadline = System.currentTimeMillis() + 5_000 + while (localeManager.applicationLocales.toLanguageTags() != "ar" && + System.currentTimeMillis() < deadline + ) { + Thread.sleep(50) + } + assertEquals( + "precondition: host must be pinned to Arabic", + "ar", + localeManager.applicationLocales.toLanguageTags(), + ) + // The above only confirms system-server state. The process-local JVM + // default is what SharinganActivity snapshots/restores, and it updates + // separately — wait for it too, so we don't snapshot a stale value. + val jvmDeadline = System.currentTimeMillis() + 2_000 + while (LocaleList.getDefault()[0].language != "ar" && + System.currentTimeMillis() < jvmDeadline + ) { + Thread.sleep(50) + } + assertEquals( + "precondition: process default locale must be Arabic before launch", + "ar", + LocaleList.getDefault()[0].language, + ) + } + + @After + fun clearAppLocale() = setAppLocale("") + + @Test + fun `Given_Arabic_host_When_logger_opens_and_closes_Then_stays_LTR_and_host_locale_untouched`() { + // Open the logger, assert it renders LTR, then close it. + ActivityScenario.launch(SharinganActivity::class.java).use { logger -> + logger.onActivity { activity -> + assertEquals( + "logger frame must be LTR while open", + View.LAYOUT_DIRECTION_LTR, + activity.resources.configuration.layoutDirection, + ) + } + } + + // Sanity: system-server per-app locale is untouched. This lives in + // system_server, not our process, so the process-local fix can't corrupt + // it — it passes regardless of the bug and just guards the test setup. + assertEquals( + "system per-app locale changed unexpectedly (test setup sanity)", + "ar", + localeManager.applicationLocales.toLanguageTags(), + ) + // The REAL leak assertions: the process-global JVM defaults the buggy + // activity flipped to English must be back to the host's Arabic. + assertEquals("Locale.getDefault() leaked", "ar", Locale.getDefault().language) + assertEquals("LocaleList.getDefault() leaked", "ar", LocaleList.getDefault()[0].language) + } +} diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/SharinganActivity.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/SharinganActivity.kt index d7c6d42..3e9a506 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/SharinganActivity.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/SharinganActivity.kt @@ -1,17 +1,43 @@ package dev.sharingan +import android.content.Context +import android.content.res.Configuration import android.os.Bundle +import android.os.LocaleList import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import dev.sharingan.ui.SharinganScreen +import java.util.Locale /** * Hosts the Sharingan log browser. Launched by tapping the capture * notification or by calling [show]; apps never need to declare it — * it ships in the library manifest. + * + * The logger is a locale-neutral surface: always English + LTR regardless of + * the host's per-app locale, and it never leaks its locale back into the host + * (issue #38). [attachBaseContext] pins an English/LTR configuration for this + * activity via the non-mutating [Context.createConfigurationContext]; that call + * has the side effect of resetting the process-global [LocaleList] default to + * English, so we snapshot the host's default and restore it — both right after, + * and again in [onDestroy] — leaving the host's locale untouched. */ public class SharinganActivity : ComponentActivity() { + + private var hostLocales: LocaleList? = null + + override fun attachBaseContext(newBase: Context) { + hostLocales = LocaleList.getDefault() + val config = Configuration(newBase.resources.configuration).apply { + setLocale(Locale.ENGLISH) + setLayoutDirection(Locale.ENGLISH) + } + // Non-mutating: createConfigurationContext, never resources.updateConfiguration. + super.attachBaseContext(newBase.createConfigurationContext(config)) + hostLocales?.let(LocaleList::setDefault) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -19,4 +45,9 @@ public class SharinganActivity : ComponentActivity() { SharinganScreen() } } + + override fun onDestroy() { + hostLocales?.let(LocaleList::setDefault) + super.onDestroy() + } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt index 6c54b22..01980e0 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt @@ -2,6 +2,7 @@ package dev.sharingan import dev.sharingan.internal.formatClockTime import dev.sharingan.ui.descriptorOf +import dev.sharingan.ui.protocolCountsLine /** * Turns captured events into shareable text. @@ -78,12 +79,8 @@ public object SharinganExport { // ── session assembly ───────────────────────────────────────── - private fun countsLine(events: List): String { - val http = events.count { it is HttpEvent } - val mqtt = events.count { it is MqttEvent } - val ble = events.count { it is BleEvent } - return "${events.size} events · HTTP $http · MQTT $mqtt · BLE $ble" - } + private fun countsLine(events: List): String = + "${events.size} events · ${protocolCountsLine(events)}" private fun eventJson(event: SharinganEvent, indent: String): String { val fields = mutableListOf>() diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/NotificationContent.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/NotificationContent.kt index 27e8f65..071d2b6 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/internal/NotificationContent.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/internal/NotificationContent.kt @@ -1,9 +1,8 @@ package dev.sharingan.internal import dev.sharingan.SharinganEvent -import dev.sharingan.ui.Protocol import dev.sharingan.ui.descriptorOf -import dev.sharingan.ui.protocolOf +import dev.sharingan.ui.protocolCountsLine /** * What the capture notification should say — the platform-free half of the @@ -29,9 +28,7 @@ internal fun notificationContentOf( ): NotificationContent? { if (events.isEmpty()) return null val stateLabel = if (recording) "Capturing" else "Paused" - val countsLine = Protocol.entries.joinToString(" · ") { protocol -> - "${protocol.name} ${events.count { protocolOf(it) == protocol }}" - } + val countsLine = protocolCountsLine(events) val ticker = events.takeLast(3).reversed().joinToString("\n") { descriptorOf(it).tickerLine(it) } return NotificationContent( title = "Sharingan — $stateLabel · ${events.size} events", diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/BleDescriptor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/BleDescriptor.kt index 5877b5d..31ff376 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/BleDescriptor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/BleDescriptor.kt @@ -1,6 +1,7 @@ package dev.sharingan.ui import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector import dev.sharingan.BleEvent import dev.sharingan.BleOperation import dev.sharingan.formatBytes @@ -11,6 +12,8 @@ internal object BleDescriptor : ProtocolDescriptor() { override val protocol: Protocol = Protocol.BLE override val eventNoun: String = "operations" + override val tabIcon: ImageVector = SharinganIcons.Bluetooth + override val searchPlaceholder: String = "Filter characteristic, device…" override val chips: List = listOf( FilterChipSpec("all", "All"), diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt index a533e52..37a13f9 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt @@ -18,6 +18,14 @@ internal data class FilterChipSpec(val key: String, val label: String) internal fun protocolOf(event: SharinganEvent): Protocol = descriptorOf(event).protocol +/** `HTTP n · MQTT n · BLE n` — one segment per protocol, in tab order. The + * single home for the cross-protocol counts line shared by the export header + * and the capture notification. */ +internal fun protocolCountsLine(events: List): String = + Protocol.entries.joinToString(" · ") { protocol -> + "${protocol.name} ${events.count { protocolOf(it) == protocol }}" + } + /** The design's per-protocol chip rows. */ internal fun chipsFor(protocol: Protocol): List = descriptorOf(protocol).chips diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt index aff292f..e49984a 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt @@ -190,11 +190,7 @@ private fun TabBar( Row(Modifier.fillMaxWidth()) { Protocol.entries.forEach { protocol -> val on = protocol == selected - val icon = when (protocol) { - Protocol.HTTP -> SharinganIcons.Globe - Protocol.MQTT -> SharinganIcons.Waves - Protocol.BLE -> SharinganIcons.Bluetooth - } + val icon = descriptorOf(protocol).tabIcon Column( Modifier.weight(1f).clickable { onSelect(protocol) }, horizontalAlignment = Alignment.CenterHorizontally, @@ -255,11 +251,7 @@ private fun SearchField( modifier: Modifier = Modifier, ) { val colors = LocalSharinganColors.current - val placeholder = when (protocol) { - Protocol.HTTP -> "Filter path, status, header…" - Protocol.MQTT -> "Filter topic, payload…" - Protocol.BLE -> "Filter characteristic, device…" - } + val placeholder = descriptorOf(protocol).searchPlaceholder Row( modifier .fillMaxWidth() diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HttpDescriptor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HttpDescriptor.kt index e20d787..1b9a2fb 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HttpDescriptor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HttpDescriptor.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -32,6 +33,8 @@ internal object HttpDescriptor : ProtocolDescriptor() { override val protocol: Protocol = Protocol.HTTP override val eventNoun: String = "requests" + override val tabIcon: ImageVector = SharinganIcons.Globe + override val searchPlaceholder: String = "Filter path, status, header…" override val chips: List = listOf( FilterChipSpec("all", "All"), diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/MqttDescriptor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/MqttDescriptor.kt index 471311b..079fd95 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/MqttDescriptor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/MqttDescriptor.kt @@ -1,6 +1,7 @@ package dev.sharingan.ui import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector import dev.sharingan.MqttDirection import dev.sharingan.MqttEvent import dev.sharingan.formatBytes @@ -12,6 +13,8 @@ internal object MqttDescriptor : ProtocolDescriptor() { override val protocol: Protocol = Protocol.MQTT override val eventNoun: String = "messages" + override val tabIcon: ImageVector = SharinganIcons.Waves + override val searchPlaceholder: String = "Filter topic, payload…" override val chips: List = listOf( FilterChipSpec("all", "All"), diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt index 42a2f32..a49acc8 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt @@ -1,6 +1,7 @@ package dev.sharingan.ui import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector import dev.sharingan.BleEvent import dev.sharingan.HttpEvent import dev.sharingan.MqttEvent @@ -34,6 +35,12 @@ internal abstract class ProtocolDescriptor { /** Quick-filter chips below the search field, in design order. */ abstract val chips: List + /** Tab-bar icon for this protocol's tab. */ + abstract val tabIcon: ImageVector + + /** Placeholder hint shown in the search field on this protocol's tab. */ + abstract val searchPlaceholder: String + /** Chip matching; `"all"` is handled by the caller before dispatch. */ protected abstract fun chipMatches(event: E, chipKey: String): Boolean diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt index 2151534..a581884 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -18,6 +19,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection import dev.sharingan.HttpEvent import dev.sharingan.Sharingan import dev.sharingan.SharinganEvent @@ -137,34 +140,39 @@ internal fun SharinganScreenContent( ) { val colors = LocalSharinganColors.current PlatformBackHandler(enabled = selectedEvent != null, onBack = onBack) - Scaffold( - modifier = modifier, - containerColor = colors.bg, - contentWindowInsets = WindowInsets.safeDrawing, - ) { innerPadding -> - Box( - Modifier - .padding(innerPadding) - .consumeWindowInsets(innerPadding) - .fillMaxSize(), - ) { - if (selectedEvent != null) { - DetailScreenContent(event = selectedEvent, onBack = onBack, onShare = onShareSingle) - } else { - HomeScreenContent( - state = homeState, - onSelectProtocol = onSelectProtocol, - onQueryChange = onQueryChange, - onChipChange = onChipChange, - onToggleRecording = onToggleRecording, - onOpenEvent = onOpenEvent, - onShareAll = onShareAll, - ) + // The logger is a locale-neutral surface — always LTR, on every platform + // (including iOS) and in Studio previews, regardless of the host's layout + // direction (issue #38). + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Scaffold( + modifier = modifier, + containerColor = colors.bg, + contentWindowInsets = WindowInsets.safeDrawing, + ) { innerPadding -> + Box( + Modifier + .padding(innerPadding) + .consumeWindowInsets(innerPadding) + .fillMaxSize(), + ) { + if (selectedEvent != null) { + DetailScreenContent(event = selectedEvent, onBack = onBack, onShare = onShareSingle) + } else { + HomeScreenContent( + state = homeState, + onSelectProtocol = onSelectProtocol, + onQueryChange = onQueryChange, + onChipChange = onChipChange, + onToggleRecording = onToggleRecording, + onOpenEvent = onOpenEvent, + onShareAll = onShareAll, + ) + } + SharinganToast(toastMessage, Modifier.align(Alignment.BottomCenter)) } - SharinganToast(toastMessage, Modifier.align(Alignment.BottomCenter)) } - } - if (shareState != null) { - ShareSheet(state = shareState, onAction = onShareAction, onDismiss = onShareDismiss) + if (shareState != null) { + ShareSheet(state = shareState, onAction = onShareAction, onDismiss = onShareDismiss) + } } } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/EventFilterTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/EventFilterTest.kt index f85e2bd..4cc799e 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/EventFilterTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/EventFilterTest.kt @@ -5,6 +5,7 @@ import dev.sharingan.BleOperation import dev.sharingan.HttpEvent import dev.sharingan.MqttDirection import dev.sharingan.MqttEvent +import dev.sharingan.SharinganEvent import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -87,6 +88,24 @@ internal class EventFilterTest { assertTrue(matchesQuery(http(), " ")) } + @Test + fun `Given a mix of events When the counts line is built Then it shows per-protocol counts`() { + val events: List = + listOf(http(), http(), mqtt(MqttDirection.PUBLISH), ble(BleOperation.NOTIFY)) + assertEquals("HTTP 2 · MQTT 1 · BLE 1", protocolCountsLine(events)) + } + + @Test + fun `When the counts line is built Then it has one segment for every protocol`() { + val events: List = + listOf(http(), mqtt(MqttDirection.PUBLISH), ble(BleOperation.NOTIFY)) + val line = protocolCountsLine(events) + assertEquals(Protocol.entries.size, line.split(" · ").size) + for (p in Protocol.entries) { + assertTrue(line.contains(p.name)) + } + } + @Test fun `When chips for a protocol are requested Then they match the design`() { assertEquals(listOf("all", "err", "2xx", "get", "post"), chipsFor(Protocol.HTTP).map { it.key })