Technical handoff for maintainers. Every claim below is checkable against a named file.
Companion docs: README.md (user-facing), AGENTS.md (terse API reference, mirrored at llms.txt), docs/RECIPES.md (integration snippets). The git log is itself a design doc — each commit message states the deliverable and rationale for that step (git log).
Sharingan is an on-device debug logger for Kotlin Multiplatform (Android API 24+, iOS arm64 + simulator arm64). It captures HTTP, MQTT and BLE traffic into an in-memory ring buffer, renders a Compose Multiplatform log browser, and exports events as agent-friendly Markdown / cURL / JSON / digest text. Nothing is ever persisted.
:sharingan debug artifact — capture core + Ktor plugin + Compose UI
+ Android notification/activity + iOS view controller
:sharingan-noop release artifact — same public API, inert bodies, no UI payload
:sample:composeApp demo app — deterministic IoT traffic via Ktor MockEngine;
can compile against either artifact (-Psharingan.noop)
Modules are declared in settings.gradle.kts. :sharingan and :sharingan-noop publish as dev.sharingan:sharingan / dev.sharingan:sharingan-noop (version in gradle/libs.versions.toml, key sharingan).
capture entry points store consumers
┌──────────────────────┐
│ SharinganKtor plugin │──┐
│ (ktor/SharinganKtor) │ │ ┌─────────────────────┐ ┌──────────────────────────┐
├──────────────────────┤ ├──▶│ SharinganStore │──▶│ UI: SharinganScreen │
│ Sharingan.http .log │ │ │ ring buffer (300) │ │ wrapper → stateless │
│ Sharingan.mqtt .pub… │──┘ │ StateFlow<List<…>> │ │ *Content composables │
│ Sharingan.ble .noti…│ │ + isRecording flag │ ├──────────────────────────┤
└──────────────────────┘ └─────────────────────┘ │ CaptureNotification │
header redaction happens ▲ │ │ (Android, observes flow) │
BEFORE record() — secrets │ │ ├──────────────────────────┤
never enter the buffer record() └────────────▶│ SharinganExport │
│ agentMarkdown/curl/json/ │
│ sessionJson/summary │
└───────────┬──────────────┘
▼
copyToClipboard / shareText
(expect/actual per platform)
Key files, in flow order:
SharinganStore is plain MutableStateFlow + atomic update {} CAS — thread-safe with no locks, callable from any thread. Eviction is a subList tail copy once capacity (default 300) is exceeded. Paused events are dropped, not queued. Event ids are process-unique atomic counters (internal/EventIds.kt).
SharinganScreen is the entire UI: home (3 protocol tabs → search → chips → terminal rows), detail (timing waterfall / headers / syntax-colored JSON), modal share sheet, confirmation toast. There is no navigation library — "navigation" is two rememberSaveable strings (protocolName, selectedId) in SharinganScreen.kt.
Each decision is verified against code; file references inline.
:sharingan-noop re-declares the entire public API with empty bodies (sharingan-noop/src/commonMain/kotlin/SharinganNoop.kt). Release builds swap the dependency; call sites are unchanged and Compose/UI bytes never ship. Bootstrap commit 728817b names the model explicitly.
- Why the Ktor plugin lives inside the core artifact (not a
:sharingan-ktorsatellite): the release swap must stay one substitution. A comment in sharingan/build.gradle.kts records this: "The Ktor plugin ships in the core artifact (Chucker model) so the release no-op swap stays a single dependency substitution." - Accepted trade-off:
ktor-client-coreis a (non-api) dependency of both artifacts even for MQTT/BLE-only consumers (see bothbuild.gradle.ktsfiles). Judged acceptable since most KMP apps already ship Ktor. - Event model classes stay real in the no-op (sharingan-noop/src/commonMain/kotlin/HttpEvent.kt etc. are verbatim copies, not stubs): app code that constructs events or
when-matches on the sealed interface behaves identically in release. Only capture, UI and export output are inert (the no-opSharinganStore.record()ignores input;SharinganExportreturns""). - The no-op
SharinganKtoris a realClientPluginthat installs cleanly and registers zero hooks (sharingan-noop/src/commonMain/kotlin/ktor/SharinganKtor.kt) — zero request-path cost in release. coroutines-coreisapiin both modules becauseStateFlowappears in the public surface (Sharingan.events).
sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt declares SharinganInitProvider, registered in the library manifest with authorities="${applicationId}.sharingan-init". ContentProvider.onCreate() runs before Application.onCreate(), captures the application context into SharinganAndroid.appContext, and starts CaptureNotification. This is the androidx.startup trick without the androidx.startup dependency — consumers add the Gradle line and get the notification with no code.
All deliberate, all verifiable:
| Choice | Evidence |
|---|---|
No androidx.core — framework Notification.Builder with an API-26 channel guard |
CaptureNotification.kt lines around Build.VERSION.SDK_INT >= O; commit 615fde8 ("avoid forcing compileSdk 37 on consumers") |
activity-compose pinned to 1.11.0 |
gradle/libs.versions.toml; newer versions pull androidx releases requiring compileSdk 37 |
No icon font / material-icons artifact — hand-built ImageVector strokes |
ui/SharinganIcons.kt (doc comment states the rationale) |
| No bundled IBM Plex — platform monospace face | ui/SharinganTheme.kt: MonoFont = FontFamily.Monospace ("~200 KB per weight" comment); commit b433cb9 lists it as a deliberate deviation from the prototype |
No navigation library — plain state in SharinganScreen.kt |
two rememberSaveable vars drive home/detail |
| No kotlinx-serialization — hand-rolled JSON pretty-printer + escaper | ui/JsonPretty.kt (recursive-descent tokenizer), jsonEscape in Format.kt |
No image assets — brand mark drawn with Canvas |
ui/SharinganMark.kt |
| No DI framework | Sharingan object wires the singleton graph by hand |
- Redaction at capture time, not render time.
HttpLogger.redact()masks values (••••) forAuthorization,Proxy-Authorization,Cookie,Set-Cookie(case-insensitive, configurable) beforestore.record()— secrets never enter the buffer, so every downstream consumer (UI, exports, notification ticker) is automatically safe. See HttpLogger.kt. - Bodies capped at
maxBodyBytes(default 64 KB) with an explicit truncation marker (truncate()inktor/SharinganKtor.kt). - Streaming never consumed.
shouldReadBody()refusestext/event-streamand anything non-textual (isTextual()whitelist:text/*,*json,*xml, form-urlencoded) — SSE and binary downloads keep streaming for the caller. - Transport failures recorded then rethrown untouched (catch block in the plugin's
on(Send)): the app's error handling is never altered. - Notification failures swallowed.
manager.notify()is wrapped intry/catch(Exception)inCaptureNotification.post(). This is a scar, not paranoia: commit8550bbdrecords a real crash where the API-26 version guard left the builder chain (.setSmallIconetc.) attached only to theelsebranch, so API 26+ posted an icon-less notification and the resultingIllegalArgumentExceptionfrom a background flow collector killed the host app. Rule extracted: a debug tool must never crash the host app. - Memory-only ring buffer. No disk, no network. Process death clears everything (a stated property, see §6).
- Stateless
*Content+ thin state wrapper.SharinganScreen(public) collects flows, owns selection/search/chip/share state, and forwards aHomeUiState+ lambdas intoSharinganScreenContent(internal, pure parameters). Same split inside:HomeScreenContent(ui/HomeScreen.kt),DetailScreenContent(ui/DetailScreen.kt),ShareSheetBody(ui/ShareSheet.kt). - Design tokens lifted verbatim from the design handoff.
SharinganColorslight/dark palettes in ui/SharinganTheme.kt, exposed viaLocalSharinganColors; a minimal Material3 scheme is derived only for sheets/ripples. Per-event presentation (method/status/direction/operation tints, rail color, row strings) is resolved once per event viapresentationOf()in ui/EventPresentation.kt, which delegates to the event's ProtocolDescriptor (§5.1). - Scaffold + safeDrawing insets.
SharinganScreenContentroots inScaffold(contentWindowInsets = WindowInsets.safeDrawing), child appliespadding(innerPadding)thenconsumeWindowInsets(innerPadding);SharinganActivitycallsenableEdgeToEdge(). - Previews only on stateless composables,
private, named<Composable>_<Variant>Preview, fake state from ui/PreviewData.kt (hardcoded IoT events mirroring the design). The VM-wrapper-equivalent (SharinganScreen) has no preview. - Reference screenshots of the shipped UI live in docs/screenshots/.
| Declaration | File (common) | Android actual | iOS actual |
|---|---|---|---|
currentTimeMillis() |
internal/Platform.kt | System.currentTimeMillis() |
NSDate().timeIntervalSince1970 * 1000 |
formatClockTime(Long) → HH:mm:ss.SSS |
same | SimpleDateFormat, Locale.US |
cached NSDateFormatter, en_US_POSIX |
copyToClipboard(String) |
internal/PlatformActions.kt | ClipboardManager.setPrimaryClip (context from SharinganAndroid.appContext) |
UIPasteboard.generalPasteboard |
shareText(String) |
same | Intent.ACTION_SEND chooser with FLAG_ACTIVITY_NEW_TASK |
UIActivityViewController on main queue, presented from the topmost VC, with iPad popover anchor |
PlatformBackHandler(enabled, onBack) |
ui/PlatformBackHandler.kt | delegates to androidx.activity.compose.BackHandler (system back pops detail) |
no-op — the in-UI Back button is the iOS-conventional path |
Actuals: *.android.kt under sharingan/src/androidMain/.../internal/ and *.ios.kt under sharingan/src/iosMain/.../internal/.
Identical capture API, identical screens, identical exports on both platforms. Divergence is limited to the entry point:
- Android: sticky silent notification (per-protocol counters, 3-event expanded ticker, Pause/Resume action, tap →
SharinganActivity). PlusSharingan.show(context)/Sharingan.setNotificationEnabled(false)(androidMain/.../SharinganAndroid.kt). - iOS:
SharinganViewController(): UIViewController(aComposeUIViewControllerwrappingSharinganScreen) — present however the host likes. iOS has no sticky-notification mechanism a library can ship; a Live Activity requires an ActivityKit widget extension, which can only live in the host app target, never in a Kotlin library. The app-side recipe for that is in docs/RECIPES.md ("Live Activity analog").
The store, models, exporters, plugin, filters and JSON printer were all TDD'd (commit messages record the red→green counts: 19 + 13 + 8 + 17 tests across commits 3855d16, a1c61fd, e81b20c, b433cb9). All tests live in commonTest and run on both JVM and Kotlin/Native:
| Layer | Test file | What it pins |
|---|---|---|
| Ring buffer | SharinganStoreTest.kt | insertion order, eviction, pause/resume/clear |
| Models / URL parsing | HttpEventTest.kt | host/path derivation (query kept, port kept, / fallback), isFailure semantics |
| Loggers | LoggersTest.kt | MQTT directions, BLE operations, header redaction, id uniqueness |
| Exporters | SharinganExportTest.kt | Markdown shape, cURL shell-escaping, JSON escaping, session wrappers, byte formatting |
| Ktor plugin | ktor/SharinganKtorTest.kt | via MockEngine: capture, redaction, truncation marker, failure propagation, downstream body still readable, paused = nothing recorded |
| Filter logic | ui/EventFilterTest.kt | chip semantics per protocol, case-insensitive search haystacks |
| Share routing | ui/ShareResolverTest.kt | action × scope × event-type → payload/delivery/toast decision table |
| Notification wording | internal/NotificationContentTest.kt | title/counters/ticker/action text, nothing-to-post case |
| JSON pretty-printer | ui/JsonPrettyTest.kt | indentation, escapes, null fallback on invalid/trailing-garbage input |
Run:
./gradlew :sharingan:testDebugUnitTest # JVM (Android unit)
./gradlew :sharingan:iosSimulatorArm64Test # Kotlin/NativeConventions & constraints:
- Names follow
`Given …, When …, Then …`BDD phrasing — but without commas: Kotlin/Native rejects backticked test names containing commas (and other invalid-identifier chars) when generating native test symbols. WriteGiven a full buffer When another event is recorded Then…, notGiven a full buffer, When…. Every existing test follows this. - Use a fresh
SharinganStore(capacity)per test, never theSharingansingleton — keeps tests isolated. - Composables are not unit-tested — they are covered by
@Previews on every stateless*Content(visual verification) plus the on-device instrumented suites below, which landed once the flows settled (per project convention). - On-device UI tests (needs a connected device or emulator):
./gradlew :sharingan:connectedDebugAndroidTest— SharinganScreenUiTest: the full browser flow (tab counts, descriptor chips, search, detail sections, share sheet, copy-for-agent toast, REC pause) via the Compose Multiplatform test API (runComposeUiTest), each test against its own seededSharinganStore. The test APK registersComponentActivityin androidInstrumentedTest/AndroidManifest.xml instead of pulling androidx'sui-test-manifest(avoids pinning a second androidx-compose version next to CMP's)../gradlew :sample:composeApp:connectedDebugAndroidTest— CaptureNotificationE2eTest: zero-setup init → capture notification with per-protocol counters → Paused/Capturing toggle, asserted via the app's ownactiveNotifications(immune to DND, no shade automation).- Instrumented test names use plain
flow_expectationstyle — backticked names with spaces are unreliable after dexing on older APIs.
Versions (gradle/libs.versions.toml): Kotlin 2.4.0, AGP 8.13.2, Compose Multiplatform 1.11.1, Ktor 3.5.0, coroutines 1.11.0, activity-compose 1.11.0; compileSdk/targetSdk 36, minSdk 24, JVM target 17.
Quirks and facts a maintainer needs:
- iOS targets are
iosArm64+iosSimulatorArm64only. CMP 1.11 droppediosX64; don't try to add it back. - AGP 8.13's bundled lint cannot read Kotlin 2.4 metadata. The sample disables release-lint:
lint.checkReleaseBuilds = falsein sample/composeApp/build.gradle.kts (the comment in that file is the record). Revisit when AGP catches up. explicitApi()mode is on in both library modules — every declaration needs an explicit visibility modifier and public members need explicit return types. The compiler is your API-surface linter.- API-parity proof: the sample selects its dependency at configuration time —
-Psharingan.noopsubstitutes:sharingan-noopfor:sharinganin the samecommonMaindependency list (see theproviders.gradleProperty("sharingan.noop")block in the sample's build file)../gradlew :sample:composeApp:assembleRelease -Psharingan.nooptherefore compiles every real call site against the no-op surface; if it builds, parity holds. - Publishing is plain
maven-publish(no signing, no Central config)../gradlew publishToMavenLocalworks today; Maven Central publishing (signing, POM metadata, Sonatype) is not yet configured. - The Android library publishes the
releasevariant only (publishLibraryVariants("release"));consumer-rules.proexists but is currently empty. - Sample install:
./gradlew :sample:composeApp:installDebug. There is no checked-in Xcode project for the sample's iOS side — onlyMainViewController()(sample/composeApp/src/iosMain/.../MainViewController.kt) for a host to wrap.
THE invariant: every public API addition to
:sharinganMUST be mirrored in:sharingan-noopwith an inert twin (same signature, defaults, package; empty body /""/ empty controller). Verify with:./gradlew :sample:composeApp:assembleRelease -Psharingan.noopIf the sample exercises the new API (add a call in
DemoTraffic.kt/App.ktif not), a green build is the parity proof. This is how every existing API landed (commit70017b9).
Per-protocol knowledge lives in one place: a ProtocolDescriptor (ui/ProtocolDescriptor.kt) — chips, chip matching, search haystack, row presentation, the notification ticker line, the per-event export fragments and the @Composable detail body. descriptorOf(event) is the single exhaustive when (event) in the codebase, so adding a sealed subtype produces a compile error at exactly one registration site, and the descriptor base class then forces every concern (including the detail body) to be implemented:
- Event model: new
data class XxxEvent(...) : SharinganEventnext to HttpEvent.kt; overrideisFailureif failure isn't justerror != null. - Logger:
XxxLogger(store)modeled on MqttLogger.kt; expose on the Sharingan.kt facade. UseEventIds.next("xxx-"). - Descriptor:
internal object XxxDescriptor : ProtocolDescriptor<XxxEvent>()modeled on ui/MqttDescriptor.kt; add the tab to theProtocolenum (ui/EventFilter.kt) and register the descriptor in bothdescriptorOf()overloads. TDD chips/search against ui/EventFilterTest.kt, exports againstSharinganExportTest.kt— both suites test through the stable shells (matchesChip,SharinganExport.agentMarkdown, …), which delegate to descriptors. - Residual UI chrome: tab icon in ui/SharinganIcons.kt and the icon
wheninTabBar(ui/HomeScreen.kt); search placeholder inSearchField; sessioncountsLinein SharinganExport.kt (session assembly deliberately stays outside descriptors). - Noop mirror: copy the event model verbatim + inert logger into sharingan-noop/src/commonMain/kotlin/; run the parity build. (Descriptors are
internal— the noop never mirrors them.) - Preview data in ui/PreviewData.kt and demo traffic in sample .../DemoTraffic.kt.
For WebSocket capture specifically: Ktor's WebSockets plugin offers no equivalent of on(Send) interception per frame, so either wrap DefaultClientWebSocketSession or document manual logging (Sharingan.ws.sent/received) like MQTT. For gRPC: there is no standard KMP gRPC client; manual logger is the consistent choice (see §6, last bullet).
- Formatter in SharinganExport.kt (public — the export API is a feature; tests first). Per-event fragments go on the descriptors; session assembly stays in
SharinganExport. - New
ShareActionenum case + sheet row in ui/ShareSheet.kt. - Payload + delivery + toast are one branch in
resolveShare()(ui/ShareResolver.kt), TDD against ui/ShareResolverTest.kt —SharinganScreenneeds no changes. - Mirror the formatter signature (returning
"") in the noopSharinganExport.
The design explored three densities — Terminal / Comfortable / Badged — and only Terminal shipped (TerminalRow in ui/HomeScreen.kt; its doc comment names it "the design's default 'Terminal' density"). To add a variant: new ComfortableRow/BadgedRow composable beside TerminalRow consuming the same EventPresentation, a density value in HomeUiState, and a row-style picker in HomeHeader. EventPresentation already carries everything (sub, sizeLabel, tints) the richer variants need.
Today the buffer is deliberately memory-only. If persistence is ever wanted: keep SharinganStore as the hot interface and add an optional sink observing store.events (the pattern CaptureNotification.start() already uses); never make persistence the source of truth. Mind: redaction already happened at capture, but bodies may still hold PII — persisting changes the security posture, opt-in only.
Add sample/iosApp/ (standard KMP template: iosApp.xcodeproj + SwiftUI ContentView wrapping MainViewController() from the ComposeApp framework). The Kotlin side is already done — MainViewController() and presentSharingan() exist in sample .../MainViewController.kt; the framework (baseName = "ComposeApp", static) is configured in the sample's build file.
SharinganScreen()exists only in the debug artifact. It is the one asymmetry in the API surface (deliberate: a noop composable would drag Compose into the release artifact). Never reference it from code compiled against the noop; useSharingan.show(context)/SharinganViewController(), which are mirrored. Documented in AGENTS.md and README §"Release builds".- DND hides the notification. The channel is
IMPORTANCE_LOWand the notification silent (CaptureNotification.ensureChannel), so Do Not Disturb suppresses it on most devices.Sharingan.show(context)always works. POST_NOTIFICATIONSmust be requested by the host app on Android 13+. The library only declares the permission (sharingan/src/androidMain/AndroidManifest.xml); the sample shows the request (MainActivity.kt). Without the grant, capture still works (areNotificationsEnabled()check + swallowednotify()failures), there's just no notification.- Buffer lost on process death — memory-only by design; capacity is per-store (
SharinganStore(capacity = …)). - The notification observer starts once per process (
CaptureNotification.startguards onscope != null) and survives for the process lifetime;setNotificationEnabled(false)cancels the posted notification but keeps observing. HttpEvent.host/pathcome from a naive string split (splitUrlinHttpEvent.kt), not a URL parser — fine for display, don't reuse it for anything semantic.- Request-body capture only sees
OutgoingContent.ByteArrayContent(outgoingBodyTextinktor/SharinganKtor.kt); streamed/chunked request bodies are not captured (responses: textual-only whitelist, SSE never read). - MQTT/BLE capture is manual by design. There is no dominant KMP MQTT or BLE client to hook the way Ktor is hooked, so the API is one-line logger calls from whatever client's callbacks (docs/RECIPES.md has KMQTT/HiveMQ/Paho and Kable adapters). Resist building adapters into the core artifact — that would add the very dependencies the two-artifact model avoids.
- Timing waterfall granularity differs by source: the Ktor plugin only measures TTFB/Download (wall-clock around
proceed()); the full DNS/Connect/TLS phases appear only when supplied viaSharingan.http.log(timing = …)(preview data shows the intended rendering).