
On-device debug logger capturing HTTP, MQTT and Bluetooth traffic — live browser and capture notification, structured AI-ready exports, secrets redacted at capture, memory-only ring buffer, noop release artifact.
Live HTTP · MQTT · Bluetooth logging for Kotlin Multiplatform — iOS & Android, one API, exportable as agent-ready Markdown.
Sharingan is an on-device debug logger for KMP apps. It captures protocol traffic while you use your app, then hands you clean, structured logs — method, URL, status, headers, bodies — to paste straight into Claude or any chat and pin down bugs across backend, app and firmware. A sticky capture notification (Android) and a built-in log browser (both platforms) keep it one tap away.
The name is a Naruto reference: the eye that sees everything.
// build.gradle.kts
dependencies {
debugImplementation("io.github.mibrahimdev:sharingan:0.1.1")
releaseImplementation("io.github.mibrahimdev:sharingan-noop:0.1.1")
}// capture — one Ktor line
HttpClient { install(SharinganKtor) }
// open the log browser (or just tap the capture notification)
Sharingan.show(context)No init code — a manifest-merged ContentProvider starts Sharingan automatically. iOS, KMP and pure-Swift setup in Setup below.
Android
SharinganViewControllerKt.presentSharingan(animated:) (tap to expand)sharingan-noop artifact: identical API, captures nothing, ships no UI.Requirements: Android API 24+, iOS arm64 + simulator arm64, Ktor 3.x for the HTTP plugin.
Sharingan is on Maven Central.
Add mavenCentral() to your repositories (settings.gradle.kts →
dependencyResolutionManagement { repositories { mavenCentral(); ... } }),
then depend on the coordinate for your build type:
| Coordinate | Use |
|---|---|
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.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.
dependencies {
debugImplementation("io.github.mibrahimdev:sharingan:0.1.1")
releaseImplementation("io.github.mibrahimdev:sharingan-noop:0.1.1")
}That's the whole integration story for build types: debug builds get capture + UI, release builds get the inert no-op (see what "no effect" means). On Android there is zero init code — Sharingan starts via a manifest-merged ContentProvider; no Application changes.
Gradle resolves one dependency list per source set, so pick the artifact with
a property — and note both iOS requirements: the dependency must be api in
iosMain (not commonMain), and the framework block must export(...) it.
Without both, Kotlin/Native generates an empty header and your Swift code
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.1" else "io.github.mibrahimdev:sharingan:0.1.1"
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "shared"
export(sharinganArtifact) // surfaces Sharingan in shared.h
}
}
sourceSets {
commonMain.dependencies {
implementation(sharinganArtifact)
}
iosMain.dependencies {
api(sharinganArtifact) // export() requires api at THIS source set
}
}
}
⚠️ Thereleaseproperty must reach every build that produces the framework Xcode links — including the Gradle invocation inside your Xcode build phase (./gradlew :shared:embedAndSignAppleFrameworkForXcode -Prelease). If the flag is missing there, a debug framework silently overwrites your noop one.
./gradlew :sharingan:assembleSharinganReleaseXCFramework # debug-tool build
./gradlew :sharingan-noop:assembleSharinganReleaseXCFramework # inert twinOutputs land in sharingan/build/XCFrameworks/release/Sharingan.xcframework
and sharingan-noop/build/XCFrameworks/release/Sharingan.xcframework — same
framework name on purpose, so import Sharingan compiles in every
configuration and your build settings decide which one links:
Vendor/Debug/Sharingan.xcframework and
Vendor/Release/Sharingan.xcframework.FRAMEWORK_SEARCH_PATHS = $(SRCROOT)/Vendor/Debug for Debug and
…/Release for Release, and reference the framework in the Embed
Frameworks phase via $(FRAMEWORK_SEARCH_PATHS). Linking one variant but
embedding the other dyld-crashes at launch — verify the embedded framework
in the built .app matches the configuration.import Sharingan, then SharinganViewControllerKt.presentSharingan(animated: true).A pure-Swift app has no Ktor plugin, so capture your URLSession traffic
manually — call Sharingan.shared.http.log(...) from your networking layer (see
Capturing traffic → HTTP for the manual-logging note and
AGENTS.md for the full parameter list). Without it the viewer
opens but stays empty. Heads-up: Kotlin default arguments don't bridge to
Swift — every log(...) parameter is required; see the
RECIPES.md gotcha
before wiring it.
Don't mix this with the Maven/KMP path in one app — pick one.
val client = HttpClient {
install(SharinganKtor) // that's it
}The plugin records:
maxBodyBytes, configurable)Authorization, Cookie, Set-Cookie and Proxy-Authorization values are masked at capture time — secrets never reach the buffer. Streaming responses (text/event-stream, binary) are never consumed. Transport failures are recorded, then rethrown untouched.
install(SharinganKtor) {
captureBodies = true // default — request/response bodies are recorded
maxBodyBytes = 64 * 1024 // default
// overriding REPLACES the default set — re-list the defaults you want to keep
redactedHeaders = setOf("Authorization", "Proxy-Authorization", "Cookie", "Set-Cookie", "X-Api-Key")
}Body capture is ON by default. To capture only metadata (method, URL, status, headers, timing) and never store body text, set captureBodies = false. To start paused and capture nothing at all until you flip it on — e.g. so a QA build records only the flow you ask for — call Sharingan.setRecording(false) at startup, then Sharingan.setRecording(true) (or tap Pause/Resume on the Android capture notification) when you're ready. Pausing applies to every protocol, not just HTTP.
Using OkHttp/NSURLSession directly? Log manually with Sharingan.http.log(...).
Sharingan is client-agnostic: wire its loggers into whatever MQTT/BLE library you use.
// MQTT (any client)
Sharingan.mqtt.publish(topic, payloadAsText, qos = 1, retained = false)
Sharingan.mqtt.received(topic, payloadAsText, qos = 1)
Sharingan.mqtt.subscribed("devices/+/commands/#", qos = 1)
// BLE (any GATT client — e.g. Kable)
Sharingan.ble.connect(device = "HR-Monitor-9F")
Sharingan.ble.notify(device, characteristic = "Heart Rate Measurement", uuid = "0x2A37", value = decodedJson)
Sharingan.ble.read(device, characteristic, uuid, value)
Sharingan.ble.error(device, message = "Attribute not found (GATT 0x0A)", characteristic, uuid)Payloads that parse as JSON get pretty-printing and syntax colors in the UI and in agent exports — log decoded JSON text whenever you can.
peripheral.observe(characteristic).onEach { bytes ->
Sharingan.ble.notify(
device = peripheral.name ?: peripheral.identifier.toString(),
characteristic = "Heart Rate Measurement",
uuid = characteristic.characteristicUuid.toString(),
value = decodeHeartRate(bytes), // your decoder; JSON renders best
)
}.launchIn(scope)More adapters (KMQTT/HiveMQ/Paho callbacks, full Kable wiring, shake-to-open) live in docs/RECIPES.md.
| Platform | Entry point |
|---|---|
| Android | Tap the capture notification (appears on first event), or Sharingan.show(context)
|
| iOS |
SharinganViewControllerKt.presentSharingan(animated: true) (one call, any thread), or embed SharinganViewControllerKt.SharinganViewController() yourself |
The capture notification shows per-protocol counters, a three-event ticker when expanded, and a Pause/Resume action. It is silent and updated in place. Two things to know:
POST_NOTIFICATIONS runtime permission.
AndroidManifest.xml declares it, so manifest merger surfaces it in your app's merged manifest (visible in Merged Manifest in Android Studio and in the APK).requestPermissions call anywhere in Sharingan, so it can't trigger a prompt or change your app's runtime behavior; your app decides whether to ask the user.Sharingan.show(context).sharingan-noop release artifact declares nothing).Sharingan.show(context) always works (wire it to a debug-drawer button or shake gesture).iOS has no sticky-notification equivalent; the view controller is the platform-conventional entry. Everything else — capture API, screens, share sheet — behaves identically on both platforms.
import SwiftUI
import shared // your shared framework
// One-call presentation (topmost view controller, any thread):
SharinganViewControllerKt.presentSharingan(animated: true)
// Or embed/present the view controller yourself:
struct SharinganView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
SharinganViewControllerKt.SharinganViewController()
}
func updateUIViewController(_ vc: UIViewController, context: Context) {}
}
.sheet(isPresented: $showLogs) { SharinganView() }Two iOS notes:
CADisableMinimumFrameDurationOnPhone; Sharingan disables
that strict check so the viewer can never take your app down. Adding the key
yourself is still worthwhile on ProMotion devices — it unlocks 120 Hz.The share sheet's primary action is Copy for AI agent: structured Markdown with method/path/status/host, request headers and the response body fenced as ```json — paste it straight into Claude or any chat. Every formatter behind the sheet is public API, so scripts and agents can produce identical output:
SharinganExport.agentMarkdown(event) // one event as structured Markdown
SharinganExport.agentMarkdown(events) // whole session, with counts header
SharinganExport.curl(httpEvent) // reproducible curl command (redactions stay masked)
SharinganExport.json(event) // machine-readable single event
SharinganExport.sessionJson(events) // full session with tool metadata
SharinganExport.summary(events) // human digest, one line per eventDebugging with a user? Ask them: open Sharingan → (optionally open the failing event) → Share → Copy for AI agent → paste.
Sharingan.events // StateFlow<List<SharinganEvent>>, oldest first
Sharingan.isRecording // StateFlow<Boolean>
Sharingan.setRecording(false) // pause capture (REC/PAUSED toggle)
Sharingan.clear() // drop everything
Sharingan.setNotificationEnabled(false) // Android: opt out of the notificationSharinganStore(capacity) overrides the default 300-event ring (memory-only; process death clears it). Loggers are thread-safe, callable from any thread.
sharingan-noop mirrors the public API: facade, loggers, SharinganKtor (installs, adds no hooks), Sharingan.show (no-op), SharinganViewController() (empty controller).SharinganScreen() (the embeddable composable) exists only in the debug artifact — embed it behind your own debug flag, or stick to the platform entry points above.sample/composeApp generates the design's IoT scenario offline (MockEngine): device state calls, a 401 token refresh, a 500 stream timeout, MQTT telemetry with a broker failure, and a BLE heart-rate session. All screenshots above come from it.
./gradlew :sample:composeApp:installDebug # Android
./gradlew :sample:composeApp:assembleRelease -Psharingan.noop # parity proofTo build against local changes, publish to your Maven local repo and add
mavenLocal() to your repositories (settings.gradle.kts →
dependencyResolutionManagement { repositories { mavenLocal(); ... } }):
git clone https://github.com/mibrahimdev/Sharingan && cd Sharingan
./gradlew publishToMavenLocalContributors: :sharingan and :sharingan-noop must expose an identical
public API so the debug→release swap is safe. ./gradlew checkApiParity (run in
CI) enforces this and fails on any drift — see
docs/api-parity.md for the contract and why a few debug-only
symbols are excluded. After an intentional API change, run ./gradlew apiDump and
commit the regenerated dumps.
Maintainers: cutting a Maven Central release is a two-step,
stage-then-manually-release flow — see docs/RELEASING.md.
| Doc | What's in it |
|---|---|
| AGENTS.md (mirrored at llms.txt) | Terse, complete API reference designed for machine consumption — point your AI agent here |
| docs/RECIPES.md | Integration recipes: MQTT clients, Kable, SwiftUI wrapper, Live Activity analog, shake-to-open |
| docs/ARCHITECTURE.md | How the library is built: module layout, capture pipeline, UI structure |
| docs/ROADMAP.md | Future enhancements, recorded but not scheduled |
| docs/api-parity.md | Why the debug/release artifacts must stay signature-identical and how checkApiParity enforces it |
| docs/RELEASING.md | Stage-then-manually-release flow to Maven Central |
Apache-2.0
Live HTTP · MQTT · Bluetooth logging for Kotlin Multiplatform — iOS & Android, one API, exportable as agent-ready Markdown.
Sharingan is an on-device debug logger for KMP apps. It captures protocol traffic while you use your app, then hands you clean, structured logs — method, URL, status, headers, bodies — to paste straight into Claude or any chat and pin down bugs across backend, app and firmware. A sticky capture notification (Android) and a built-in log browser (both platforms) keep it one tap away.
The name is a Naruto reference: the eye that sees everything.
// build.gradle.kts
dependencies {
debugImplementation("io.github.mibrahimdev:sharingan:0.1.1")
releaseImplementation("io.github.mibrahimdev:sharingan-noop:0.1.1")
}// capture — one Ktor line
HttpClient { install(SharinganKtor) }
// open the log browser (or just tap the capture notification)
Sharingan.show(context)No init code — a manifest-merged ContentProvider starts Sharingan automatically. iOS, KMP and pure-Swift setup in Setup below.
Android
SharinganViewControllerKt.presentSharingan(animated:) (tap to expand)sharingan-noop artifact: identical API, captures nothing, ships no UI.Requirements: Android API 24+, iOS arm64 + simulator arm64, Ktor 3.x for the HTTP plugin.
Sharingan is on Maven Central.
Add mavenCentral() to your repositories (settings.gradle.kts →
dependencyResolutionManagement { repositories { mavenCentral(); ... } }),
then depend on the coordinate for your build type:
| Coordinate | Use |
|---|---|
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.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.
dependencies {
debugImplementation("io.github.mibrahimdev:sharingan:0.1.1")
releaseImplementation("io.github.mibrahimdev:sharingan-noop:0.1.1")
}That's the whole integration story for build types: debug builds get capture + UI, release builds get the inert no-op (see what "no effect" means). On Android there is zero init code — Sharingan starts via a manifest-merged ContentProvider; no Application changes.
Gradle resolves one dependency list per source set, so pick the artifact with
a property — and note both iOS requirements: the dependency must be api in
iosMain (not commonMain), and the framework block must export(...) it.
Without both, Kotlin/Native generates an empty header and your Swift code
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.1" else "io.github.mibrahimdev:sharingan:0.1.1"
listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
target.binaries.framework {
baseName = "shared"
export(sharinganArtifact) // surfaces Sharingan in shared.h
}
}
sourceSets {
commonMain.dependencies {
implementation(sharinganArtifact)
}
iosMain.dependencies {
api(sharinganArtifact) // export() requires api at THIS source set
}
}
}
⚠️ Thereleaseproperty must reach every build that produces the framework Xcode links — including the Gradle invocation inside your Xcode build phase (./gradlew :shared:embedAndSignAppleFrameworkForXcode -Prelease). If the flag is missing there, a debug framework silently overwrites your noop one.
./gradlew :sharingan:assembleSharinganReleaseXCFramework # debug-tool build
./gradlew :sharingan-noop:assembleSharinganReleaseXCFramework # inert twinOutputs land in sharingan/build/XCFrameworks/release/Sharingan.xcframework
and sharingan-noop/build/XCFrameworks/release/Sharingan.xcframework — same
framework name on purpose, so import Sharingan compiles in every
configuration and your build settings decide which one links:
Vendor/Debug/Sharingan.xcframework and
Vendor/Release/Sharingan.xcframework.FRAMEWORK_SEARCH_PATHS = $(SRCROOT)/Vendor/Debug for Debug and
…/Release for Release, and reference the framework in the Embed
Frameworks phase via $(FRAMEWORK_SEARCH_PATHS). Linking one variant but
embedding the other dyld-crashes at launch — verify the embedded framework
in the built .app matches the configuration.import Sharingan, then SharinganViewControllerKt.presentSharingan(animated: true).A pure-Swift app has no Ktor plugin, so capture your URLSession traffic
manually — call Sharingan.shared.http.log(...) from your networking layer (see
Capturing traffic → HTTP for the manual-logging note and
AGENTS.md for the full parameter list). Without it the viewer
opens but stays empty. Heads-up: Kotlin default arguments don't bridge to
Swift — every log(...) parameter is required; see the
RECIPES.md gotcha
before wiring it.
Don't mix this with the Maven/KMP path in one app — pick one.
val client = HttpClient {
install(SharinganKtor) // that's it
}The plugin records:
maxBodyBytes, configurable)Authorization, Cookie, Set-Cookie and Proxy-Authorization values are masked at capture time — secrets never reach the buffer. Streaming responses (text/event-stream, binary) are never consumed. Transport failures are recorded, then rethrown untouched.
install(SharinganKtor) {
captureBodies = true // default — request/response bodies are recorded
maxBodyBytes = 64 * 1024 // default
// overriding REPLACES the default set — re-list the defaults you want to keep
redactedHeaders = setOf("Authorization", "Proxy-Authorization", "Cookie", "Set-Cookie", "X-Api-Key")
}Body capture is ON by default. To capture only metadata (method, URL, status, headers, timing) and never store body text, set captureBodies = false. To start paused and capture nothing at all until you flip it on — e.g. so a QA build records only the flow you ask for — call Sharingan.setRecording(false) at startup, then Sharingan.setRecording(true) (or tap Pause/Resume on the Android capture notification) when you're ready. Pausing applies to every protocol, not just HTTP.
Using OkHttp/NSURLSession directly? Log manually with Sharingan.http.log(...).
Sharingan is client-agnostic: wire its loggers into whatever MQTT/BLE library you use.
// MQTT (any client)
Sharingan.mqtt.publish(topic, payloadAsText, qos = 1, retained = false)
Sharingan.mqtt.received(topic, payloadAsText, qos = 1)
Sharingan.mqtt.subscribed("devices/+/commands/#", qos = 1)
// BLE (any GATT client — e.g. Kable)
Sharingan.ble.connect(device = "HR-Monitor-9F")
Sharingan.ble.notify(device, characteristic = "Heart Rate Measurement", uuid = "0x2A37", value = decodedJson)
Sharingan.ble.read(device, characteristic, uuid, value)
Sharingan.ble.error(device, message = "Attribute not found (GATT 0x0A)", characteristic, uuid)Payloads that parse as JSON get pretty-printing and syntax colors in the UI and in agent exports — log decoded JSON text whenever you can.
peripheral.observe(characteristic).onEach { bytes ->
Sharingan.ble.notify(
device = peripheral.name ?: peripheral.identifier.toString(),
characteristic = "Heart Rate Measurement",
uuid = characteristic.characteristicUuid.toString(),
value = decodeHeartRate(bytes), // your decoder; JSON renders best
)
}.launchIn(scope)More adapters (KMQTT/HiveMQ/Paho callbacks, full Kable wiring, shake-to-open) live in docs/RECIPES.md.
| Platform | Entry point |
|---|---|
| Android | Tap the capture notification (appears on first event), or Sharingan.show(context)
|
| iOS |
SharinganViewControllerKt.presentSharingan(animated: true) (one call, any thread), or embed SharinganViewControllerKt.SharinganViewController() yourself |
The capture notification shows per-protocol counters, a three-event ticker when expanded, and a Pause/Resume action. It is silent and updated in place. Two things to know:
POST_NOTIFICATIONS runtime permission.
AndroidManifest.xml declares it, so manifest merger surfaces it in your app's merged manifest (visible in Merged Manifest in Android Studio and in the APK).requestPermissions call anywhere in Sharingan, so it can't trigger a prompt or change your app's runtime behavior; your app decides whether to ask the user.Sharingan.show(context).sharingan-noop release artifact declares nothing).Sharingan.show(context) always works (wire it to a debug-drawer button or shake gesture).iOS has no sticky-notification equivalent; the view controller is the platform-conventional entry. Everything else — capture API, screens, share sheet — behaves identically on both platforms.
import SwiftUI
import shared // your shared framework
// One-call presentation (topmost view controller, any thread):
SharinganViewControllerKt.presentSharingan(animated: true)
// Or embed/present the view controller yourself:
struct SharinganView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
SharinganViewControllerKt.SharinganViewController()
}
func updateUIViewController(_ vc: UIViewController, context: Context) {}
}
.sheet(isPresented: $showLogs) { SharinganView() }Two iOS notes:
CADisableMinimumFrameDurationOnPhone; Sharingan disables
that strict check so the viewer can never take your app down. Adding the key
yourself is still worthwhile on ProMotion devices — it unlocks 120 Hz.The share sheet's primary action is Copy for AI agent: structured Markdown with method/path/status/host, request headers and the response body fenced as ```json — paste it straight into Claude or any chat. Every formatter behind the sheet is public API, so scripts and agents can produce identical output:
SharinganExport.agentMarkdown(event) // one event as structured Markdown
SharinganExport.agentMarkdown(events) // whole session, with counts header
SharinganExport.curl(httpEvent) // reproducible curl command (redactions stay masked)
SharinganExport.json(event) // machine-readable single event
SharinganExport.sessionJson(events) // full session with tool metadata
SharinganExport.summary(events) // human digest, one line per eventDebugging with a user? Ask them: open Sharingan → (optionally open the failing event) → Share → Copy for AI agent → paste.
Sharingan.events // StateFlow<List<SharinganEvent>>, oldest first
Sharingan.isRecording // StateFlow<Boolean>
Sharingan.setRecording(false) // pause capture (REC/PAUSED toggle)
Sharingan.clear() // drop everything
Sharingan.setNotificationEnabled(false) // Android: opt out of the notificationSharinganStore(capacity) overrides the default 300-event ring (memory-only; process death clears it). Loggers are thread-safe, callable from any thread.
sharingan-noop mirrors the public API: facade, loggers, SharinganKtor (installs, adds no hooks), Sharingan.show (no-op), SharinganViewController() (empty controller).SharinganScreen() (the embeddable composable) exists only in the debug artifact — embed it behind your own debug flag, or stick to the platform entry points above.sample/composeApp generates the design's IoT scenario offline (MockEngine): device state calls, a 401 token refresh, a 500 stream timeout, MQTT telemetry with a broker failure, and a BLE heart-rate session. All screenshots above come from it.
./gradlew :sample:composeApp:installDebug # Android
./gradlew :sample:composeApp:assembleRelease -Psharingan.noop # parity proofTo build against local changes, publish to your Maven local repo and add
mavenLocal() to your repositories (settings.gradle.kts →
dependencyResolutionManagement { repositories { mavenLocal(); ... } }):
git clone https://github.com/mibrahimdev/Sharingan && cd Sharingan
./gradlew publishToMavenLocalContributors: :sharingan and :sharingan-noop must expose an identical
public API so the debug→release swap is safe. ./gradlew checkApiParity (run in
CI) enforces this and fails on any drift — see
docs/api-parity.md for the contract and why a few debug-only
symbols are excluded. After an intentional API change, run ./gradlew apiDump and
commit the regenerated dumps.
Maintainers: cutting a Maven Central release is a two-step,
stage-then-manually-release flow — see docs/RELEASING.md.
| Doc | What's in it |
|---|---|
| AGENTS.md (mirrored at llms.txt) | Terse, complete API reference designed for machine consumption — point your AI agent here |
| docs/RECIPES.md | Integration recipes: MQTT clients, Kable, SwiftUI wrapper, Live Activity analog, shake-to-open |
| docs/ARCHITECTURE.md | How the library is built: module layout, capture pipeline, UI structure |
| docs/ROADMAP.md | Future enhancements, recorded but not scheduled |
| docs/api-parity.md | Why the debug/release artifacts must stay signature-identical and how checkApiParity enforces it |
| docs/RELEASING.md | Stage-then-manually-release flow to Maven Central |
Apache-2.0