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
54 changes: 53 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/
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()
}
}
62 changes: 35 additions & 27 deletions sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
}
Loading