From c442ef65783f3fc82a0fe45e44b0212d73533a0d Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Wed, 17 Jun 2026 21:17:36 +0100 Subject: [PATCH 1/8] ci: gate the dev integration trunk with build+test, same as main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish the two-trunk flow: dev is the development integration branch, main stays the release/stable trunk. The build+test gate already runs on every PR (so PRs into dev are gated); add dev to the push trigger so direct pushes to the trunk are built too. Pages deploy (pages.yml) and the tag-driven Maven Central publish stay pinned to main — releases cut from main. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index daefa2e..f5c9754 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,7 +1,8 @@ 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 +# dev 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 @@ -22,7 +23,7 @@ on: - "site/**" - ".github/workflows/pages.yml" push: - branches: [main] + branches: [main, dev] paths-ignore: - "**.md" - "docs/**" From d61e16f7c7d7fc54f9c927cbcd18fc663253b91f Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Wed, 17 Jun 2026 21:35:18 +0100 Subject: [PATCH 2/8] =?UTF-8?q?ci:=20point=20the=20push=20gate=20at=20deve?= =?UTF-8?q?lop=20after=20the=20dev=20=E2=86=92=20develop=20rename?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The development trunk was renamed dev → develop (now the GitHub default branch). The earlier commit wired the build+test push trigger to a now-dead `dev` ref; correct it to `develop` so direct pushes to the trunk are gated. Pages deploy and the tag-driven publish stay on main. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f5c9754..1cd7cf7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,8 @@ name: Build & Test # PR-time build/test gate (issue #22). Every pull request and every push to the -# dev integration trunk or main must build all KMP targets and run the JVM + iOS -# unit tests before it can merge. +# 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 @@ -23,7 +23,7 @@ on: - "site/**" - ".github/workflows/pages.yml" push: - branches: [main, dev] + branches: [main, develop] paths-ignore: - "**.md" - "docs/**" From f305dce5de15ed04b21fd7fa6770324ecdd68aea Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Wed, 17 Jun 2026 21:39:19 +0100 Subject: [PATCH 3/8] fix(export): unify the protocol counts line to iterate Protocol.entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SharinganExport.countsLine() hardcoded three `count { it is X }` calls and a literal "HTTP h · MQTT m · BLE b" template, so a fourth protocol would be silently omitted from the session export header — while NotificationContent already iterated Protocol.entries. Extract one internal protocolCountsLine() helper next to the Protocol registry (EventFilter.kt) and route both callers through it. Output is byte-for-byte identical for the three current protocols (SharinganExportTest / NotificationContentTest stay green); a new protocol now appears in the export header automatically. TDD: EventFilterTest pins the format and asserts one segment per Protocol.entries (red -> green). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/dev/sharingan/SharinganExport.kt | 9 +++------ .../sharingan/internal/NotificationContent.kt | 7 ++----- .../kotlin/dev/sharingan/ui/EventFilter.kt | 8 ++++++++ .../dev/sharingan/ui/EventFilterTest.kt | 19 +++++++++++++++++++ 4 files changed, 32 insertions(+), 11 deletions(-) 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/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/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 }) From 0833be41cd7cf38d9983b7b9eb31dfa65fa5b704 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Wed, 17 Jun 2026 21:42:59 +0100 Subject: [PATCH 4/8] refactor(ui): move tab icon and search placeholder onto ProtocolDescriptor Completes the descriptor as the single home for per-protocol knowledge. The tab-bar icon and the search-field placeholder were the last two per-protocol facts resolved by `when (protocol)` blocks inside HomeScreen; lift them to abstract tabIcon / searchPlaceholder members on ProtocolDescriptor, implemented by each descriptor, and collapse the two HomeScreen `when`s to descriptorOf(protocol).tabIcon / .searchPlaceholder. Adding a protocol now touches one place and the compiler forces both members to be supplied. Internal-only; no public API or noop change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../kotlin/dev/sharingan/ui/BleDescriptor.kt | 3 +++ .../commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt | 12 ++---------- .../kotlin/dev/sharingan/ui/HttpDescriptor.kt | 3 +++ .../kotlin/dev/sharingan/ui/MqttDescriptor.kt | 3 +++ .../kotlin/dev/sharingan/ui/ProtocolDescriptor.kt | 7 +++++++ 5 files changed, 18 insertions(+), 10 deletions(-) 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/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 From b70bbf366e61d442808c31ce1ddf943f83691df3 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Wed, 1 Jul 2026 16:37:17 +0100 Subject: [PATCH 5/8] fix(android): isolate logger locale so it can't corrupt the host app Opening the Sharingan logger from a host that uses per-app locales (e.g. AppCompat set to Arabic/RTL) rendered the logger in an RTL frame and, worse, permanently corrupted the host: after closing the logger the host was stuck in English text with RTL layout (issue #38). Root cause: SharinganActivity is a plain ComponentActivity that neither pins its own locale nor guards the process-global one. On API 33+ the framework applied the host's per-app locale (ar -> RTL) to the logger, and the logger's config handling flipped the process-global Locale/LocaleList default to English, which leaked back to the host. The logger is a locale-neutral surface: always English + LTR, and it must never read or mutate the host's locale. Two-layer fix: - Activity: attachBaseContext pins an en/LTR Configuration via the non-mutating createConfigurationContext (never resources.updateConfiguration). - Compose: the stateless SharinganScreenContent forces LocalLayoutDirection = Ltr around its whole body, so the frame is LTR on every platform (including iOS) and in Studio previews. The global-locale guard was needed: an instrumented test proved that createConfigurationContext(en) resets LocaleList.getDefault() to English. SharinganActivity now snapshots the host's LocaleList in attachBaseContext and restores it (right after, and again in onDestroy), leaving the host's locale untouched. Verified red->green on an API 34 emulator with a new instrumented test that pins the process to Arabic, opens/closes the logger, and asserts the logger is LTR while open and every process-global JVM locale default is still Arabic after. The test is @SdkSuppress(minSdkVersion = 33) since it drives the API 33+ LocaleManager. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sharingan/sample/LoggerLocaleLeakTest.kt | 107 ++++++++++++++++++ .../kotlin/dev/sharingan/SharinganActivity.kt | 31 +++++ .../dev/sharingan/ui/SharinganScreen.kt | 62 +++++----- 3 files changed, 173 insertions(+), 27 deletions(-) create mode 100644 sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt 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..22cf322 --- /dev/null +++ b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt @@ -0,0 +1,107 @@ +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+ (the emulators CI runs on). + */ +@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/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) + } } } From 79026e34936f4e531a6cd7e82ee8727e1a02be1e Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Thu, 2 Jul 2026 14:56:33 +0100 Subject: [PATCH 6/8] ci: gate PRs on the instrumented suites with an API 34 emulator job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The locale-leak regression (LoggerLocaleLeakTest) and the other connected suites (:sharingan UI tests, CaptureNotificationE2eTest) previously never ran in CI — build.yml only covered JVM and iOS unit tests, so the issue-#38 fix was only verified by manual emulator runs. Add an `android` job (reactivecircus/android-emulator-runner, API 34, KVM-accelerated ubuntu) that runs both modules' connectedDebugAndroidTest and uploads reports on failure, and correct the LoggerLocaleLeakTest KDoc that wrongly claimed CI already ran emulators. Co-Authored-By: Claude Fable 5 --- .github/workflows/build.yml | 54 ++++++++++++++++++- .../sharingan/sample/LoggerLocaleLeakTest.kt | 3 +- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1cd7cf7..2477449 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,9 @@ name: Build & Test # 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 @@ -106,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/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt index 22cf322..2b1b988 100644 --- a/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt +++ b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt @@ -30,7 +30,8 @@ import java.util.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+ (the emulators CI runs on). + * 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. From df967827cfe119517969360ce204e07b69861170 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Thu, 2 Jul 2026 18:08:42 +0100 Subject: [PATCH 7/8] chore(release): bump version to 0.2.0 Locale-leak fix (#38) plus the completed ProtocolDescriptor feature (#37) ship together, so this is a minor bump per semver rather than a patch. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 4 ++-- README.md | 12 ++++++------ gradle/libs.versions.toml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ac2188..c2c8feb 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.2.0` | debug builds (capture + UI) | +| `io.github.mibrahimdev:sharingan-noop:0.2.0` | 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..5771ff6 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.2.0` | debug builds (capture + UI) | +| `io.github.mibrahimdev:sharingan-noop:0.2.0` | 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.2.0 → 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.2.0") + releaseImplementation("io.github.mibrahimdev:sharingan-noop:0.2.0") } ``` @@ -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.2.0" else "io.github.mibrahimdev:sharingan:0.2.0" listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> target.binaries.framework { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a5a68e..02cee16 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -sharingan = "0.1.0" +sharingan = "0.2.0" kotlin = "2.4.0" agp = "8.13.2" From 774a2ee146e7ab25323b3a464891b29a25e03f62 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Thu, 2 Jul 2026 18:12:02 +0100 Subject: [PATCH 8/8] chore(release): set version to 0.1.1 instead of 0.2.0 Maintainer call: this release is framed as the locale-leak fix release, so it takes the next patch number rather than a minor bump. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 4 ++-- README.md | 12 ++++++------ gradle/libs.versions.toml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c2c8feb..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.2.0` | debug builds (capture + UI) | -| `io.github.mibrahimdev:sharingan-noop:0.2.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 5771ff6..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.2.0` | debug builds (capture + UI) | -| `io.github.mibrahimdev:sharingan-noop:0.2.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.2.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.2.0") - releaseImplementation("io.github.mibrahimdev:sharingan-noop:0.2.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.2.0" else "io.github.mibrahimdev:sharingan:0.2.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 02cee16..14e6533 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -sharingan = "0.2.0" +sharingan = "0.1.1" kotlin = "2.4.0" agp = "8.13.2"