Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f250a69
feat: write-behind capture — events survive process death (#50)
mibrahimdev Aug 22, 2026
bf3b804
fix: review follow-ups — DROP_OLDEST, idempotent start, drain-on-stop…
mibrahimdev Aug 22, 2026
5cc14cb
fix: use a real UUID for session ids (#50)
mibrahimdev Aug 22, 2026
1018167
test(ios): unique per-driver in-memory DB name (#50)
mibrahimdev Aug 22, 2026
cea0cef
fix: enforce foreign keys so session deletes cascade (#50)
mibrahimdev Aug 23, 2026
73a474a
fix: flush on a real latency deadline not an idle timeout (#50)
mibrahimdev Aug 23, 2026
623350c
refactor: controller owns the driver it opens (#50)
mibrahimdev Aug 23, 2026
9c72cd4
refactor: extract :sharingan-db so persistence leaves the public ABI …
mibrahimdev Aug 23, 2026
abbb289
docs: warn that PersistenceController.stop() does not detach the seam…
mibrahimdev Aug 24, 2026
05b646a
test: add EventDto encode coverage (#50)
mibrahimdev Aug 24, 2026
af9ae5b
fix: swallow toRow mapper throws inside flush so the flusher survives…
mibrahimdev Aug 24, 2026
8804961
fix: use atomic select instead of withTimeoutOrNull in flusher (#50)
mibrahimdev Aug 24, 2026
15e08fb
fix: hold flusherJob in an AtomicReference to avoid a null race with …
mibrahimdev Aug 24, 2026
d20d7d3
refactor: make channel capacity an internal constructor param and tes…
mibrahimdev Aug 24, 2026
bcfddb2
test: document SharinganStore onRecord seam contract (#50)
mibrahimdev Aug 24, 2026
bf7d6ce
fix: make PersistenceController single-use and start flusher LAZY (#50)
mibrahimdev Aug 24, 2026
af725da
fix: PersistenceController native compile blocker, stop() race, and d…
mibrahimdev Aug 24, 2026
5546477
test: move flusherStartCount assertion after flush to remove race (#50)
mibrahimdev Aug 24, 2026
b542b2e
docs: trim over-explained comments in the persistence layer (#50)
mibrahimdev Aug 25, 2026
4cf00dd
docs: apply reviewers clarity/minimalism cut-list (#50)
mibrahimdev Aug 26, 2026
0dead77
fix: BCV-guard :sharingan-db's published ABI (#50)
mibrahimdev Aug 31, 2026
e43b227
docs: describe shipped persistence (flight recorder) accurately (#50)
mibrahimdev Aug 31, 2026
32bf19a
docs: fix remaining memory-only claims across README, AGENTS, llms.tx…
mibrahimdev Aug 31, 2026
4df0781
docs: keep CONTEXT glossary formatting uniform (#50)
mibrahimdev Aug 31, 2026
bcf6a33
fix: clear() drops persisted rows and pending batch; bodies stay off …
mibrahimdev Aug 31, 2026
8c70940
fix: give the write-behind seam an owner (#50)
mibrahimdev Aug 31, 2026
8b04244
chore: refresh :sharingan-db API dump for clear() (#50)
mibrahimdev Aug 31, 2026
377e2bb
style: conform to the ktlint comments policy from develop (#50)
mibrahimdev Aug 31, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
# commonTest is executed by the Android debug unit-test task. A failing
# test here blocks the PR.
- name: Run JVM/common unit tests
run: ./gradlew :sharingan:testDebugUnitTest :sharingan-noop:testDebugUnitTest
run: ./gradlew :sharingan:testDebugUnitTest :sharingan-db:testDebugUnitTest :sharingan-noop:testDebugUnitTest

# Full multiplatform build + iOS simulator unit tests on macOS — the only
# host that can link the Kotlin/Native iOS frameworks. This job (a) runs
Expand Down Expand Up @@ -107,7 +107,7 @@ jobs:
# default. If a runtime lacks the device the step fails fast (no silent
# skip) — a deviceId follow-up, not a hidden gap.
- name: Run iOS simulator unit tests
run: ./gradlew :sharingan:iosSimulatorArm64Test :sharingan-noop:iosSimulatorArm64Test
run: ./gradlew :sharingan:iosSimulatorArm64Test :sharingan-db: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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,6 @@ jobs:
echo "::notice title=Staged to Central Portal::Artifacts uploaded to a NEW deployment on the Central Portal and are NOT yet public."
echo "Next: a maintainer must verify and release manually. See docs/RELEASING.md."
echo " 1. Open https://central.sonatype.com/publishing/deployments"
echo " 2. Confirm io.github.mibrahimdev:sharingan and io.github.mibrahimdev:sharingan-noop are present, validated, and signed."
echo " 2. Confirm io.github.mibrahimdev:sharingan, io.github.mibrahimdev:sharingan-db, and io.github.mibrahimdev:sharingan-noop are present, validated, and signed."
echo " 3. Click Publish on the deployment to release it to Central."
echo " 4. Cut the matching GitHub Release for this tag."
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Sharingan — agent reference

On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, MQTT and BLE traffic into an in-memory ring buffer (default 300 events, never persisted) and renders a log browser. This file is the complete public API; everything lives in the `dev.sharingan` package unless noted.
On-device debug logger for Kotlin Multiplatform (Android + iOS). Captures HTTP, MQTT and BLE traffic into an in-memory ring buffer (default 300 events) mirrored to an on-device SQLite flight recorder — bodies are never written to disk — and renders a log browser. This file is the complete public API; everything lives in the `dev.sharingan` package unless noted.

## Artifacts

Expand Down Expand Up @@ -103,7 +103,7 @@ presentSharingan(animated: Boolean = true) // presents over topmost VC, any thr
## Facts agents commonly need

- Zero-setup init on Android via manifest-merged ContentProvider; no Application code.
- Buffer is memory-only; process death clears it. Capacity via `SharinganStore(capacity)`.
- Ring buffer is memory-only; process death clears it (the on-disk flight recorder survives). Bodies are never written to disk. Capacity via `SharinganStore(capacity)`.
- The Ktor plugin rethrows transport failures untouched after recording them (statusCode null, error set).
- Thread-safe: store updates are atomic CAS; loggers callable from any thread.
- Min targets: Android API 24, iOS arm64 + simulator arm64. Requires Ktor 3.x for the plugin.
Expand Down
16 changes: 14 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,29 @@ The single home for everything Sharingan knows about one Protocol: its filter ch
_Avoid_: handler, strategy, adapter (reserved for things satisfying an interface at a seam)

**Capture**:
The act of recording an Event into the Store, via a Logger or the Ktor plugin. Header redaction happens at capture — secrets never enter the Store.
The act of recording an Event into the Store, via a Logger or the Ktor plugin. Header redaction happens at capture — secrets never enter the Store, and redaction applies before disk too.
_Avoid_: logging, tracking, interception

**Flight Recorder**:
The on-disk SQLite mirror of the Store, written via the Write-Behind Seam. Memory stays the source of truth; the Flight Recorder is a post-mortem log, not a database to query. Request/response bodies are never written to disk.
_Avoid_: database, cache

**Logger**:
A per-Protocol capture entry point (`Sharingan.http`, `Sharingan.mqtt`, `Sharingan.ble`). Client-agnostic: host apps wire their own MQTT/BLE libraries into it.
_Avoid_: tracker, recorder

**Run**:
One process lifetime — the Flight Recorder groups all Events captured in a process under a single `session` row.
_Avoid_: session (reserved for the v2 epic)

**Store**:
The in-memory ring buffer of Events (default 300). Memory-only by design — nothing is ever written to disk; process death clears it.
The in-memory ring buffer of Events (default 300). The source of truth while the process lives; each Event is mirrored to the Flight Recorder via the Write-Behind Seam. Process death clears the ring buffer, not the Flight Recorder.
_Avoid_: database, cache, repository, buffer (alone)

**Write-Behind Seam**:
The internal seam (`SharinganStore.onRecord` → `PersistenceController`) that mirrors each accepted Event to the Flight Recorder after the ring-buffer append. It observes Capture, never blocks it.
_Avoid_: listener, hook

**Capture Notification**:
The Android sticky notification showing per-Protocol counters, a three-event Ticker when expanded, and Pause/Resume. Silent, updated in place, and must never crash the host app.
_Avoid_: status bar entry, foreground notification (it isn't a foreground service)
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ No init code — a manifest-merged ContentProvider starts Sharingan automaticall
- **Detail view per event** — timing waterfall, request/response headers (secrets redacted at capture), syntax-colored JSON bodies, QoS/retain flags, GATT operations and decoded values.
- **Share sheet built for agents** — "Copy for AI agent" produces structured Markdown an LLM parses reliably; cURL, raw JSON and the system share sheet are one tap away.
- **Zero release impact** — swap in the `sharingan-noop` artifact: identical API, captures nothing, ships no UI.
- **Memory-only** — an in-memory ring buffer (default 300 events); nothing is ever written to disk.
- **Ring buffer, not a database** — events live in an in-memory ring buffer (default 300 events) mirrored to an on-device SQLite flight recorder (debug builds only); bodies are never written to disk.
- **Minimal** — no image assets, no icon fonts, no bundled typefaces, no DI, no navigation library. The capture core depends only on coroutines (+ Ktor when you use the plugin).

Requirements: Android API 24+, iOS arm64 + simulator arm64, Ktor 3.x for the HTTP plugin.
Expand Down Expand Up @@ -336,7 +336,7 @@ Sharingan.clear() // drop everything
Sharingan.setNotificationEnabled(false) // Android: opt out of the notification
```

`SharinganStore(capacity)` overrides the default 300-event ring (memory-only; process death clears it). Loggers are thread-safe, callable from any thread.
`SharinganStore(capacity)` overrides the default 300-event ring (memory-only; process death clears the ring, not the flight recorder). Loggers are thread-safe, callable from any thread.

## Release builds: what "no effect" means

Expand Down
12 changes: 2 additions & 10 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,7 @@ apiValidation {
// Custom ktlint ruleset (AGENTS.md comments policy) — internal tooling.
ignoredProjects += "ktlint-rules"

// Flight-recorder persistence (issue #27/#49) is SQLDelight-generated code
// that the compiler emits `public` (SQLDelight offers no internal-generation
// option). It is a debug-only internal seam, not consumer API, so it is
// excluded from the guarded public surface. Wrapped behind `internal`
// DriverFactory/controller symbols; never mirrored in :sharingan-noop.
ignoredPackages += "dev.sharingan.persistence"
// `:sharingan-db` ships to Maven Central as a dependency of `:sharingan`, so its ABI is guarded like any published module.

// KMP: also dump/verify the Kotlin/Native (iOS) ABI, not just the JVM/Android
// surface, so iosMain-only declarations (SharinganViewController,
Expand Down Expand Up @@ -78,13 +73,11 @@ tasks.register("checkApiParity") {
// directly, so :sharingan-noop correctly omits them. Excluded from parity:
// - dev/sharingan/ui/** : the in-app Compose viewer screens
// - dev/sharingan/internal/** : init ContentProvider + notification receiver
// - dev/sharingan/persistence/** : SQLDelight-generated flight-recorder DB (public in bytecode only)
// - dev/sharingan/SharinganActivity : the Android viewer Activity
// - any ComposableSingletons$* : Compose-compiler lambda synthetics
fun isExcludedJvmClass(name: String): Boolean =
name.startsWith("dev/sharingan/ui/") ||
name.startsWith("dev/sharingan/internal/") ||
name.startsWith("dev/sharingan/persistence/") ||
name == "dev/sharingan/SharinganActivity" ||
name.contains("ComposableSingletons\$")

Expand Down Expand Up @@ -135,13 +128,12 @@ tasks.register("checkApiParity") {
// - `$stableprop` / `$stableprop_getter`: the native equivalent of the JVM
// `$stable` field, present only in the Compose-carrying real module;
// - `dev.sharingan.ui/SharinganScreen`: the top-level UI composable (ui/**).
// - `dev.sharingan.persistence/`: SQLDelight-generated flight-recorder DB (ui-adjacent internal seam).
fun nativeContractEntries(klibFile: File): Set<String> {
val entries = linkedSetOf<String>()
for (raw in klibFile.readLines()) {
val t = raw.trim()
if (t.isEmpty() || t == "}" || t.startsWith("//")) continue
if ("\$stableprop" in raw || "dev.sharingan.ui/" in raw || "dev.sharingan.persistence/" in raw) continue
if ("\$stableprop" in raw || "dev.sharingan.ui/" in raw) continue
entries.add(t)
}
return entries
Expand Down
10 changes: 5 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Companion docs: [README.md](../README.md) (user-facing), [AGENTS.md](../AGENTS.m

## 1. System overview

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 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. Events live in an in-memory ring buffer (default 300) that is also mirrored to an on-device SQLite flight recorder via a write-behind seam; persistence is on by default in debug, and request/response bodies are never written to disk.

### Module map

Expand Down Expand Up @@ -105,7 +105,7 @@ All deliberate, all verifiable:
- **Streaming never consumed.** `shouldReadBody()` refuses `text/event-stream` and 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 in `try/catch(Exception)` in `CaptureNotification.post()`. This is a scar, not paranoia: commit `8550bbd` records a real crash where the API-26 version guard left the builder chain (`.setSmallIcon` etc.) attached only to the `else` branch, so API 26+ posted an icon-less notification and the resulting `IllegalArgumentException` from 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).
- **Memory-only ring buffer, mirrored on disk.** The ring buffer never touches the network; each event is mirrored to the SQLite flight recorder (§5.4). Process death clears the ring, not the recorder (a stated property, see §6).

### 2.5 UI architecture

Expand Down Expand Up @@ -219,9 +219,9 @@ For **WebSocket capture** specifically: Ktor's `WebSockets` plugin offers no equ

The design explored three densities — **Terminal / Comfortable / Badged** — and only Terminal shipped (`TerminalRow` in [ui/HomeScreen.kt](../sharingan/src/commonMain/kotlin/dev/sharingan/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.

### 5.4 Persist events
### 5.4 Flight recorder (persistence)

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.
The in-memory ring buffer stays the source of truth. Each accepted Event is also mirrored to an on-device SQLite database by a write-behind seam: `SharinganStore.onRecord` (internal callback, invoked after the ring-buffer CAS append) → `PersistenceController` ([sharingan-db](../sharingan-db), SQLDelight). Persistence ships ON by default in debug. The Flight Recorder is a post-mortem log, never queried back into the UI and never the source of truth; one session row groups the Events of each process lifetime (a Run). Redaction applies before disk, and request/response bodies are never written to disk — persisting bodies would change the security posture and is deliberately out of scope.

### 5.5 Sample iosApp Xcode project

Expand All @@ -234,7 +234,7 @@ Add `sample/iosApp/` (standard KMP template: `iosApp.xcodeproj` + SwiftUI `Conte
- **`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; use `Sharingan.show(context)` / `SharinganViewController()`, which *are* mirrored. Documented in [AGENTS.md](../AGENTS.md) and README §"Release builds".
- **DND hides the notification.** The channel is `IMPORTANCE_LOW` and the notification silent (`CaptureNotification.ensureChannel`), so Do Not Disturb suppresses it on most devices. `Sharingan.show(context)` always works.
- **`POST_NOTIFICATIONS` must be requested by the host app** on Android 13+. The library only *declares* the permission ([sharingan/src/androidMain/AndroidManifest.xml](../sharingan/src/androidMain/AndroidManifest.xml)); the sample shows the request ([MainActivity.kt](../sample/composeApp/src/androidMain/kotlin/dev/sharingan/sample/MainActivity.kt)). Without the grant, capture still works (`areNotificationsEnabled()` check + swallowed `notify()` failures), there's just no notification.
- **Buffer lost on process death** — memory-only by design; capacity is per-store (`SharinganStore(capacity = …)`).
- **Ring buffer lost on process death** — memory-only by design, so the ring resets each process; the flight recorder survives (§5.4). Capacity is per-store (`SharinganStore(capacity = …)`).
- **The notification observer starts once per process** (`CaptureNotification.start` guards on `scope != null`) and survives for the process lifetime; `setNotificationEnabled(false)` cancels the posted notification but keeps observing.
- **`HttpEvent.host/path` come from a naive string split** (`splitUrl` in `HttpEvent.kt`), not a URL parser — fine for display, don't reuse it for anything semantic.
- **Request-body capture only sees `OutgoingContent.ByteArrayContent`** (`outgoingBodyText` in `ktor/SharinganKtor.kt`); streamed/chunked request bodies are not captured (responses: textual-only whitelist, SSE never read).
Expand Down
24 changes: 13 additions & 11 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Releasing Sharingan

Sharingan ships two artifacts to Maven Central under `io.github.mibrahimdev`:
Sharingan ships three artifacts to Maven Central under `io.github.mibrahimdev`:

| Coordinate | Module |
| ------------------------------------- | ----------------- |
| `io.github.mibrahimdev:sharingan` | `:sharingan` |
| `io.github.mibrahimdev:sharingan-noop`| `:sharingan-noop` |
| Coordinate | Module |
| -------------------------------------- | ----------------- |
| `io.github.mibrahimdev:sharingan` | `:sharingan` |
| `io.github.mibrahimdev:sharingan-db` | `:sharingan-db` |
| `io.github.mibrahimdev:sharingan-noop` | `:sharingan-noop` |

Releases are **two steps by deliberate design**: CI *stages* a deployment to
the Central Portal, then a human *verifies and releases* it. We do **not**
Expand Down Expand Up @@ -37,7 +38,7 @@ push tag v<version> ──▶ CI stages to Central Portal ──▶ STOP (aw
(`scripts/check-version-tag.sh`).

3. The `Publish to Maven Central` workflow runs `./gradlew publishToMavenCentral`.
This **uploads both modules to a new deployment on the Central Portal and
This **uploads all three modules to a new deployment on the Central Portal and
stops** — nothing is public yet. The job's `Staged - next steps` summary
links straight back to this checklist.

Expand All @@ -48,12 +49,13 @@ push tag v<version> ──▶ CI stages to Central Portal ──▶ STOP (aw
2. Find the new deployment from the CI run. Confirm **all** of:
- **Status** is `VALIDATED` (not `FAILED`). If it failed, expand it to read
why — do **not** publish a failed deployment; fix and re-stage instead.
- **Both** components are present under group `io.github.mibrahimdev`:
- `io.github.mibrahimdev:sharingan:<version>`
- `io.github.mibrahimdev:sharingan-noop:<version>`
- **All three** components are present under group `io.github.mibrahimdev`:
- `io.github.mibrahimdev:sharingan:<version>`
- `io.github.mibrahimdev:sharingan-db:<version>`
- `io.github.mibrahimdev:sharingan-noop:<version>`

If only one module is there, something is wrong — drop the deployment and
re-stage. Never release a half-uploaded version.
If any module is missing, something is wrong — drop the deployment and
re-stage. Never release a half-uploaded version.
- The **version** matches the tag (`<version>`, no stray `-SNAPSHOT`).
- Each component carries its **`.pom`**, the main artifact, **`-sources`**
and **`-javadoc`** jars, and a matching **`.asc` signature** for every
Expand Down
12 changes: 12 additions & 0 deletions docs/api-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ Kotlin/Native link, no Android or iOS SDK — so it runs in a couple of seconds.
./gradlew checkApiParity
```

### What BCV itself excludes

`apiCheck`/`apiDump` are also configured in the root `build.gradle.kts`. The
following modules are intentionally **not** BCV-guarded, because they are not
consumer-facing public API:

- `:sample:composeApp` — the sample application, not a published library.
- `:sharingan-db` — the on-device SQLDelight database is an implementation
detail of `:sharingan`. Its generated surface changes with every schema edit in
slices 3–5, and no consumer codes against it. It is published only as a
required runtime dependency of `:sharingan`.

In CI it runs as the `api-parity` job in
[`.github/workflows/api-check.yml`](../.github/workflows/api-check.yml), on a cheap
`ubuntu` runner (it needs no macOS host).
Expand Down
Loading
Loading