Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 57 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -22,7 +25,7 @@ on:
- "site/**"
- ".github/workflows/pages.yml"
push:
branches: [main]
branches: [main, develop]
paths-ignore:
- "**.md"
- "docs/**"
Expand Down Expand Up @@ -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/
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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")
}
```

Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[versions]
sharingan = "0.1.0"
sharingan = "0.1.1"

kotlin = "2.4.0"
agp = "8.13.2"
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,53 @@
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()
setContent {
SharinganScreen()
}
}

override fun onDestroy() {
hostLocales?.let(LocaleList::setDefault)
super.onDestroy()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -78,12 +79,8 @@ public object SharinganExport {

// ── session assembly ─────────────────────────────────────────

private fun countsLine(events: List<SharinganEvent>): 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<SharinganEvent>): String =
"${events.size} events · ${protocolCountsLine(events)}"

private fun eventJson(event: SharinganEvent, indent: String): String {
val fields = mutableListOf<Pair<String, String>>()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +12,8 @@ internal object BleDescriptor : ProtocolDescriptor<BleEvent>() {

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<FilterChipSpec> = listOf(
FilterChipSpec("all", "All"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SharinganEvent>): 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<FilterChipSpec> = descriptorOf(protocol).chips

Expand Down
Loading
Loading