From f7b634eaae9752473811aec59ecedd8d6bbf9560 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 16:07:08 +0100 Subject: [PATCH 1/7] chore: add ktlint and apply one-shot format pass - ktlint Gradle plugin 14.2.0 via version catalog, applied to all modules; ktlintCheck runs as part of check (CI enforcement lands in a follow-up PR) - .editorconfig: ktlint_official style, trailing commas, max line 140; @Composable/@ObjCName functions exempt from function-naming - ktlintFormat across the codebase: 67 files, mechanical changes only (indentation, trailing commas, import order); no logic changes --- .editorconfig | 10 + build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + sample/composeApp/build.gradle.kts | 16 +- .../sample/CaptureNotificationE2eTest.kt | 29 ++- .../sharingan/sample/LoggerLocaleLeakTest.kt | 8 +- .../dev/sharingan/sample/MainActivity.kt | 1 - .../dev/sharingan/sample/DemoTraffic.kt | 77 +++--- .../sharingan/sample/MainViewController.kt | 7 +- sharingan-noop/build.gradle.kts | 13 +- .../androidMain/kotlin/SharinganAndroid.kt | 8 +- .../src/commonMain/kotlin/BleEvent.kt | 1 - .../src/commonMain/kotlin/HttpEvent.kt | 8 +- .../src/commonMain/kotlin/SharinganNoop.kt | 82 ++++++- .../iosMain/kotlin/SharinganViewController.kt | 2 +- sharingan/build.gradle.kts | 13 +- .../dev/sharingan/ui/SharinganScreenUiTest.kt | 230 +++++++++-------- .../kotlin/dev/sharingan/SharinganActivity.kt | 10 +- .../sharingan/internal/CaptureNotification.kt | 89 ++++--- .../sharingan/internal/Platform.android.kt | 3 +- .../internal/PlatformActions.android.kt | 16 +- .../sharingan/internal/SharinganAndroid.kt | 14 +- .../persistence/DriverFactory.android.kt | 5 +- .../ui/PlatformBackHandler.android.kt | 5 +- .../kotlin/dev/sharingan/BleEvent.kt | 1 - .../kotlin/dev/sharingan/BleLogger.kt | 17 +- .../commonMain/kotlin/dev/sharingan/Format.kt | 44 ++-- .../kotlin/dev/sharingan/HttpEvent.kt | 8 +- .../kotlin/dev/sharingan/HttpLogger.kt | 9 +- .../kotlin/dev/sharingan/MqttLogger.kt | 12 +- .../kotlin/dev/sharingan/Sharingan.kt | 1 - .../kotlin/dev/sharingan/SharinganExport.kt | 92 +++---- .../kotlin/dev/sharingan/SharinganStore.kt | 7 +- .../dev/sharingan/ktor/SharinganKtor.kt | 80 +++--- .../kotlin/dev/sharingan/ui/BleDescriptor.kt | 85 ++++--- .../kotlin/dev/sharingan/ui/DetailScreen.kt | 54 ++-- .../kotlin/dev/sharingan/ui/EventFilter.kt | 27 +- .../dev/sharingan/ui/EventPresentation.kt | 76 +++--- .../kotlin/dev/sharingan/ui/HomeScreen.kt | 105 +++++--- .../kotlin/dev/sharingan/ui/HttpDescriptor.kt | 156 ++++++------ .../kotlin/dev/sharingan/ui/JsonPretty.kt | 34 ++- .../kotlin/dev/sharingan/ui/MqttDescriptor.kt | 67 ++--- .../dev/sharingan/ui/PlatformBackHandler.kt | 5 +- .../kotlin/dev/sharingan/ui/PreviewData.kt | 232 +++++++++++------- .../dev/sharingan/ui/ProtocolDescriptor.kt | 59 +++-- .../kotlin/dev/sharingan/ui/ShareResolver.kt | 47 ++-- .../kotlin/dev/sharingan/ui/ShareSheet.kt | 57 +++-- .../kotlin/dev/sharingan/ui/SharinganIcons.kt | 179 ++++++++------ .../kotlin/dev/sharingan/ui/SharinganMark.kt | 18 +- .../dev/sharingan/ui/SharinganScreen.kt | 60 +++-- .../kotlin/dev/sharingan/ui/SharinganTheme.kt | 149 +++++------ .../kotlin/dev/sharingan/ui/Toast.kt | 5 +- .../kotlin/dev/sharingan/HttpEventTest.kt | 81 +++--- .../kotlin/dev/sharingan/LoggersTest.kt | 6 +- .../dev/sharingan/SharinganExportTest.kt | 79 +++--- .../dev/sharingan/SharinganStoreTest.kt | 84 ++++--- .../internal/NotificationContentTest.kt | 44 ++-- .../dev/sharingan/ktor/SharinganKtorTest.kt | 194 ++++++++------- .../SharinganDatabaseRoundTripTest.kt | 1 - .../dev/sharingan/ui/EventFilterTest.kt | 24 +- .../kotlin/dev/sharingan/ui/JsonPrettyTest.kt | 1 - .../dev/sharingan/ui/ScreenInsetsTest.kt | 1 - .../dev/sharingan/ui/ShareResolverTest.kt | 29 +-- .../dev/sharingan/SharinganViewController.kt | 15 +- .../dev/sharingan/internal/Platform.ios.kt | 3 +- .../sharingan/internal/PlatformActions.ios.kt | 20 +- .../persistence/DriverFactory.ios.kt | 3 +- .../sharingan/ui/PlatformBackHandler.ios.kt | 5 +- .../internal/TopmostViewControllerTest.kt | 1 - 69 files changed, 1721 insertions(+), 1206 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..de33917 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*.{kt,kts}] +ij_kotlin_code_style = ktlint_official +max_line_length = 140 +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_with_matching_name = 2147483647 +ktlint_standard_trailing-comma-on-call-site = enabled +ktlint_standard_trailing-comma-on-declaration-site = enabled +ktlint_function_naming_ignore_when_annotated_with = Composable, ObjCName diff --git a/build.gradle.kts b/build.gradle.kts index 10bdd0d..58b489b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.sqldelight) apply false alias(libs.plugins.kotlinSerialization) apply false + alias(libs.plugins.ktlint) apply false // Applied to the root project: BCV injects apiDump/apiCheck into every // subproject and guards the committed public-API dumps (issue #11). alias(libs.plugins.binaryCompatibilityValidator) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 18f7201..d78fe8d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ agp = "8.13.2" composeMultiplatform = "1.11.1" vanniktechMavenPublish = "0.36.0" binaryCompatibilityValidator = "0.18.1" +ktlintGradle = "14.2.0" ktor = "3.5.0" kotlinx-coroutines = "1.11.0" sqldelight = "2.0.2" @@ -48,3 +49,4 @@ mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechM binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidator" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts index 9d9c821..de110e5 100644 --- a/sample/composeApp/build.gradle.kts +++ b/sample/composeApp/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) + alias(libs.plugins.ktlint) } kotlin { @@ -59,12 +60,21 @@ kotlin { android { namespace = "dev.sharingan.sample" - compileSdk = libs.versions.android.compileSdk.get().toInt() + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() defaultConfig { applicationId = "dev.sharingan.sample" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.targetSdk.get().toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + targetSdk = + libs.versions.android.targetSdk + .get() + .toInt() versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/CaptureNotificationE2eTest.kt b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/CaptureNotificationE2eTest.kt index 9905cc9..da1ce91 100644 --- a/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/CaptureNotificationE2eTest.kt +++ b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/CaptureNotificationE2eTest.kt @@ -30,7 +30,6 @@ import org.junit.runner.RunWith */ @RunWith(AndroidJUnit4::class) class CaptureNotificationE2eTest { - // POST_NOTIFICATIONS exists only on API 33+; below that the grant list is empty. @get:Rule val grantNotifications: GrantPermissionRule = @@ -47,12 +46,19 @@ class CaptureNotificationE2eTest { val capturing = awaitNotification(titlePrefix = "Sharingan — Capturing") assertNotNull("capture notification was never posted", capturing) - val text = capturing!!.notification.extras.getCharSequence(Notification.EXTRA_TEXT).toString() + val text = + capturing!! + .notification.extras + .getCharSequence(Notification.EXTRA_TEXT) + .toString() assertTrue( "counters line malformed: $text", text.matches(Regex("""HTTP \d+ · MQTT \d+ · BLE \d+""")), ) - val bigText = capturing.notification.extras.getCharSequence(Notification.EXTRA_BIG_TEXT).toString() + val bigText = + capturing.notification.extras + .getCharSequence(Notification.EXTRA_BIG_TEXT) + .toString() assertTrue("expanded ticker missing: $bigText", bigText.lines().size >= 2) try { @@ -71,14 +77,19 @@ class CaptureNotificationE2eTest { titlePrefix: String, timeoutMillis: Long = 15_000, ): StatusBarNotification? { - val manager = ApplicationProvider.getApplicationContext() - .getSystemService(NotificationManager::class.java) + val manager = + ApplicationProvider + .getApplicationContext() + .getSystemService(NotificationManager::class.java) val deadline = System.currentTimeMillis() + timeoutMillis while (System.currentTimeMillis() < deadline) { - manager.activeNotifications.firstOrNull { posted -> - posted.notification.extras.getCharSequence(Notification.EXTRA_TITLE) - ?.toString()?.startsWith(titlePrefix) == true - }?.let { return it } + manager.activeNotifications + .firstOrNull { posted -> + posted.notification.extras + .getCharSequence(Notification.EXTRA_TITLE) + ?.toString() + ?.startsWith(titlePrefix) == true + }?.let { return it } Thread.sleep(250) } return null diff --git a/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt index 2b1b988..8d7c62f 100644 --- a/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt +++ b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt @@ -36,14 +36,14 @@ import java.util.Locale @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) - } + private fun setAppLocale(tags: String) = + instrumentation.runOnMainSync { + localeManager.applicationLocales = LocaleList.forLanguageTags(tags) + } @Before fun pinHostToArabic() { diff --git a/sample/composeApp/src/androidMain/kotlin/dev/sharingan/sample/MainActivity.kt b/sample/composeApp/src/androidMain/kotlin/dev/sharingan/sample/MainActivity.kt index 3a3bb51..16bdc91 100644 --- a/sample/composeApp/src/androidMain/kotlin/dev/sharingan/sample/MainActivity.kt +++ b/sample/composeApp/src/androidMain/kotlin/dev/sharingan/sample/MainActivity.kt @@ -11,7 +11,6 @@ import dev.sharingan.Sharingan import dev.sharingan.show class MainActivity : ComponentActivity() { - private val requestNotifications = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } diff --git a/sample/composeApp/src/commonMain/kotlin/dev/sharingan/sample/DemoTraffic.kt b/sample/composeApp/src/commonMain/kotlin/dev/sharingan/sample/DemoTraffic.kt index 3ce12bf..b6b7bf4 100644 --- a/sample/composeApp/src/commonMain/kotlin/dev/sharingan/sample/DemoTraffic.kt +++ b/sample/composeApp/src/commonMain/kotlin/dev/sharingan/sample/DemoTraffic.kt @@ -23,44 +23,47 @@ import kotlinx.coroutines.delay * SharinganKtor plugin exactly like production traffic would. */ object DemoTraffic { - - private val client = HttpClient( - MockEngine { request -> - delay((40..220).random().toLong()) - when { - request.url.encodedPath.endsWith("/auth/refresh") -> respond( - """{"error":"invalid_token","message":"Refresh token expired","code":"AUTH_017"}""", - HttpStatusCode.Unauthorized, - jsonHeaders(), - ) - request.url.encodedPath.endsWith("/stream") -> { - delay(900) - respond( - """{"error":"upstream_timeout","message":"Telemetry shard 3 did not respond","traceId":"e0c2-7741"}""", - HttpStatusCode.InternalServerError, - jsonHeaders(), - ) + private val client = + HttpClient( + MockEngine { request -> + delay((40..220).random().toLong()) + when { + request.url.encodedPath.endsWith("/auth/refresh") -> + respond( + """{"error":"invalid_token","message":"Refresh token expired","code":"AUTH_017"}""", + HttpStatusCode.Unauthorized, + jsonHeaders(), + ) + request.url.encodedPath.endsWith("/stream") -> { + delay(900) + respond( + """{"error":"upstream_timeout","message":"Telemetry shard 3 did not respond","traceId":"e0c2-7741"}""", + HttpStatusCode.InternalServerError, + jsonHeaders(), + ) + } + request.url.encodedPath.endsWith("/commands") -> + respond( + """{"accepted":true,"commandId":9921,"queuedAt":"2026-06-10T12:04:33Z"}""", + HttpStatusCode.Accepted, + jsonHeaders(), + ) + request.method.value == "DELETE" -> respond("", HttpStatusCode.NoContent) + else -> + respond( + """{"deviceId":4471,"online":true,"firmware":"2.4.1","battery":0.86,"sensors":{"temp":23.4,"humidity":48}}""", + HttpStatusCode.OK, + headersOf( + HttpHeaders.ContentType to listOf("application/json; charset=utf-8"), + "X-Trace-Id" to listOf("b1f4-22a9"), + HttpHeaders.CacheControl to listOf("no-store"), + ), + ) } - request.url.encodedPath.endsWith("/commands") -> respond( - """{"accepted":true,"commandId":9921,"queuedAt":"2026-06-10T12:04:33Z"}""", - HttpStatusCode.Accepted, - jsonHeaders(), - ) - request.method.value == "DELETE" -> respond("", HttpStatusCode.NoContent) - else -> respond( - """{"deviceId":4471,"online":true,"firmware":"2.4.1","battery":0.86,"sensors":{"temp":23.4,"humidity":48}}""", - HttpStatusCode.OK, - headersOf( - HttpHeaders.ContentType to listOf("application/json; charset=utf-8"), - "X-Trace-Id" to listOf("b1f4-22a9"), - HttpHeaders.CacheControl to listOf("no-store"), - ), - ) - } - }, - ) { - install(SharinganKtor) - } + }, + ) { + install(SharinganKtor) + } private fun jsonHeaders() = headersOf(HttpHeaders.ContentType, "application/json") diff --git a/sample/composeApp/src/iosMain/kotlin/dev/sharingan/sample/MainViewController.kt b/sample/composeApp/src/iosMain/kotlin/dev/sharingan/sample/MainViewController.kt index f2b0b74..0841452 100644 --- a/sample/composeApp/src/iosMain/kotlin/dev/sharingan/sample/MainViewController.kt +++ b/sample/composeApp/src/iosMain/kotlin/dev/sharingan/sample/MainViewController.kt @@ -6,6 +6,7 @@ import platform.UIKit.UIViewController /** Entry point for the iOS sample's `UIViewControllerRepresentable`. */ @Suppress("unused", "FunctionName") -fun MainViewController(): UIViewController = ComposeUIViewController { - App(openSharingan = { presentSharingan() }) -} +fun MainViewController(): UIViewController = + ComposeUIViewController { + App(openSharingan = { presentSharingan() }) + } diff --git a/sharingan-noop/build.gradle.kts b/sharingan-noop/build.gradle.kts index bb3fd43..fca3445 100644 --- a/sharingan-noop/build.gradle.kts +++ b/sharingan-noop/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidLibrary) alias(libs.plugins.mavenPublish) + alias(libs.plugins.ktlint) } // Maven coordinate only — Kotlin packages and the Android namespace stay @@ -47,10 +48,16 @@ kotlin { android { namespace = "dev.sharingan.noop" - compileSdk = libs.versions.android.compileSdk.get().toInt() + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() defaultConfig { - minSdk = libs.versions.android.minSdk.get().toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() } compileOptions { @@ -72,7 +79,7 @@ mavenPublishing { pom { name.set("Sharingan (no-op)") description.set( - "Inert release replacement for Sharingan — same API, zero runtime cost." + "Inert release replacement for Sharingan — same API, zero runtime cost.", ) url.set("https://mibrahimdev.github.io/Sharingan/") licenses { diff --git a/sharingan-noop/src/androidMain/kotlin/SharinganAndroid.kt b/sharingan-noop/src/androidMain/kotlin/SharinganAndroid.kt index 7f83ea5..a7fdd5e 100644 --- a/sharingan-noop/src/androidMain/kotlin/SharinganAndroid.kt +++ b/sharingan-noop/src/androidMain/kotlin/SharinganAndroid.kt @@ -3,7 +3,11 @@ package dev.sharingan import android.content.Context /** No-op: the release artifact has no log browser to open. */ -public fun Sharingan.show(@Suppress("UNUSED_PARAMETER") context: Context) {} +public fun Sharingan.show( + @Suppress("UNUSED_PARAMETER") context: Context, +) {} /** No-op: the release artifact posts no notification. */ -public fun Sharingan.setNotificationEnabled(@Suppress("UNUSED_PARAMETER") enabled: Boolean) {} +public fun Sharingan.setNotificationEnabled( + @Suppress("UNUSED_PARAMETER") enabled: Boolean, +) {} diff --git a/sharingan-noop/src/commonMain/kotlin/BleEvent.kt b/sharingan-noop/src/commonMain/kotlin/BleEvent.kt index 4ae3e69..b7e4545 100644 --- a/sharingan-noop/src/commonMain/kotlin/BleEvent.kt +++ b/sharingan-noop/src/commonMain/kotlin/BleEvent.kt @@ -29,7 +29,6 @@ public data class BleEvent internal constructor( public val sizeBytes: Long? = null, override val error: String? = null, ) : SharinganEvent { - /** Failures are explicit errors or [BleOperation.ERROR] operations. */ override val isFailure: Boolean get() = error != null || operation == BleOperation.ERROR } diff --git a/sharingan-noop/src/commonMain/kotlin/HttpEvent.kt b/sharingan-noop/src/commonMain/kotlin/HttpEvent.kt index 41e83c6..a79083c 100644 --- a/sharingan-noop/src/commonMain/kotlin/HttpEvent.kt +++ b/sharingan-noop/src/commonMain/kotlin/HttpEvent.kt @@ -34,7 +34,6 @@ public data class HttpEvent internal constructor( public val timing: List = emptyList(), override val error: String? = null, ) : SharinganEvent { - /** Authority part of [url], including the port when present. */ public val host: String get() = splitUrl(url).first @@ -48,8 +47,11 @@ public data class HttpEvent internal constructor( fun splitUrl(url: String): Pair { val afterScheme = url.substringAfter("://", url) val slash = afterScheme.indexOf('/') - return if (slash == -1) afterScheme to "/" - else afterScheme.take(slash) to afterScheme.substring(slash) + return if (slash == -1) { + afterScheme to "/" + } else { + afterScheme.take(slash) to afterScheme.substring(slash) + } } } } diff --git a/sharingan-noop/src/commonMain/kotlin/SharinganNoop.kt b/sharingan-noop/src/commonMain/kotlin/SharinganNoop.kt index f1870ce..ac28508 100644 --- a/sharingan-noop/src/commonMain/kotlin/SharinganNoop.kt +++ b/sharingan-noop/src/commonMain/kotlin/SharinganNoop.kt @@ -18,7 +18,9 @@ public class SharinganStore( public val isRecording: StateFlow = _isRecording.asStateFlow() public fun record(event: SharinganEvent) {} + public fun setRecording(recording: Boolean) {} + public fun clear() {} public companion object { @@ -54,7 +56,9 @@ public class HttpLogger( } /** No-op MQTT logger. */ -public class MqttLogger(@Suppress("UNUSED_PARAMETER") store: SharinganStore) { +public class MqttLogger( + @Suppress("UNUSED_PARAMETER") store: SharinganStore, +) { public fun publish( topic: String, payload: String?, @@ -63,19 +67,69 @@ public class MqttLogger(@Suppress("UNUSED_PARAMETER") store: SharinganStore) { error: String? = null, ) {} - public fun received(topic: String, payload: String?, qos: Int = 0, retained: Boolean = false) {} - public fun subscribed(topicFilter: String, qos: Int = 0) {} + public fun received( + topic: String, + payload: String?, + qos: Int = 0, + retained: Boolean = false, + ) {} + + public fun subscribed( + topicFilter: String, + qos: Int = 0, + ) {} } /** No-op BLE logger. */ -public class BleLogger(@Suppress("UNUSED_PARAMETER") store: SharinganStore) { - public fun connect(device: String, error: String? = null) {} - public fun disconnect(device: String, error: String? = null) {} - public fun discover(device: String, service: String? = null, uuid: String? = null, detail: String? = null) {} - public fun read(device: String, characteristic: String? = null, uuid: String? = null, value: String? = null, error: String? = null) {} - public fun write(device: String, characteristic: String? = null, uuid: String? = null, value: String? = null, error: String? = null) {} - public fun notify(device: String, characteristic: String? = null, uuid: String? = null, value: String? = null) {} - public fun error(device: String, message: String, characteristic: String? = null, uuid: String? = null) {} +public class BleLogger( + @Suppress("UNUSED_PARAMETER") store: SharinganStore, +) { + public fun connect( + device: String, + error: String? = null, + ) {} + + public fun disconnect( + device: String, + error: String? = null, + ) {} + + public fun discover( + device: String, + service: String? = null, + uuid: String? = null, + detail: String? = null, + ) {} + + public fun read( + device: String, + characteristic: String? = null, + uuid: String? = null, + value: String? = null, + error: String? = null, + ) {} + + public fun write( + device: String, + characteristic: String? = null, + uuid: String? = null, + value: String? = null, + error: String? = null, + ) {} + + public fun notify( + device: String, + characteristic: String? = null, + uuid: String? = null, + value: String? = null, + ) {} + + public fun error( + device: String, + message: String, + characteristic: String? = null, + uuid: String? = null, + ) {} } /** No-op facade: same surface as the real artifact, captures nothing. */ @@ -89,15 +143,21 @@ public object Sharingan { public val isRecording: StateFlow get() = store.isRecording public fun setRecording(recording: Boolean) {} + public fun clear() {} } /** No-op exporters: signatures match, output is empty. */ public object SharinganExport { public fun agentMarkdown(event: SharinganEvent): String = "" + public fun agentMarkdown(events: List): String = "" + public fun curl(event: HttpEvent): String = "" + public fun json(event: SharinganEvent): String = "" + public fun sessionJson(events: List): String = "" + public fun summary(events: List): String = "" } diff --git a/sharingan-noop/src/iosMain/kotlin/SharinganViewController.kt b/sharingan-noop/src/iosMain/kotlin/SharinganViewController.kt index e6bd46d..2035486 100644 --- a/sharingan-noop/src/iosMain/kotlin/SharinganViewController.kt +++ b/sharingan-noop/src/iosMain/kotlin/SharinganViewController.kt @@ -1,8 +1,8 @@ package dev.sharingan +import platform.UIKit.UIViewController import kotlin.experimental.ExperimentalObjCName import kotlin.native.ObjCName -import platform.UIKit.UIViewController /** * No-op twin: returns an empty view controller so Swift call sites compile diff --git a/sharingan/build.gradle.kts b/sharingan/build.gradle.kts index 653cd7d..212e442 100644 --- a/sharingan/build.gradle.kts +++ b/sharingan/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.sqldelight) alias(libs.plugins.kotlinSerialization) alias(libs.plugins.mavenPublish) + alias(libs.plugins.ktlint) } // Maven coordinate only — Kotlin packages and the Android namespace stay @@ -101,10 +102,16 @@ kotlin { android { namespace = "dev.sharingan" - compileSdk = libs.versions.android.compileSdk.get().toInt() + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() defaultConfig { - minSdk = libs.versions.android.minSdk.get().toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() consumerProguardFiles("consumer-rules.pro") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -132,7 +139,7 @@ mavenPublishing { pom { name.set("Sharingan") description.set( - "On-device debug logger and HTTP/MQTT/BLE inspector for Android & Kotlin Multiplatform." + "On-device debug logger and HTTP/MQTT/BLE inspector for Android & Kotlin Multiplatform.", ) url.set("https://mibrahimdev.github.io/Sharingan/") licenses { diff --git a/sharingan/src/androidInstrumentedTest/kotlin/dev/sharingan/ui/SharinganScreenUiTest.kt b/sharingan/src/androidInstrumentedTest/kotlin/dev/sharingan/ui/SharinganScreenUiTest.kt index d8cfaae..3bfa73e 100644 --- a/sharingan/src/androidInstrumentedTest/kotlin/dev/sharingan/ui/SharinganScreenUiTest.kt +++ b/sharingan/src/androidInstrumentedTest/kotlin/dev/sharingan/ui/SharinganScreenUiTest.kt @@ -30,142 +30,164 @@ import org.junit.Test */ @OptIn(ExperimentalTestApi::class) internal class SharinganScreenUiTest { - /** A miniature of the sample's IoT round: 3 HTTP (one 401, one 500), 1 MQTT, 1 BLE. */ - private fun seededStore(): SharinganStore = SharinganStore().apply { - record( - HttpEvent( - id = "h1", timestampMillis = 1_000, method = "GET", - url = "https://api.acme-iot.com/api/v2/devices/4471/state", - statusCode = 200, durationMillis = 82, - ), - ) - record( - HttpEvent( - id = "h2", timestampMillis = 2_000, method = "POST", - url = "https://api.acme-iot.com/api/v2/auth/refresh", - statusCode = 401, durationMillis = 178, - ), - ) - record( - HttpEvent( - id = "h3", timestampMillis = 3_000, method = "GET", - url = "https://api.acme-iot.com/api/v2/telemetry/4471/stream", - statusCode = 500, durationMillis = 1037, - responseBody = """{"error":"upstream_timeout"}""", - ), - ) - record( - MqttEvent( - id = "m1", timestampMillis = 4_000, - direction = MqttDirection.PUBLISH, topic = "devices/4471/telemetry", qos = 1, - ), - ) - record( - BleEvent( - id = "b1", timestampMillis = 5_000, - operation = BleOperation.NOTIFY, device = "HR-9F", - characteristic = "Heart Rate Measurement", - ), - ) - } + private fun seededStore(): SharinganStore = + SharinganStore().apply { + record( + HttpEvent( + id = "h1", + timestampMillis = 1_000, + method = "GET", + url = "https://api.acme-iot.com/api/v2/devices/4471/state", + statusCode = 200, + durationMillis = 82, + ), + ) + record( + HttpEvent( + id = "h2", + timestampMillis = 2_000, + method = "POST", + url = "https://api.acme-iot.com/api/v2/auth/refresh", + statusCode = 401, + durationMillis = 178, + ), + ) + record( + HttpEvent( + id = "h3", + timestampMillis = 3_000, + method = "GET", + url = "https://api.acme-iot.com/api/v2/telemetry/4471/stream", + statusCode = 500, + durationMillis = 1037, + responseBody = """{"error":"upstream_timeout"}""", + ), + ) + record( + MqttEvent( + id = "m1", + timestampMillis = 4_000, + direction = MqttDirection.PUBLISH, + topic = "devices/4471/telemetry", + qos = 1, + ), + ) + record( + BleEvent( + id = "b1", + timestampMillis = 5_000, + operation = BleOperation.NOTIFY, + device = "HR-9F", + characteristic = "Heart Rate Measurement", + ), + ) + } @Test - fun homeTab_showsHttpRowsWithStatusAndCount() = runComposeUiTest { - setContent { SharinganScreen(store = seededStore()) } + fun homeTab_showsHttpRowsWithStatusAndCount() = + runComposeUiTest { + setContent { SharinganScreen(store = seededStore()) } - onNodeWithText("3 REQUESTS").assertExists() - onNodeWithText("/api/v2/devices/4471/state").assertExists() - onNodeWithText("401").assertExists() - onNodeWithText("500").assertExists() - } + onNodeWithText("3 REQUESTS").assertExists() + onNodeWithText("/api/v2/devices/4471/state").assertExists() + onNodeWithText("401").assertExists() + onNodeWithText("500").assertExists() + } @Test - fun errorsChip_filtersToFailuresOnly() = runComposeUiTest { - setContent { SharinganScreen(store = seededStore()) } + fun errorsChip_filtersToFailuresOnly() = + runComposeUiTest { + setContent { SharinganScreen(store = seededStore()) } - onNodeWithText("Errors").performClick() + onNodeWithText("Errors").performClick() - onNodeWithText("2 REQUESTS").assertExists() - onNodeWithText("/api/v2/devices/4471/state").assertDoesNotExist() - onNodeWithText("401").assertExists() - onNodeWithText("500").assertExists() - } + onNodeWithText("2 REQUESTS").assertExists() + onNodeWithText("/api/v2/devices/4471/state").assertDoesNotExist() + onNodeWithText("401").assertExists() + onNodeWithText("500").assertExists() + } @Test - fun mqttTab_showsDescriptorChipsAndRows() = runComposeUiTest { - setContent { SharinganScreen(store = seededStore()) } + fun mqttTab_showsDescriptorChipsAndRows() = + runComposeUiTest { + setContent { SharinganScreen(store = seededStore()) } - onNodeWithText("MQTT").performClick() + onNodeWithText("MQTT").performClick() - onNodeWithText("1 MESSAGES").assertExists() - onNodeWithText("Pub").assertExists() - onNodeWithText("Sub").assertExists() - onNodeWithText("PUB").assertExists() - onNodeWithText("devices/4471/telemetry").assertExists() - } + onNodeWithText("1 MESSAGES").assertExists() + onNodeWithText("Pub").assertExists() + onNodeWithText("Sub").assertExists() + onNodeWithText("PUB").assertExists() + onNodeWithText("devices/4471/telemetry").assertExists() + } @Test - fun search_narrowsRowsByUrl() = runComposeUiTest { - setContent { SharinganScreen(store = seededStore()) } + fun search_narrowsRowsByUrl() = + runComposeUiTest { + setContent { SharinganScreen(store = seededStore()) } - onNode(hasSetTextAction()).performTextInput("telemetry") + onNode(hasSetTextAction()).performTextInput("telemetry") - onNodeWithText("1 REQUESTS").assertExists() - onNodeWithText("/api/v2/telemetry/4471/stream").assertExists() - onNodeWithText("/api/v2/auth/refresh").assertDoesNotExist() - } + onNodeWithText("1 REQUESTS").assertExists() + onNodeWithText("/api/v2/telemetry/4471/stream").assertExists() + onNodeWithText("/api/v2/auth/refresh").assertDoesNotExist() + } @Test - fun rowTap_opensDetailWithSummaryAndBody_backReturnsHome() = runComposeUiTest { - setContent { SharinganScreen(store = seededStore()) } + fun rowTap_opensDetailWithSummaryAndBody_backReturnsHome() = + runComposeUiTest { + setContent { SharinganScreen(store = seededStore()) } - onNodeWithText("/api/v2/telemetry/4471/stream").performClick() + onNodeWithText("/api/v2/telemetry/4471/stream").performClick() - onNodeWithText("SUMMARY").assertExists() - onNodeWithText("STATUS").assertExists() - onNodeWithText("upstream_timeout", substring = true).assertExists() + onNodeWithText("SUMMARY").assertExists() + onNodeWithText("STATUS").assertExists() + onNodeWithText("upstream_timeout", substring = true).assertExists() - onNodeWithText("Back").performClick() - onNodeWithText("3 REQUESTS").assertExists() - } + onNodeWithText("Back").performClick() + onNodeWithText("3 REQUESTS").assertExists() + } @Test - fun shareSheet_offersAgentCopyFirstAndCurlForSingleHttp() = runComposeUiTest { - setContent { SharinganScreen(store = seededStore()) } + fun shareSheet_offersAgentCopyFirstAndCurlForSingleHttp() = + runComposeUiTest { + setContent { SharinganScreen(store = seededStore()) } - onNodeWithText("/api/v2/auth/refresh").performClick() - onNodeWithText("Share").performClick() + onNodeWithText("/api/v2/auth/refresh").performClick() + onNodeWithText("Share").performClick() - onNodeWithText("Share this request").assertExists() - onNodeWithText("Copy for AI agent").assertExists() - onNodeWithText("Copy as cURL").assertExists() - // Preview pane already renders the agent Markdown for the selected event. - onNodeWithText("## POST /api/v2/auth/refresh", substring = true).assertExists() - } + onNodeWithText("Share this request").assertExists() + onNodeWithText("Copy for AI agent").assertExists() + onNodeWithText("Copy as cURL").assertExists() + // Preview pane already renders the agent Markdown for the selected event. + onNodeWithText("## POST /api/v2/auth/refresh", substring = true).assertExists() + } @Test - fun copyForAgent_showsToastThatAutoDismisses() = runComposeUiTest { - setContent { SharinganScreen(store = seededStore()) } + fun copyForAgent_showsToastThatAutoDismisses() = + runComposeUiTest { + setContent { SharinganScreen(store = seededStore()) } - onNodeWithText("/api/v2/auth/refresh").performClick() - onNodeWithText("Share").performClick() - onNodeWithText("Copy for AI agent").performClick() + onNodeWithText("/api/v2/auth/refresh").performClick() + onNodeWithText("Share").performClick() + onNodeWithText("Copy for AI agent").performClick() - onNodeWithText("Copied for agent ✓").assertExists() + onNodeWithText("Copied for agent ✓").assertExists() - mainClock.advanceTimeBy(2_500) - onNodeWithText("Copied for agent ✓").assertDoesNotExist() - } + mainClock.advanceTimeBy(2_500) + onNodeWithText("Copied for agent ✓").assertDoesNotExist() + } @Test - fun recPill_pausesCaptureAndShowsPausedState() = runComposeUiTest { - val store = seededStore() - setContent { SharinganScreen(store = store) } + fun recPill_pausesCaptureAndShowsPausedState() = + runComposeUiTest { + val store = seededStore() + setContent { SharinganScreen(store = store) } - onNodeWithText("REC").performClick() + onNodeWithText("REC").performClick() - onNodeWithText("PAUSED").assertExists() - assertFalse(store.isRecording.value) - } + onNodeWithText("PAUSED").assertExists() + assertFalse(store.isRecording.value) + } } diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/SharinganActivity.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/SharinganActivity.kt index 3e9a506..15ef975 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/SharinganActivity.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/SharinganActivity.kt @@ -24,15 +24,15 @@ import java.util.Locale * 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) - } + 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) diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/CaptureNotification.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/CaptureNotification.kt index 11e8e74..65c5387 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/CaptureNotification.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/CaptureNotification.kt @@ -26,7 +26,6 @@ import kotlinx.coroutines.launch * vibration, and updated in place. */ internal object CaptureNotification { - private const val CHANNEL_ID = "sharingan.capture" private const val NOTIFICATION_ID = 0x5EE1 private var scope: CoroutineScope? = null @@ -42,53 +41,64 @@ internal object CaptureNotification { /** Starts observing the store; first event posts the notification. */ fun start(context: Context) { if (scope != null) return - scope = CoroutineScope(SupervisorJob() + Dispatchers.Default).also { observer -> - observer.launch { - Sharingan.store.events - .combine(Sharingan.store.isRecording) { events, recording -> events to recording } - .conflate() - .collect { (events, recording) -> - if (enabled) post(context, events, recording) - } + scope = + CoroutineScope(SupervisorJob() + Dispatchers.Default).also { observer -> + observer.launch { + Sharingan.store.events + .combine(Sharingan.store.isRecording) { events, recording -> events to recording } + .conflate() + .collect { (events, recording) -> + if (enabled) post(context, events, recording) + } + } } - } } - private fun post(context: Context, events: List, recording: Boolean) { + private fun post( + context: Context, + events: List, + recording: Boolean, + ) { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (!manager.areNotificationsEnabled()) return val content = notificationContentOf(events, recording) ?: return ensureChannel(manager) - val openIntent = PendingIntent.getActivity( - context, - 0, - Intent(context, SharinganActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - val toggleIntent = PendingIntent.getBroadcast( - context, - 1, - Intent(context, SharinganNotificationReceiver::class.java) - .setAction(SharinganNotificationReceiver.ACTION_TOGGLE_RECORDING), - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) + val openIntent = + PendingIntent.getActivity( + context, + 0, + Intent(context, SharinganActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + val toggleIntent = + PendingIntent.getBroadcast( + context, + 1, + Intent(context, SharinganNotificationReceiver::class.java) + .setAction(SharinganNotificationReceiver.ACTION_TOGGLE_RECORDING), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) @Suppress("DEPRECATION") val base = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) Notification.Builder(context, CHANNEL_ID) - else Notification.Builder(context) - val builder = base - .setSmallIcon(R.drawable.sharingan_ic_notification) - .setContentTitle(content.title) - .setContentText(content.countsLine) - .setStyle(Notification.BigTextStyle().bigText(content.expandedText)) - .setContentIntent(openIntent) - .setOngoing(true) - .setOnlyAlertOnce(true) - .addAction( - Notification.Action.Builder(null, content.actionLabel, toggleIntent).build(), - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, CHANNEL_ID) + } else { + Notification.Builder(context) + } + val builder = + base + .setSmallIcon(R.drawable.sharingan_ic_notification) + .setContentTitle(content.title) + .setContentText(content.countsLine) + .setStyle(Notification.BigTextStyle().bigText(content.expandedText)) + .setContentIntent(openIntent) + .setOngoing(true) + .setOnlyAlertOnce(true) + .addAction( + Notification.Action.Builder(null, content.actionLabel, toggleIntent).build(), + ) try { manager.notify(NOTIFICATION_ID, builder.build()) @@ -116,7 +126,10 @@ internal object CaptureNotification { /** Handles the notification's Pause/Resume action. */ public class SharinganNotificationReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { + override fun onReceive( + context: Context, + intent: Intent, + ) { if (intent.action == ACTION_TOGGLE_RECORDING) { Sharingan.setRecording(!Sharingan.isRecording.value) } diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/Platform.android.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/Platform.android.kt index 302727d..dad750d 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/Platform.android.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/Platform.android.kt @@ -6,5 +6,4 @@ import java.util.Locale internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() -internal actual fun formatClockTime(epochMillis: Long): String = - SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date(epochMillis)) +internal actual fun formatClockTime(epochMillis: Long): String = SimpleDateFormat("HH:mm:ss.SSS", Locale.US).format(Date(epochMillis)) diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/PlatformActions.android.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/PlatformActions.android.kt index 41cc575..d9f7d81 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/PlatformActions.android.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/PlatformActions.android.kt @@ -13,12 +13,14 @@ internal actual fun copyToClipboard(text: String) { internal actual fun shareText(text: String) { val context = SharinganAndroid.appContext ?: return - val send = Intent(Intent.ACTION_SEND).apply { - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, text) - } - val chooser = Intent.createChooser(send, "Share Sharingan log").apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } + val send = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, text) + } + val chooser = + Intent.createChooser(send, "Share Sharingan log").apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } context.startActivity(chooser) } diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt index 75973be..5473e27 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/SharinganAndroid.kt @@ -40,8 +40,18 @@ public class SharinganInitProvider : ContentProvider() { ): Cursor? = null override fun getType(uri: Uri): String? = null - override fun insert(uri: Uri, values: ContentValues?): Uri? = null - override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + + override fun insert( + uri: Uri, + values: ContentValues?, + ): Uri? = null + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + override fun update( uri: Uri, values: ContentValues?, diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt index 98a7580..e8d1fc6 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/persistence/DriverFactory.android.kt @@ -6,8 +6,9 @@ import dev.sharingan.internal.SharinganAndroid internal actual class DriverFactory { actual fun create(): SqlDriver { - val context = SharinganAndroid.appContext - ?: error("Sharingan application context is unavailable; the manifest ContentProvider must initialize it first") + val context = + SharinganAndroid.appContext + ?: error("Sharingan application context is unavailable; the manifest ContentProvider must initialize it first") return AndroidSqliteDriver(SharinganDatabase.Schema, context, "sharingan.db") } } diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/ui/PlatformBackHandler.android.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/ui/PlatformBackHandler.android.kt index 994f75f..b06cb51 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/ui/PlatformBackHandler.android.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/ui/PlatformBackHandler.android.kt @@ -4,6 +4,9 @@ import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable @Composable -internal actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) { +internal actual fun PlatformBackHandler( + enabled: Boolean, + onBack: () -> Unit, +) { BackHandler(enabled = enabled, onBack = onBack) } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/BleEvent.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/BleEvent.kt index 4ae3e69..b7e4545 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/BleEvent.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/BleEvent.kt @@ -29,7 +29,6 @@ public data class BleEvent internal constructor( public val sizeBytes: Long? = null, override val error: String? = null, ) : SharinganEvent { - /** Failures are explicit errors or [BleOperation.ERROR] operations. */ override val isFailure: Boolean get() = error != null || operation == BleOperation.ERROR } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/BleLogger.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/BleLogger.kt index 26553bf..4c48398 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/BleLogger.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/BleLogger.kt @@ -10,15 +10,22 @@ import dev.sharingan.internal.currentTimeMillis * callbacks (Kable, RxAndroidBle, CoreBluetooth bridges…). See the project * README for adapter recipes. */ -public class BleLogger(private val store: SharinganStore) { - +public class BleLogger( + private val store: SharinganStore, +) { /** Records a successful connection to [device]. */ - public fun connect(device: String, error: String? = null) { + public fun connect( + device: String, + error: String? = null, + ) { record(BleOperation.CONNECT, device, error = error) } /** Records a disconnection from [device]. */ - public fun disconnect(device: String, error: String? = null) { + public fun disconnect( + device: String, + error: String? = null, + ) { record(BleOperation.DISCONNECT, device, error = error) } @@ -93,7 +100,7 @@ public class BleLogger(private val store: SharinganStore) { payload = value, sizeBytes = value?.encodeToByteArray()?.size?.toLong(), error = error, - ) + ), ) } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/Format.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/Format.kt index 2ac4dbc..631ef87 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/Format.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/Format.kt @@ -21,28 +21,30 @@ private fun oneDecimal(value: Double): String { /** `PUB` / `RECV` / `SUB` — the compact labels used in rows and exports. */ internal val MqttDirection.shortLabel: String - get() = when (this) { - MqttDirection.PUBLISH -> "PUB" - MqttDirection.RECEIVE -> "RECV" - MqttDirection.SUBSCRIBE -> "SUB" - } + get() = + when (this) { + MqttDirection.PUBLISH -> "PUB" + MqttDirection.RECEIVE -> "RECV" + MqttDirection.SUBSCRIBE -> "SUB" + } /** Escapes a string for embedding inside a JSON string literal. */ -internal fun jsonEscape(value: String): String = buildString(value.length + 8) { - for (ch in value) { - when (ch) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> - if (ch < ' ') { - append("\\u") - append(ch.code.toString(16).padStart(4, '0')) - } else { - append(ch) - } +internal fun jsonEscape(value: String): String = + buildString(value.length + 8) { + for (ch in value) { + when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> + if (ch < ' ') { + append("\\u") + append(ch.code.toString(16).padStart(4, '0')) + } else { + append(ch) + } + } } } -} diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/HttpEvent.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/HttpEvent.kt index e956d10..5840221 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/HttpEvent.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/HttpEvent.kt @@ -47,7 +47,6 @@ public data class HttpEvent internal constructor( public val timing: List = emptyList(), override val error: String? = null, ) : SharinganEvent { - /** Authority part of [url], including the port when present. */ public val host: String get() = splitUrl(url).first @@ -61,8 +60,11 @@ public data class HttpEvent internal constructor( fun splitUrl(url: String): Pair { val afterScheme = url.substringAfter("://", url) val slash = afterScheme.indexOf('/') - return if (slash == -1) afterScheme to "/" - else afterScheme.take(slash) to afterScheme.substring(slash) + return if (slash == -1) { + afterScheme to "/" + } else { + afterScheme.take(slash) to afterScheme.substring(slash) + } } } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/HttpLogger.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/HttpLogger.kt index 19f893e..b712eda 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/HttpLogger.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/HttpLogger.kt @@ -56,14 +56,17 @@ public class HttpLogger( responseSizeBytes = responseSizeBytes, timing = timing, error = error, - ) + ), ) } private fun redact(headers: List>): List> = headers.map { (name, value) -> - if (redactedHeaders.any { it.equals(name, ignoreCase = true) }) name to REDACTED_VALUE - else name to value + if (redactedHeaders.any { it.equals(name, ignoreCase = true) }) { + name to REDACTED_VALUE + } else { + name to value + } } public companion object { diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/MqttLogger.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/MqttLogger.kt index 314d87d..ec08bef 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/MqttLogger.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/MqttLogger.kt @@ -10,8 +10,9 @@ import dev.sharingan.internal.currentTimeMillis * client's callbacks (one line per hook). See the project README for adapter * recipes. */ -public class MqttLogger(private val store: SharinganStore) { - +public class MqttLogger( + private val store: SharinganStore, +) { /** Records an outgoing publish. */ public fun publish( topic: String, @@ -34,7 +35,10 @@ public class MqttLogger(private val store: SharinganStore) { } /** Records an established subscription. [topicFilter] may contain wildcards. */ - public fun subscribed(topicFilter: String, qos: Int = 0) { + public fun subscribed( + topicFilter: String, + qos: Int = 0, + ) { record(MqttDirection.SUBSCRIBE, topicFilter, payload = null, qos = qos, retained = false, error = null) } @@ -57,7 +61,7 @@ public class MqttLogger(private val store: SharinganStore) { payload = payload, payloadSizeBytes = payload?.encodeToByteArray()?.size?.toLong(), error = error, - ) + ), ) } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/Sharingan.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/Sharingan.kt index 04409c1..5262fe0 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/Sharingan.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/Sharingan.kt @@ -23,7 +23,6 @@ import kotlinx.coroutines.flow.StateFlow * API with empty implementations, so calls compile identically and ship no UI. */ public object Sharingan { - /** The shared event buffer backing the notification and the log browser. */ public val store: SharinganStore = SharinganStore() diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt index 01980e0..14153fc 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt @@ -21,68 +21,72 @@ import dev.sharingan.ui.protocolCountsLine * separators, tool metadata). */ public object SharinganExport { - /** Structured Markdown for one event — the "Copy for AI agent" format. */ - public fun agentMarkdown(event: SharinganEvent): String = - descriptorOf(event).agentMarkdown(event) + public fun agentMarkdown(event: SharinganEvent): String = descriptorOf(event).agentMarkdown(event) /** Structured Markdown for a whole session: a count header, then one section per event. */ - public fun agentMarkdown(events: List): String = buildString { - appendLine("# Sharingan session export") - appendLine(countsLine(events)) - for (event in events) { - appendLine() - appendLine("---") - appendLine() - append(agentMarkdown(event)) - appendLine() - } - }.trimEnd() + public fun agentMarkdown(events: List): String = + buildString { + appendLine("# Sharingan session export") + appendLine(countsLine(events)) + for (event in events) { + appendLine() + appendLine("---") + appendLine() + append(agentMarkdown(event)) + appendLine() + } + }.trimEnd() /** A reproducible `curl` command for [event]. Redacted headers stay masked. */ - public fun curl(event: HttpEvent): String = buildString { - append("curl -X ").append(event.method) - append(" ").append(shellQuote(event.url)) - for ((name, value) in event.requestHeaders) { - append(" \\\n -H ").append(shellQuote("$name: $value")) - } - event.requestBody?.let { body -> - append(" \\\n --data ").append(shellQuote(body)) + public fun curl(event: HttpEvent): String = + buildString { + append("curl -X ").append(event.method) + append(" ").append(shellQuote(event.url)) + for ((name, value) in event.requestHeaders) { + append(" \\\n -H ").append(shellQuote("$name: $value")) + } + event.requestBody?.let { body -> + append(" \\\n --data ").append(shellQuote(body)) + } } - } /** Machine-readable JSON for one event. */ public fun json(event: SharinganEvent): String = eventJson(event, indent = "") /** Machine-readable JSON for a whole session, wrapped with tool metadata. */ - public fun sessionJson(events: List): String = buildString { - appendLine("{") - appendLine(" \"tool\": \"sharingan\",") - appendLine(" \"eventCount\": ${events.size},") - appendLine(" \"events\": [") - events.forEachIndexed { index, event -> - append(eventJson(event, indent = " ")) - appendLine(if (index < events.lastIndex) "," else "") + public fun sessionJson(events: List): String = + buildString { + appendLine("{") + appendLine(" \"tool\": \"sharingan\",") + appendLine(" \"eventCount\": ${events.size},") + appendLine(" \"events\": [") + events.forEachIndexed { index, event -> + append(eventJson(event, indent = " ")) + appendLine(if (index < events.lastIndex) "," else "") + } + appendLine(" ]") + append("}") } - appendLine(" ]") - append("}") - } /** Human-readable digest: a count header, then one line per event. */ - public fun summary(events: List): String = buildString { - appendLine("Sharingan session · ${countsLine(events)}") - for (event in events) { - append(formatClockTime(event.timestampMillis)).append(" ") - appendLine(descriptorOf(event).summaryLine(event)) - } - }.trimEnd() + public fun summary(events: List): String = + buildString { + appendLine("Sharingan session · ${countsLine(events)}") + for (event in events) { + append(formatClockTime(event.timestampMillis)).append(" ") + appendLine(descriptorOf(event).summaryLine(event)) + } + }.trimEnd() // ── session assembly ───────────────────────────────────────── - private fun countsLine(events: List): String = - "${events.size} events · ${protocolCountsLine(events)}" + private fun countsLine(events: List): String = "${events.size} events · ${protocolCountsLine(events)}" - private fun eventJson(event: SharinganEvent, indent: String): String { + private fun eventJson( + event: SharinganEvent, + indent: String, + ): String { val fields = mutableListOf>() fields += "id" to "\"${jsonEscape(event.id)}\"" fields += "timestampMillis" to event.timestampMillis.toString() diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt index 78a6373..5eca7dd 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganStore.kt @@ -33,8 +33,11 @@ public class SharinganStore( if (!_isRecording.value) return _events.update { current -> val appended = current + event - if (appended.size > capacity) appended.subList(appended.size - capacity, appended.size).toList() - else appended + if (appended.size > capacity) { + appended.subList(appended.size - capacity, appended.size).toList() + } else { + appended + } } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ktor/SharinganKtor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ktor/SharinganKtor.kt index c750364..b4af7d7 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ktor/SharinganKtor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ktor/SharinganKtor.kt @@ -12,9 +12,9 @@ import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.Headers +import io.ktor.http.content.OutgoingContent import io.ktor.http.contentLength import io.ktor.http.contentType -import io.ktor.http.content.OutgoingContent /** * Configuration for the [SharinganKtor] client plugin. @@ -58,26 +58,28 @@ public val SharinganKtor: ClientPlugin = val method = request.method.value val url = request.url.buildString() val content = request.body as? OutgoingContent - val requestHeaders = buildList { - request.headers.entries().forEach { (name, values) -> values.forEach { add(name to it) } } - content?.contentType?.let { add("Content-Type" to it.toString()) } - content?.contentLength?.let { add("Content-Length" to it.toString()) } - } + val requestHeaders = + buildList { + request.headers.entries().forEach { (name, values) -> values.forEach { add(name to it) } } + content?.contentType?.let { add("Content-Type" to it.toString()) } + content?.contentLength?.let { add("Content-Length" to it.toString()) } + } val requestBody = if (captureBodies) outgoingBodyText(content, maxBodyBytes) else null - val call = try { - proceed(request) - } catch (failure: Throwable) { - logger.log( - method = method, - url = url, - durationMillis = currentTimeMillis() - startMillis, - requestHeaders = requestHeaders, - requestBody = requestBody, - error = failure.message ?: failure::class.simpleName ?: "request failed", - ) - throw failure - } + val call = + try { + proceed(request) + } catch (failure: Throwable) { + logger.log( + method = method, + url = url, + durationMillis = currentTimeMillis() - startMillis, + requestHeaders = requestHeaders, + requestBody = requestBody, + error = failure.message ?: failure::class.simpleName ?: "request failed", + ) + throw failure + } val firstByteMillis = currentTimeMillis() val responseBody = @@ -98,12 +100,14 @@ public val SharinganKtor: ClientPlugin = requestBody = requestBody, responseBody = responseBody, contentType = call.response.contentType()?.let { "${it.contentType}/${it.contentSubtype}" }, - responseSizeBytes = call.response.contentLength() - ?: responseBody?.encodeToByteArray()?.size?.toLong(), - timing = listOf( - TimingPhase("TTFB", firstByteMillis - startMillis), - TimingPhase("Download", endMillis - firstByteMillis), - ), + responseSizeBytes = + call.response.contentLength() + ?: responseBody?.encodeToByteArray()?.size?.toLong(), + timing = + listOf( + TimingPhase("TTFB", firstByteMillis - startMillis), + TimingPhase("Download", endMillis - firstByteMillis), + ), ) call } @@ -112,11 +116,15 @@ public val SharinganKtor: ClientPlugin = private fun flattenHeaders(headers: Headers): List> = headers.entries().flatMap { (name, values) -> values.map { name to it } } -private fun outgoingBodyText(content: OutgoingContent?, maxBytes: Int): String? = when (content) { - is OutgoingContent.ByteArrayContent -> - if (isTextual(content.contentType)) truncate(content.bytes().decodeToString(), maxBytes) else null - else -> null -} +private fun outgoingBodyText( + content: OutgoingContent?, + maxBytes: Int, +): String? = + when (content) { + is OutgoingContent.ByteArrayContent -> + if (isTextual(content.contentType)) truncate(content.bytes().decodeToString(), maxBytes) else null + else -> null + } private fun shouldReadBody(response: HttpResponse): Boolean { val contentType = response.contentType() ?: return false @@ -135,6 +143,12 @@ private fun isTextual(contentType: ContentType?): Boolean { contentType.contentSubtype == "x-www-form-urlencoded" } -private fun truncate(body: String, maxBytes: Int): String = - if (body.length <= maxBytes) body - else body.take(maxBytes) + "\n… (+${body.length - maxBytes} chars truncated)" +private fun truncate( + body: String, + maxBytes: Int, +): String = + if (body.length <= maxBytes) { + body + } else { + body.take(maxBytes) + "\n… (+${body.length - maxBytes} chars truncated)" + } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/BleDescriptor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/BleDescriptor.kt index 31ff376..fde4b38 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/BleDescriptor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/BleDescriptor.kt @@ -9,31 +9,44 @@ import dev.sharingan.internal.formatClockTime /** Everything Sharingan knows about BLE events. */ internal object BleDescriptor : ProtocolDescriptor() { - 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 = listOf( - FilterChipSpec("all", "All"), - FilterChipSpec("notify", "Notify"), - FilterChipSpec("read", "Read"), - FilterChipSpec("err", "Errors"), - ) + override val chips: List = + listOf( + FilterChipSpec("all", "All"), + FilterChipSpec("notify", "Notify"), + FilterChipSpec("read", "Read"), + FilterChipSpec("err", "Errors"), + ) - override fun chipMatches(event: BleEvent, chipKey: String): Boolean = when (chipKey) { - "notify" -> event.operation == BleOperation.NOTIFY - "read" -> event.operation == BleOperation.READ - "err" -> event.isFailure - else -> true - } + override fun chipMatches( + event: BleEvent, + chipKey: String, + ): Boolean = + when (chipKey) { + "notify" -> event.operation == BleOperation.NOTIFY + "read" -> event.operation == BleOperation.READ + "err" -> event.isFailure + else -> true + } - override fun searchHaystack(event: BleEvent): List = listOf( - event.operation.name, event.device, event.characteristic, event.uuid, event.payload, event.error, - ) + override fun searchHaystack(event: BleEvent): List = + listOf( + event.operation.name, + event.device, + event.characteristic, + event.uuid, + event.payload, + event.error, + ) - override fun present(colors: SharinganColors, event: BleEvent): EventPresentation { + override fun present( + colors: SharinganColors, + event: BleEvent, + ): EventPresentation { val opTint = colors.bleOperationTint(event.operation) return EventPresentation( lead = event.operation.name, @@ -51,28 +64,28 @@ internal object BleDescriptor : ProtocolDescriptor() { ) } - override fun ticker(event: BleEvent): String = - "${event.operation.name} ${event.characteristic ?: event.device}" + override fun ticker(event: BleEvent): String = "${event.operation.name} ${event.characteristic ?: event.device}" - override fun markdown(event: BleEvent): String = buildString { - appendLine("## BLE ${event.operation.name} ${event.characteristic ?: event.device}") - appendLine("**Device:** ${event.device} · **UUID:** ${event.uuid ?: "—"} · ${formatBytes(event.sizeBytes)}") - event.error?.let { appendLine("**Error:** $it") } - event.payload?.let { appendBodySection("Decoded value", it) } - }.trimEnd() + override fun markdown(event: BleEvent): String = + buildString { + appendLine("## BLE ${event.operation.name} ${event.characteristic ?: event.device}") + appendLine("**Device:** ${event.device} · **UUID:** ${event.uuid ?: "—"} · ${formatBytes(event.sizeBytes)}") + event.error?.let { appendLine("**Error:** $it") } + event.payload?.let { appendBodySection("Decoded value", it) } + }.trimEnd() - override fun summary(event: BleEvent): String = - "${event.operation.name} ${event.characteristic ?: "—"} (${event.device})" + override fun summary(event: BleEvent): String = "${event.operation.name} ${event.characteristic ?: "—"} (${event.device})" - override fun fields(event: BleEvent): List> = buildList { - putString("protocol", "ble") - putString("operation", event.operation.name) - putString("device", event.device) - putString("characteristic", event.characteristic) - putString("uuid", event.uuid) - putString("payload", event.payload) - put("sizeBytes", event.sizeBytes?.toString()) - } + override fun fields(event: BleEvent): List> = + buildList { + putString("protocol", "ble") + putString("operation", event.operation.name) + putString("device", event.device) + putString("characteristic", event.characteristic) + putString("uuid", event.uuid) + putString("payload", event.payload) + put("sizeBytes", event.sizeBytes?.toString()) + } @Composable override fun Body(event: BleEvent) { diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/DetailScreen.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/DetailScreen.kt index a0c986b..d5077ab 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/DetailScreen.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/DetailScreen.kt @@ -57,7 +57,10 @@ internal fun DetailScreenContent( } @Composable -private fun DetailHeader(onBack: () -> Unit, onShare: () -> Unit) { +private fun DetailHeader( + onBack: () -> Unit, + onShare: () -> Unit, +) { val colors = LocalSharinganColors.current Column(Modifier.fillMaxWidth().background(colors.bgElev)) { Row( @@ -118,7 +121,10 @@ private fun TitleBlock(event: SharinganEvent) { } @Composable -internal fun BadgeChip(label: String, tint: Tint) { +internal fun BadgeChip( + label: String, + tint: Tint, +) { Text( label, color = tint.color, @@ -126,11 +132,12 @@ internal fun BadgeChip(label: String, tint: Tint) { fontWeight = FontWeight.Bold, fontFamily = MonoFont, textAlign = TextAlign.Center, - modifier = Modifier - .clip(RoundedCornerShape(5.dp)) - .background(tint.soft) - .padding(horizontal = 6.dp, vertical = 1.dp) - .widthIn(min = 30.dp), + modifier = + Modifier + .clip(RoundedCornerShape(5.dp)) + .background(tint.soft) + .padding(horizontal = 6.dp, vertical = 1.dp) + .widthIn(min = 30.dp), ) } @@ -167,7 +174,11 @@ internal fun Section( } @Composable -internal fun KeyValueRow(key: String, value: String, valueColor: Color? = null) { +internal fun KeyValueRow( + key: String, + value: String, + valueColor: Color? = null, +) { val colors = LocalSharinganColors.current Column { Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) { @@ -220,20 +231,25 @@ internal fun BodyBlock(raw: String) { } @Composable -private fun rememberAnnotatedJson(raw: String, colors: SharinganColors): AnnotatedString = +private fun rememberAnnotatedJson( + raw: String, + colors: SharinganColors, +): AnnotatedString = remember(raw, colors) { - val tokens = prettyJsonTokens(raw) - ?: return@remember AnnotatedString(raw, SpanStyle(color = colors.text)) + val tokens = + prettyJsonTokens(raw) + ?: return@remember AnnotatedString(raw, SpanStyle(color = colors.text)) buildAnnotatedString { for (token in tokens) { - val color = when (token.type) { - JsonTokenType.KEY -> colors.info - JsonTokenType.STRING -> colors.ok - JsonTokenType.NUMBER -> colors.warn - JsonTokenType.LITERAL -> colors.violet - JsonTokenType.PUNCT -> colors.textDim - JsonTokenType.WS -> colors.textDim - } + val color = + when (token.type) { + JsonTokenType.KEY -> colors.info + JsonTokenType.STRING -> colors.ok + JsonTokenType.NUMBER -> colors.warn + JsonTokenType.LITERAL -> colors.violet + JsonTokenType.PUNCT -> colors.textDim + JsonTokenType.WS -> colors.textDim + } pushStyle(SpanStyle(color = color)) append(token.text) pop() diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt index 37a13f9..075d7b1 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt @@ -3,14 +3,19 @@ package dev.sharingan.ui import dev.sharingan.SharinganEvent /** The three protocol tabs, in design order. */ -internal enum class Protocol(val label: String) { +internal enum class Protocol( + val label: String, +) { HTTP("HTTP"), MQTT("MQTT"), BLE("Bluetooth"), } /** One quick-filter chip below the search field. */ -internal data class FilterChipSpec(val key: String, val label: String) +internal data class FilterChipSpec( + val key: String, + val label: String, +) // Per-protocol knowledge (chips, matching, search haystacks) lives in the // ProtocolDescriptors; these functions are the stable seam callers and @@ -29,10 +34,15 @@ internal fun protocolCountsLine(events: List): String = /** The design's per-protocol chip rows. */ internal fun chipsFor(protocol: Protocol): List = descriptorOf(protocol).chips -internal fun matchesChip(event: SharinganEvent, chipKey: String): Boolean = - chipKey == "all" || descriptorOf(event).matchesChip(event, chipKey) +internal fun matchesChip( + event: SharinganEvent, + chipKey: String, +): Boolean = chipKey == "all" || descriptorOf(event).matchesChip(event, chipKey) -internal fun matchesQuery(event: SharinganEvent, query: String): Boolean { +internal fun matchesQuery( + event: SharinganEvent, + query: String, +): Boolean { val q = query.trim().lowercase() if (q.isEmpty()) return true return descriptorOf(event).haystack(event).any { it != null && q in it.lowercase() } @@ -44,6 +54,7 @@ internal fun visibleEvents( protocol: Protocol, chipKey: String, query: String, -): List = events.filter { - protocolOf(it) == protocol && matchesChip(it, chipKey) && matchesQuery(it, query) -} +): List = + events.filter { + protocolOf(it) == protocol && matchesChip(it, chipKey) && matchesQuery(it, query) + } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventPresentation.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventPresentation.kt index 4a1d313..abd1cd5 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventPresentation.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventPresentation.kt @@ -6,7 +6,10 @@ import dev.sharingan.MqttDirection import dev.sharingan.SharinganEvent /** A color plus its soft (translucent) background companion. */ -internal data class Tint(val color: Color, val soft: Color) +internal data class Tint( + val color: Color, + val soft: Color, +) /** Everything a row or detail title needs, resolved once per event. */ internal data class EventPresentation( @@ -26,41 +29,47 @@ internal data class EventPresentation( // Palette helpers shared by the ProtocolDescriptors and detail bodies. -internal fun SharinganColors.httpStatusTint(status: Int?): Tint = when { - status == null -> Tint(err, errSoft) - status >= 500 -> Tint(err, errSoft) - status >= 400 -> Tint(warn, warnSoft) - status >= 300 -> Tint(info, infoSoft) - else -> Tint(ok, okSoft) -} +internal fun SharinganColors.httpStatusTint(status: Int?): Tint = + when { + status == null -> Tint(err, errSoft) + status >= 500 -> Tint(err, errSoft) + status >= 400 -> Tint(warn, warnSoft) + status >= 300 -> Tint(info, infoSoft) + else -> Tint(ok, okSoft) + } -internal fun SharinganColors.httpMethodTint(method: String): Tint = when (method.uppercase()) { - "GET" -> Tint(info, infoSoft) - "POST" -> Tint(ok, okSoft) - "PUT" -> Tint(warn, warnSoft) - "PATCH" -> Tint(violet, violetSoft) - "DELETE" -> Tint(err, errSoft) - else -> Tint(textMid, faint) -} +internal fun SharinganColors.httpMethodTint(method: String): Tint = + when (method.uppercase()) { + "GET" -> Tint(info, infoSoft) + "POST" -> Tint(ok, okSoft) + "PUT" -> Tint(warn, warnSoft) + "PATCH" -> Tint(violet, violetSoft) + "DELETE" -> Tint(err, errSoft) + else -> Tint(textMid, faint) + } -internal fun SharinganColors.mqttDirectionTint(direction: MqttDirection): Tint = when (direction) { - MqttDirection.PUBLISH -> Tint(ok, okSoft) - MqttDirection.RECEIVE -> Tint(info, infoSoft) - MqttDirection.SUBSCRIBE -> Tint(violet, violetSoft) -} +internal fun SharinganColors.mqttDirectionTint(direction: MqttDirection): Tint = + when (direction) { + MqttDirection.PUBLISH -> Tint(ok, okSoft) + MqttDirection.RECEIVE -> Tint(info, infoSoft) + MqttDirection.SUBSCRIBE -> Tint(violet, violetSoft) + } -internal fun SharinganColors.bleOperationTint(operation: BleOperation): Tint = when (operation) { - BleOperation.CONNECT -> Tint(info, infoSoft) - BleOperation.DISCONNECT -> Tint(textDim, faint) - BleOperation.DISCOVER -> Tint(violet, violetSoft) - BleOperation.NOTIFY -> Tint(ok, okSoft) - BleOperation.READ -> Tint(info, infoSoft) - BleOperation.WRITE -> Tint(warn, warnSoft) - BleOperation.ERROR -> Tint(err, errSoft) -} +internal fun SharinganColors.bleOperationTint(operation: BleOperation): Tint = + when (operation) { + BleOperation.CONNECT -> Tint(info, infoSoft) + BleOperation.DISCONNECT -> Tint(textDim, faint) + BleOperation.DISCOVER -> Tint(violet, violetSoft) + BleOperation.NOTIFY -> Tint(ok, okSoft) + BleOperation.READ -> Tint(info, infoSoft) + BleOperation.WRITE -> Tint(warn, warnSoft) + BleOperation.ERROR -> Tint(err, errSoft) + } -internal fun presentationOf(colors: SharinganColors, event: SharinganEvent): EventPresentation = - descriptorOf(event).presentation(colors, event) +internal fun presentationOf( + colors: SharinganColors, + event: SharinganEvent, +): EventPresentation = descriptorOf(event).presentation(colors, event) /** `requests` / `messages` / `operations` — the column-header noun. */ internal fun Protocol.eventNoun(): String = descriptorOf(this).eventNoun @@ -69,5 +78,4 @@ internal fun Protocol.eventNoun(): String = descriptorOf(this).eventNoun internal fun Protocol.eventNounSingular(): String = eventNoun().dropLast(1) /** Row time column: `mm:ss` slice of the clock time, like the design. */ -internal fun rowTime(clockTime: String): String = - if (clockTime.length >= 8) clockTime.substring(3, 8) else clockTime +internal fun rowTime(clockTime: String): String = if (clockTime.length >= 8) clockTime.substring(3, 8) else clockTime diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt index e49984a..9328502 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -142,7 +141,10 @@ private fun HomeHeader( } @Composable -internal fun RecordingPill(recording: Boolean, onToggle: () -> Unit) { +internal fun RecordingPill( + recording: Boolean, + onToggle: () -> Unit, +) { val colors = LocalSharinganColors.current val pulse = rememberInfiniteTransition(label = "rec") val pulseAlpha by pulse.animateFloat( @@ -308,19 +310,24 @@ private fun FilterChips( fontSize = 11.sp, fontWeight = FontWeight.SemiBold, fontFamily = MonoFont, - modifier = Modifier - .clip(RoundedCornerShape(20.dp)) - .background(if (on) colors.text else colors.surface) - .border(1.dp, if (on) Color.Transparent else colors.border, RoundedCornerShape(20.dp)) - .clickable { onSelect(chip.key) } - .padding(horizontal = 11.dp, vertical = 4.dp), + modifier = + Modifier + .clip(RoundedCornerShape(20.dp)) + .background(if (on) colors.text else colors.surface) + .border(1.dp, if (on) Color.Transparent else colors.border, RoundedCornerShape(20.dp)) + .clickable { onSelect(chip.key) } + .padding(horizontal = 11.dp, vertical = 4.dp), ) } } } @Composable -private fun ColumnHeader(count: Int, noun: String, recording: Boolean) { +private fun ColumnHeader( + count: Int, + noun: String, + recording: Boolean, +) { val colors = LocalSharinganColors.current Row( Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 7.dp), @@ -358,7 +365,10 @@ private fun EmptyState(hasAny: Boolean) { /** Compact single-line row — the design's default "Terminal" density. */ @Composable -internal fun TerminalRow(event: SharinganEvent, onOpen: () -> Unit) { +internal fun TerminalRow( + event: SharinganEvent, + onOpen: () -> Unit, +) { val colors = LocalSharinganColors.current val p = presentationOf(colors, event) val isBle = protocolOf(event) == Protocol.BLE @@ -443,16 +453,21 @@ internal fun HorizontalDivider(modifier: Modifier = Modifier) { private fun HomeScreenContent_LightPreview() { SharinganTheme(darkTheme = false) { HomeScreenContent( - state = HomeUiState( - protocol = Protocol.HTTP, - counts = previewCounts(), - rows = PreviewData.http, - query = "", - chipKey = "all", - recording = true, - ), - onSelectProtocol = {}, onQueryChange = {}, onChipChange = {}, - onToggleRecording = {}, onOpenEvent = {}, onShareAll = {}, + state = + HomeUiState( + protocol = Protocol.HTTP, + counts = previewCounts(), + rows = PreviewData.http, + query = "", + chipKey = "all", + recording = true, + ), + onSelectProtocol = {}, + onQueryChange = {}, + onChipChange = {}, + onToggleRecording = {}, + onOpenEvent = {}, + onShareAll = {}, ) } } @@ -462,16 +477,21 @@ private fun HomeScreenContent_LightPreview() { private fun HomeScreenContent_DarkPreview() { SharinganTheme(darkTheme = true) { HomeScreenContent( - state = HomeUiState( - protocol = Protocol.MQTT, - counts = previewCounts(), - rows = PreviewData.mqtt, - query = "", - chipKey = "all", - recording = true, - ), - onSelectProtocol = {}, onQueryChange = {}, onChipChange = {}, - onToggleRecording = {}, onOpenEvent = {}, onShareAll = {}, + state = + HomeUiState( + protocol = Protocol.MQTT, + counts = previewCounts(), + rows = PreviewData.mqtt, + query = "", + chipKey = "all", + recording = true, + ), + onSelectProtocol = {}, + onQueryChange = {}, + onChipChange = {}, + onToggleRecording = {}, + onOpenEvent = {}, + onShareAll = {}, ) } } @@ -481,16 +501,21 @@ private fun HomeScreenContent_DarkPreview() { private fun HomeScreenContent_PausedEmptyPreview() { SharinganTheme(darkTheme = false) { HomeScreenContent( - state = HomeUiState( - protocol = Protocol.BLE, - counts = mapOf(), - rows = emptyList(), - query = "", - chipKey = "all", - recording = false, - ), - onSelectProtocol = {}, onQueryChange = {}, onChipChange = {}, - onToggleRecording = {}, onOpenEvent = {}, onShareAll = {}, + state = + HomeUiState( + protocol = Protocol.BLE, + counts = mapOf(), + rows = emptyList(), + query = "", + chipKey = "all", + recording = false, + ), + onSelectProtocol = {}, + onQueryChange = {}, + onChipChange = {}, + onToggleRecording = {}, + onOpenEvent = {}, + onShareAll = {}, ) } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HttpDescriptor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HttpDescriptor.kt index 1b9a2fb..7e0277a 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HttpDescriptor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HttpDescriptor.kt @@ -30,40 +30,48 @@ import dev.sharingan.jsonEscape /** Everything Sharingan knows about HTTP events. */ internal object HttpDescriptor : ProtocolDescriptor() { - override val protocol: Protocol = Protocol.HTTP override val eventNoun: String = "requests" override val tabIcon: ImageVector = SharinganIcons.Globe override val searchPlaceholder: String = "Filter path, status, header…" - override val chips: List = listOf( - FilterChipSpec("all", "All"), - FilterChipSpec("err", "Errors"), - FilterChipSpec("2xx", "2xx"), - FilterChipSpec("get", "GET"), - FilterChipSpec("post", "POST"), - ) + override val chips: List = + listOf( + FilterChipSpec("all", "All"), + FilterChipSpec("err", "Errors"), + FilterChipSpec("2xx", "2xx"), + FilterChipSpec("get", "GET"), + FilterChipSpec("post", "POST"), + ) - override fun chipMatches(event: HttpEvent, chipKey: String): Boolean = when (chipKey) { - "err" -> event.isFailure - "2xx" -> (event.statusCode ?: 0) in 200..299 - "get" -> event.method.equals("GET", ignoreCase = true) - "post" -> event.method.equals("POST", ignoreCase = true) - else -> true - } + override fun chipMatches( + event: HttpEvent, + chipKey: String, + ): Boolean = + when (chipKey) { + "err" -> event.isFailure + "2xx" -> (event.statusCode ?: 0) in 200..299 + "get" -> event.method.equals("GET", ignoreCase = true) + "post" -> event.method.equals("POST", ignoreCase = true) + else -> true + } - override fun searchHaystack(event: HttpEvent): List = buildList { - add(event.method) - add(event.url) - add(event.statusCode?.toString()) - add(event.error) - add(event.requestBody) - add(event.responseBody) - event.requestHeaders.forEach { add("${it.first}: ${it.second}") } - event.responseHeaders.forEach { add("${it.first}: ${it.second}") } - } + override fun searchHaystack(event: HttpEvent): List = + buildList { + add(event.method) + add(event.url) + add(event.statusCode?.toString()) + add(event.error) + add(event.requestBody) + add(event.responseBody) + event.requestHeaders.forEach { add("${it.first}: ${it.second}") } + event.responseHeaders.forEach { add("${it.first}: ${it.second}") } + } - override fun present(colors: SharinganColors, event: HttpEvent): EventPresentation { + override fun present( + colors: SharinganColors, + event: HttpEvent, + ): EventPresentation { val statusTint = colors.httpStatusTint(event.statusCode) return EventPresentation( lead = event.method.uppercase(), @@ -81,30 +89,30 @@ internal object HttpDescriptor : ProtocolDescriptor() { ) } - override fun ticker(event: HttpEvent): String = - "${event.method} ${event.path} → ${event.statusCode ?: "ERR"}" - - override fun markdown(event: HttpEvent): String = buildString { - appendLine("## ${event.method} ${event.path}") - val status = event.statusCode?.toString() ?: "—" - val duration = event.durationMillis?.let { " · **${it}ms**" } ?: "" - val size = event.responseSizeBytes?.let { " · ${formatBytes(it)}" } ?: "" - appendLine("**Status:** $status$duration$size") - appendLine("**Host:** ${event.host}") - event.error?.let { appendLine("**Error:** $it") } - if (event.requestHeaders.isNotEmpty()) { - appendLine() - appendLine("### Request headers") - event.requestHeaders.forEach { (k, v) -> appendLine("- $k: $v") } - } - if (event.responseHeaders.isNotEmpty()) { - appendLine() - appendLine("### Response headers") - event.responseHeaders.forEach { (k, v) -> appendLine("- $k: $v") } - } - event.requestBody?.let { appendBodySection("Request body", it) } - event.responseBody?.let { appendBodySection("Response body", it) } - }.trimEnd() + override fun ticker(event: HttpEvent): String = "${event.method} ${event.path} → ${event.statusCode ?: "ERR"}" + + override fun markdown(event: HttpEvent): String = + buildString { + appendLine("## ${event.method} ${event.path}") + val status = event.statusCode?.toString() ?: "—" + val duration = event.durationMillis?.let { " · **${it}ms**" } ?: "" + val size = event.responseSizeBytes?.let { " · ${formatBytes(it)}" } ?: "" + appendLine("**Status:** $status$duration$size") + appendLine("**Host:** ${event.host}") + event.error?.let { appendLine("**Error:** $it") } + if (event.requestHeaders.isNotEmpty()) { + appendLine() + appendLine("### Request headers") + event.requestHeaders.forEach { (k, v) -> appendLine("- $k: $v") } + } + if (event.responseHeaders.isNotEmpty()) { + appendLine() + appendLine("### Response headers") + event.responseHeaders.forEach { (k, v) -> appendLine("- $k: $v") } + } + event.requestBody?.let { appendBodySection("Request body", it) } + event.responseBody?.let { appendBodySection("Response body", it) } + }.trimEnd() override fun summary(event: HttpEvent): String { val status = event.statusCode?.toString() ?: (event.error ?: "—") @@ -112,26 +120,27 @@ internal object HttpDescriptor : ProtocolDescriptor() { return "${event.method} ${event.path} → $status$duration" } - override fun fields(event: HttpEvent): List> = buildList { - putString("protocol", "http") - putString("method", event.method) - putString("url", event.url) - put("statusCode", event.statusCode?.toString()) - put("durationMillis", event.durationMillis?.toString()) - put("requestHeaders", headersJson(event.requestHeaders)) - put("responseHeaders", headersJson(event.responseHeaders)) - putString("requestBody", event.requestBody) - putString("responseBody", event.responseBody) - putString("contentType", event.contentType) - put("responseSizeBytes", event.responseSizeBytes?.toString()) - put( - "timing", - event.timing.takeIf { it.isNotEmpty() }?.joinToString( - prefix = "[", - postfix = "]", - ) { "{\"label\": \"${jsonEscape(it.label)}\", \"millis\": ${it.millis}}" }, - ) - } + override fun fields(event: HttpEvent): List> = + buildList { + putString("protocol", "http") + putString("method", event.method) + putString("url", event.url) + put("statusCode", event.statusCode?.toString()) + put("durationMillis", event.durationMillis?.toString()) + put("requestHeaders", headersJson(event.requestHeaders)) + put("responseHeaders", headersJson(event.responseHeaders)) + putString("requestBody", event.requestBody) + putString("responseBody", event.responseBody) + putString("contentType", event.contentType) + put("responseSizeBytes", event.responseSizeBytes?.toString()) + put( + "timing", + event.timing.takeIf { it.isNotEmpty() }?.joinToString( + prefix = "[", + postfix = "]", + ) { "{\"label\": \"${jsonEscape(it.label)}\", \"millis\": ${it.millis}}" }, + ) + } private fun headersJson(headers: List>): String? { if (headers.isEmpty()) return null @@ -179,7 +188,12 @@ internal object HttpDescriptor : ProtocolDescriptor() { } @Composable -private fun SummaryCard(label: String, value: String, valueColor: Color, modifier: Modifier = Modifier) { +private fun SummaryCard( + label: String, + value: String, + valueColor: Color, + modifier: Modifier = Modifier, +) { val colors = LocalSharinganColors.current Column( modifier diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/JsonPretty.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/JsonPretty.kt index 4e662b7..ba32c26 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/JsonPretty.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/JsonPretty.kt @@ -3,25 +3,28 @@ package dev.sharingan.ui /** Token classes the JSON body view colors differently. */ internal enum class JsonTokenType { KEY, STRING, NUMBER, LITERAL, PUNCT, WS } -internal data class JsonToken(val type: JsonTokenType, val text: String) +internal data class JsonToken( + val type: JsonTokenType, + val text: String, +) /** * Parses [raw] and re-emits it as a pretty-printed (2-space indented) token * stream for syntax coloring. Returns `null` when [raw] is not valid JSON so * callers can fall back to plain text. */ -internal fun prettyJsonTokens(raw: String): List? = try { - val parser = JsonParser(raw) - parser.parseValue(indent = 0) - parser.skipWhitespace() - if (parser.position < raw.length) null else parser.tokens -} catch (_: JsonParseException) { - null -} +internal fun prettyJsonTokens(raw: String): List? = + try { + val parser = JsonParser(raw) + parser.parseValue(indent = 0) + parser.skipWhitespace() + if (parser.position < raw.length) null else parser.tokens + } catch (_: JsonParseException) { + null + } /** Pretty-printed JSON text, or `null` when [raw] is not valid JSON. */ -internal fun prettyJson(raw: String): String? = - prettyJsonTokens(raw)?.joinToString("") { it.text } +internal fun prettyJson(raw: String): String? = prettyJsonTokens(raw)?.joinToString("") { it.text } private class JsonParseException : Exception() { companion object { @@ -30,7 +33,9 @@ private class JsonParseException : Exception() { } } -private class JsonParser(private val source: String) { +private class JsonParser( + private val source: String, +) { var position: Int = 0 private set val tokens = mutableListOf() @@ -43,7 +48,10 @@ private class JsonParser(private val source: String) { while (position < source.length && source[position].isWhitespace()) position++ } - private fun emit(type: JsonTokenType, text: String) { + private fun emit( + type: JsonTokenType, + text: String, + ) { tokens += JsonToken(type, text) } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/MqttDescriptor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/MqttDescriptor.kt index 079fd95..1c6b456 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/MqttDescriptor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/MqttDescriptor.kt @@ -10,30 +10,37 @@ import dev.sharingan.shortLabel /** Everything Sharingan knows about MQTT events. */ internal object MqttDescriptor : ProtocolDescriptor() { - override val protocol: Protocol = Protocol.MQTT override val eventNoun: String = "messages" override val tabIcon: ImageVector = SharinganIcons.Waves override val searchPlaceholder: String = "Filter topic, payload…" - override val chips: List = listOf( - FilterChipSpec("all", "All"), - FilterChipSpec("pub", "Pub"), - FilterChipSpec("recv", "Recv"), - FilterChipSpec("sub", "Sub"), - ) + override val chips: List = + listOf( + FilterChipSpec("all", "All"), + FilterChipSpec("pub", "Pub"), + FilterChipSpec("recv", "Recv"), + FilterChipSpec("sub", "Sub"), + ) - override fun chipMatches(event: MqttEvent, chipKey: String): Boolean = when (chipKey) { - "pub" -> event.direction == MqttDirection.PUBLISH - "recv" -> event.direction == MqttDirection.RECEIVE - "sub" -> event.direction == MqttDirection.SUBSCRIBE - else -> true - } + override fun chipMatches( + event: MqttEvent, + chipKey: String, + ): Boolean = + when (chipKey) { + "pub" -> event.direction == MqttDirection.PUBLISH + "recv" -> event.direction == MqttDirection.RECEIVE + "sub" -> event.direction == MqttDirection.SUBSCRIBE + else -> true + } override fun searchHaystack(event: MqttEvent): List = listOf(event.direction.shortLabel, event.topic, event.payload, event.error) - override fun present(colors: SharinganColors, event: MqttEvent): EventPresentation { + override fun present( + colors: SharinganColors, + event: MqttEvent, + ): EventPresentation { val dirTint = colors.mqttDirectionTint(event.direction) return EventPresentation( lead = event.direction.shortLabel, @@ -53,25 +60,27 @@ internal object MqttDescriptor : ProtocolDescriptor() { override fun ticker(event: MqttEvent): String = "${event.direction.shortLabel} ${event.topic}" - override fun markdown(event: MqttEvent): String = buildString { - appendLine("## MQTT ${event.direction.shortLabel} ${event.topic}") - appendLine("**QoS:** ${event.qos} · **Retained:** ${event.retained} · ${formatBytes(event.payloadSizeBytes)}") - event.error?.let { appendLine("**Error:** $it") } - event.payload?.let { appendBodySection("Payload", it) } - }.trimEnd() + override fun markdown(event: MqttEvent): String = + buildString { + appendLine("## MQTT ${event.direction.shortLabel} ${event.topic}") + appendLine("**QoS:** ${event.qos} · **Retained:** ${event.retained} · ${formatBytes(event.payloadSizeBytes)}") + event.error?.let { appendLine("**Error:** $it") } + event.payload?.let { appendBodySection("Payload", it) } + }.trimEnd() override fun summary(event: MqttEvent): String = "${event.direction.shortLabel} ${event.topic} QoS${event.qos} ${formatBytes(event.payloadSizeBytes)}" - override fun fields(event: MqttEvent): List> = buildList { - putString("protocol", "mqtt") - putString("direction", event.direction.shortLabel) - putString("topic", event.topic) - put("qos", event.qos.toString()) - put("retained", event.retained.toString()) - putString("payload", event.payload) - put("payloadSizeBytes", event.payloadSizeBytes?.toString()) - } + override fun fields(event: MqttEvent): List> = + buildList { + putString("protocol", "mqtt") + putString("direction", event.direction.shortLabel) + putString("topic", event.topic) + put("qos", event.qos.toString()) + put("retained", event.retained.toString()) + putString("payload", event.payload) + put("payloadSizeBytes", event.payloadSizeBytes?.toString()) + } @Composable override fun Body(event: MqttEvent) { diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/PlatformBackHandler.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/PlatformBackHandler.kt index b07faf9..8a24ef4 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/PlatformBackHandler.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/PlatformBackHandler.kt @@ -8,4 +8,7 @@ import androidx.compose.runtime.Composable * path, so this is a no-op there. */ @Composable -internal expect fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) +internal expect fun PlatformBackHandler( + enabled: Boolean, + onBack: () -> Unit, +) diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/PreviewData.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/PreviewData.kt index eb27977..c6c5cef 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/PreviewData.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/PreviewData.kt @@ -9,98 +9,154 @@ import dev.sharingan.TimingPhase /** Hardcoded fake state for @Preview composables — mirrors the design's IoT traffic. */ internal object PreviewData { - - val http = listOf( - HttpEvent( - id = "h1", timestampMillis = 1_749_556_771_882, method = "GET", - url = "https://api.acme-iot.com/api/v2/devices/4471/state", - statusCode = 200, durationMillis = 142, responseSizeBytes = 4300, - requestHeaders = listOf("Authorization" to "••••", "Accept" to "application/json"), - responseHeaders = listOf("Content-Type" to "application/json; charset=utf-8", "X-Trace-Id" to "b1f4-22a9"), - responseBody = """{"deviceId":4471,"online":true,"firmware":"2.4.1","battery":0.86,"sensors":{"temp":23.4,"humidity":48}}""", - contentType = "application/json", - timing = listOf( - TimingPhase("DNS", 8), TimingPhase("Connect", 21), TimingPhase("TLS", 34), - TimingPhase("TTFB", 71), TimingPhase("Download", 8), + val http = + listOf( + HttpEvent( + id = "h1", + timestampMillis = 1_749_556_771_882, + method = "GET", + url = "https://api.acme-iot.com/api/v2/devices/4471/state", + statusCode = 200, + durationMillis = 142, + responseSizeBytes = 4300, + requestHeaders = listOf("Authorization" to "••••", "Accept" to "application/json"), + responseHeaders = listOf("Content-Type" to "application/json; charset=utf-8", "X-Trace-Id" to "b1f4-22a9"), + responseBody = + """{"deviceId":4471,"online":true,"firmware":"2.4.1","battery":0.86,"sensors":{"temp":23.4,"humidity":48}}""", + contentType = "application/json", + timing = + listOf( + TimingPhase("DNS", 8), + TimingPhase("Connect", 21), + TimingPhase("TLS", 34), + TimingPhase("TTFB", 71), + TimingPhase("Download", 8), + ), ), - ), - HttpEvent( - id = "h2", timestampMillis = 1_749_556_773_201, method = "POST", - url = "https://api.acme-iot.com/api/v2/devices/4471/commands", - statusCode = 202, durationMillis = 96, responseSizeBytes = 312, - requestBody = """{"cmd":"reboot","delay":5}""", - responseBody = """{"accepted":true,"commandId":9921}""", - contentType = "application/json", - ), - HttpEvent( - id = "h3", timestampMillis = 1_749_556_780_118, method = "POST", - url = "https://auth.acme-iot.com/api/v2/auth/refresh", - statusCode = 401, durationMillis = 74, responseSizeBytes = 128, - responseBody = """{"error":"invalid_token","message":"Refresh token expired","code":"AUTH_017"}""", - contentType = "application/json", - ), - HttpEvent( - id = "h4", timestampMillis = 1_749_556_782_455, method = "GET", - url = "https://api.acme-iot.com/api/v2/telemetry/4471/stream", - statusCode = 500, durationMillis = 1240, responseSizeBytes = 96, - responseHeaders = listOf("Content-Type" to "application/json", "X-Trace-Id" to "e0c2-7741"), - responseBody = """{"error":"upstream_timeout","message":"Telemetry shard 3 did not respond","traceId":"e0c2-7741"}""", - contentType = "application/json", - timing = listOf(TimingPhase("TTFB", 1220), TimingPhase("Download", 15)), - ), - ) + HttpEvent( + id = "h2", + timestampMillis = 1_749_556_773_201, + method = "POST", + url = "https://api.acme-iot.com/api/v2/devices/4471/commands", + statusCode = 202, + durationMillis = 96, + responseSizeBytes = 312, + requestBody = """{"cmd":"reboot","delay":5}""", + responseBody = """{"accepted":true,"commandId":9921}""", + contentType = "application/json", + ), + HttpEvent( + id = "h3", + timestampMillis = 1_749_556_780_118, + method = "POST", + url = "https://auth.acme-iot.com/api/v2/auth/refresh", + statusCode = 401, + durationMillis = 74, + responseSizeBytes = 128, + responseBody = """{"error":"invalid_token","message":"Refresh token expired","code":"AUTH_017"}""", + contentType = "application/json", + ), + HttpEvent( + id = "h4", + timestampMillis = 1_749_556_782_455, + method = "GET", + url = "https://api.acme-iot.com/api/v2/telemetry/4471/stream", + statusCode = 500, + durationMillis = 1240, + responseSizeBytes = 96, + responseHeaders = listOf("Content-Type" to "application/json", "X-Trace-Id" to "e0c2-7741"), + responseBody = """{"error":"upstream_timeout","message":"Telemetry shard 3 did not respond","traceId":"e0c2-7741"}""", + contentType = "application/json", + timing = listOf(TimingPhase("TTFB", 1220), TimingPhase("Download", 15)), + ), + ) - val mqtt = listOf( - MqttEvent( - id = "m1", timestampMillis = 1_749_556_773_114, direction = MqttDirection.PUBLISH, - topic = "devices/4471/telemetry", qos = 1, - payload = """{"temp":23.4,"hum":48,"ts":1749556800}""", payloadSizeBytes = 24, - ), - MqttEvent( - id = "m2", timestampMillis = 1_749_556_773_120, direction = MqttDirection.SUBSCRIBE, - topic = "devices/4471/commands/#", qos = 1, - ), - MqttEvent( - id = "m3", timestampMillis = 1_749_556_775_441, direction = MqttDirection.RECEIVE, - topic = "devices/4471/commands/reboot", qos = 1, - payload = """{"cmd":"reboot","delay":5,"by":"console"}""", payloadSizeBytes = 40, - ), - MqttEvent( - id = "m4", timestampMillis = 1_749_556_779_233, direction = MqttDirection.PUBLISH, - topic = "devices/4471/ack", qos = 1, - payload = """{"ack":9921,"ok":false,"err":"busy"}""", payloadSizeBytes = 18, - error = "broker rejected: busy", - ), - ) + val mqtt = + listOf( + MqttEvent( + id = "m1", + timestampMillis = 1_749_556_773_114, + direction = MqttDirection.PUBLISH, + topic = "devices/4471/telemetry", + qos = 1, + payload = """{"temp":23.4,"hum":48,"ts":1749556800}""", + payloadSizeBytes = 24, + ), + MqttEvent( + id = "m2", + timestampMillis = 1_749_556_773_120, + direction = MqttDirection.SUBSCRIBE, + topic = "devices/4471/commands/#", + qos = 1, + ), + MqttEvent( + id = "m3", + timestampMillis = 1_749_556_775_441, + direction = MqttDirection.RECEIVE, + topic = "devices/4471/commands/reboot", + qos = 1, + payload = """{"cmd":"reboot","delay":5,"by":"console"}""", + payloadSizeBytes = 40, + ), + MqttEvent( + id = "m4", + timestampMillis = 1_749_556_779_233, + direction = MqttDirection.PUBLISH, + topic = "devices/4471/ack", + qos = 1, + payload = """{"ack":9921,"ok":false,"err":"busy"}""", + payloadSizeBytes = 18, + error = "broker rejected: busy", + ), + ) - val ble = listOf( - BleEvent( - id = "b1", timestampMillis = 1_749_556_770_120, operation = BleOperation.CONNECT, - device = "C8:3A:35:9F:01:22", characteristic = "HR-Monitor-9F", - payload = """{"event":"GATT connected","mtu":185,"rssi":-58}""", - ), - BleEvent( - id = "b2", timestampMillis = 1_749_556_770_551, operation = BleOperation.NOTIFY, - device = "HR-Monitor-9F", characteristic = "Heart Rate Measurement", uuid = "0x2A37", - payload = """{"flags":"0x06","bpm":72,"raw":"06 48 00 12 03"}""", sizeBytes = 8, - ), - BleEvent( - id = "b3", timestampMillis = 1_749_556_771_002, operation = BleOperation.READ, - device = "HR-Monitor-9F", characteristic = "Battery Level", uuid = "0x2A19", - payload = """{"raw":"0x5C","battery":92}""", sizeBytes = 1, - ), - BleEvent( - id = "b4", timestampMillis = 1_749_556_773_120, operation = BleOperation.ERROR, - device = "HR-Monitor-9F", characteristic = "Body Sensor Location", uuid = "0x2A38", - error = "Attribute not found (GATT 0x0A)", - ), - ) + val ble = + listOf( + BleEvent( + id = "b1", + timestampMillis = 1_749_556_770_120, + operation = BleOperation.CONNECT, + device = "C8:3A:35:9F:01:22", + characteristic = "HR-Monitor-9F", + payload = """{"event":"GATT connected","mtu":185,"rssi":-58}""", + ), + BleEvent( + id = "b2", + timestampMillis = 1_749_556_770_551, + operation = BleOperation.NOTIFY, + device = "HR-Monitor-9F", + characteristic = "Heart Rate Measurement", + uuid = "0x2A37", + payload = """{"flags":"0x06","bpm":72,"raw":"06 48 00 12 03"}""", + sizeBytes = 8, + ), + BleEvent( + id = "b3", + timestampMillis = 1_749_556_771_002, + operation = BleOperation.READ, + device = "HR-Monitor-9F", + characteristic = "Battery Level", + uuid = "0x2A19", + payload = """{"raw":"0x5C","battery":92}""", + sizeBytes = 1, + ), + BleEvent( + id = "b4", + timestampMillis = 1_749_556_773_120, + operation = BleOperation.ERROR, + device = "HR-Monitor-9F", + characteristic = "Body Sensor Location", + uuid = "0x2A38", + error = "Attribute not found (GATT 0x0A)", + ), + ) val all = http + mqtt + ble } -internal fun previewCounts(): Map = mapOf( - Protocol.HTTP to PreviewData.http.size, - Protocol.MQTT to PreviewData.mqtt.size, - Protocol.BLE to PreviewData.ble.size, -) +internal fun previewCounts(): Map = + mapOf( + Protocol.HTTP to PreviewData.http.size, + Protocol.MQTT to PreviewData.mqtt.size, + Protocol.BLE to PreviewData.ble.size, + ) diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt index a49acc8..c93b3f2 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt @@ -26,7 +26,6 @@ import dev.sharingan.jsonEscape * so same-named overloads would clash on the JVM.) */ internal abstract class ProtocolDescriptor { - abstract val protocol: Protocol /** `requests` / `messages` / `operations` — the column-header noun. */ @@ -42,13 +41,19 @@ internal abstract class ProtocolDescriptor { abstract val searchPlaceholder: String /** Chip matching; `"all"` is handled by the caller before dispatch. */ - protected abstract fun chipMatches(event: E, chipKey: String): Boolean + protected abstract fun chipMatches( + event: E, + chipKey: String, + ): Boolean /** Strings the search box matches against; nulls are skipped. */ protected abstract fun searchHaystack(event: E): List /** Everything a row or detail title needs, resolved once per event. */ - protected abstract fun present(colors: SharinganColors, event: E): EventPresentation + protected abstract fun present( + colors: SharinganColors, + event: E, + ): EventPresentation /** One-line text for the capture notification's expanded ticker. */ protected abstract fun ticker(event: E): String @@ -77,12 +82,17 @@ internal abstract class ProtocolDescriptor { @Suppress("UNCHECKED_CAST") private fun typed(event: SharinganEvent): E = event as E - fun matchesChip(event: SharinganEvent, chipKey: String): Boolean = chipMatches(typed(event), chipKey) + fun matchesChip( + event: SharinganEvent, + chipKey: String, + ): Boolean = chipMatches(typed(event), chipKey) fun haystack(event: SharinganEvent): List = searchHaystack(typed(event)) - fun presentation(colors: SharinganColors, event: SharinganEvent): EventPresentation = - present(colors, typed(event)) + fun presentation( + colors: SharinganColors, + event: SharinganEvent, + ): EventPresentation = present(colors, typed(event)) fun tickerLine(event: SharinganEvent): String = ticker(typed(event)) @@ -100,17 +110,26 @@ internal abstract class ProtocolDescriptor { // ── shared fragment helpers for implementations ───────────── /** Adds a raw JSON value if non-null. */ - protected fun MutableList>.put(name: String, raw: String?) { + protected fun MutableList>.put( + name: String, + raw: String?, + ) { raw?.let { add(name to it) } } /** Adds an escaped JSON string value if non-null. */ - protected fun MutableList>.putString(name: String, value: String?) { + protected fun MutableList>.putString( + name: String, + value: String?, + ) { value?.let { add(name to "\"${jsonEscape(it)}\"") } } /** Fenced body section for agent Markdown; ```json when it looks like JSON. */ - protected fun StringBuilder.appendBodySection(title: String, body: String) { + protected fun StringBuilder.appendBodySection( + title: String, + body: String, + ) { val looksJson = body.trimStart().firstOrNull() in setOf('{', '[') appendLine() appendLine("### $title") @@ -121,15 +140,17 @@ internal abstract class ProtocolDescriptor { } /** The one exhaustive event → descriptor mapping in the codebase. */ -internal fun descriptorOf(event: SharinganEvent): ProtocolDescriptor<*> = when (event) { - is HttpEvent -> HttpDescriptor - is MqttEvent -> MqttDescriptor - is BleEvent -> BleDescriptor -} +internal fun descriptorOf(event: SharinganEvent): ProtocolDescriptor<*> = + when (event) { + is HttpEvent -> HttpDescriptor + is MqttEvent -> MqttDescriptor + is BleEvent -> BleDescriptor + } /** Tab-level lookup for concerns keyed by [Protocol] (chips, noun) rather than by event. */ -internal fun descriptorOf(protocol: Protocol): ProtocolDescriptor<*> = when (protocol) { - Protocol.HTTP -> HttpDescriptor - Protocol.MQTT -> MqttDescriptor - Protocol.BLE -> BleDescriptor -} +internal fun descriptorOf(protocol: Protocol): ProtocolDescriptor<*> = + when (protocol) { + Protocol.HTTP -> HttpDescriptor + Protocol.MQTT -> MqttDescriptor + Protocol.BLE -> BleDescriptor + } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareResolver.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareResolver.kt index 9cc2e4f..af4aeed 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareResolver.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareResolver.kt @@ -31,26 +31,31 @@ internal fun resolveShare( ): ShareResolution { val single = if (scope == ShareScope.SINGLE) selectedEvent else null return when (action) { - ShareAction.COPY_AGENT -> ShareResolution( - payload = single?.let { SharinganExport.agentMarkdown(it) } ?: SharinganExport.agentMarkdown(tabEvents), - delivery = ShareDelivery.CLIPBOARD, - toast = "Copied for agent ✓", - ) - ShareAction.COPY_HUMAN -> ShareResolution( - payload = (single as? HttpEvent)?.let { SharinganExport.curl(it) } - ?: SharinganExport.summary(single?.let { listOf(it) } ?: tabEvents), - delivery = ShareDelivery.CLIPBOARD, - toast = "Copied ✓", - ) - ShareAction.COPY_RAW -> ShareResolution( - payload = single?.let { SharinganExport.json(it) } ?: SharinganExport.sessionJson(tabEvents), - delivery = ShareDelivery.CLIPBOARD, - toast = "Copied JSON ✓", - ) - ShareAction.SYSTEM_SHARE -> ShareResolution( - payload = single?.let { SharinganExport.agentMarkdown(it) } ?: SharinganExport.agentMarkdown(tabEvents), - delivery = ShareDelivery.SYSTEM_SHARE, - toast = null, - ) + ShareAction.COPY_AGENT -> + ShareResolution( + payload = single?.let { SharinganExport.agentMarkdown(it) } ?: SharinganExport.agentMarkdown(tabEvents), + delivery = ShareDelivery.CLIPBOARD, + toast = "Copied for agent ✓", + ) + ShareAction.COPY_HUMAN -> + ShareResolution( + payload = + (single as? HttpEvent)?.let { SharinganExport.curl(it) } + ?: SharinganExport.summary(single?.let { listOf(it) } ?: tabEvents), + delivery = ShareDelivery.CLIPBOARD, + toast = "Copied ✓", + ) + ShareAction.COPY_RAW -> + ShareResolution( + payload = single?.let { SharinganExport.json(it) } ?: SharinganExport.sessionJson(tabEvents), + delivery = ShareDelivery.CLIPBOARD, + toast = "Copied JSON ✓", + ) + ShareAction.SYSTEM_SHARE -> + ShareResolution( + payload = single?.let { SharinganExport.agentMarkdown(it) } ?: SharinganExport.agentMarkdown(tabEvents), + delivery = ShareDelivery.SYSTEM_SHARE, + toast = null, + ) } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareSheet.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareSheet.kt index ef9a3d3..f5a17d3 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareSheet.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareSheet.kt @@ -24,9 +24,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.text.font.FontWeight import org.jetbrains.compose.ui.tooling.preview.Preview /** What the user picked in the share sheet. */ @@ -79,8 +79,11 @@ internal fun ShareSheetBody( val colors = LocalSharinganColors.current Column(modifier.fillMaxWidth().padding(start = 14.dp, end = 14.dp, bottom = 26.dp, top = 8.dp)) { Text( - if (state.scope == ShareScope.ALL) "Share session" - else "Share this ${state.protocol.eventNounSingular()}", + if (state.scope == ShareScope.ALL) { + "Share session" + } else { + "Share this ${state.protocol.eventNounSingular()}" + }, color = colors.text, fontSize = 16.sp, fontWeight = FontWeight.Bold, @@ -130,16 +133,18 @@ internal fun ShareSheetBody( ) ShareOption( icon = { tint -> Icon(SharinganIcons.Copy, null, tint = tint, modifier = Modifier.size(16.dp)) }, - title = when { - state.scope == ShareScope.ALL -> "Copy summary" - state.curlAvailable -> "Copy as cURL" - else -> "Copy summary" - }, - subtitle = if (state.scope == ShareScope.SINGLE && state.curlAvailable) { - "Reproducible request command" - } else { - "Human-readable digest" - }, + title = + when { + state.scope == ShareScope.ALL -> "Copy summary" + state.curlAvailable -> "Copy as cURL" + else -> "Copy summary" + }, + subtitle = + if (state.scope == ShareScope.SINGLE && state.curlAvailable) { + "Reproducible request command" + } else { + "Human-readable digest" + }, onClick = { onAction(ShareAction.COPY_HUMAN) }, ) ShareOption( @@ -218,12 +223,13 @@ private fun ShareSheetBody_SinglePreview() { SharinganTheme(darkTheme = false) { Box(Modifier.background(LocalSharinganColors.current.bgElev)) { ShareSheetBody( - state = ShareSheetState( - scope = ShareScope.SINGLE, - protocol = Protocol.HTTP, - preview = "## GET /api/v2/devices/4471/state\n**Status:** 200 · **142ms** · 4.2 KB\n**Host:** api.acme-iot.com", - curlAvailable = true, - ), + state = + ShareSheetState( + scope = ShareScope.SINGLE, + protocol = Protocol.HTTP, + preview = "## GET /api/v2/devices/4471/state\n**Status:** 200 · **142ms** · 4.2 KB\n**Host:** api.acme-iot.com", + curlAvailable = true, + ), onAction = {}, ) } @@ -236,12 +242,13 @@ private fun ShareSheetBody_SessionDarkPreview() { SharinganTheme(darkTheme = true) { Box(Modifier.background(LocalSharinganColors.current.bgElev)) { ShareSheetBody( - state = ShareSheetState( - scope = ShareScope.ALL, - protocol = Protocol.MQTT, - preview = "# Sharingan session export\n4 events · HTTP 0 · MQTT 4 · BLE 0", - curlAvailable = false, - ), + state = + ShareSheetState( + scope = ShareScope.ALL, + protocol = Protocol.MQTT, + preview = "# Sharingan session export\n4 events · HTTP 0 · MQTT 4 · BLE 0", + curlAvailable = false, + ), onAction = {}, ) } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganIcons.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganIcons.kt index f6615d5..8b658bb 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganIcons.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganIcons.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.unit.dp * pulling the material-icons artifact into consumers' debug builds. */ internal object SharinganIcons { - val Search: ImageVector by lazy { stroke("search") { // circle approximated with two arcs @@ -28,17 +27,23 @@ internal object SharinganIcons { val Close: ImageVector by lazy { stroke("close") { - moveTo(6f, 6f); lineTo(18f, 18f) - moveTo(18f, 6f); lineTo(6f, 18f) + moveTo(6f, 6f) + lineTo(18f, 18f) + moveTo(18f, 6f) + lineTo(6f, 18f) } } val Share: ImageVector by lazy { stroke("share") { - moveTo(12f, 3f); lineTo(12f, 16f) - moveTo(12f, 3f); lineTo(8f, 7f) - moveTo(12f, 3f); lineTo(16f, 7f) - moveTo(5f, 13f); lineTo(5f, 19f) + moveTo(12f, 3f) + lineTo(12f, 16f) + moveTo(12f, 3f) + lineTo(8f, 7f) + moveTo(12f, 3f) + lineTo(16f, 7f) + moveTo(5f, 13f) + lineTo(5f, 19f) arcTo(1f, 1f, 0f, false, false, 6f, 20f) lineTo(18f, 20f) arcTo(1f, 1f, 0f, false, false, 19f, 19f) @@ -58,7 +63,8 @@ internal object SharinganIcons { lineTo(8f, 10.5f) arcTo(2.5f, 2.5f, 0f, false, true, 10.5f, 8f) close() - moveTo(16f, 8f); lineTo(16f, 5.5f) + moveTo(16f, 8f) + lineTo(16f, 5.5f) arcTo(1.5f, 1.5f, 0f, false, false, 14.5f, 4f) lineTo(5.5f, 4f) arcTo(1.5f, 1.5f, 0f, false, false, 4f, 5.5f) @@ -70,61 +76,81 @@ internal object SharinganIcons { val ChevronRight: ImageVector by lazy { stroke("chevronRight", width = 2.2f) { - moveTo(9f, 5f); lineTo(16f, 12f); lineTo(9f, 19f) + moveTo(9f, 5f) + lineTo(16f, 12f) + lineTo(9f, 19f) } } val Back: ImageVector by lazy { stroke("back", width = 2.2f) { - moveTo(15f, 5f); lineTo(8f, 12f); lineTo(15f, 19f) + moveTo(15f, 5f) + lineTo(8f, 12f) + lineTo(15f, 19f) } } val Check: ImageVector by lazy { stroke("check", width = 2.4f) { - moveTo(5f, 12.5f); lineTo(9.5f, 17f); lineTo(19f, 7f) + moveTo(5f, 12.5f) + lineTo(9.5f, 17f) + lineTo(19f, 7f) } } val Bolt: ImageVector by lazy { fill("bolt") { - moveTo(13f, 2f); lineTo(4f, 14f); lineTo(10f, 14f) - lineTo(9f, 22f); lineTo(18f, 10f); lineTo(12f, 10f); close() + moveTo(13f, 2f) + lineTo(4f, 14f) + lineTo(10f, 14f) + lineTo(9f, 22f) + lineTo(18f, 10f) + lineTo(12f, 10f) + close() } } /** The "AI agent" robot face used by the share sheet's primary action. */ val Agent: ImageVector by lazy { - ImageVector.Builder( - name = "sharingan.agent", - defaultWidth = 24.dp, defaultHeight = 24.dp, - viewportWidth = 24f, viewportHeight = 24f, - ).apply { - path( - stroke = SolidColor(Color.Black), strokeLineWidth = 2f, - strokeLineCap = StrokeCap.Round, strokeLineJoin = StrokeJoin.Round, - fill = null, - ) { - moveTo(7f, 7f); lineTo(17f, 7f) - arcTo(3f, 3f, 0f, false, true, 20f, 10f) - lineTo(20f, 16f) - arcTo(3f, 3f, 0f, false, true, 17f, 19f) - lineTo(7f, 19f) - arcTo(3f, 3f, 0f, false, true, 4f, 16f) - lineTo(4f, 10f) - arcTo(3f, 3f, 0f, false, true, 7f, 7f) - close() - moveTo(12f, 4f); lineTo(12f, 7f) - moveTo(9f, 19f); lineTo(9f, 21f) - moveTo(15f, 19f); lineTo(15f, 21f) - } - path(fill = SolidColor(Color.Black)) { - moveTo(9f, 11.6f) - arcTo(1.4f, 1.4f, 0f, true, true, 8.99f, 11.6f) - moveTo(15f, 11.6f) - arcTo(1.4f, 1.4f, 0f, true, true, 14.99f, 11.6f) - } - }.build() + ImageVector + .Builder( + name = "sharingan.agent", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path( + stroke = SolidColor(Color.Black), + strokeLineWidth = 2f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + fill = null, + ) { + moveTo(7f, 7f) + lineTo(17f, 7f) + arcTo(3f, 3f, 0f, false, true, 20f, 10f) + lineTo(20f, 16f) + arcTo(3f, 3f, 0f, false, true, 17f, 19f) + lineTo(7f, 19f) + arcTo(3f, 3f, 0f, false, true, 4f, 16f) + lineTo(4f, 10f) + arcTo(3f, 3f, 0f, false, true, 7f, 7f) + close() + moveTo(12f, 4f) + lineTo(12f, 7f) + moveTo(9f, 19f) + lineTo(9f, 21f) + moveTo(15f, 19f) + lineTo(15f, 21f) + } + path(fill = SolidColor(Color.Black)) { + moveTo(9f, 11.6f) + arcTo(1.4f, 1.4f, 0f, true, true, 8.99f, 11.6f) + moveTo(15f, 11.6f) + arcTo(1.4f, 1.4f, 0f, true, true, 14.99f, 11.6f) + } + }.build() } /** HTTP tab: globe. */ @@ -132,7 +158,8 @@ internal object SharinganIcons { stroke("globe", width = 1.9f) { moveTo(12f, 3f) arcTo(9f, 9f, 0f, true, true, 11.99f, 3f) - moveTo(3f, 12f); lineTo(21f, 12f) + moveTo(3f, 12f) + lineTo(21f, 12f) moveTo(12f, 3f) curveTo(14.5f, 5.4f, 14.5f, 18.6f, 12f, 21f) moveTo(12f, 3f) @@ -155,8 +182,12 @@ internal object SharinganIcons { /** Bluetooth tab: the BLE rune. */ val Bluetooth: ImageVector by lazy { stroke("bluetooth", width = 1.9f) { - moveTo(8f, 7f); lineTo(16f, 12f); lineTo(11f, 16f) - lineTo(11f, 4f); lineTo(16f, 8f); lineTo(8f, 13f) + moveTo(8f, 7f) + lineTo(16f, 12f) + lineTo(11f, 16f) + lineTo(11f, 4f) + lineTo(16f, 8f) + lineTo(8f, 13f) } } @@ -164,27 +195,37 @@ internal object SharinganIcons { name: String, width: Float = 2f, block: PathBuilder.() -> Unit, - ): ImageVector = ImageVector.Builder( - name = "sharingan.$name", - defaultWidth = 24.dp, defaultHeight = 24.dp, - viewportWidth = 24f, viewportHeight = 24f, - ).apply { - path( - stroke = SolidColor(Color.Black), - strokeLineWidth = width, - strokeLineCap = StrokeCap.Round, - strokeLineJoin = StrokeJoin.Round, - fill = null, - pathBuilder = block, - ) - }.build() - - private fun fill(name: String, block: PathBuilder.() -> Unit): ImageVector = - ImageVector.Builder( - name = "sharingan.$name", - defaultWidth = 24.dp, defaultHeight = 24.dp, - viewportWidth = 24f, viewportHeight = 24f, - ).apply { - path(fill = SolidColor(Color.Black), pathBuilder = block) - }.build() + ): ImageVector = + ImageVector + .Builder( + name = "sharingan.$name", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path( + stroke = SolidColor(Color.Black), + strokeLineWidth = width, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + fill = null, + pathBuilder = block, + ) + }.build() + + private fun fill( + name: String, + block: PathBuilder.() -> Unit, + ): ImageVector = + ImageVector + .Builder( + name = "sharingan.$name", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + path(fill = SolidColor(Color.Black), pathBuilder = block) + }.build() } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganMark.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganMark.kt index b3e85e1..2dbd224 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganMark.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganMark.kt @@ -28,15 +28,17 @@ internal fun SharinganMark( val r = this.size.minDimension / 2f val center = Offset(this.size.width / 2f, this.size.height / 2f) drawCircle( - brush = Brush.radialGradient( - colorStops = arrayOf( - 0.0f to Color(0xFFFF5A4E), - 0.55f to Color(0xFFE5342B), - 1.0f to Color(0xFFA21C16), + brush = + Brush.radialGradient( + colorStops = + arrayOf( + 0.0f to Color(0xFFFF5A4E), + 0.55f to Color(0xFFE5342B), + 1.0f to Color(0xFFA21C16), + ), + center = Offset(center.x - r * 0.24f, center.y - r * 0.4f), + radius = r * 1.6f, ), - center = Offset(center.x - r * 0.24f, center.y - r * 0.4f), - radius = r * 1.6f, - ), radius = r, center = center, ) diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt index f615b43..77a641c 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt @@ -35,9 +35,15 @@ import kotlinx.coroutines.delay // need it. internal -> not part of the checked public API surface. internal val LocalStripTopInset = staticCompositionLocalOf { false } -internal fun sharinganContentInsets(safeDrawing: WindowInsets, stripTop: Boolean): WindowInsets = - if (stripTop) safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) - else safeDrawing +internal fun sharinganContentInsets( + safeDrawing: WindowInsets, + stripTop: Boolean, +): WindowInsets = + if (stripTop) { + safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) + } else { + safeDrawing + } /** * The Sharingan log browser: home (three protocol tabs), event detail, and @@ -67,12 +73,14 @@ public fun SharinganScreen( var query by remember(protocol) { mutableStateOf("") } var chipKey by remember(protocol) { mutableStateOf("all") } - val counts = remember(events) { - Protocol.entries.associateWith { p -> events.count { protocolOf(it) == p } } - } - val rows = remember(events, protocol, chipKey, query) { - visibleEvents(events, protocol, chipKey, query) - } + val counts = + remember(events) { + Protocol.entries.associateWith { p -> events.count { protocolOf(it) == p } } + } + val rows = + remember(events, protocol, chipKey, query) { + visibleEvents(events, protocol, chipKey, query) + } val selectedEvent = selectedId?.let { id -> events.firstOrNull { it.id == id } } val tabEvents = remember(events, protocol) { events.filter { protocolOf(it) == protocol } } @@ -83,25 +91,27 @@ public fun SharinganScreen( } } - val shareState = shareScope?.let { scope -> - ShareSheetState( - scope = scope, - protocol = protocol, - preview = resolveShare(ShareAction.COPY_AGENT, scope, selectedEvent, tabEvents).payload.take(400), - curlAvailable = scope == ShareScope.SINGLE && selectedEvent is HttpEvent, - ) - } + val shareState = + shareScope?.let { scope -> + ShareSheetState( + scope = scope, + protocol = protocol, + preview = resolveShare(ShareAction.COPY_AGENT, scope, selectedEvent, tabEvents).payload.take(400), + curlAvailable = scope == ShareScope.SINGLE && selectedEvent is HttpEvent, + ) + } SharinganTheme(darkTheme = darkTheme) { SharinganScreenContent( - homeState = HomeUiState( - protocol = protocol, - counts = counts, - rows = rows, - query = query, - chipKey = chipKey, - recording = recording, - ), + homeState = + HomeUiState( + protocol = protocol, + counts = counts, + rows = rows, + query = query, + chipKey = chipKey, + recording = recording, + ), selectedEvent = selectedEvent, shareState = shareState, toastMessage = toastMessage, diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganTheme.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganTheme.kt index 5ec4f81..013ac98 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganTheme.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganTheme.kt @@ -43,59 +43,61 @@ internal data class SharinganColors( val violetSoft: Color, ) -internal val SharinganLightColors = SharinganColors( - isDark = false, - bg = Color(0xFFF4F5F7), - bgElev = Color(0xFFFFFFFF), - surface = Color(0xFFFFFFFF), - surface2 = Color(0xFFF1F3F5), - hover = Color(0xFFEAEDF0), - border = Color(0x170F141E), - borderStrong = Color(0x290F141E), - text = Color(0xFF16191D), - textMid = Color(0xFF565C64), - textDim = Color(0xFF8A909A), - faint = Color(0x090F141E), - accent = Color(0xFFD6322A), - accentSoft = Color(0x1AD6322A), - ok = Color(0xFF1F9D57), - okSoft = Color(0x1F1F9D57), - warn = Color(0xFFB97B0E), - warnSoft = Color(0x1FB97B0E), - err = Color(0xFFD6322A), - errSoft = Color(0x1AD6322A), - info = Color(0xFF2563EB), - infoSoft = Color(0x1A2563EB), - violet = Color(0xFF7C5CE0), - violetSoft = Color(0x1F7C5CE0), -) +internal val SharinganLightColors = + SharinganColors( + isDark = false, + bg = Color(0xFFF4F5F7), + bgElev = Color(0xFFFFFFFF), + surface = Color(0xFFFFFFFF), + surface2 = Color(0xFFF1F3F5), + hover = Color(0xFFEAEDF0), + border = Color(0x170F141E), + borderStrong = Color(0x290F141E), + text = Color(0xFF16191D), + textMid = Color(0xFF565C64), + textDim = Color(0xFF8A909A), + faint = Color(0x090F141E), + accent = Color(0xFFD6322A), + accentSoft = Color(0x1AD6322A), + ok = Color(0xFF1F9D57), + okSoft = Color(0x1F1F9D57), + warn = Color(0xFFB97B0E), + warnSoft = Color(0x1FB97B0E), + err = Color(0xFFD6322A), + errSoft = Color(0x1AD6322A), + info = Color(0xFF2563EB), + infoSoft = Color(0x1A2563EB), + violet = Color(0xFF7C5CE0), + violetSoft = Color(0x1F7C5CE0), + ) -internal val SharinganDarkColors = SharinganColors( - isDark = true, - bg = Color(0xFF0E0F13), - bgElev = Color(0xFF15171C), - surface = Color(0xFF181B21), - surface2 = Color(0xFF1F232B), - hover = Color(0xFF242933), - border = Color(0x14FFFFFF), - borderStrong = Color(0x24FFFFFF), - text = Color(0xFFE8EAED), - textMid = Color(0xFFA6ACB5), - textDim = Color(0xFF6B7280), - faint = Color(0x0AFFFFFF), - accent = Color(0xFFE5342B), - accentSoft = Color(0x29E5342B), - ok = Color(0xFF3DD68C), - okSoft = Color(0x243DD68C), - warn = Color(0xFFE0A33B), - warnSoft = Color(0x24E0A33B), - err = Color(0xFFFF5D55), - errSoft = Color(0x26FF5D55), - info = Color(0xFF5AA9FF), - infoSoft = Color(0x245AA9FF), - violet = Color(0xFFA78BFA), - violetSoft = Color(0x26A78BFA), -) +internal val SharinganDarkColors = + SharinganColors( + isDark = true, + bg = Color(0xFF0E0F13), + bgElev = Color(0xFF15171C), + surface = Color(0xFF181B21), + surface2 = Color(0xFF1F232B), + hover = Color(0xFF242933), + border = Color(0x14FFFFFF), + borderStrong = Color(0x24FFFFFF), + text = Color(0xFFE8EAED), + textMid = Color(0xFFA6ACB5), + textDim = Color(0xFF6B7280), + faint = Color(0x0AFFFFFF), + accent = Color(0xFFE5342B), + accentSoft = Color(0x29E5342B), + ok = Color(0xFF3DD68C), + okSoft = Color(0x243DD68C), + warn = Color(0xFFE0A33B), + warnSoft = Color(0x24E0A33B), + err = Color(0xFFFF5D55), + errSoft = Color(0x26FF5D55), + info = Color(0xFF5AA9FF), + infoSoft = Color(0x245AA9FF), + violet = Color(0xFFA78BFA), + violetSoft = Color(0x26A78BFA), + ) internal val LocalSharinganColors = staticCompositionLocalOf { SharinganLightColors } @@ -113,27 +115,28 @@ internal fun SharinganTheme( content: @Composable () -> Unit, ) { val colors = if (darkTheme) SharinganDarkColors else SharinganLightColors - val materialScheme = if (darkTheme) { - darkColorScheme( - primary = colors.accent, - background = colors.bg, - surface = colors.bgElev, - surfaceContainerLow = colors.bgElev, - onSurface = colors.text, - onBackground = colors.text, - outline = colors.borderStrong, - ) - } else { - lightColorScheme( - primary = colors.accent, - background = colors.bg, - surface = colors.bgElev, - surfaceContainerLow = colors.bgElev, - onSurface = colors.text, - onBackground = colors.text, - outline = colors.borderStrong, - ) - } + val materialScheme = + if (darkTheme) { + darkColorScheme( + primary = colors.accent, + background = colors.bg, + surface = colors.bgElev, + surfaceContainerLow = colors.bgElev, + onSurface = colors.text, + onBackground = colors.text, + outline = colors.borderStrong, + ) + } else { + lightColorScheme( + primary = colors.accent, + background = colors.bg, + surface = colors.bgElev, + surfaceContainerLow = colors.bgElev, + onSurface = colors.text, + onBackground = colors.text, + outline = colors.borderStrong, + ) + } CompositionLocalProvider(LocalSharinganColors provides colors) { MaterialTheme(colorScheme = materialScheme, content = content) } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/Toast.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/Toast.kt index 6f23ef4..83c4389 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/Toast.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/Toast.kt @@ -28,7 +28,10 @@ import androidx.compose.ui.unit.sp /** The design's confirmation pill ("Copied for agent ✓"), bottom-centered. */ @Composable -internal fun SharinganToast(message: String?, modifier: Modifier = Modifier) { +internal fun SharinganToast( + message: String?, + modifier: Modifier = Modifier, +) { val colors = LocalSharinganColors.current // Keep the last text visible while the pill animates out. var lastMessage by remember { mutableStateOf("") } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/HttpEventTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/HttpEventTest.kt index eac9fe4..017f426 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/HttpEventTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/HttpEventTest.kt @@ -6,70 +6,93 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue internal class HttpEventTest { - @Test fun `When constructed from a full URL Then host and path are derived`() { - val e = HttpEvent( - id = "1", timestampMillis = 0L, method = "GET", - url = "https://api.acme-iot.com/api/v2/devices/4471/state", - ) + val e = + HttpEvent( + id = "1", + timestampMillis = 0L, + method = "GET", + url = "https://api.acme-iot.com/api/v2/devices/4471/state", + ) assertEquals("api.acme-iot.com", e.host) assertEquals("/api/v2/devices/4471/state", e.path) } @Test fun `Given a URL with a query string When path is derived Then the query is kept`() { - val e = HttpEvent( - id = "1", timestampMillis = 0L, method = "GET", - url = "https://cdn.acme-iot.com/api/v2/firmware/manifest?ch=stable", - ) + val e = + HttpEvent( + id = "1", + timestampMillis = 0L, + method = "GET", + url = "https://cdn.acme-iot.com/api/v2/firmware/manifest?ch=stable", + ) assertEquals("/api/v2/firmware/manifest?ch=stable", e.path) assertEquals("cdn.acme-iot.com", e.host) } @Test fun `Given a URL with a port When host is derived Then the port is included`() { - val e = HttpEvent( - id = "1", timestampMillis = 0L, method = "GET", - url = "http://localhost:8080/health", - ) + val e = + HttpEvent( + id = "1", + timestampMillis = 0L, + method = "GET", + url = "http://localhost:8080/health", + ) assertEquals("localhost:8080", e.host) assertEquals("/health", e.path) } @Test fun `Given a URL with no path When path is derived Then it falls back to a single slash`() { - val e = HttpEvent( - id = "1", timestampMillis = 0L, method = "GET", - url = "https://example.com", - ) + val e = + HttpEvent( + id = "1", + timestampMillis = 0L, + method = "GET", + url = "https://example.com", + ) assertEquals("/", e.path) } @Test fun `Given a 500 response Then the event is a failure`() { - val e = HttpEvent( - id = "1", timestampMillis = 0L, method = "GET", - url = "https://example.com/x", statusCode = 500, - ) + val e = + HttpEvent( + id = "1", + timestampMillis = 0L, + method = "GET", + url = "https://example.com/x", + statusCode = 500, + ) assertTrue(e.isFailure) } @Test fun `Given a 2xx response with no transport error Then the event is not a failure`() { - val e = HttpEvent( - id = "1", timestampMillis = 0L, method = "GET", - url = "https://example.com/x", statusCode = 200, - ) + val e = + HttpEvent( + id = "1", + timestampMillis = 0L, + method = "GET", + url = "https://example.com/x", + statusCode = 200, + ) assertFalse(e.isFailure) } @Test fun `Given a transport error and no status Then the event is a failure`() { - val e = HttpEvent( - id = "1", timestampMillis = 0L, method = "GET", - url = "https://example.com/x", error = "connect timeout", - ) + val e = + HttpEvent( + id = "1", + timestampMillis = 0L, + method = "GET", + url = "https://example.com/x", + error = "connect timeout", + ) assertTrue(e.isFailure) } } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/LoggersTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/LoggersTest.kt index 4edcd14..0e87248 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/LoggersTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/LoggersTest.kt @@ -7,7 +7,6 @@ import kotlin.test.assertNotEquals import kotlin.test.assertTrue internal class LoggersTest { - @Test fun `When an MQTT publish is logged Then a PUBLISH event with topic qos and payload is stored`() { val store = SharinganStore(capacity = 10) @@ -27,7 +26,10 @@ internal class LoggersTest { val mqtt = MqttLogger(store) mqtt.received(topic = "devices/4471/commands/reboot", payload = "{}", qos = 1) mqtt.subscribed(topicFilter = "devices/4471/commands/#", qos = 1) - val dirs = store.events.value.filterIsInstance().map { it.direction } + val dirs = + store.events.value + .filterIsInstance() + .map { it.direction } assertEquals(listOf(MqttDirection.RECEIVE, MqttDirection.SUBSCRIBE), dirs) } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganExportTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganExportTest.kt index 6051e24..7b86e20 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganExportTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganExportTest.kt @@ -7,42 +7,44 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue internal class SharinganExportTest { + private val httpEvent = + HttpEvent( + id = "h1", + timestampMillis = 0L, + method = "GET", + url = "https://api.acme-iot.com/api/v2/devices/4471/state", + statusCode = 200, + durationMillis = 142, + requestHeaders = listOf("Authorization" to "••••", "Accept" to "application/json"), + responseHeaders = listOf("Content-Type" to "application/json"), + responseBody = """{"deviceId":4471,"online":true}""", + contentType = "application/json", + responseSizeBytes = 4300, + ) - private val httpEvent = HttpEvent( - id = "h1", - timestampMillis = 0L, - method = "GET", - url = "https://api.acme-iot.com/api/v2/devices/4471/state", - statusCode = 200, - durationMillis = 142, - requestHeaders = listOf("Authorization" to "••••", "Accept" to "application/json"), - responseHeaders = listOf("Content-Type" to "application/json"), - responseBody = """{"deviceId":4471,"online":true}""", - contentType = "application/json", - responseSizeBytes = 4300, - ) - - private val mqttEvent = MqttEvent( - id = "m1", - timestampMillis = 0L, - direction = MqttDirection.PUBLISH, - topic = "devices/4471/telemetry", - qos = 1, - retained = false, - payload = """{"temp":23.4}""", - payloadSizeBytes = 13, - ) - - private val bleEvent = BleEvent( - id = "b1", - timestampMillis = 0L, - operation = BleOperation.NOTIFY, - device = "HR-Monitor-9F", - characteristic = "Heart Rate Measurement", - uuid = "0x2A37", - payload = """{"bpm":72}""", - sizeBytes = 8, - ) + private val mqttEvent = + MqttEvent( + id = "m1", + timestampMillis = 0L, + direction = MqttDirection.PUBLISH, + topic = "devices/4471/telemetry", + qos = 1, + retained = false, + payload = """{"temp":23.4}""", + payloadSizeBytes = 13, + ) + + private val bleEvent = + BleEvent( + id = "b1", + timestampMillis = 0L, + operation = BleOperation.NOTIFY, + device = "HR-Monitor-9F", + characteristic = "Heart Rate Measurement", + uuid = "0x2A37", + payload = """{"bpm":72}""", + sizeBytes = 8, + ) // ── agent markdown: single event ───────────────────────────── @@ -108,9 +110,10 @@ internal class SharinganExportTest { @Test fun `Given a request body When exported as cURL Then a data flag carries the body`() { - val curl = SharinganExport.curl( - httpEvent.copy(method = "POST", requestBody = """{"cmd":"reboot"}""") - ) + val curl = + SharinganExport.curl( + httpEvent.copy(method = "POST", requestBody = """{"cmd":"reboot"}"""), + ) assertContains(curl, "curl -X POST") assertContains(curl, "--data '{\"cmd\":\"reboot\"}'") } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt index 165a059..9487300 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt @@ -10,17 +10,17 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue internal class SharinganStoreTest { - - private fun event(id: String): SharinganEvent = MqttEvent( - id = id, - timestampMillis = 0L, - direction = MqttDirection.PUBLISH, - topic = "t", - qos = 0, - retained = false, - payload = null, - payloadSizeBytes = null, - ) + private fun event(id: String): SharinganEvent = + MqttEvent( + id = id, + timestampMillis = 0L, + direction = MqttDirection.PUBLISH, + topic = "t", + qos = 0, + retained = false, + payload = null, + payloadSizeBytes = null, + ) @Test fun `When an event is recorded Then it appears in the events flow in insertion order`() { @@ -70,15 +70,19 @@ internal class SharinganStoreTest { store.record(event("m1")) store.record( HttpEvent( - id = "h1", timestampMillis = 0L, method = "GET", + id = "h1", + timestampMillis = 0L, + method = "GET", url = "https://api.example.com/v1/state", - ) + ), ) store.record( BleEvent( - id = "b1", timestampMillis = 0L, operation = BleOperation.READ, + id = "b1", + timestampMillis = 0L, + operation = BleOperation.READ, device = "HR-Monitor", - ) + ), ) val events = store.events.value assertEquals(1, events.count { it is HttpEvent }) @@ -87,35 +91,37 @@ internal class SharinganStoreTest { } @Test - fun `Given many producers recording concurrently When all complete Then no event is lost or duplicated`() = runTest { - val producers = 16 - val perProducer = 500 - val total = producers * perProducer + fun `Given many producers recording concurrently When all complete Then no event is lost or duplicated`() = + runTest { + val producers = 16 + val perProducer = 500 + val total = producers * perProducer - // Capacity holds every event, so a lost compare-and-set update surfaces - // as a missing event rather than as legitimate ring-buffer eviction — - // making this a direct check of the lock-free `record` path under load. - val store = SharinganStore(capacity = total) + // Capacity holds every event, so a lost compare-and-set update surfaces + // as a missing event rather than as legitimate ring-buffer eviction — + // making this a direct check of the lock-free `record` path under load. + val store = SharinganStore(capacity = total) - // Each producer records on a real background thread (Dispatchers.Default - // is multi-threaded on both the JVM and Kotlin/Native), so the producers - // genuinely contend on the same MutableStateFlow rather than interleaving - // cooperatively on a single test thread. - coroutineScope { - repeat(producers) { producer -> - launch(Dispatchers.Default) { - repeat(perProducer) { seq -> store.record(event("p$producer-$seq")) } + // Each producer records on a real background thread (Dispatchers.Default + // is multi-threaded on both the JVM and Kotlin/Native), so the producers + // genuinely contend on the same MutableStateFlow rather than interleaving + // cooperatively on a single test thread. + coroutineScope { + repeat(producers) { producer -> + launch(Dispatchers.Default) { + repeat(perProducer) { seq -> store.record(event("p$producer-$seq")) } + } } } - } - val ids = store.events.value.map { it.id } - assertEquals(total, ids.size, "lost events: a concurrent record() dropped an update") - assertEquals(total, ids.toSet().size, "duplicate event ids in the store") + val ids = store.events.value.map { it.id } + assertEquals(total, ids.size, "lost events: a concurrent record() dropped an update") + assertEquals(total, ids.toSet().size, "duplicate event ids in the store") - val expected = buildSet { - repeat(producers) { producer -> repeat(perProducer) { seq -> add("p$producer-$seq") } } + val expected = + buildSet { + repeat(producers) { producer -> repeat(perProducer) { seq -> add("p$producer-$seq") } } + } + assertEquals(expected, ids.toSet()) } - assertEquals(expected, ids.toSet()) - } } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/internal/NotificationContentTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/NotificationContentTest.kt index 9d8bdc2..2ecb975 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/internal/NotificationContentTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/internal/NotificationContentTest.kt @@ -11,27 +11,29 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull internal class NotificationContentTest { - - private val http = HttpEvent( - id = "h1", - timestampMillis = 0, - method = "GET", - url = "https://api.acme.com/state", - statusCode = 200, - ) - private val mqtt = MqttEvent( - id = "m1", - timestampMillis = 1, - direction = MqttDirection.PUBLISH, - topic = "devices/4471/telemetry", - ) - private val ble = BleEvent( - id = "b1", - timestampMillis = 2, - operation = BleOperation.NOTIFY, - device = "HR-9F", - characteristic = "Heart Rate Measurement", - ) + private val http = + HttpEvent( + id = "h1", + timestampMillis = 0, + method = "GET", + url = "https://api.acme.com/state", + statusCode = 200, + ) + private val mqtt = + MqttEvent( + id = "m1", + timestampMillis = 1, + direction = MqttDirection.PUBLISH, + topic = "devices/4471/telemetry", + ) + private val ble = + BleEvent( + id = "b1", + timestampMillis = 2, + operation = BleOperation.NOTIFY, + device = "HR-9F", + characteristic = "Heart Rate Measurement", + ) @Test fun `When there are no events Then there is nothing to post`() { diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/ktor/SharinganKtorTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/ktor/SharinganKtorTest.kt index 52beb84..0fc3441 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/ktor/SharinganKtorTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/ktor/SharinganKtorTest.kt @@ -24,125 +24,139 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue internal class SharinganKtorTest { - - private fun client(store: SharinganStore, configure: SharinganKtorConfig.() -> Unit = {}) = - HttpClient(MockEngine { request -> + private fun client( + store: SharinganStore, + configure: SharinganKtorConfig.() -> Unit = {}, + ) = HttpClient( + MockEngine { request -> when (request.url.encodedPath) { "/fail" -> throw RuntimeException("connect timeout") - "/big" -> respond( - content = "x".repeat(100_000), - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "text/plain"), - ) - else -> respond( - content = """{"deviceId":4471,"online":true}""", - status = HttpStatusCode.OK, - headers = headersOf(HttpHeaders.ContentType, "application/json"), - ) - } - }) { - install(SharinganKtor) { - this.store = store - configure() + "/big" -> + respond( + content = "x".repeat(100_000), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + else -> + respond( + content = """{"deviceId":4471,"online":true}""", + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) } + }, + ) { + install(SharinganKtor) { + this.store = store + configure() } + } @Test - fun `When a request succeeds Then an HttpEvent with method url status headers and body is recorded`() = runTest { - val store = SharinganStore(capacity = 10) - client(store).use { http -> - http.get("https://api.acme-iot.com/api/v2/devices/4471/state") { - header("Accept", "application/json") + fun `When a request succeeds Then an HttpEvent with method url status headers and body is recorded`() = + runTest { + val store = SharinganStore(capacity = 10) + client(store).use { http -> + http.get("https://api.acme-iot.com/api/v2/devices/4471/state") { + header("Accept", "application/json") + } } + val e = assertIs(store.events.value.single()) + assertEquals("GET", e.method) + assertEquals("api.acme-iot.com", e.host) + assertEquals("/api/v2/devices/4471/state", e.path) + assertEquals(200, e.statusCode) + assertEquals("""{"deviceId":4471,"online":true}""", e.responseBody) + assertEquals("application/json", e.contentType) + assertNotNull(e.durationMillis) + assertTrue(e.requestHeaders.any { it.first == "Accept" && it.second == "application/json" }) + assertTrue(e.responseHeaders.any { it.first.equals("Content-Type", ignoreCase = true) }) } - val e = assertIs(store.events.value.single()) - assertEquals("GET", e.method) - assertEquals("api.acme-iot.com", e.host) - assertEquals("/api/v2/devices/4471/state", e.path) - assertEquals(200, e.statusCode) - assertEquals("""{"deviceId":4471,"online":true}""", e.responseBody) - assertEquals("application/json", e.contentType) - assertNotNull(e.durationMillis) - assertTrue(e.requestHeaders.any { it.first == "Accept" && it.second == "application/json" }) - assertTrue(e.responseHeaders.any { it.first.equals("Content-Type", ignoreCase = true) }) - } @Test - fun `When a request carries a body Then the request body is captured`() = runTest { - val store = SharinganStore(capacity = 10) - client(store).use { http -> - http.post("https://api.acme-iot.com/api/v2/devices/4471/commands") { - setBody(TextContent("""{"cmd":"reboot"}""", ContentType.Application.Json)) + fun `When a request carries a body Then the request body is captured`() = + runTest { + val store = SharinganStore(capacity = 10) + client(store).use { http -> + http.post("https://api.acme-iot.com/api/v2/devices/4471/commands") { + setBody(TextContent("""{"cmd":"reboot"}""", ContentType.Application.Json)) + } } + val e = assertIs(store.events.value.single()) + assertEquals("POST", e.method) + assertEquals("""{"cmd":"reboot"}""", e.requestBody) } - val e = assertIs(store.events.value.single()) - assertEquals("POST", e.method) - assertEquals("""{"cmd":"reboot"}""", e.requestBody) - } @Test - fun `Given a redacted header When a request is captured Then its value is masked`() = runTest { - val store = SharinganStore(capacity = 10) - client(store).use { http -> - http.get("https://api.acme-iot.com/state") { - header("Authorization", "Bearer secret") + fun `Given a redacted header When a request is captured Then its value is masked`() = + runTest { + val store = SharinganStore(capacity = 10) + client(store).use { http -> + http.get("https://api.acme-iot.com/state") { + header("Authorization", "Bearer secret") + } } + val e = assertIs(store.events.value.single()) + assertEquals("••••", e.requestHeaders.first { it.first == "Authorization" }.second) } - val e = assertIs(store.events.value.single()) - assertEquals("••••", e.requestHeaders.first { it.first == "Authorization" }.second) - } @Test - fun `When a request fails at transport level Then a failure event is recorded and the exception propagates`() = runTest { - val store = SharinganStore(capacity = 10) - client(store).use { http -> - assertFailsWith { http.get("https://api.acme-iot.com/fail") } + fun `When a request fails at transport level Then a failure event is recorded and the exception propagates`() = + runTest { + val store = SharinganStore(capacity = 10) + client(store).use { http -> + assertFailsWith { http.get("https://api.acme-iot.com/fail") } + } + val e = assertIs(store.events.value.single()) + assertEquals(null, e.statusCode) + assertTrue(e.isFailure) + assertNotNull(e.error) + assertTrue("connect timeout" in e.error!!) } - val e = assertIs(store.events.value.single()) - assertEquals(null, e.statusCode) - assertTrue(e.isFailure) - assertNotNull(e.error) - assertTrue("connect timeout" in e.error!!) - } @Test - fun `Given a response larger than the cap When captured Then the body is truncated with a marker`() = runTest { - val store = SharinganStore(capacity = 10) - client(store) { maxBodyBytes = 1024 }.use { http -> - http.get("https://api.acme-iot.com/big") + fun `Given a response larger than the cap When captured Then the body is truncated with a marker`() = + runTest { + val store = SharinganStore(capacity = 10) + client(store) { maxBodyBytes = 1024 }.use { http -> + http.get("https://api.acme-iot.com/big") + } + val e = assertIs(store.events.value.single()) + val body = assertNotNull(e.responseBody) + assertTrue(body.length < 100_000) + assertTrue("truncated" in body) } - val e = assertIs(store.events.value.single()) - val body = assertNotNull(e.responseBody) - assertTrue(body.length < 100_000) - assertTrue("truncated" in body) - } @Test - fun `Given body capture is disabled When a request succeeds Then bodies are omitted`() = runTest { - val store = SharinganStore(capacity = 10) - client(store) { captureBodies = false }.use { http -> - http.get("https://api.acme-iot.com/state") + fun `Given body capture is disabled When a request succeeds Then bodies are omitted`() = + runTest { + val store = SharinganStore(capacity = 10) + client(store) { captureBodies = false }.use { http -> + http.get("https://api.acme-iot.com/state") + } + val e = assertIs(store.events.value.single()) + assertEquals(null, e.responseBody) } - val e = assertIs(store.events.value.single()) - assertEquals(null, e.responseBody) - } @Test - fun `When the plugin captures a response Then the caller can still read the body downstream`() = runTest { - val store = SharinganStore(capacity = 10) - val body = client(store).use { http -> - http.get("https://api.acme-iot.com/state").bodyAsText() + fun `When the plugin captures a response Then the caller can still read the body downstream`() = + runTest { + val store = SharinganStore(capacity = 10) + val body = + client(store).use { http -> + http.get("https://api.acme-iot.com/state").bodyAsText() + } + assertEquals("""{"deviceId":4471,"online":true}""", body) } - assertEquals("""{"deviceId":4471,"online":true}""", body) - } @Test - fun `Given recording is paused When a request succeeds Then nothing is recorded`() = runTest { - val store = SharinganStore(capacity = 10) - store.setRecording(false) - client(store).use { http -> - http.get("https://api.acme-iot.com/state") + fun `Given recording is paused When a request succeeds Then nothing is recorded`() = + runTest { + val store = SharinganStore(capacity = 10) + store.setRecording(false) + client(store).use { http -> + http.get("https://api.acme-iot.com/state") + } + assertTrue(store.events.value.isEmpty()) } - assertTrue(store.events.value.isEmpty()) - } } diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt index 24d254c..3dfe7dd 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/persistence/SharinganDatabaseRoundTripTest.kt @@ -4,7 +4,6 @@ import kotlin.test.Test import kotlin.test.assertEquals internal class SharinganDatabaseRoundTripTest { - @Test fun `Given a session and an event inserted When read back Then all fields round trip`() { val db = SharinganDatabase(createTestDriver()) diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/EventFilterTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/EventFilterTest.kt index 4cc799e..e179b18 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/EventFilterTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/EventFilterTest.kt @@ -12,15 +12,21 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue internal class EventFilterTest { - - private fun http(method: String = "GET", status: Int? = 200, url: String = "https://api.acme.com/state") = - HttpEvent(id = "h", timestampMillis = 0, method = method, url = url, statusCode = status) - - private fun mqtt(direction: MqttDirection, topic: String = "devices/4471/telemetry") = - MqttEvent(id = "m", timestampMillis = 0, direction = direction, topic = topic) - - private fun ble(op: BleOperation, characteristic: String? = "Heart Rate Measurement") = - BleEvent(id = "b", timestampMillis = 0, operation = op, device = "HR-9F", characteristic = characteristic) + private fun http( + method: String = "GET", + status: Int? = 200, + url: String = "https://api.acme.com/state", + ) = HttpEvent(id = "h", timestampMillis = 0, method = method, url = url, statusCode = status) + + private fun mqtt( + direction: MqttDirection, + topic: String = "devices/4471/telemetry", + ) = MqttEvent(id = "m", timestampMillis = 0, direction = direction, topic = topic) + + private fun ble( + op: BleOperation, + characteristic: String? = "Heart Rate Measurement", + ) = BleEvent(id = "b", timestampMillis = 0, operation = op, device = "HR-9F", characteristic = characteristic) @Test fun `When events are grouped by protocol Then each event lands in its protocol tab`() { diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/JsonPrettyTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/JsonPrettyTest.kt index c79aadb..a6c18d3 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/JsonPrettyTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/JsonPrettyTest.kt @@ -5,7 +5,6 @@ import kotlin.test.assertEquals import kotlin.test.assertNull internal class JsonPrettyTest { - @Test fun `When minified JSON is prettified Then it is re-indented with two spaces`() { val pretty = prettyJson("""{"deviceId":4471,"online":true,"sensors":{"temp":23.4}}""") diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ScreenInsetsTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ScreenInsetsTest.kt index aa6c272..cd136fa 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ScreenInsetsTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ScreenInsetsTest.kt @@ -7,7 +7,6 @@ import kotlin.test.Test import kotlin.test.assertEquals internal class ScreenInsetsTest { - @Test fun `Given strip=true When content insets built Then top side absent`() { val density = Density(1f) diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ShareResolverTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ShareResolverTest.kt index e869e0e..a21d56b 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ShareResolverTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/ui/ShareResolverTest.kt @@ -10,20 +10,21 @@ import kotlin.test.assertNull import kotlin.test.assertTrue internal class ShareResolverTest { - - private val http = HttpEvent( - id = "h1", - timestampMillis = 0, - method = "GET", - url = "https://api.acme.com/state", - statusCode = 200, - ) - private val mqtt = MqttEvent( - id = "m1", - timestampMillis = 1, - direction = MqttDirection.PUBLISH, - topic = "devices/4471/telemetry", - ) + private val http = + HttpEvent( + id = "h1", + timestampMillis = 0, + method = "GET", + url = "https://api.acme.com/state", + statusCode = 200, + ) + private val mqtt = + MqttEvent( + id = "m1", + timestampMillis = 1, + direction = MqttDirection.PUBLISH, + topic = "devices/4471/telemetry", + ) private val tab = listOf(http, mqtt) @Test diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt b/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt index ffa64da..38d34c3 100644 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt +++ b/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt @@ -5,14 +5,14 @@ import androidx.compose.ui.window.ComposeUIViewController import dev.sharingan.internal.topmostViewController import dev.sharingan.ui.LocalStripTopInset import dev.sharingan.ui.SharinganScreen -import kotlin.experimental.ExperimentalObjCName -import kotlin.native.ObjCName import platform.UIKit.UIApplication import platform.UIKit.UIViewController import platform.UIKit.UIWindow import platform.UIKit.UIWindowScene import platform.darwin.dispatch_async import platform.darwin.dispatch_get_main_queue +import kotlin.experimental.ExperimentalObjCName +import kotlin.native.ObjCName /** * The Sharingan log browser as a `UIViewController` — embed or push it @@ -61,11 +61,12 @@ private fun composeVc(stripTopInset: Boolean): UIViewController = @OptIn(ExperimentalObjCName::class) public fun presentSharingan(animated: Boolean = true) { dispatch_async(dispatch_get_main_queue()) { - val root = UIApplication.sharedApplication.connectedScenes - .filterIsInstance() - .flatMap { it.windows.filterIsInstance() } - .firstOrNull { it.keyWindow } - ?.rootViewController ?: return@dispatch_async + val root = + UIApplication.sharedApplication.connectedScenes + .filterIsInstance() + .flatMap { it.windows.filterIsInstance() } + .firstOrNull { it.keyWindow } + ?.rootViewController ?: return@dispatch_async val top = topmostViewController(root) // Guard: UIKit silently swallows a present() while another // presentation/dismissal is already in flight; make the no-op explicit. diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/internal/Platform.ios.kt b/sharingan/src/iosMain/kotlin/dev/sharingan/internal/Platform.ios.kt index f60dd0b..4512759 100644 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/internal/Platform.ios.kt +++ b/sharingan/src/iosMain/kotlin/dev/sharingan/internal/Platform.ios.kt @@ -7,8 +7,7 @@ import platform.Foundation.dateWithTimeIntervalSince1970 import platform.Foundation.localeWithLocaleIdentifier import platform.Foundation.timeIntervalSince1970 -internal actual fun currentTimeMillis(): Long = - (NSDate().timeIntervalSince1970 * 1000.0).toLong() +internal actual fun currentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong() private val clockFormatter: NSDateFormatter by lazy { NSDateFormatter().apply { diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/internal/PlatformActions.ios.kt b/sharingan/src/iosMain/kotlin/dev/sharingan/internal/PlatformActions.ios.kt index af93be3..f801246 100644 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/internal/PlatformActions.ios.kt +++ b/sharingan/src/iosMain/kotlin/dev/sharingan/internal/PlatformActions.ios.kt @@ -14,16 +14,18 @@ internal actual fun copyToClipboard(text: String) { internal actual fun shareText(text: String) { NSOperationQueue.mainQueue.addOperationWithBlock { - val root = UIApplication.sharedApplication.connectedScenes - .filterIsInstance() - .flatMap { it.windows.filterIsInstance() } - .firstOrNull { it.keyWindow } - ?.rootViewController ?: return@addOperationWithBlock + val root = + UIApplication.sharedApplication.connectedScenes + .filterIsInstance() + .flatMap { it.windows.filterIsInstance() } + .firstOrNull { it.keyWindow } + ?.rootViewController ?: return@addOperationWithBlock val presenter = topmostViewController(root) - val controller = UIActivityViewController( - activityItems = listOf(text), - applicationActivities = null, - ) + val controller = + UIActivityViewController( + activityItems = listOf(text), + applicationActivities = null, + ) // iPad requires a popover anchor; anchor to the presenter's view. controller.popoverPresentationController?.sourceView = presenter.view presenter.presentViewController(controller, animated = true, completion = null) diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt b/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt index af5fae2..880a0f9 100644 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt +++ b/sharingan/src/iosMain/kotlin/dev/sharingan/persistence/DriverFactory.ios.kt @@ -4,6 +4,5 @@ import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver internal actual class DriverFactory { - actual fun create(): SqlDriver = - NativeSqliteDriver(SharinganDatabase.Schema, "sharingan.db") + actual fun create(): SqlDriver = NativeSqliteDriver(SharinganDatabase.Schema, "sharingan.db") } diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/ui/PlatformBackHandler.ios.kt b/sharingan/src/iosMain/kotlin/dev/sharingan/ui/PlatformBackHandler.ios.kt index f3263fc..8ac9d8d 100644 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/ui/PlatformBackHandler.ios.kt +++ b/sharingan/src/iosMain/kotlin/dev/sharingan/ui/PlatformBackHandler.ios.kt @@ -3,6 +3,9 @@ package dev.sharingan.ui import androidx.compose.runtime.Composable @Composable -internal actual fun PlatformBackHandler(enabled: Boolean, onBack: () -> Unit) { +internal actual fun PlatformBackHandler( + enabled: Boolean, + onBack: () -> Unit, +) { // No system back affordance on iOS; the in-UI Back button handles it. } diff --git a/sharingan/src/iosTest/kotlin/dev/sharingan/internal/TopmostViewControllerTest.kt b/sharingan/src/iosTest/kotlin/dev/sharingan/internal/TopmostViewControllerTest.kt index 1600d20..a830263 100644 --- a/sharingan/src/iosTest/kotlin/dev/sharingan/internal/TopmostViewControllerTest.kt +++ b/sharingan/src/iosTest/kotlin/dev/sharingan/internal/TopmostViewControllerTest.kt @@ -5,7 +5,6 @@ import kotlin.test.Test import kotlin.test.assertTrue class TopmostViewControllerTest { - @Test fun `When root has no presentations Then root is the topmost`() { val root = UIViewController(nibName = null, bundle = null) From b5e248d1c1bf944fa0889b6b11e370140612255e Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 16:15:37 +0100 Subject: [PATCH 2/7] chore: adopt JetBrains Kotlin style guide and comments policy - .editorconfig: ij_kotlin_code_style = intellij_idea (JetBrains coding conventions from kotlinlang.org), trailing commas and 140-col limit kept - ktlint comment mechanics: comment-wrapping, no-single-line-block-comment - AGENTS.md: comments policy for agents - comments only for 'why', never narrating 'what'; AI-style narration comments prohibited --- .editorconfig | 7 ++++++- AGENTS.md | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index de33917..3cbd425 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,10 +1,15 @@ root = true [*.{kt,kts}] -ij_kotlin_code_style = ktlint_official +ij_kotlin_code_style = intellij_idea max_line_length = 140 ij_kotlin_name_count_to_use_star_import = 2147483647 ij_kotlin_name_count_to_use_star_import_with_matching_name = 2147483647 ktlint_standard_trailing-comma-on-call-site = enabled ktlint_standard_trailing-comma-on-declaration-site = enabled ktlint_function_naming_ignore_when_annotated_with = Composable, ObjCName + +# Comment mechanics: comments wrap at max_line_length, `//` over `/* */` for +# single-line remarks. +ktlint_standard_comment-wrapping = enabled +ktlint_standard_no-single-line-block-comment = enabled diff --git a/AGENTS.md b/AGENTS.md index 3a96fd1..2b57d6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,17 @@ binary-compatibility-validator. The golden dumps are committed at - `./gradlew apiCheck` — fails if the public API drifts from the committed dumps. Runs on every PR (`.github/workflows/api-check.yml`, macOS so the iOS klib targets build). - `./gradlew apiDump` — regenerate the dumps after an *intentional* API change, then commit the updated `api/*.api` files in the same PR. +## Comments policy + +Code comments are for *why*, never *what*. Default to **no comment**; write code that explains itself. + +- DO NOT add comments narrating the obvious (`// increment counter`, `// call the API`, section-divider banners). +- DO NOT add "AI notes": comments narrating the change you just made (`// added null check`, `// new implementation`, `// refactored for clarity`), restating the function name, or addressing the reviewer. +- DO NOT leave TODO/FIXME without an issue reference (`// TODO(#NN):`). +- Comments ARE justified for: a non-obvious invariant or trade-off, a workaround with its bug/issue link, a pointer to the primary source (spec/ADR), or a public-API contract the signature can't express — prefer KDoc for that. +- If a comment only survives because the code is hard to read, fix the code instead. +- ktlint enforces the mechanics (wrapping, `//` over `/* */`); this policy is enforced by review. + ## Agent skills ### Issue tracker From 63b571b7108146bc8a9e7c74720082bec7d972fc Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 16:35:28 +0100 Subject: [PATCH 3/7] feat: enforce comments policy via custom ktlint ruleset - ktlint-rules module: sharingan-comments ruleset (ktlint 1.5.0 API) - todo-without-issue: TODO/FIXME must reference an issue number - no-narration-comment: flags AI-style change notes ('// added ...') - wired into every ktlint-enabled module via ktlintRuleset; excluded from BCV; generated code (SQLDelight) excluded from all ktlint runs - rules are heuristics with a documented ceiling (see ponytail note in SharinganRules.kt); suppress with // ktlint-disable if a false positive appears --- build.gradle.kts | 25 ++++ gradle/libs.versions.toml | 1 + ktlint-rules/build.gradle.kts | 29 +++++ .../dev/sharingan/ktlint/SharinganRules.kt | 109 ++++++++++++++++++ ...int.cli.ruleset.core.api.RuleSetProviderV3 | 1 + ...int.rule.engine.core.api.RuleSetProviderV3 | 1 + settings.gradle.kts | 1 + 7 files changed, 167 insertions(+) create mode 100644 ktlint-rules/build.gradle.kts create mode 100644 ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt create mode 100644 ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 create mode 100644 ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.rule.engine.core.api.RuleSetProviderV3 diff --git a/build.gradle.kts b/build.gradle.kts index 58b489b..1e2e9e4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,9 @@ apiValidation { // The sample app is not a published library — nothing to protect. ignoredProjects += "composeApp" + // 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 @@ -207,3 +210,25 @@ tasks.register("checkApiParity") { // Make the standard `check` lifecycle run the parity gate locally when present. tasks.matching { it.name == "check" }.configureEach { dependsOn("checkApiParity") } + +// --------------------------------------------------------------------------- +// Custom ktlint ruleset (issue tracker: AGENTS.md "Comments policy"). +// Every project that applies the ktlint plugin also loads the local +// `:ktlint-rules` ruleset, so `ktlintCheck` enforces the comments policy in +// addition to the standard style rules. The ruleset jar ships no runtime +// deps; ktlint provides its own classes at load time. +// --------------------------------------------------------------------------- +subprojects { + plugins.withId("org.jlleitschuh.gradle.ktlint") { + // Generated code (SQLDelight etc.) lives under build/generated and + // does not follow our style — never lint it. + configure { + filter { + exclude { it.file.invariantSeparatorsPath.contains("/build/generated/") } + } + } + dependencies { + add("ktlintRuleset", rootProject.project(":ktlint-rules")) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d78fe8d..2ff15d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,4 +49,5 @@ mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechM binaryCompatibilityValidator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidator" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } diff --git a/ktlint-rules/build.gradle.kts b/ktlint-rules/build.gradle.kts new file mode 100644 index 0000000..3c8234d --- /dev/null +++ b/ktlint-rules/build.gradle.kts @@ -0,0 +1,29 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + // No version: the Kotlin plugins are already on the root classpath + // (root applies kotlinMultiplatform `apply false` for the whole build). + kotlin("jvm") +} + +// Internal tooling — the custom ktlint ruleset enforcing the AGENTS.md +// comments policy. Not published, not API-guarded, and deliberately not +// ktlint-checked itself (it would depend on itself via ktlintRuleset). +dependencies { + // Must match the ktlint version resolved by org.jlleitschuh.gradle.ktlint + // (14.2.0 defaults to 1.5.0). compileOnly: the ktlint runtime provides + // these classes when the ruleset jar is loaded. + compileOnly("com.pinterest.ktlint:ktlint-rule-engine-core:1.5.0") + compileOnly("com.pinterest.ktlint:ktlint-cli-ruleset-core:1.5.0") +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} diff --git a/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt b/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt new file mode 100644 index 0000000..58a3528 --- /dev/null +++ b/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt @@ -0,0 +1,109 @@ +package dev.sharingan.ktlint + +import com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 +import com.pinterest.ktlint.rule.engine.core.api.ElementType +import com.pinterest.ktlint.rule.engine.core.api.Rule +import com.pinterest.ktlint.rule.engine.core.api.RuleId +import com.pinterest.ktlint.rule.engine.core.api.RuleProvider +import com.pinterest.ktlint.rule.engine.core.api.RuleSetId +import org.jetbrains.kotlin.com.intellij.lang.ASTNode +import org.jetbrains.kotlin.com.intellij.psi.PsiComment + +// ponytail: heuristics — these rules catch the common AI narration patterns +// and bare TODOs; they cannot judge genuine usefulness. If a false positive +// appears in legit code, refine the word list or use a `// ktlint-disable`. +const val SHARINGAN_RULE_SET_ID = "sharingan-comments" + +/** + * `TODO` / `FIXME` comments must reference an issue: `TODO(#123): ...`. + * Enforces the AGENTS.md comments policy — an unowned TODO is a promise + * nobody will keep. + */ +class TodoWithoutIssueRule : + Rule( + RuleId("$SHARINGAN_RULE_SET_ID:todo-without-issue"), + Rule.About( + maintainer = "Sharingan maintainers", + repositoryUrl = "https://github.com/mibrahimdev/Sharingan", + issueTrackerUrl = "https://github.com/mibrahimdev/Sharingan/issues", + ), + ) { + private val unownedTodo = Regex("\\b(TODO|FIXME)\\b(?!\\s*\\(\\s*#\\d+\\s*\\))") + + override fun beforeVisitChildNodes( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, + ) { + if (node.elementType != ElementType.EOL_COMMENT && node.elementType != ElementType.BLOCK_COMMENT) return + val text = (node.psi as? PsiComment)?.text ?: return + if (text.contains("ktlint-disable") || text.contains("ktlint-enable")) return + if (unownedTodo.containsMatchIn(text)) { + emit( + node.startOffset, + "TODO/FIXME must reference an issue: 'TODO(#123): ...' (AGENTS.md comments policy)", + false, + ) + } + } +} + +/** + * Flags narration comments — the AI-generated "what I just did" notes: + * `// added null check`, `// removed unused import`, `// refactored ...`. + * Per the AGENTS.md comments policy, comments explain *why*, never + * narrate *what* or the change history (git log does that). + */ +class NoNarrationCommentRule : + Rule( + RuleId("$SHARINGAN_RULE_SET_ID:no-narration-comment"), + Rule.About( + maintainer = "Sharingan maintainers", + repositoryUrl = "https://github.com/mibrahimdev/Sharingan", + issueTrackerUrl = "https://github.com/mibrahimdev/Sharingan/issues", + ), + ) { + override fun beforeVisitChildNodes( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, + ) { + if (node.elementType != ElementType.EOL_COMMENT && node.elementType != ElementType.BLOCK_COMMENT) return + val comment = node.psi as? PsiComment ?: return + val text = comment.text + if (text.contains("ktlint-disable") || text.contains("ktlint-enable")) return + val body = text + .removePrefix("//") + .removePrefix("/*") + .trim() + if (NARRATION_START.containsMatchIn(body)) { + emit( + node.startOffset, + "Narration comment: comments explain *why*, not what was just changed " + + "(AGENTS.md comments policy). Delete it or state the invariant/reason.", + false, + ) + } + } + + private companion object { + // Past-tense change verbs + classic AI note openers. Deliberately + // conservative: present-tense instructions (`// add the item ...`) + // are often legitimate and are not flagged. + val NARRATION_START = Regex( + "^(?:added|removed|fixed|updated|changed|refactored|renamed|moved|" + + "implemented|extracted|replaced|deprecated|bumped|adjusted|modified|" + + "addressed|cleaned(?:\\s+up)?|new\\b|now\\b)\\b", + RegexOption.IGNORE_CASE, + ) + } +} + +class SharinganRuleSetProvider : + RuleSetProviderV3(RuleSetId(SHARINGAN_RULE_SET_ID)) { + override fun getRuleProviders(): Set = + setOf( + RuleProvider { TodoWithoutIssueRule() }, + RuleProvider { NoNarrationCommentRule() }, + ) +} diff --git a/ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 b/ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 new file mode 100644 index 0000000..3167ca2 --- /dev/null +++ b/ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.cli.ruleset.core.api.RuleSetProviderV3 @@ -0,0 +1 @@ +dev.sharingan.ktlint.SharinganRuleSetProvider diff --git a/ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.rule.engine.core.api.RuleSetProviderV3 b/ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.rule.engine.core.api.RuleSetProviderV3 new file mode 100644 index 0000000..3167ca2 --- /dev/null +++ b/ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.rule.engine.core.api.RuleSetProviderV3 @@ -0,0 +1 @@ +dev.sharingan.ktlint.SharinganRuleSetProvider diff --git a/settings.gradle.kts b/settings.gradle.kts index ee6cbb6..3a1f064 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,4 +29,5 @@ rootProject.name = "sharingan" include(":sharingan") include(":sharingan-noop") +include(":ktlint-rules") include(":sample:composeApp") From 957f24702f12bc538621dd8429a5ecfae21ec2eb Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 16:35:46 +0100 Subject: [PATCH 4/7] chore: drop stale services registration (wrong package path) --- .../com.pinterest.ktlint.rule.engine.core.api.RuleSetProviderV3 | 1 - 1 file changed, 1 deletion(-) delete mode 100644 ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.rule.engine.core.api.RuleSetProviderV3 diff --git a/ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.rule.engine.core.api.RuleSetProviderV3 b/ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.rule.engine.core.api.RuleSetProviderV3 deleted file mode 100644 index 3167ca2..0000000 --- a/ktlint-rules/src/main/resources/META-INF/services/com.pinterest.ktlint.rule.engine.core.api.RuleSetProviderV3 +++ /dev/null @@ -1 +0,0 @@ -dev.sharingan.ktlint.SharinganRuleSetProvider From b18fa419cb83fa445b64e550fa49ad6577a13d44 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 16:40:14 +0100 Subject: [PATCH 5/7] chore: suppress OVERRIDE_DEPRECATION on getRuleProviders override The member is deprecated in ktlint's 1.5.x API but remains the abstract member RuleSetProviderV3 requires; no replacement exists on this version. --- .../src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt b/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt index 58a3528..74438c7 100644 --- a/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt +++ b/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt @@ -101,6 +101,9 @@ class NoNarrationCommentRule : class SharinganRuleSetProvider : RuleSetProviderV3(RuleSetId(SHARINGAN_RULE_SET_ID)) { + // getRuleProviders is deprecated in ktlint's API but remains the abstract + // member in 1.5.x — there is no replacement to migrate to on this version. + @Suppress("OVERRIDE_DEPRECATION") override fun getRuleProviders(): Set = setOf( RuleProvider { TodoWithoutIssueRule() }, From f0a49c41eb250736e276dcd90597bdd91aa4e302 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 16:49:48 +0100 Subject: [PATCH 6/7] feat: ban multi-line comments; condense existing runs to one line - ktlint-rules: no-multi-line-comment flags runs of 2+ adjacent // lines and non-KDoc block comments spanning lines; KDoc is exempt (docs) - existing multi-line comments condensed to single lines, keeping the 'why' (tests, descriptors, build scripts, notification guard) - AGENTS.md: comments policy updated to cover the new rule --- AGENTS.md | 3 +- .../dev/sharingan/ktlint/SharinganRules.kt | 58 +++++++++++++++++++ sample/composeApp/build.gradle.kts | 9 +-- .../sharingan/sample/LoggerLocaleLeakTest.kt | 11 +--- sharingan-noop/build.gradle.kts | 10 +--- .../src/commonMain/kotlin/SharinganNoop.kt | 4 +- sharingan/build.gradle.kts | 21 ++----- .../sharingan/internal/CaptureNotification.kt | 3 +- .../kotlin/dev/sharingan/ui/EventFilter.kt | 4 +- .../dev/sharingan/ui/ProtocolDescriptor.kt | 4 +- .../dev/sharingan/ui/SharinganScreen.kt | 8 +-- .../dev/sharingan/SharinganStoreTest.kt | 9 +-- .../dev/sharingan/SharinganViewController.kt | 7 +-- .../internal/TopmostViewControllerTest.kt | 3 +- 14 files changed, 86 insertions(+), 68 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2b57d6e..a624a63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,8 @@ Code comments are for *why*, never *what*. Default to **no comment**; write code - DO NOT leave TODO/FIXME without an issue reference (`// TODO(#NN):`). - Comments ARE justified for: a non-obvious invariant or trade-off, a workaround with its bug/issue link, a pointer to the primary source (spec/ADR), or a public-API contract the signature can't express — prefer KDoc for that. - If a comment only survives because the code is hard to read, fix the code instead. -- ktlint enforces the mechanics (wrapping, `//` over `/* */`); this policy is enforced by review. +- NO multi-line comments: a `//` run of two or more adjacent lines, or a `/* ... */` block spanning lines, is flagged by ktlint (`sharingan-comments:no-multi-line-comment`). If it doesn't fit on one line, shorten it — or if it is documentation, write KDoc (`/** ... */`), which is exempt. +- ktlint enforces the mechanics (wrapping, `//` over `/* */`, no multi-line comments); this policy is enforced by review. ## Agent skills diff --git a/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt b/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt index 74438c7..8202a44 100644 --- a/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt +++ b/ktlint-rules/src/main/kotlin/dev/sharingan/ktlint/SharinganRules.kt @@ -99,6 +99,63 @@ class NoNarrationCommentRule : } } +/** + * Flags multi-line comments: + * - a run of consecutive `//` lines (comment continuation), and + * - a `/* ... */` block comment spanning more than one line. + * + * Per the AGENTS.md comments policy, if it doesn't fit on one line it is + * either not worth saying or it is documentation — and documentation is + * KDoc (`/** ... */`), which this rule leaves untouched. + */ +class NoMultiLineCommentRule : + Rule( + RuleId("$SHARINGAN_RULE_SET_ID:no-multi-line-comment"), + Rule.About( + maintainer = "Sharingan maintainers", + repositoryUrl = "https://github.com/mibrahimdev/Sharingan", + issueTrackerUrl = "https://github.com/mibrahimdev/Sharingan/issues", + ), + ) { + override fun beforeVisitChildNodes( + node: ASTNode, + autoCorrect: Boolean, + emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> Unit, + ) { + when (node.elementType) { + ElementType.EOL_COMMENT -> if (isContinuation(node)) { + emit( + node.startOffset, + "Multi-line comment: keep it to one line, or use KDoc if it is documentation " + + "(AGENTS.md comments policy)", + false, + ) + } + ElementType.BLOCK_COMMENT -> if (node.text.contains('\n')) { + emit( + node.startOffset, + "Multi-line block comment: keep it to one line, or use KDoc if it is documentation " + + "(AGENTS.md comments policy)", + false, + ) + } + } + } + + /** + * True when the next non-whitespace sibling is another `//` comment on the + * immediately following line. The whitespace between them must hold exactly + * one newline: a blank line or code in between breaks the "run". + */ + private fun isContinuation(node: ASTNode): Boolean { + var next = node.treeNext + while (next != null && next.elementType == ElementType.WHITE_SPACE) next = next.treeNext + if (next == null || next.elementType != ElementType.EOL_COMMENT) return false + if (next.treePrev.elementType != ElementType.WHITE_SPACE) return false + return next.treePrev.text.count { it == '\n' } == 1 + } +} + class SharinganRuleSetProvider : RuleSetProviderV3(RuleSetId(SHARINGAN_RULE_SET_ID)) { // getRuleProviders is deprecated in ktlint's API but remains the abstract @@ -108,5 +165,6 @@ class SharinganRuleSetProvider : setOf( RuleProvider { TodoWithoutIssueRule() }, RuleProvider { NoNarrationCommentRule() }, + RuleProvider { NoMultiLineCommentRule() }, ) } diff --git a/sample/composeApp/build.gradle.kts b/sample/composeApp/build.gradle.kts index de110e5..096d282 100644 --- a/sample/composeApp/build.gradle.kts +++ b/sample/composeApp/build.gradle.kts @@ -32,12 +32,10 @@ kotlin { implementation(compose.material3) implementation(compose.ui) implementation(libs.ktor.client.core) - // Demo traffic is served by MockEngine so the sample works offline - // and mirrors the design's deterministic IoT data. + // Demo traffic via MockEngine so the sample works offline with deterministic IoT data. implementation(libs.ktor.client.mock) - // KMP no-op swap pattern: `-Psharingan.noop` builds the sample - // against the no-op artifact, proving API parity and zero UI payload. + // `-Psharingan.noop` builds against the no-op twin: proves API parity, zero UI payload. if (providers.gradleProperty("sharingan.noop").isPresent) { implementation(project(":sharingan-noop")) } else { @@ -47,8 +45,7 @@ kotlin { androidMain.dependencies { implementation(libs.androidx.activity.compose) } - // Capture-notification E2E (needs the real app + zero-setup init): - // ./gradlew :sample:composeApp:connectedDebugAndroidTest + // Capture-notification E2E: ./gradlew :sample:composeApp:connectedDebugAndroidTest androidInstrumentedTest.dependencies { implementation(libs.junit) implementation(libs.androidx.test.runner) diff --git a/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt index 8d7c62f..18ea221 100644 --- a/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt +++ b/sample/composeApp/src/androidInstrumentedTest/kotlin/dev/sharingan/sample/LoggerLocaleLeakTest.kt @@ -60,9 +60,7 @@ class LoggerLocaleLeakTest { "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. + // System-server state (above) updates before the process-local JVM default — wait for both. val jvmDeadline = System.currentTimeMillis() + 2_000 while (LocaleList.getDefault()[0].language != "ar" && System.currentTimeMillis() < jvmDeadline @@ -92,16 +90,13 @@ class LoggerLocaleLeakTest { } } - // 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. + // Sanity: per-app locale lives in system_server, not our process — the process-local fix can't corrupt it. 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. + // The real leak assertions: JVM-default locales the buggy activity flipped to English must be back to Arabic. assertEquals("Locale.getDefault() leaked", "ar", Locale.getDefault().language) assertEquals("LocaleList.getDefault() leaked", "ar", LocaleList.getDefault()[0].language) } diff --git a/sharingan-noop/build.gradle.kts b/sharingan-noop/build.gradle.kts index fca3445..12433cf 100644 --- a/sharingan-noop/build.gradle.kts +++ b/sharingan-noop/build.gradle.kts @@ -8,8 +8,7 @@ plugins { alias(libs.plugins.ktlint) } -// Maven coordinate only — Kotlin packages and the Android namespace stay -// `dev.sharingan(.noop)`. See :sharingan for the rationale (design decision 1a). +// Maven coordinate only — Kotlin packages/namespace stay dev.sharingan(.noop); see :sharingan (design decision 1a). group = "io.github.mibrahimdev" version = libs.versions.sharingan.get() @@ -35,8 +34,7 @@ kotlin { sourceSets { all { languageSettings.optIn("kotlin.experimental.ExperimentalObjCName") } commonMain.dependencies { - // Mirrors the real artifact's API surface (StateFlow, Ktor plugin - // types) with no Compose/UI payload — release builds carry ~nothing. + // Mirrors the real API surface (StateFlow, Ktor plugin types) with no Compose/UI payload. api(libs.kotlinx.coroutines.core) implementation(libs.ktor.client.core) } @@ -68,9 +66,7 @@ android { mavenPublishing { publishToMavenCentral() - // signAllPublications() is the default for real publishes (local + CI). The - // `-PlocalPublishNoSign` flag disables it for offline POM verification where - // no GPG key is available (real signing is provisioned in a separate issue). + // signAllPublications() is the default; -PlocalPublishNoSign disables it for offline POM verification (no GPG key). if (!providers.gradleProperty("localPublishNoSign").isPresent) { signAllPublications() } diff --git a/sharingan-noop/src/commonMain/kotlin/SharinganNoop.kt b/sharingan-noop/src/commonMain/kotlin/SharinganNoop.kt index ac28508..860a75d 100644 --- a/sharingan-noop/src/commonMain/kotlin/SharinganNoop.kt +++ b/sharingan-noop/src/commonMain/kotlin/SharinganNoop.kt @@ -1,6 +1,4 @@ -// sharingan-noop — API mirror of dev.sharingan:sharingan with empty -// implementations. Depend on this artifact in release builds: every call -// compiles identically, nothing is captured, no UI ships. +// API mirror of :sharingan with empty implementations — depend on this in release builds. package dev.sharingan import kotlinx.coroutines.flow.MutableStateFlow diff --git a/sharingan/build.gradle.kts b/sharingan/build.gradle.kts index 212e442..2b8e57a 100644 --- a/sharingan/build.gradle.kts +++ b/sharingan/build.gradle.kts @@ -13,9 +13,7 @@ plugins { alias(libs.plugins.ktlint) } -// Maven coordinate only — Kotlin packages and the Android namespace stay -// `dev.sharingan`. Central auto-verifies `io.github.mibrahimdev` against the -// GitHub repo, so no domain ownership is required (design decision 1/1a). +// Maven coordinate only — Central auto-verifies io.github.mibrahimdev against the GitHub repo (design decision 1/1a). group = "io.github.mibrahimdev" version = libs.versions.sharingan.get() @@ -37,13 +35,10 @@ kotlin { } } - // Same baseName in :sharingan and :sharingan-noop — pure-Swift consumers - // swap debug/noop per build configuration by search path, so - // `import Sharingan` must resolve identically in both. + // Same baseName as :sharingan-noop so pure-Swift consumers swap debug/noop and `import Sharingan` resolves identically. val sharinganXCFramework = XCFramework("Sharingan") iosSimulatorArm64 { - // The default KGP simulator id may not exist in newer Xcodes; pin to - // a device present on this machine (xcrun simctl list devices). + // The default KGP simulator id may not exist in newer Xcodes; pin to a device present here (xcrun simctl list devices). testRuns.configureEach { deviceId = "iPhone 17 Pro" } } listOf(iosArm64(), iosSimulatorArm64()).forEach { target -> @@ -59,8 +54,7 @@ kotlin { commonMain.dependencies { // StateFlow types appear in the public API surface. api(libs.kotlinx.coroutines.core) - // The Ktor plugin ships in the core artifact (Chucker model) so the - // release no-op swap stays a single dependency substitution. + // Ships in the core artifact (Chucker model) so the release no-op swap stays a single dependency substitution. implementation(libs.ktor.client.core) // Flight-recorder persistence (issue #27 / #49): DTO JSON codec. implementation(libs.kotlinx.serialization.json) @@ -89,8 +83,7 @@ kotlin { iosTest.dependencies { implementation(libs.sqldelight.native.driver) } - // On-device UI tests (JetBrains Compose Multiplatform test API, run as - // Android instrumented tests): ./gradlew :sharingan:connectedDebugAndroidTest + // On-device UI tests (CMP test API): ./gradlew :sharingan:connectedDebugAndroidTest androidInstrumentedTest.dependencies { @OptIn(ExperimentalComposeLibrary::class) implementation(compose.uiTest) @@ -128,9 +121,7 @@ dependencies { mavenPublishing { publishToMavenCentral() - // signAllPublications() is the default for real publishes (local + CI). The - // `-PlocalPublishNoSign` flag disables it for offline POM verification where - // no GPG key is available (real signing is provisioned in a separate issue). + // signAllPublications() is the default; -PlocalPublishNoSign disables it for offline POM verification (no GPG key). if (!providers.gradleProperty("localPublishNoSign").isPresent) { signAllPublications() } diff --git a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/CaptureNotification.kt b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/CaptureNotification.kt index 65c5387..e9db8b8 100644 --- a/sharingan/src/androidMain/kotlin/dev/sharingan/internal/CaptureNotification.kt +++ b/sharingan/src/androidMain/kotlin/dev/sharingan/internal/CaptureNotification.kt @@ -103,8 +103,7 @@ internal object CaptureNotification { try { manager.notify(NOTIFICATION_ID, builder.build()) } catch (_: Exception) { - // Missing POST_NOTIFICATIONS or any notification failure must never - // crash the host app; capture continues silently. + // Notification failures (incl. missing POST_NOTIFICATIONS) must never crash the host app; capture continues. } } diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt index 075d7b1..9be1e34 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/EventFilter.kt @@ -17,9 +17,7 @@ internal data class FilterChipSpec( val label: String, ) -// Per-protocol knowledge (chips, matching, search haystacks) lives in the -// ProtocolDescriptors; these functions are the stable seam callers and -// tests go through. +// Per-protocol knowledge (chips, matching, haystacks) lives in the ProtocolDescriptors; these are the stable seam. internal fun protocolOf(event: SharinganEvent): Protocol = descriptorOf(event).protocol diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt index c93b3f2..7677adb 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt @@ -75,9 +75,7 @@ internal abstract class ProtocolDescriptor { @Composable protected abstract fun Body(event: E) - // ── untyped bridges ────────────────────────────────────────── - // descriptorOf(event) returns the descriptor matching the event's - // concrete type, so this cast cannot fail at runtime. + // Untyped bridges: descriptorOf(event) always matches the event's concrete type, so this cast cannot fail. @Suppress("UNCHECKED_CAST") private fun typed(event: SharinganEvent): E = event as E diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt index 77a641c..56ac67e 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/SharinganScreen.kt @@ -30,9 +30,7 @@ import dev.sharingan.SharinganEvent import dev.sharingan.SharinganStore import kotlinx.coroutines.delay -// Sheet hosts (iOS page sheet) already sit below the status bar, so the top -// safe-area inset would be paid twice (#42). Full-screen/embedded hosts still -// need it. internal -> not part of the checked public API surface. +// Sheet hosts sit below the status bar, so the top inset would be paid twice (#42); full-screen hosts still need it. internal val LocalStripTopInset = staticCompositionLocalOf { false } internal fun sharinganContentInsets( @@ -162,9 +160,7 @@ internal fun SharinganScreenContent( ) { val colors = LocalSharinganColors.current PlatformBackHandler(enabled = selectedEvent != null, onBack = onBack) - // 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). + // The logger is a locale-neutral surface — always LTR, on every platform and preview (#38). CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { Scaffold( modifier = modifier, diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt index 9487300..008f130 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganStoreTest.kt @@ -97,15 +97,10 @@ internal class SharinganStoreTest { val perProducer = 500 val total = producers * perProducer - // Capacity holds every event, so a lost compare-and-set update surfaces - // as a missing event rather than as legitimate ring-buffer eviction — - // making this a direct check of the lock-free `record` path under load. + // Capacity == total so a lost CAS update surfaces as a missing event, not legit eviction — direct check of record(). val store = SharinganStore(capacity = total) - // Each producer records on a real background thread (Dispatchers.Default - // is multi-threaded on both the JVM and Kotlin/Native), so the producers - // genuinely contend on the same MutableStateFlow rather than interleaving - // cooperatively on a single test thread. + // Dispatchers.Default is multi-threaded on JVM and K/N, so producers genuinely contend on the store. coroutineScope { repeat(producers) { producer -> launch(Dispatchers.Default) { diff --git a/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt b/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt index 38d34c3..f5e5b7f 100644 --- a/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt +++ b/sharingan/src/iosMain/kotlin/dev/sharingan/SharinganViewController.kt @@ -29,9 +29,7 @@ import kotlin.native.ObjCName @OptIn(ExperimentalObjCName::class) public fun SharinganViewController(): UIViewController = composeVc(stripTopInset = false) -// internal — presentSharingan() uses this; strips the phantom top inset (#42): -// a page sheet already sits below the status bar, but CMP 1.11 reads insets -// from the UIWindow, so safeDrawing would pay the top inset twice. +// internal — used by presentSharingan(); strips the phantom sheet top inset (#42): CMP 1.11 reads insets from the UIWindow. internal fun sharinganSheetViewController(): UIViewController = composeVc(stripTopInset = true) private fun composeVc(stripTopInset: Boolean): UIViewController = @@ -68,8 +66,7 @@ public fun presentSharingan(animated: Boolean = true) { .firstOrNull { it.keyWindow } ?.rootViewController ?: return@dispatch_async val top = topmostViewController(root) - // Guard: UIKit silently swallows a present() while another - // presentation/dismissal is already in flight; make the no-op explicit. + // Guard: UIKit silently swallows a present() while another presentation/dismissal is in flight; make it explicit. if (top.isBeingDismissed() || top.isBeingPresented()) return@dispatch_async top.presentViewController(sharinganSheetViewController(), animated = animated, completion = null) } diff --git a/sharingan/src/iosTest/kotlin/dev/sharingan/internal/TopmostViewControllerTest.kt b/sharingan/src/iosTest/kotlin/dev/sharingan/internal/TopmostViewControllerTest.kt index a830263..2a74ccf 100644 --- a/sharingan/src/iosTest/kotlin/dev/sharingan/internal/TopmostViewControllerTest.kt +++ b/sharingan/src/iosTest/kotlin/dev/sharingan/internal/TopmostViewControllerTest.kt @@ -9,8 +9,7 @@ class TopmostViewControllerTest { fun `When root has no presentations Then root is the topmost`() { val root = UIViewController(nibName = null, bundle = null) - // K/N wraps each ObjC return in a fresh Kotlin peer; use ObjC isEqual - // (pointer equality for NSObject) instead of Kotlin reference identity. + // K/N wraps ObjC returns in fresh Kotlin peers; compare via ObjC isEqual (pointer equality), not identity. assertTrue(root.isEqual(topmostViewController(root))) } From 59eea3cf4c013e9fb77a09f34ed9733dae3b1e59 Mon Sep 17 00:00:00 2001 From: Mohamed Ibrahim Date: Mon, 31 Aug 2026 16:57:56 +0100 Subject: [PATCH 7/7] chore: remove section banners and what-narration comments Audit of all remaining inline comments against the AGENTS.md policy: - deleted 9 pure banner dividers (test names and function names already say what follows) and one what-narration comment (bottom fade) - rewrote the DetailScreen banner to a plain single-line pointer - everything else kept: issue-linked why-comments, invariants, workarounds, seam pointers --- .../commonMain/kotlin/dev/sharingan/SharinganExport.kt | 2 -- .../commonMain/kotlin/dev/sharingan/ui/DetailScreen.kt | 4 +--- .../commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt | 2 -- .../kotlin/dev/sharingan/ui/ProtocolDescriptor.kt | 2 -- .../commonMain/kotlin/dev/sharingan/ui/ShareSheet.kt | 1 - .../kotlin/dev/sharingan/SharinganExportTest.kt | 10 ---------- 6 files changed, 1 insertion(+), 20 deletions(-) diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt index 14153fc..6d0ac6c 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/SharinganExport.kt @@ -79,8 +79,6 @@ public object SharinganExport { } }.trimEnd() - // ── session assembly ───────────────────────────────────────── - private fun countsLine(events: List): String = "${events.size} events · ${protocolCountsLine(events)}" private fun eventJson( diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/DetailScreen.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/DetailScreen.kt index d5077ab..0bf3d2c 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/DetailScreen.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/DetailScreen.kt @@ -141,7 +141,7 @@ internal fun BadgeChip( ) } -// ── shared section scaffolding (used by the ProtocolDescriptors) ── +// Shared section scaffolding for the ProtocolDescriptors. @Composable internal fun Section( @@ -257,8 +257,6 @@ private fun rememberAnnotatedJson( } } -// ── Previews ───────────────────────────────────────────────── - @Preview @Composable private fun DetailScreenContent_HttpErrorPreview() { diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt index 9328502..b537a90 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/HomeScreen.kt @@ -446,8 +446,6 @@ internal fun HorizontalDivider(modifier: Modifier = Modifier) { Box(modifier.fillMaxWidth().height(1.dp).background(colors.faint)) } -// ── Previews ───────────────────────────────────────────────── - @Preview @Composable private fun HomeScreenContent_LightPreview() { diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt index 7677adb..79f02b4 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ProtocolDescriptor.kt @@ -105,8 +105,6 @@ internal abstract class ProtocolDescriptor { Body(typed(event)) } - // ── shared fragment helpers for implementations ───────────── - /** Adds a raw JSON value if non-null. */ protected fun MutableList>.put( name: String, diff --git a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareSheet.kt b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareSheet.kt index f5a17d3..a8f9dfc 100644 --- a/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareSheet.kt +++ b/sharingan/src/commonMain/kotlin/dev/sharingan/ui/ShareSheet.kt @@ -97,7 +97,6 @@ internal fun ShareSheetBody( modifier = Modifier.padding(top = 4.dp, bottom = 12.dp), ) - // payload preview with a bottom fade Box( Modifier .fillMaxWidth() diff --git a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganExportTest.kt b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganExportTest.kt index 7b86e20..75ca3ec 100644 --- a/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganExportTest.kt +++ b/sharingan/src/commonTest/kotlin/dev/sharingan/SharinganExportTest.kt @@ -46,8 +46,6 @@ internal class SharinganExportTest { sizeBytes = 8, ) - // ── agent markdown: single event ───────────────────────────── - @Test fun `When an HTTP event is exported as agent markdown Then it has heading status host headers and body fence`() { val md = SharinganExport.agentMarkdown(httpEvent) @@ -84,8 +82,6 @@ internal class SharinganExportTest { assertContains(md, "**Error:** connect timeout") } - // ── agent markdown: session ────────────────────────────────── - @Test fun `When a session is exported as agent markdown Then it has a header with counts and one section per event`() { val md = SharinganExport.agentMarkdown(listOf(httpEvent, mqttEvent, bleEvent)) @@ -97,8 +93,6 @@ internal class SharinganExportTest { assertContains(md, "## BLE NOTIFY Heart Rate Measurement") } - // ── cURL ───────────────────────────────────────────────────── - @Test fun `When an HTTP event is exported as cURL Then method url and headers are present`() { val curl = SharinganExport.curl(httpEvent) @@ -124,8 +118,6 @@ internal class SharinganExportTest { assertContains(curl, """--data 'it'\''s'""") } - // ── JSON ───────────────────────────────────────────────────── - @Test fun `When an HTTP event is exported as JSON Then protocol fields are present and strings are escaped`() { val json = SharinganExport.json(httpEvent.copy(responseBody = "line1\n\"quoted\"")) @@ -144,8 +136,6 @@ internal class SharinganExportTest { assertContains(json, "\"protocol\": \"ble\"") } - // ── byte formatting ────────────────────────────────────────── - @Test fun `When byte counts are formatted Then they render like the design rows`() { assertEquals("0 B", formatBytes(0))