diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e18f736..c7f2470 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,6 +11,8 @@ updates: update-types: ["version-update:semver-major", "version-update:semver-minor"] - dependency-name: "org.jetbrains.kotlin*" update-types: ["version-update:semver-major", "version-update:semver-minor"] + - dependency-name: "com.android.*" + update-types: ["version-update:semver-major", "version-update:semver-minor"] - dependency-name: "com.squareup.okhttp3:*" update-types: ["version-update:semver-major"] - dependency-name: "com.jakewharton.timber:*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ff1c75..c4181a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: java-version: 17 - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 - name: ktlint and Android Lint - run: ./gradlew ktlintCheck lintDebug --continue + run: ./gradlew ktlintCheck lintDebug lintRelease --continue - name: Upload lint reports if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 @@ -51,6 +51,10 @@ jobs: - uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 - name: Build, test and publish locally run: ./gradlew assembleDebug assembleRelease testDebugUnitTest publishToMavenLocal + - name: Public API check + run: ./gradlew apiCheck + - name: Minified sample build (R8 with the consumer rules) + run: ./gradlew :sample:assembleQa - name: Upload test reports if: failure() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aae0818..bc46498 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,11 +14,13 @@ permissions: jobs: release: name: Tag and release + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 0 + persist-credentials: false - name: Read version id: version @@ -42,7 +44,7 @@ jobs: - name: Build and test if: steps.version.outputs.exists == 'false' - run: ./gradlew assembleRelease testDebugUnitTest publishToMavenLocal + run: ./gradlew ktlintCheck lintRelease apiCheck assembleRelease testDebugUnitTest publishToMavenLocal - name: Release notes from CHANGELOG.md if: steps.version.outputs.exists == 'false' diff --git a/CHANGELOG.md b/CHANGELOG.md index b1d1337..f631fc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl ### Added - MIT license, code of conduct, and a demo GIF in the README showing each feature's effect in the sample app. +- `NetworkLogger.maxTotalBodyChars` (default 12M characters) caps the bodies kept across all calls; the oldest bodies are dropped first, their calls stay listed. +- `AppActions.clearCacheInBackground()` clears the cache off the main thread. +- `TextInputItem`, `NumberInputItem` and `JsonInputItem` take an optional `subtitle`; feature flags of every type now show *Overridden* and a *Reset to default* action. +- Share buttons on the Logs and Network activity pages, and share-all / delete-all in the capture gallery. +- Consumer R8 rules for devdeck-okhttp, devdeck-timber and devdeck-appdistribution, so minified builds don't fail when OkHttp, Timber or Firebase isn't on the classpath. ### Changed - The sample's fake update feed offers 1.1.0 now that the sample is 1.0.0. @@ -17,10 +22,28 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - The device & app snapshot shows share, save and copy as small icons on the card; they are not part of the shared image. - The deep link launcher opens links from a send icon inside the field, leaving more room on small phones. - The sample app is named DevDeck. +- Clearing logs, crash logs, breadcrumbs, requests, console lines, captures or shared preferences, and resetting all feature flags, now ask for a second tap. +- Network request actions (*Copy URL*, *Copy cURL*, *Share*) and label rows wrap on narrow screens instead of overflowing. +- Category, level and status colors have dark-theme variants, and labels on colored fills pick a readable text color. +- Filter pills, action pills, suggestion rows and code block buttons have 48dp touch targets; expandable log rows announce their state to TalkBack. +- Bug report and log exports are written to the app's cache and only the newest 20 are kept, instead of piling up in external storage. +- Network redaction matches field names regardless of case, `_` or `-` (`accessToken`, `client-secret`), masks numbers and booleans too, and also hides `X-Auth-Token`, `X-Access-Token`, `X-CSRF-Token` and `X-XSRF-Token` headers by default. +- README states that DevDeck stays out of release builds only when added with `debugImplementation`. ### Fixed - Search placeholders and the network count row stay on one line on small phones. - The permissions list refreshes after a permission dialog or settings screen closes, and shows when a revoke only applies after the app restarts. +- Tap breadcrumbs no longer record what was typed into text fields, so passwords can't end up in crash files or bug reports. +- Taking a screenshot no longer crashes the app when the window has no surface or the disk is full, and two captures in the same second no longer overwrite each other. +- The OkHttp interceptor no longer holds back streaming text responses while capturing them, and skips bodies with no content type. +- Clearing the console no longer brings back lines that were waiting to be shown. +- Starting a screen recording again while one is running no longer stops it. +- Logcat timestamps get the right year after New Year. +- Lifecycle logs are no longer recorded twice after `DevDeck.updateConfig`. +- Opening display settings or signing out of Firebase App Distribution no longer crashes when it fails. +- Crash logs, captures, shared preferences, bug reports and cache clearing no longer read or write files on the main thread. +- Expanded log rows keep their state when new lines arrive. +- Dependabot no longer proposes Android Gradle Plugin major or minor updates, as the 1.0.0 entry promised. ## [1.0.0] - 2026-10-24 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39329e7..3a023ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,14 +5,28 @@ Run these before opening a pull request. They are the same checks CI runs: ```bash -./gradlew ktlintFormat # fix code style -./gradlew ktlintCheck lintDebug # code style + Android Lint -./gradlew testDebugUnitTest # unit tests +./gradlew ktlintFormat # fix code style +./gradlew ktlintCheck lintDebug lintRelease # code style + Android Lint +./gradlew testDebugUnitTest # unit tests +./gradlew apiCheck # public API unchanged (see below) ./gradlew assembleDebug assembleRelease publishToMavenLocal +./gradlew :sample:assembleQa # minified sample, catches R8 problems ``` Lint warnings fail the build, so fix them rather than ignoring them. Dependency-version warnings are turned off because Dependabot handles updates. +The build needs JDK 17. Gradle picks up an installed JDK 17 for its daemon (see `gradle/gradle-daemon-jvm.properties`) and downloads one only if none is found. + +## Public API and binary compatibility + +Each published module records its public API in `/api/.api`. `./gradlew apiCheck` (run by CI, the release workflow and `./gradlew check`) fails when the compiled release classes no longer match that file. + +- **Adding API** (a new public class, function or property) is fine in a feature release. Run `./gradlew apiDump` and commit the updated `.api` files with your change. +- **Removing or changing API** (renaming, changing a signature, making something `internal`) breaks apps built against the previous version. That needs a major release and a `BREAKING:` changelog entry, so avoid it where an addition would do. Adding a parameter to an existing public function or constructor changes its JVM signature too, even with a default value: keep the old signature as an overload (it can be `@Deprecated(level = DeprecationLevel.HIDDEN)`). +- Mark anything users shouldn't call `internal`, so it stays out of the dump. + +Review `.api` diffs like code: every line is a promise to library users. + ## Pull requests - Branch from `main` and open a PR against `main`. diff --git a/README.md b/README.md index 2c1592c..2d88070 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A debug drawer for Android apps, built with Jetpack Compose. Open it with a two- - take screenshots and screen recordings, share bug reports and device info - browse storage, watch frame rate and memory, and more -Everything is a plugin: pick the ones you need or write your own in a few lines. It works on Compose and classic View screens, and it never ships in release builds. +Everything is a plugin: pick the ones you need or write your own in a few lines. It works on Compose and classic View screens, and it stays out of release builds when you add it with `debugImplementation` (see [Keep it out of release builds](#keep-it-out-of-release-builds)).

DevDeck sample app: feature flag, slow and failing network, network inspector, frame rate and keyline overlays, dark mode, breadcrumbs, test build update and permissions @@ -80,7 +80,10 @@ For Timber, add `TimberLogsPlugin()` to your plugins (see [Choosing plugins](#ch ### Keep it out of release builds -DevDeck is a `debugImplementation` dependency, so release code can't reference it. Put all DevDeck code in the **debug** source set and an empty version in the **release** source set: +> [!WARNING] +> DevDeck doesn't check the build type at runtime. Added with `implementation`, it ships in your release build, with its network inspector, logs, file browser and app controls. Add it with `debugImplementation` (or `qaImplementation` and the like for internal build types), never to the build you publish. + +With `debugImplementation`, release code can't reference DevDeck. Put all DevDeck code in the **debug** source set and an empty version in the **release** source set: ```kotlin // src/debug/kotlin/com/myapp/DebugTools.kt @@ -202,8 +205,9 @@ val enabled: Boolean = flags[Flags.newCheckout] - *Timeline* or *By endpoint* view: calls are grouped by GraphQL operation, or by method + path, with call counts, failures and timings. - Each call shows its overview, query params, headers and pretty-printed bodies, with Copy URL, Copy cURL and Share. -- GraphQL operation names are detected automatically. `Authorization`, cookie and API-key headers are redacted, and so are passwords, tokens and API keys in URLs and JSON or form bodies (`NetworkLogger.redactedHeaders` / `redactedFields`). -- Streaming responses (server-sent events) are passed through untouched. +- GraphQL operation names are detected automatically. `Authorization`, cookie, API-key and auth/CSRF token headers are redacted, and so are passwords, tokens, secrets and API keys in URLs and JSON or form bodies (`NetworkLogger.redactedHeaders` / `redactedFields`). Field names match regardless of case, `_` or `-`, so `access_token`, `accessToken` and `access-token` are all hidden. +- Streaming responses (server-sent events, chunked plain text) and bodies without a content type are passed through without being captured. +- Memory stays bounded: the last `NetworkLogger.maxCalls` calls (300), up to `maxBodyChars` per body and `maxTotalBodyChars` across all bodies. The oldest bodies are dropped first; their calls stay listed. - Highlight what matters: ```kotlin @@ -374,8 +378,9 @@ DevDeck.install(this, plugins, excludedActivityPrefixes = emptySet()) | `devdeck-appdistribution` | `io.devdeck.appdistribution` | `FirebaseAppDistributionSource` for `UpdatesPlugin`. The Firebase App Distribution API is `compileOnly`; add the full SDK to your tester builds. | - **Requirements:** minSdk 23, compileSdk 36. Built with AGP 9.4, Kotlin 2.2 and Compose BOM 2026.05. If your app uses an older Compose, debug builds resolve to this BOM's versions. -- **Stored data:** DevDeck keeps its own settings in one SharedPreferences file, `devdeck_settings`. Feature flags from `SharedPreferencesFlagStore` go to `devdeck_flags` (or the name you pass). Captures and crash logs live under `files/devdeck/`. +- **Stored data:** DevDeck keeps its own settings in one SharedPreferences file, `devdeck_settings`. Feature flags from `SharedPreferencesFlagStore` go to `devdeck_flags` (or the name you pass). Crash logs live in `files/devdeck/crashes/`, screenshots and recordings in `devdeck/` under the app's external files directory, and exported bug reports and logs in `cache/devdeck-reports/` (the newest 20 are kept). - **Manifest additions:** `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_MEDIA_PROJECTION` and `POST_NOTIFICATIONS` for screen recording, a `FileProvider` (`${applicationId}.devdeck.files`) and the recording service. Debug builds only, if you use `debugImplementation`. +- **R8:** each module ships consumer rules, so minified internal builds (say, a `qa` build type with `isMinifyEnabled = true`) need no extra keep rules. OkHttp, Timber and Firebase are `compileOnly`, and the rules stop R8 from failing when your app doesn't include them. - **Limits:** *Slow animations* affects View animations only, because Compose follows the system animation scale. Logcat shows your app's process only. Some OEM builds hide debug-level logs. ## Security diff --git a/build.gradle.kts b/build.gradle.kts index 7654c8d..d8a53d1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,13 @@ +import kotlinx.validation.KotlinApiBuildTask +import kotlinx.validation.KotlinApiCompareTask +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.binary.compatibility.validator) } val ktlint: Configuration = configurations.create("ktlint") @@ -17,19 +22,83 @@ dependencies { val ktlintSources = listOf("**/src/**/*.kt", "**/*.gradle.kts", "!**/build/**") +val ktlintFormat = tasks.register("ktlintFormat") { + group = "formatting" + description = "Fixes Kotlin code style with ktlint." + classpath = ktlint + mainClass.set("com.pinterest.ktlint.Main") + jvmArgs("--add-opens=java.base/java.lang=ALL-UNNAMED") + args(listOf("-F") + ktlintSources + listOf("--relative")) + // Rewrites sources in place, so it always runs. + outputs.upToDateWhen { false } +} + tasks.register("ktlintCheck") { group = "verification" description = "Checks Kotlin code style with ktlint." classpath = ktlint mainClass.set("com.pinterest.ktlint.Main") args(ktlintSources + listOf("--relative", "--reporter=plain", "--reporter=checkstyle,output=build/reports/ktlint.xml")) + inputs.files( + fileTree(rootDir) { + include("**/src/**/*.kt", "**/*.gradle.kts") + exclude("**/build/**", ".*/**") + }, + ).withPathSensitivity(PathSensitivity.RELATIVE).withPropertyName("sources") + outputs.file(layout.buildDirectory.file("reports/ktlint.xml")).withPropertyName("report") + mustRunAfter(ktlintFormat) } -tasks.register("ktlintFormat") { - group = "formatting" - description = "Fixes Kotlin code style with ktlint." - classpath = ktlint - mainClass.set("com.pinterest.ktlint.Main") - jvmArgs("--add-opens=java.base/java.lang=ALL-UNNAMED") - args(listOf("-F") + ktlintSources + listOf("--relative")) +// Binary compatibility: the public API of each published module is recorded in /api/.api. +// `./gradlew apiCheck` fails when it changes; `./gradlew apiDump` records an intended change. +// The validator plugin only hooks into the standalone kotlin-android plugin, not AGP's built-in Kotlin, +// so its tasks are wired here against each library's release Kotlin classes. +apiValidation { + ignoredProjects += "sample" +} + +val publishedModules = setOf("devdeck", "devdeck-okhttp", "devdeck-timber", "devdeck-appdistribution") +val apiValidatorRuntime = libs.bundles.binary.compatibility.validator.runtime + +subprojects { + if (name !in publishedModules) return@subprojects + + val validatorClasspath = configurations.create("binaryCompatibilityValidator") { + isCanBeConsumed = false + isCanBeResolved = true + } + dependencies { apiValidatorRuntime.get().forEach { add(validatorClasspath.name, it) } } + + val dumpFileName = "$name.api" + val dumpFile = layout.projectDirectory.file("api/$dumpFileName") + val apiBuild = tasks.register("apiBuild") { + description = "Builds the public API dump of the release variant." + val classesDir = tasks.named("compileReleaseKotlin").flatMap { it.destinationDirectory } + inputClassesDirs.from(classesDir) + // Compose compiler output whose names change with every lambda edit; not callable API. + ignoredClasses.addAll( + classesDir.map { dir -> + dir.asFileTree.matching { include("**/ComposableSingletons$*.class") }.files.map { + it.relativeTo(dir.asFile).path.removeSuffix(".class").replace(File.separatorChar, '.') + } + }, + ) + outputApiFile.set(layout.buildDirectory.file("api/$dumpFileName")) + runtimeClasspath.from(validatorClasspath) + } + val apiCheck = tasks.register("apiCheck") { + group = "verification" + description = "Checks the public API against the committed dump in api/." + projectApiFile.set(dumpFile) + generatedApiFile.set(apiBuild.flatMap { it.outputApiFile }) + } + tasks.register("apiDump") { + group = "other" + description = "Records the current public API in api/." + from(apiBuild.flatMap { it.outputApiFile }) + into(layout.projectDirectory.dir("api")) + } + plugins.withId("com.android.library") { + tasks.named("check") { dependsOn(apiCheck) } + } } diff --git a/devdeck-appdistribution/api/devdeck-appdistribution.api b/devdeck-appdistribution/api/devdeck-appdistribution.api new file mode 100644 index 0000000..bcdbfc7 --- /dev/null +++ b/devdeck-appdistribution/api/devdeck-appdistribution.api @@ -0,0 +1,15 @@ +public final class io/devdeck/appdistribution/FirebaseAppDistributionSource : io/devdeck/updates/UpdateSource { + public fun ()V + public fun (Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun availableBuilds (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun checkForUpdate (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getBrowseBuildsUrl ()Ljava/lang/String; + public fun getName ()Ljava/lang/String; + public fun getRequiresSignIn ()Z + public fun install (Lio/devdeck/updates/AvailableUpdate;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun isSignedIn ()Z + public fun signIn (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun signOut ()V +} + diff --git a/devdeck-appdistribution/build.gradle.kts b/devdeck-appdistribution/build.gradle.kts index 5069d29..1e9700f 100644 --- a/devdeck-appdistribution/build.gradle.kts +++ b/devdeck-appdistribution/build.gradle.kts @@ -14,6 +14,7 @@ android { defaultConfig { minSdk = 23 + consumerProguardFiles("consumer-rules.pro") } lint { @@ -28,6 +29,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + testOptions { + unitTests.isReturnDefaultValues = true + } + publishing { singleVariant("release") { withSourcesJar() @@ -45,6 +50,8 @@ kotlin { dependencies { api(project(":devdeck")) compileOnly(libs.firebase.appdistribution.api) + testImplementation(libs.firebase.appdistribution.api) + testImplementation(libs.junit) } afterEvaluate { diff --git a/devdeck-appdistribution/consumer-rules.pro b/devdeck-appdistribution/consumer-rules.pro new file mode 100644 index 0000000..e6c435d --- /dev/null +++ b/devdeck-appdistribution/consumer-rules.pro @@ -0,0 +1,4 @@ +# firebase-appdistribution-api is a compileOnly dependency: the app brings its own. Don't fail +# R8 in apps that add devdeck-appdistribution without the Firebase SDK on the classpath. +-dontwarn com.google.firebase.appdistribution.** +-dontwarn com.google.android.gms.tasks.** diff --git a/devdeck-appdistribution/src/test/kotlin/io/devdeck/appdistribution/FirebaseAppDistributionSourceTest.kt b/devdeck-appdistribution/src/test/kotlin/io/devdeck/appdistribution/FirebaseAppDistributionSourceTest.kt new file mode 100644 index 0000000..14c8101 --- /dev/null +++ b/devdeck-appdistribution/src/test/kotlin/io/devdeck/appdistribution/FirebaseAppDistributionSourceTest.kt @@ -0,0 +1,190 @@ +package io.devdeck.appdistribution + +import android.app.Activity +import android.net.Uri +import com.google.android.gms.tasks.OnCompleteListener +import com.google.android.gms.tasks.OnFailureListener +import com.google.android.gms.tasks.OnSuccessListener +import com.google.android.gms.tasks.Task +import com.google.firebase.appdistribution.AppDistributionRelease +import com.google.firebase.appdistribution.BinaryType +import com.google.firebase.appdistribution.FirebaseAppDistribution +import com.google.firebase.appdistribution.InterruptionLevel +import com.google.firebase.appdistribution.OnProgressListener +import com.google.firebase.appdistribution.UpdateProgress +import com.google.firebase.appdistribution.UpdateStatus +import com.google.firebase.appdistribution.UpdateTask +import io.devdeck.updates.AvailableUpdate +import java.io.IOException +import java.util.concurrent.Executor +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +class FirebaseAppDistributionSourceTest { + + private val distribution = FakeDistribution() + private val source = FirebaseAppDistributionSource { distribution } + + @Test + fun `new release becomes an available update`() = runBlocking { + distribution.release = FakeRelease(displayVersion = "1.4.0", versionCode = 412, releaseNotes = "Fixes") + + assertEquals(AvailableUpdate("1.4.0", 412, "Fixes"), source.checkForUpdate()) + } + + @Test + fun `blank release notes are dropped`() = runBlocking { + distribution.release = FakeRelease(displayVersion = "1.4.0", versionCode = 412, releaseNotes = " ") + + assertNull(source.checkForUpdate()?.releaseNotes) + } + + @Test + fun `no new release means no update`() = runBlocking { + distribution.release = null + + assertNull(source.checkForUpdate()) + } + + @Test + fun `failed checks are rethrown`() = runBlocking { + distribution.checkError = IOException("Tester not signed in") + + try { + source.checkForUpdate() + fail("Expected the check to fail") + } catch (e: IOException) { + assertEquals("Tester not signed in", e.message) + } + } + + @Test + fun `sign in state comes from Firebase`() = runBlocking { + assertFalse(source.isSignedIn()) + + source.signIn() + assertTrue(source.isSignedIn()) + + source.signOut() + assertFalse(source.isSignedIn()) + } + + @Test + fun `missing Firebase setup reads as signed out`() { + val unavailable = FirebaseAppDistributionSource { throw IllegalStateException("FirebaseApp is not initialized") } + + assertFalse(unavailable.isSignedIn()) + } + + @Test + fun `install reports download progress`() = runBlocking { + distribution.progress = listOf(0L to 200L, 50L to 200L, 200L to 200L, 10L to 0L) + val reported = mutableListOf() + + source.install(AvailableUpdate("1.4.0")) { reported += it } + + assertEquals(listOf(0f, 0.25f, 1f, null), reported) + } + + @Test + fun `describes itself as a sign-in source`() { + assertEquals("Firebase App Distribution", source.name) + assertTrue(source.requiresSignIn) + assertEquals("https://appdistribution.firebase.google.com/testerapps", source.browseBuildsUrl) + } +} + +private class FakeRelease(private val displayVersion: String, private val versionCode: Long, private val releaseNotes: String?) : + AppDistributionRelease { + override fun getDisplayVersion(): String = displayVersion + override fun getVersionCode(): Long = versionCode + override fun getReleaseNotes(): String? = releaseNotes + override fun getBinaryType(): BinaryType = BinaryType.APK +} + +private class FakeDistribution : FirebaseAppDistribution { + var release: AppDistributionRelease? = null + var checkError: Exception? = null + var progress: List> = emptyList() + private var signedIn = false + + override fun isTesterSignedIn(): Boolean = signedIn + + override fun signInTester(): Task { + signedIn = true + return CompletedTask(null) + } + + override fun signOutTester() { + signedIn = false + } + + override fun checkForNewRelease(): Task = CompletedTask(release, checkError) + + override fun updateApp(): UpdateTask = CompletedUpdateTask(progress) + + override fun updateIfNewReleaseAvailable(): UpdateTask = CompletedUpdateTask(progress) + + override fun startFeedback(infoTextResourceId: Int) = Unit + override fun startFeedback(infoText: CharSequence) = Unit + override fun startFeedback(infoTextResourceId: Int, screenshotUri: Uri?) = Unit + override fun startFeedback(infoText: CharSequence, screenshotUri: Uri?) = Unit + override fun showFeedbackNotification(infoTextResourceId: Int, interruptionLevel: InterruptionLevel) = Unit + override fun showFeedbackNotification(infoText: CharSequence, interruptionLevel: InterruptionLevel) = Unit + override fun cancelFeedbackNotification() = Unit +} + +/** A task that has already finished, so listeners run immediately on the calling thread. */ +private class CompletedTask(private val value: T?, private val error: Exception? = null) : Task() { + override fun addOnCompleteListener(listener: OnCompleteListener): Task = apply { listener.onComplete(this) } + override fun addOnSuccessListener(listener: OnSuccessListener): Task = unsupported() + override fun addOnSuccessListener(activity: Activity, listener: OnSuccessListener): Task = unsupported() + override fun addOnSuccessListener(executor: Executor, listener: OnSuccessListener): Task = unsupported() + override fun addOnFailureListener(listener: OnFailureListener): Task = unsupported() + override fun addOnFailureListener(activity: Activity, listener: OnFailureListener): Task = unsupported() + override fun addOnFailureListener(executor: Executor, listener: OnFailureListener): Task = unsupported() + override fun getException(): Exception? = error + + @Suppress("UNCHECKED_CAST") + override fun getResult(): T = value as T + override fun getResult(exceptionType: Class): T = result + override fun isCanceled(): Boolean = false + override fun isComplete(): Boolean = true + override fun isSuccessful(): Boolean = error == null +} + +private class CompletedUpdateTask(private val progress: List>) : UpdateTask() { + private val task = CompletedTask(null) + + override fun addOnProgressListener(listener: OnProgressListener): UpdateTask = apply { + progress.forEach { (downloaded, total) -> listener.onProgressUpdate(FakeProgress(downloaded, total)) } + } + + override fun addOnProgressListener(executor: Executor?, listener: OnProgressListener): UpdateTask = addOnProgressListener(listener) + override fun addOnCompleteListener(listener: OnCompleteListener): Task = apply { listener.onComplete(this) } + override fun addOnSuccessListener(listener: OnSuccessListener): Task = unsupported() + override fun addOnSuccessListener(activity: Activity, listener: OnSuccessListener): Task = unsupported() + override fun addOnSuccessListener(executor: Executor, listener: OnSuccessListener): Task = unsupported() + override fun addOnFailureListener(listener: OnFailureListener): Task = unsupported() + override fun addOnFailureListener(activity: Activity, listener: OnFailureListener): Task = unsupported() + override fun addOnFailureListener(executor: Executor, listener: OnFailureListener): Task = unsupported() + override fun getException(): Exception? = null + override fun getResult(): Void? = task.result + override fun getResult(exceptionType: Class): Void? = task.result + override fun isCanceled(): Boolean = false + override fun isComplete(): Boolean = true + override fun isSuccessful(): Boolean = true +} + +private class FakeProgress(private val downloaded: Long, private val total: Long) : UpdateProgress { + override fun getApkBytesDownloaded(): Long = downloaded + override fun getApkFileTotalBytes(): Long = total + override fun getUpdateStatus(): UpdateStatus = UpdateStatus.DOWNLOADING +} + +private fun unsupported(): Nothing = throw UnsupportedOperationException("Not used by FirebaseAppDistributionSource") diff --git a/devdeck-okhttp/api/devdeck-okhttp.api b/devdeck-okhttp/api/devdeck-okhttp.api new file mode 100644 index 0000000..e65a8a2 --- /dev/null +++ b/devdeck-okhttp/api/devdeck-okhttp.api @@ -0,0 +1,14 @@ +public final class io/devdeck/okhttp/DevDeckOkHttpInterceptor : okhttp3/Interceptor { + public static final field DEFAULT_TIMEOUT_MILLIS J + public static final field HTTP_UNAVAILABLE I + public static final field LARGE_BODY_FACTOR I + public static final field NANOS_PER_MILLI J + public static final field PAUSE_STEP_MILLIS J + public static final field SIMULATED Ljava/lang/String; + public static final field THROTTLE_CHUNK_BYTES J + public fun ()V + public fun (JZLkotlin/jvm/functions/Function0;)V + public synthetic fun (JZLkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun intercept (Lokhttp3/Interceptor$Chain;)Lokhttp3/Response; +} + diff --git a/devdeck-okhttp/build.gradle.kts b/devdeck-okhttp/build.gradle.kts index 8b59739..350ef50 100644 --- a/devdeck-okhttp/build.gradle.kts +++ b/devdeck-okhttp/build.gradle.kts @@ -14,6 +14,7 @@ android { defaultConfig { minSdk = 23 + consumerProguardFiles("consumer-rules.pro") } lint { diff --git a/devdeck-okhttp/consumer-rules.pro b/devdeck-okhttp/consumer-rules.pro new file mode 100644 index 0000000..617e591 --- /dev/null +++ b/devdeck-okhttp/consumer-rules.pro @@ -0,0 +1,4 @@ +# OkHttp is a compileOnly dependency: the app brings its own. Don't fail R8 in apps that +# add devdeck-okhttp without using OkHttp directly. +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/devdeck-okhttp/src/main/kotlin/io/devdeck/okhttp/DevDeckOkHttpInterceptor.kt b/devdeck-okhttp/src/main/kotlin/io/devdeck/okhttp/DevDeckOkHttpInterceptor.kt index c6f928a..98cfae4 100644 --- a/devdeck-okhttp/src/main/kotlin/io/devdeck/okhttp/DevDeckOkHttpInterceptor.kt +++ b/devdeck-okhttp/src/main/kotlin/io/devdeck/okhttp/DevDeckOkHttpInterceptor.kt @@ -66,11 +66,15 @@ public class DevDeckOkHttpInterceptor( val responseBody = response.body val contentType = responseBody?.contentType() + val peeked = responseBody != null && contentType != null && !contentType.isStreaming() && contentType.isText() && + isSafeToPeek(responseBody.contentLength(), contentType) val bodyText = when { responseBody == null -> null + peeked -> runCatching { response.peekBody(maxBodyBytes).string() }.getOrNull() contentType.isStreaming() -> "(streamed body not captured: $contentType)" - contentType.isText() -> runCatching { response.peekBody(maxBodyBytes).string() }.getOrNull() - else -> "(binary body, ${contentType ?: "unknown type"})" + contentType == null -> "(untyped body not captured)" + !contentType.isText() -> "(binary body, $contentType)" + else -> "(open-ended body not captured: $contentType)" } NetworkLogger.complete( id = id, @@ -79,7 +83,7 @@ public class DevDeckOkHttpInterceptor( headers = response.headers.toPairs(), body = bodyText, contentType = contentType?.toString(), - size = responseBody?.contentLength()?.takeIf { it >= 0 } ?: bodyText?.length?.toLong(), + size = responseBody?.contentLength()?.takeIf { it >= 0 } ?: bodyText?.takeIf { peeked }?.length?.toLong(), durationMillis = duration, protocol = response.protocol.toString(), ) @@ -141,9 +145,12 @@ public class DevDeckOkHttpInterceptor( private fun RequestBody.readText(): String? { if (isDuplex() || isOneShot()) return "(streamed body not captured)" - if (!contentType().isText()) return "(binary body, ${contentType() ?: "unknown type"})" val length = contentLength() - if (contentType() == null && length !in 0..maxBodyBytes) return "(untyped body not captured)" + if (contentType() == null) { + if (length !in 0..maxBodyBytes) return "(untyped body not captured)" + } else if (!contentType().isText()) { + return "(binary body, ${contentType()})" + } if (length > maxBodyBytes * LARGE_BODY_FACTOR) return "(body too large to capture: $length bytes)" return runCatching { val buffer = Buffer() @@ -170,11 +177,20 @@ public class DevDeckOkHttpInterceptor( } private fun MediaType?.isText(): Boolean { - if (this == null) return true + if (this == null) return false val subtype = subtype.lowercase() return type == "text" || TEXT_SUBTYPES.any { subtype.contains(it) } } + // peekBody blocks until maxBodyBytes or EOF, so only peek bodies that end: a known length, or a document + // type (JSON, XML, HTML, GraphQL) that is not used for open-ended streams. Chunked HTTP/1.1, HTTP/2 and + // close-delimited bodies all report an unknown length. + private fun isSafeToPeek(contentLength: Long, contentType: MediaType): Boolean { + if (contentLength >= 0) return true + val subtype = contentType.subtype.lowercase() + return DOCUMENT_SUBTYPES.any { subtype.contains(it) } + } + private fun MediaType.charset(): Charset? = charset(null) private fun elapsedMillis(startNanos: Long) = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos) @@ -188,6 +204,7 @@ public class DevDeckOkHttpInterceptor( const val LARGE_BODY_FACTOR = 4 const val THROTTLE_CHUNK_BYTES = 8_192L val STREAMING_SUBTYPES = listOf("event-stream", "x-mixed-replace", "x-ndjson") + val DOCUMENT_SUBTYPES = listOf("json", "xml", "html", "graphql") val TEXT_SUBTYPES = listOf("json", "xml", "html", "plain", "x-www-form-urlencoded", "graphql", "javascript") } } diff --git a/devdeck-okhttp/src/test/kotlin/io/devdeck/okhttp/DevDeckOkHttpInterceptorBodyTest.kt b/devdeck-okhttp/src/test/kotlin/io/devdeck/okhttp/DevDeckOkHttpInterceptorBodyTest.kt new file mode 100644 index 0000000..373feb2 --- /dev/null +++ b/devdeck-okhttp/src/test/kotlin/io/devdeck/okhttp/DevDeckOkHttpInterceptorBodyTest.kt @@ -0,0 +1,84 @@ +package io.devdeck.okhttp + +import io.devdeck.network.NetworkLogger +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class DevDeckOkHttpInterceptorBodyTest { + + private val server = MockWebServer() + + private val client = OkHttpClient.Builder() + .addInterceptor(DevDeckOkHttpInterceptor()) + .readTimeout(10, TimeUnit.SECONDS) + .build() + + @Before + fun setUp() { + server.start() + NetworkLogger.clear() + } + + @After + fun tearDown() { + server.shutdown() + NetworkLogger.clear() + } + + @Test + fun `captures text bodies with a known length`() { + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody("""{"ok":true}""")) + + call().close() + + assertEquals("""{"ok":true}""", loggedBody()) + } + + @Test + fun `does not peek untyped bodies`() { + server.enqueue(MockResponse().setBody("raw")) + + assertEquals("raw", call().use { it.body!!.string() }) + assertEquals("(untyped body not captured)", loggedBody()) + } + + @Test + fun `does not block on a chunked text stream`() { + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/plain") + .setChunkedBody("tick\n".repeat(20), 5) + .throttleBody(5, 1, TimeUnit.SECONDS), + ) + + val started = System.nanoTime() + val response = call() + val elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) + response.close() + + assertTrue("interceptor blocked for $elapsedMillis ms", elapsedMillis < 2_000) + assertTrue(loggedBody().orEmpty().startsWith("(open-ended body not captured")) + assertEquals(null, NetworkLogger.calls.value.single().responseSize) + } + + @Test + fun `still captures chunked JSON documents`() { + server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setChunkedBody("""{"items":[1,2,3]}""", 4)) + + call().close() + + assertEquals("""{"items":[1,2,3]}""", loggedBody()) + } + + private fun call() = client.newCall(Request.Builder().url(server.url("/feed")).build()).execute() + + private fun loggedBody() = NetworkLogger.calls.value.single().responseBody +} diff --git a/devdeck-timber/api/devdeck-timber.api b/devdeck-timber/api/devdeck-timber.api new file mode 100644 index 0000000..a844784 --- /dev/null +++ b/devdeck-timber/api/devdeck-timber.api @@ -0,0 +1,24 @@ +public final class io/devdeck/timber/DevDeckTimberTree : timber/log/Timber$DebugTree { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class io/devdeck/timber/TimberLogs { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/timber/TimberLogs; + public final fun getBuffer ()Lio/devdeck/console/ConsoleBuffer; +} + +public final class io/devdeck/timber/TimberLogsPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;ZLio/devdeck/console/ConsoleView;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ZLio/devdeck/console/ConsoleView;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + diff --git a/devdeck-timber/build.gradle.kts b/devdeck-timber/build.gradle.kts index 657ad2c..3edfdb9 100644 --- a/devdeck-timber/build.gradle.kts +++ b/devdeck-timber/build.gradle.kts @@ -15,6 +15,7 @@ android { defaultConfig { minSdk = 23 + consumerProguardFiles("consumer-rules.pro") } buildFeatures { @@ -33,6 +34,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + testOptions { + unitTests.isReturnDefaultValues = true + } + publishing { singleVariant("release") { withSourcesJar() @@ -50,6 +55,8 @@ kotlin { dependencies { api(project(":devdeck")) compileOnly(libs.timber) + testImplementation(libs.timber) + testImplementation(libs.junit) } afterEvaluate { diff --git a/devdeck-timber/consumer-rules.pro b/devdeck-timber/consumer-rules.pro new file mode 100644 index 0000000..5203861 --- /dev/null +++ b/devdeck-timber/consumer-rules.pro @@ -0,0 +1,3 @@ +# Timber is a compileOnly dependency: the app brings its own. Don't fail R8 in apps that +# add devdeck-timber without Timber on the classpath. +-dontwarn timber.log.** diff --git a/devdeck-timber/src/test/kotlin/io/devdeck/timber/DevDeckTimberTreeTest.kt b/devdeck-timber/src/test/kotlin/io/devdeck/timber/DevDeckTimberTreeTest.kt new file mode 100644 index 0000000..e3b29a0 --- /dev/null +++ b/devdeck-timber/src/test/kotlin/io/devdeck/timber/DevDeckTimberTreeTest.kt @@ -0,0 +1,84 @@ +package io.devdeck.timber + +import android.content.ContextWrapper +import io.devdeck.console.ConsoleLevel +import io.devdeck.console.ConsoleLine +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import timber.log.Timber + +class DevDeckTimberTreeTest { + + @Before + fun setUp() { + Timber.uprootAll() + TimberLogs.buffer.clear() + } + + @After + fun tearDown() { + Timber.uprootAll() + TimberLogs.buffer.clear() + } + + @Test + fun `logs reach the buffer with their tag and level`() { + Timber.plant(DevDeckTimberTree()) + + Timber.tag("Checkout").w("Card declined") + Timber.tag("Checkout").e("Payment failed") + + val lines = awaitLines(2) + assertEquals(listOf("Card declined", "Payment failed"), lines.map { it.message }) + assertEquals(listOf("Checkout", "Checkout"), lines.map { it.tag }) + assertEquals(listOf(ConsoleLevel.Warn, ConsoleLevel.Error), lines.map { it.level }) + } + + @Test + fun `untagged logs get a tag from the calling class`() { + Timber.plant(DevDeckTimberTree()) + + Timber.i("Started") + + val line = awaitLines(1).single() + assertEquals("DevDeckTimberTreeTest", line.tag) + assertEquals(ConsoleLevel.Info, line.level) + } + + @Test + fun `plugin plants its tree once`() { + val plugin = TimberLogsPlugin() + + plugin.onAttach(FakeContext) + plugin.onAttach(FakeContext) + + assertEquals(1, Timber.forest().count { it is DevDeckTimberTree }) + } + + @Test + fun `plugin leaves Timber alone when planting is turned off`() { + TimberLogsPlugin(plantTree = false).onAttach(FakeContext) + + assertTrue(Timber.forest().isEmpty()) + } + + @Test + fun `plugin does not plant a second tree next to the app's own`() { + Timber.plant(DevDeckTimberTree(defaultTag = "App")) + + TimberLogsPlugin().onAttach(FakeContext) + + assertEquals(1, Timber.forest().size) + } + + private fun awaitLines(count: Int): List { + val deadline = System.currentTimeMillis() + 5_000 + while (TimberLogs.buffer.lines.value.size < count && System.currentTimeMillis() < deadline) Thread.sleep(20) + return TimberLogs.buffer.lines.value.also { assertEquals(count, it.size) } + } + + private object FakeContext : ContextWrapper(null) +} diff --git a/devdeck/api/devdeck.api b/devdeck/api/devdeck.api new file mode 100644 index 0000000..1a94f3b --- /dev/null +++ b/devdeck/api/devdeck.api @@ -0,0 +1,1486 @@ +public final class io/devdeck/DeckConfig { + public static final field $stable I + public fun ()V + public fun (Ljava/util/List;Lio/devdeck/DeckSide;Lio/devdeck/DeckGestures;Lio/devdeck/DeckTrigger;Lio/devdeck/DeckStyle;Lkotlin/jvm/functions/Function1;Ljava/util/Set;Lkotlin/jvm/functions/Function3;)V + public synthetic fun (Ljava/util/List;Lio/devdeck/DeckSide;Lio/devdeck/DeckGestures;Lio/devdeck/DeckTrigger;Lio/devdeck/DeckStyle;Lkotlin/jvm/functions/Function1;Ljava/util/Set;Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Lio/devdeck/DeckSide; + public final fun component3 ()Lio/devdeck/DeckGestures; + public final fun component4 ()Lio/devdeck/DeckTrigger; + public final fun component5 ()Lio/devdeck/DeckStyle; + public final fun component6 ()Lkotlin/jvm/functions/Function1; + public final fun component7 ()Ljava/util/Set; + public final fun component8 ()Lkotlin/jvm/functions/Function3; + public final fun copy (Ljava/util/List;Lio/devdeck/DeckSide;Lio/devdeck/DeckGestures;Lio/devdeck/DeckTrigger;Lio/devdeck/DeckStyle;Lkotlin/jvm/functions/Function1;Ljava/util/Set;Lkotlin/jvm/functions/Function3;)Lio/devdeck/DeckConfig; + public static synthetic fun copy$default (Lio/devdeck/DeckConfig;Ljava/util/List;Lio/devdeck/DeckSide;Lio/devdeck/DeckGestures;Lio/devdeck/DeckTrigger;Lio/devdeck/DeckStyle;Lkotlin/jvm/functions/Function1;Ljava/util/Set;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lio/devdeck/DeckConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getContent ()Lkotlin/jvm/functions/Function3; + public final fun getExcludedActivityPrefixes ()Ljava/util/Set; + public final fun getGestures ()Lio/devdeck/DeckGestures; + public final fun getIncludeActivity ()Lkotlin/jvm/functions/Function1; + public final fun getPlugins ()Ljava/util/List; + public final fun getSide ()Lio/devdeck/DeckSide; + public final fun getStyle ()Lio/devdeck/DeckStyle; + public final fun getTrigger ()Lio/devdeck/DeckTrigger; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/DeckExclusions { + public static final field $stable I + public static final field ADYEN Ljava/lang/String; + public static final field BRAINTREE Ljava/lang/String; + public static final field CARDINAL Ljava/lang/String; + public static final field CHECKOUT_COM Ljava/lang/String; + public static final field INSTANCE Lio/devdeck/DeckExclusions; + public static final field NETCETERA Ljava/lang/String; + public static final field PAYPAL Ljava/lang/String; + public static final field PRIMER Ljava/lang/String; + public static final field STRIPE Ljava/lang/String; + public final fun getPAYMENT_SDKS ()Ljava/util/Set; +} + +public final class io/devdeck/DeckGestures { + public static final field $stable I + public static final field Companion Lio/devdeck/DeckGestures$Companion; + public fun ()V + public fun (ZLio/devdeck/DeckShortcut;Z)V + public synthetic fun (ZLio/devdeck/DeckShortcut;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()Lio/devdeck/DeckShortcut; + public final fun component3 ()Z + public final fun copy (ZLio/devdeck/DeckShortcut;Z)Lio/devdeck/DeckGestures; + public static synthetic fun copy$default (Lio/devdeck/DeckGestures;ZLio/devdeck/DeckShortcut;ZILjava/lang/Object;)Lio/devdeck/DeckGestures; + public fun equals (Ljava/lang/Object;)Z + public final fun getKeyboardShortcut ()Lio/devdeck/DeckShortcut; + public final fun getSwipeToClose ()Z + public final fun getTwoFingerSwipe ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/DeckGestures$Companion { + public final fun getDisabled ()Lio/devdeck/DeckGestures; +} + +public final class io/devdeck/DeckPage { + public static final field $stable I + public final fun getTitle ()Ljava/lang/String; +} + +public abstract interface class io/devdeck/DeckScope { + public abstract fun getDrawerState ()Lio/devdeck/DeckState; +} + +public final class io/devdeck/DeckScopeKt { + public static final fun getLocalDeckState ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + +public final class io/devdeck/DeckShortcut { + public static final field $stable I + public fun ()V + public fun (IZZZ)V + public synthetic fun (IZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()Z + public final fun component3 ()Z + public final fun component4 ()Z + public final fun copy (IZZZ)Lio/devdeck/DeckShortcut; + public static synthetic fun copy$default (Lio/devdeck/DeckShortcut;IZZZILjava/lang/Object;)Lio/devdeck/DeckShortcut; + public fun equals (Ljava/lang/Object;)Z + public final fun getAlt ()Z + public final fun getCtrl ()Z + public final fun getKeyCode ()I + public final fun getShift ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/DeckSide : java/lang/Enum { + public static final field End Lio/devdeck/DeckSide; + public static final field Start Lio/devdeck/DeckSide; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/DeckSide; + public static fun values ()[Lio/devdeck/DeckSide; +} + +public final class io/devdeck/DeckState { + public static final field $stable I + public static final field Companion Lio/devdeck/DeckState$Companion; + public fun ()V + public fun (Lio/devdeck/DeckValue;)V + public synthetic fun (Lio/devdeck/DeckValue;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun close ()V + public final fun getPages ()Ljava/util/List; + public final fun getTargetValue ()Lio/devdeck/DeckValue; + public final fun isOpen ()Z + public final fun isVisible ()Z + public final fun navigateBack ()Z + public final fun navigateTo (Ljava/lang/String;Lkotlin/jvm/functions/Function3;)V + public final fun navigateToRoot ()V + public final fun open ()V + public final fun toggle ()V +} + +public final class io/devdeck/DeckState$Companion { +} + +public final class io/devdeck/DeckStateKt { + public static final fun rememberDeckState (Lio/devdeck/DeckValue;Landroidx/compose/runtime/Composer;II)Lio/devdeck/DeckState; +} + +public final class io/devdeck/DeckStyle { + public static final field $stable I + public synthetic fun (Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component10 ()I + public final fun component11 ()Z + public final fun component12 ()Z + public final fun component2 ()Ljava/lang/String; + public final fun component3-D9Ej5fM ()F + public final fun component4 ()F + public final fun component5-0d7_KjU ()J + public final fun component6-0d7_KjU ()J + public final fun component7-0d7_KjU ()J + public final fun component8 ()Landroidx/compose/material3/ColorScheme; + public final fun component9 ()Landroidx/compose/material3/ColorScheme; + public final fun copy-JPBzAow (Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZ)Lio/devdeck/DeckStyle; + public static synthetic fun copy-JPBzAow$default (Lio/devdeck/DeckStyle;Ljava/lang/String;Ljava/lang/String;FFJJJLandroidx/compose/material3/ColorScheme;Landroidx/compose/material3/ColorScheme;IZZILjava/lang/Object;)Lio/devdeck/DeckStyle; + public fun equals (Ljava/lang/Object;)Z + public final fun getAnimationDurationMillis ()I + public final fun getCardColor-0d7_KjU ()J + public final fun getCollapsibleSections ()Z + public final fun getContainerColor-0d7_KjU ()J + public final fun getDarkColorScheme ()Landroidx/compose/material3/ColorScheme; + public final fun getLightColorScheme ()Landroidx/compose/material3/ColorScheme; + public final fun getMaxWidth-D9Ej5fM ()F + public final fun getOneSectionAtATime ()Z + public final fun getScrimColor-0d7_KjU ()J + public final fun getSubtitle ()Ljava/lang/String; + public final fun getTitle ()Ljava/lang/String; + public final fun getWidthFraction ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class io/devdeck/DeckTrigger { +} + +public final class io/devdeck/DeckTrigger$FloatingButton : io/devdeck/DeckTrigger { + public static final field $stable I + public fun ()V + public fun (Landroidx/compose/ui/Alignment;ZLkotlin/jvm/functions/Function2;)V + public synthetic fun (Landroidx/compose/ui/Alignment;ZLkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Landroidx/compose/ui/Alignment; + public final fun component2 ()Z + public final fun component3 ()Lkotlin/jvm/functions/Function2; + public final fun copy (Landroidx/compose/ui/Alignment;ZLkotlin/jvm/functions/Function2;)Lio/devdeck/DeckTrigger$FloatingButton; + public static synthetic fun copy$default (Lio/devdeck/DeckTrigger$FloatingButton;Landroidx/compose/ui/Alignment;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/devdeck/DeckTrigger$FloatingButton; + public fun equals (Ljava/lang/Object;)Z + public final fun getAlignment ()Landroidx/compose/ui/Alignment; + public final fun getDraggable ()Z + public final fun getIcon ()Lkotlin/jvm/functions/Function2; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/DeckTrigger$None : io/devdeck/DeckTrigger { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/DeckTrigger$None; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/DeckValue : java/lang/Enum { + public static final field Closed Lio/devdeck/DeckValue; + public static final field Open Lio/devdeck/DeckValue; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/DeckValue; + public static fun values ()[Lio/devdeck/DeckValue; +} + +public final class io/devdeck/DevDeck { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/DevDeck; + public final fun close ()V + public final fun getConfig ()Lio/devdeck/DeckConfig; + public final fun getState ()Lio/devdeck/DeckState; + public final fun install (Landroid/app/Application;Lio/devdeck/DeckConfig;)V + public final fun install (Landroid/app/Application;Ljava/util/List;Lio/devdeck/DeckTrigger;Lio/devdeck/DeckGestures;Lio/devdeck/DeckStyle;Lio/devdeck/DeckSide;Lkotlin/jvm/functions/Function1;Ljava/util/Set;)V + public static synthetic fun install$default (Lio/devdeck/DevDeck;Landroid/app/Application;Ljava/util/List;Lio/devdeck/DeckTrigger;Lio/devdeck/DeckGestures;Lio/devdeck/DeckStyle;Lio/devdeck/DeckSide;Lkotlin/jvm/functions/Function1;Ljava/util/Set;ILjava/lang/Object;)V + public final fun isEnabled ()Z + public final fun isInstalled ()Z + public final fun open ()Z + public final fun setEnabled (Z)V + public final fun toggle ()V + public final fun uninstall ()V + public final fun updateConfig (Lkotlin/jvm/functions/Function1;)V +} + +public final class io/devdeck/DevDeckHostKt { + public static final fun DevDeckHost (Ljava/util/List;Landroidx/compose/ui/Modifier;Lio/devdeck/DeckState;ZLio/devdeck/DeckSide;Lio/devdeck/DeckGestures;Lio/devdeck/DeckTrigger;Lio/devdeck/DeckStyle;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun DevDeckHost (Lkotlin/jvm/functions/Function3;Landroidx/compose/ui/Modifier;Lio/devdeck/DeckState;ZLio/devdeck/DeckSide;Lio/devdeck/DeckGestures;Lio/devdeck/DeckTrigger;Lio/devdeck/DeckStyle;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/actions/AppActions { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/actions/AppActions; + public final fun clearAppData (Landroid/content/Context;)V + public final fun clearCache (Landroid/content/Context;)V + public final fun clearCacheInBackground (Landroid/content/Context;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun isWebViewDebuggingEnabled ()Z + public final fun killApp ()V + public final fun restart (Landroid/content/Context;)V + public final fun setWebViewDebugging (Z)Z +} + +public final class io/devdeck/actions/DeepLinkLauncherKt { + public static final fun DeepLinkLauncher (Landroidx/compose/ui/Modifier;Ljava/util/List;ZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/appearance/AppearanceKt { + public static final fun DisplaySettingsButton (Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V + public static final fun LanguageSelector (Ljava/util/List;Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V + public static final fun NightModeSelector (Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/appearance/NightModeOption : java/lang/Enum { + public static final field Dark Lio/devdeck/appearance/NightModeOption; + public static final field Light Lio/devdeck/appearance/NightModeOption; + public static final field System Lio/devdeck/appearance/NightModeOption; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getLabel ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/appearance/NightModeOption; + public static fun values ()[Lio/devdeck/appearance/NightModeOption; +} + +public final class io/devdeck/breadcrumbs/Breadcrumb { + public static final field $stable I + public fun (JLio/devdeck/breadcrumbs/BreadcrumbType;Ljava/lang/String;)V + public final fun component1 ()J + public final fun component2 ()Lio/devdeck/breadcrumbs/BreadcrumbType; + public final fun component3 ()Ljava/lang/String; + public final fun copy (JLio/devdeck/breadcrumbs/BreadcrumbType;Ljava/lang/String;)Lio/devdeck/breadcrumbs/Breadcrumb; + public static synthetic fun copy$default (Lio/devdeck/breadcrumbs/Breadcrumb;JLio/devdeck/breadcrumbs/BreadcrumbType;Ljava/lang/String;ILjava/lang/Object;)Lio/devdeck/breadcrumbs/Breadcrumb; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public final fun getTimestampMillis ()J + public final fun getType ()Lio/devdeck/breadcrumbs/BreadcrumbType; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/breadcrumbs/BreadcrumbListKt { + public static final fun BreadcrumbList (Landroidx/compose/ui/Modifier;ILandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/breadcrumbs/BreadcrumbModifierKt { + public static final fun breadcrumb (Landroidx/compose/ui/Modifier;Ljava/lang/String;)Landroidx/compose/ui/Modifier; +} + +public final class io/devdeck/breadcrumbs/BreadcrumbType : java/lang/Enum { + public static final field App Lio/devdeck/breadcrumbs/BreadcrumbType; + public static final field Custom Lio/devdeck/breadcrumbs/BreadcrumbType; + public static final field Network Lio/devdeck/breadcrumbs/BreadcrumbType; + public static final field Screen Lio/devdeck/breadcrumbs/BreadcrumbType; + public static final field Tap Lio/devdeck/breadcrumbs/BreadcrumbType; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getLabel ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/breadcrumbs/BreadcrumbType; + public static fun values ()[Lio/devdeck/breadcrumbs/BreadcrumbType; +} + +public final class io/devdeck/breadcrumbs/Breadcrumbs { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/breadcrumbs/Breadcrumbs; + public final fun add (Ljava/lang/String;Lio/devdeck/breadcrumbs/BreadcrumbType;)V + public static synthetic fun add$default (Lio/devdeck/breadcrumbs/Breadcrumbs;Ljava/lang/String;Lio/devdeck/breadcrumbs/BreadcrumbType;ILjava/lang/Object;)V + public final fun clear ()V + public final fun format (Ljava/util/List;)Ljava/lang/String; + public final fun getCaptureNetwork ()Z + public final fun getEntries ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getMaxEntries ()I + public final fun recent (I)Ljava/util/List; + public final fun screen (Ljava/lang/String;)V + public final fun setCaptureNetwork (Z)V + public final fun setMaxEntries (I)V + public final fun tap (Ljava/lang/String;)V +} + +public final class io/devdeck/bugreport/BugReportButtonKt { + public static final fun BugReportButton (Landroidx/compose/ui/Modifier;Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/bugreport/BugReportScreenKt { + public static final fun BugReportScreen (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/capture/CaptureGalleryKt { + public static final fun CaptureGallery (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun GalleryButton (Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/capture/DeckFileProvider : androidx/core/content/FileProvider { + public static final field $stable I + public fun ()V +} + +public final class io/devdeck/capture/ScreenCapture { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/capture/ScreenCapture; + public final fun captureDirectory (Landroid/content/Context;)Ljava/io/File; + public final fun captures (Landroid/content/Context;)Ljava/util/List; + public final fun isRecording ()Z + public final fun takeScreenshot (Landroid/app/Activity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun uriFor (Landroid/content/Context;Ljava/io/File;)Landroid/net/Uri; +} + +public final class io/devdeck/capture/ScreenCaptureToolsKt { + public static final fun ScreenCaptureTools (Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/capture/ScreenRecordButtonKt { + public static final fun ScreenRecordButton (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/capture/ScreenRecordingService : android/app/Service { + public static final field $stable I + public static final field ACTION_START Ljava/lang/String; + public static final field ACTION_STOP Ljava/lang/String; + public static final field EXTRA_DATA Ljava/lang/String; + public static final field EXTRA_RESULT_CODE Ljava/lang/String; + public fun ()V + public fun onBind (Landroid/content/Intent;)Landroid/os/IBinder; + public fun onDestroy ()V + public fun onStartCommand (Landroid/content/Intent;II)I +} + +public final class io/devdeck/capture/ScreenshotButtonKt { + public static final fun ScreenshotButton (Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/ActionPillKt { + public static final fun ActionPill-fWhpE4E (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;JZZLandroidx/compose/runtime/Composer;II)V + public static final fun ActionPillRow (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/BulletItemKt { + public static final fun BulletItem (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun BulletItem (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/ButtonItemKt { + public static final fun ButtonItem (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/CheckboxItemKt { + public static final fun CheckboxItem (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/CodeBlockKt { + public static final fun CodeBlock (Ljava/lang/String;Landroidx/compose/ui/Modifier;Lio/devdeck/components/CodeLanguage;Ljava/lang/String;IZZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/CodeLanguage : java/lang/Enum { + public static final field Json Lio/devdeck/components/CodeLanguage; + public static final field Plain Lio/devdeck/components/CodeLanguage; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/components/CodeLanguage; + public static fun values ()[Lio/devdeck/components/CodeLanguage; +} + +public final class io/devdeck/components/CollapsibleSectionKt { + public static final fun CollapsibleSection (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/CompactToggleKt { + public static final fun CompactToggle (ZLandroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/ConfirmButtonItemKt { + public static final fun ConfirmButtonItem (Ljava/lang/String;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;ZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/DeckCardKt { + public static final fun DeckCard (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/DropdownItemKt { + public static final fun DropdownItem (Ljava/lang/String;Ljava/util/List;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZLjava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/ExpandableItemKt { + public static final fun ExpandableItem (Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/FilterPillKt { + public static final fun FilterPill-FHprtrg (Ljava/lang/String;ZLkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;JZLandroidx/compose/runtime/Composer;II)V + public static final fun FilterPillRow (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/Alignment$Vertical;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/InfoGroupKt { + public static final fun InfoGroup (Landroidx/compose/ui/Modifier;ILkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/JsonEditorKt { + public static final fun JsonEditor (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/JsonInputItemKt { + public static final synthetic fun JsonInputItem (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZILandroidx/compose/runtime/Composer;II)V + public static final fun JsonInputItem (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZILjava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/KeyValueItemKt { + public static final fun KeyValueItem (Ljava/lang/String;Ljava/lang/String;Landroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/NumberInputItemKt { + public static final synthetic fun NumberInputItem (Ljava/lang/String;DLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZZLkotlin/ranges/ClosedFloatingPointRange;Landroidx/compose/runtime/Composer;II)V + public static final fun NumberInputItem (Ljava/lang/String;DLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZZLkotlin/ranges/ClosedFloatingPointRange;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V + public static final synthetic fun NumberInputItem (Ljava/lang/String;JLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZLkotlin/ranges/LongRange;Landroidx/compose/runtime/Composer;II)V + public static final fun NumberInputItem (Ljava/lang/String;JLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZLkotlin/ranges/LongRange;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/PageLinkItemKt { + public static final fun PageLinkItem (Ljava/lang/String;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/SearchFieldKt { + public static final fun SearchField (Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/SectionDividerKt { + public static final fun SectionDivider (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/SectionKt { + public static final fun Section (Ljava/lang/String;Landroidx/compose/ui/Modifier;ZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun SectionTitle (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/SegmentedPillsKt { + public static final fun SegmentedPills (Ljava/util/List;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/SelectionIndicatorsKt { + public static final fun DeckCheckbox (ZLandroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;II)V + public static final fun DeckRadio (ZLandroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/SingleSelectItemKt { + public static final fun SingleSelectItem (Ljava/lang/String;Ljava/util/List;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/SwitchItemKt { + public static final fun SwitchItem (Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Ljava/lang/String;ZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/TextInputItemKt { + public static final synthetic fun TextInputItem-lnmUPtQ (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZIZZLkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun TextInputItem-yDE52rk (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;ZIZZLkotlin/jvm/functions/Function1;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/components/TextItemKt { + public static final fun TextItem (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/console/ConsoleBuffer { + public static final field $stable I + public static final field FLUSH_DELAY_MILLIS J + public fun ()V + public fun (I)V + public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun append (Lio/devdeck/console/ConsoleLine;)V + public final fun clear ()V + public final fun getCapacity ()I + public final fun getLines ()Lkotlinx/coroutines/flow/StateFlow; +} + +public final class io/devdeck/console/ConsoleLevel : java/lang/Enum { + public static final field Assert Lio/devdeck/console/ConsoleLevel; + public static final field Companion Lio/devdeck/console/ConsoleLevel$Companion; + public static final field Debug Lio/devdeck/console/ConsoleLevel; + public static final field Error Lio/devdeck/console/ConsoleLevel; + public static final field Info Lio/devdeck/console/ConsoleLevel; + public static final field Verbose Lio/devdeck/console/ConsoleLevel; + public static final field Warn Lio/devdeck/console/ConsoleLevel; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getLabel ()Ljava/lang/String; + public final fun getLetter ()C + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/console/ConsoleLevel; + public static fun values ()[Lio/devdeck/console/ConsoleLevel; +} + +public final class io/devdeck/console/ConsoleLevel$Companion { + public final fun fromLetter (C)Lio/devdeck/console/ConsoleLevel; + public final fun fromPriority (I)Lio/devdeck/console/ConsoleLevel; +} + +public final class io/devdeck/console/ConsoleLine { + public static final field $stable I + public fun (Lio/devdeck/console/ConsoleLevel;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Integer;J)V + public synthetic fun (Lio/devdeck/console/ConsoleLevel;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Integer;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/devdeck/console/ConsoleLevel; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()J + public final fun component5 ()Ljava/lang/Integer; + public final fun component6 ()J + public final fun copy (Lio/devdeck/console/ConsoleLevel;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Integer;J)Lio/devdeck/console/ConsoleLine; + public static synthetic fun copy$default (Lio/devdeck/console/ConsoleLine;Lio/devdeck/console/ConsoleLevel;Ljava/lang/String;Ljava/lang/String;JLjava/lang/Integer;JILjava/lang/Object;)Lio/devdeck/console/ConsoleLine; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()J + public final fun getLevel ()Lio/devdeck/console/ConsoleLevel; + public final fun getMessage ()Ljava/lang/String; + public final fun getTag ()Ljava/lang/String; + public final fun getThreadId ()Ljava/lang/Integer; + public final fun getTimestampMillis ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/console/ConsoleView : java/lang/Enum { + public static final field All Lio/devdeck/console/ConsoleView; + public static final field ByTag Lio/devdeck/console/ConsoleView; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getLabel ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/console/ConsoleView; + public static fun values ()[Lio/devdeck/console/ConsoleView; +} + +public final class io/devdeck/console/ConsoleViewerKt { + public static final fun ConsoleViewer (Lio/devdeck/console/ConsoleBuffer;Landroidx/compose/ui/Modifier;Ljava/lang/String;Ljava/lang/String;Lio/devdeck/console/ConsoleView;ILandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/crash/CrashListKt { + public static final fun CrashList (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/crash/CrashRecord { + public static final field $stable I + public fun (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/io/File; + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun copy (Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/devdeck/crash/CrashRecord; + public static synthetic fun copy$default (Lio/devdeck/crash/CrashRecord;Ljava/io/File;JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/devdeck/crash/CrashRecord; + public fun equals (Ljava/lang/Object;)Z + public final fun getFile ()Ljava/io/File; + public final fun getStackTrace ()Ljava/lang/String; + public final fun getSteps ()Ljava/lang/String; + public final fun getThread ()Ljava/lang/String; + public final fun getTimestampMillis ()J + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/crash/CrashReporter { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/crash/CrashReporter; + public final fun clear ()V + public final fun crashes ()Ljava/util/List; + public final fun getMaxCrashes ()I + public final fun install (Landroid/content/Context;)V + public final fun setMaxCrashes (I)V +} + +public final class io/devdeck/device/AppInfoKt { + public static final fun AppInfo (Landroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/device/AppVersionItemKt { + public static final fun AppVersionItem (Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/device/DeviceInfoKt { + public static final fun DeviceInfo (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/device/DeviceSnapshotCardKt { + public static final fun DeviceSnapshotCard (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/device/PermissionsListKt { + public static final fun PermissionsList (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/device/SnapshotSection { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/util/List;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Ljava/util/List;)Lio/devdeck/device/SnapshotSection; + public static synthetic fun copy$default (Lio/devdeck/device/SnapshotSection;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lio/devdeck/device/SnapshotSection; + public fun equals (Ljava/lang/Object;)Z + public final fun getRows ()Ljava/util/List; + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/device/SystemShortcutsKt { + public static final fun AppSettingsButton (Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V + public static final fun DeveloperOptionsButton (Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/device/SystemSnapshot { + public static final field $stable I + public fun (Ljava/lang/String;JLjava/util/List;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()J + public final fun component3 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;JLjava/util/List;)Lio/devdeck/device/SystemSnapshot; + public static synthetic fun copy$default (Lio/devdeck/device/SystemSnapshot;Ljava/lang/String;JLjava/util/List;ILjava/lang/Object;)Lio/devdeck/device/SystemSnapshot; + public fun equals (Ljava/lang/Object;)Z + public final fun getAppName ()Ljava/lang/String; + public final fun getGeneratedAtMillis ()J + public final fun getSections ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; + public final fun toText ()Ljava/lang/String; +} + +public final class io/devdeck/device/SystemSnapshotKt { + public static final fun systemSnapshot (Landroid/content/Context;Ljava/util/Map;)Lio/devdeck/device/SystemSnapshot; + public static synthetic fun systemSnapshot$default (Landroid/content/Context;Ljava/util/Map;ILjava/lang/Object;)Lio/devdeck/device/SystemSnapshot; +} + +public abstract class io/devdeck/flags/FeatureFlag { + public static final field $stable I + public static final field Companion Lio/devdeck/flags/FeatureFlag$Companion; + public static final field DEFAULT_CATEGORY Ljava/lang/String; + public static final field DEFAULT_SECTION Ljava/lang/String; + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getCategory ()Ljava/lang/String; + public final fun getDefault ()Ljava/lang/Object; + public final fun getEditable ()Z + public final fun getKey ()Ljava/lang/String; + public final fun getSection ()Ljava/lang/String; + public final fun getTitle ()Ljava/lang/String; +} + +public final class io/devdeck/flags/FeatureFlag$Companion { +} + +public final class io/devdeck/flags/FeatureFlag$Json : io/devdeck/flags/FeatureFlag { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun decode$devdeck (Ljava/lang/String;)Ljava/lang/Object; + public synthetic fun toJson$devdeck (Ljava/lang/Object;)Ljava/lang/Object; +} + +public final class io/devdeck/flags/FeatureFlag$Number : io/devdeck/flags/FeatureFlag { + public static final field $stable I + public fun (Ljava/lang/String;DLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZLkotlin/ranges/ClosedFloatingPointRange;)V + public synthetic fun (Ljava/lang/String;DLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ZZLkotlin/ranges/ClosedFloatingPointRange;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun decode$devdeck (Ljava/lang/String;)Ljava/lang/Object; + public final fun getRange ()Lkotlin/ranges/ClosedFloatingPointRange; + public final fun getWholeNumber ()Z + public synthetic fun toJson$devdeck (Ljava/lang/Object;)Ljava/lang/Object; +} + +public final class io/devdeck/flags/FeatureFlag$Text : io/devdeck/flags/FeatureFlag { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun decode$devdeck (Ljava/lang/String;)Ljava/lang/Object; + public synthetic fun toJson$devdeck (Ljava/lang/Object;)Ljava/lang/Object; +} + +public final class io/devdeck/flags/FeatureFlag$Toggle : io/devdeck/flags/FeatureFlag { + public static final field $stable I + public fun (Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Z)V + public synthetic fun (Ljava/lang/String;ZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun decode$devdeck (Ljava/lang/String;)Ljava/lang/Object; + public synthetic fun toJson$devdeck (Ljava/lang/Object;)Ljava/lang/Object; +} + +public abstract interface class io/devdeck/flags/FeatureFlagStore { + public abstract fun get (Lio/devdeck/flags/FeatureFlag;)Ljava/lang/Object; + public abstract fun isOverridden (Lio/devdeck/flags/FeatureFlag;)Z + public abstract fun reset (Lio/devdeck/flags/FeatureFlag;)V + public abstract fun set (Lio/devdeck/flags/FeatureFlag;Ljava/lang/Object;)V +} + +public final class io/devdeck/flags/SharedPreferencesFlagStore : io/devdeck/flags/FeatureFlagStore { + public static final field $stable I + public fun (Landroid/content/Context;Ljava/lang/String;)V + public synthetic fun (Landroid/content/Context;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun get (Lio/devdeck/flags/FeatureFlag;)Ljava/lang/Object; + public fun isOverridden (Lio/devdeck/flags/FeatureFlag;)Z + public fun reset (Lio/devdeck/flags/FeatureFlag;)V + public fun set (Lio/devdeck/flags/FeatureFlag;Ljava/lang/Object;)V +} + +public final class io/devdeck/logcat/LogcatNoise { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/logcat/LogcatNoise; + public final fun getPLATFORM_MESSAGE_PREFIXES ()Ljava/util/Set; + public final fun getPLATFORM_TAGS ()Ljava/util/Set; + public final fun getPLATFORM_TAG_PREFIXES ()Ljava/util/Set; +} + +public final class io/devdeck/logcat/LogcatReader { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/logcat/LogcatReader; + public final fun getBuffer ()Lio/devdeck/console/ConsoleBuffer; + public final fun getIgnoredMessagePrefixes ()Ljava/util/Set; + public final fun getIgnoredTagPrefixes ()Ljava/util/Set; + public final fun getIgnoredTags ()Ljava/util/Set; + public final fun isRunning ()Z + public final fun setIgnoredMessagePrefixes (Ljava/util/Set;)V + public final fun setIgnoredTagPrefixes (Ljava/util/Set;)V + public final fun setIgnoredTags (Ljava/util/Set;)V + public final fun start ()V + public final fun stop ()V +} + +public final class io/devdeck/logs/DeckLogger { + public static final field $stable I + public static final field DEFAULT_TAG Ljava/lang/String; + public static final field INSTANCE Lio/devdeck/logs/DeckLogger; + public final fun clear (Ljava/lang/String;)V + public static synthetic fun clear$default (Lio/devdeck/logs/DeckLogger;Ljava/lang/String;ILjava/lang/Object;)V + public final fun getEntries ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getMaxEntries ()I + public final fun log (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public static synthetic fun log$default (Lio/devdeck/logs/DeckLogger;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V + public final fun setMaxEntries (I)V +} + +public final class io/devdeck/logs/LogEntry { + public static final field $stable I + public fun (JJLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()J + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun copy (JJLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/devdeck/logs/LogEntry; + public static synthetic fun copy$default (Lio/devdeck/logs/LogEntry;JJLjava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/devdeck/logs/LogEntry; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()J + public final fun getMessage ()Ljava/lang/String; + public final fun getPayload ()Ljava/lang/String; + public final fun getTag ()Ljava/lang/String; + public final fun getTimestampMillis ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/logs/LogListKt { + public static final fun LogList (Landroidx/compose/ui/Modifier;Ljava/lang/String;ILandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/network/NetworkCall { + public static final field $stable I + public fun (JJLjava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (JJLjava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()J + public final fun component10 ()Ljava/lang/Integer; + public final fun component11 ()Ljava/lang/String; + public final fun component12 ()Ljava/util/List; + public final fun component13 ()Ljava/lang/String; + public final fun component14 ()Ljava/lang/String; + public final fun component15 ()Ljava/lang/Long; + public final fun component16 ()Ljava/lang/Long; + public final fun component17 ()Ljava/lang/String; + public final fun component18 ()Ljava/lang/String; + public final fun component19 ()Ljava/lang/String; + public final fun component2 ()J + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/util/List; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ljava/lang/Long; + public final fun component9 ()Ljava/lang/String; + public final fun copy (JJLjava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/devdeck/network/NetworkCall; + public static synthetic fun copy$default (Lio/devdeck/network/NetworkCall;JJLjava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/devdeck/network/NetworkCall; + public fun equals (Ljava/lang/Object;)Z + public final fun getDisplayName ()Ljava/lang/String; + public final fun getDurationMillis ()Ljava/lang/Long; + public final fun getError ()Ljava/lang/String; + public final fun getHost ()Ljava/lang/String; + public final fun getId ()J + public final fun getMethod ()Ljava/lang/String; + public final fun getOperationName ()Ljava/lang/String; + public final fun getPath ()Ljava/lang/String; + public final fun getProtocol ()Ljava/lang/String; + public final fun getQueryParameters ()Ljava/util/List; + public final fun getRequestBody ()Ljava/lang/String; + public final fun getRequestContentType ()Ljava/lang/String; + public final fun getRequestHeaders ()Ljava/util/List; + public final fun getRequestSize ()Ljava/lang/Long; + public final fun getResponseBody ()Ljava/lang/String; + public final fun getResponseCode ()Ljava/lang/Integer; + public final fun getResponseContentType ()Ljava/lang/String; + public final fun getResponseHeaders ()Ljava/util/List; + public final fun getResponseMessage ()Ljava/lang/String; + public final fun getResponseSize ()Ljava/lang/Long; + public final fun getSimulation ()Ljava/lang/String; + public final fun getStartedAtMillis ()J + public final fun getUrl ()Ljava/lang/String; + public fun hashCode ()I + public final fun isComplete ()Z + public final fun isFailure ()Z + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/network/NetworkGroup { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/util/List;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Z + public final fun component6 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/util/List;)Lio/devdeck/network/NetworkGroup; + public static synthetic fun copy$default (Lio/devdeck/network/NetworkGroup;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLjava/util/List;ILjava/lang/Object;)Lio/devdeck/network/NetworkGroup; + public fun equals (Ljava/lang/Object;)Z + public final fun getAverageDurationMillis ()Ljava/lang/Long; + public final fun getCalls ()Ljava/util/List; + public final fun getCount ()I + public final fun getFailures ()I + public final fun getHost ()Ljava/lang/String; + public final fun getKey ()Ljava/lang/String; + public final fun getLatest ()Lio/devdeck/network/NetworkCall; + public final fun getMaxDurationMillis ()Ljava/lang/Long; + public final fun getMethod ()Ljava/lang/String; + public final fun getMinDurationMillis ()Ljava/lang/Long; + public final fun getPending ()I + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public final fun isGraphQl ()Z + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/network/NetworkGroupKt { + public static final fun getGroupKey (Lio/devdeck/network/NetworkCall;)Ljava/lang/String; +} + +public final class io/devdeck/network/NetworkHighlight { + public static final field $stable I + public static final field Companion Lio/devdeck/network/NetworkHighlight$Companion; + public synthetic fun (Ljava/lang/String;JLkotlin/jvm/functions/Function1;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getColor-0d7_KjU ()J + public final fun getLabel ()Ljava/lang/String; + public final fun getMatches ()Lkotlin/jvm/functions/Function1; +} + +public final class io/devdeck/network/NetworkHighlight$Companion { + public final fun graphQl-4WTKRHQ (Ljava/lang/String;J)Lio/devdeck/network/NetworkHighlight; + public static synthetic fun graphQl-4WTKRHQ$default (Lio/devdeck/network/NetworkHighlight$Companion;Ljava/lang/String;JILjava/lang/Object;)Lio/devdeck/network/NetworkHighlight; + public final fun operation-bw27NRU (Ljava/lang/String;J[Ljava/lang/String;)Lio/devdeck/network/NetworkHighlight; + public final fun urlContains-bw27NRU (Ljava/lang/String;JLjava/lang/String;)Lio/devdeck/network/NetworkHighlight; +} + +public final class io/devdeck/network/NetworkInspectorKt { + public static final fun NetworkInspector (Landroidx/compose/ui/Modifier;Ljava/util/List;Lio/devdeck/network/NetworkView;ILandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/network/NetworkLogger { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/network/NetworkLogger; + public final fun clear ()V + public final fun complete (JILjava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/String;)V + public static synthetic fun complete$default (Lio/devdeck/network/NetworkLogger;JILjava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/String;ILjava/lang/Object;)V + public final fun fail (JLjava/lang/String;Ljava/lang/Long;)V + public static synthetic fun fail$default (Lio/devdeck/network/NetworkLogger;JLjava/lang/String;Ljava/lang/Long;ILjava/lang/Object;)V + public final fun getCalls ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getMaxBodyChars ()I + public final fun getMaxCalls ()I + public final fun getMaxTotalBodyChars ()I + public final fun getRedactedFields ()Ljava/util/Set; + public final fun getRedactedHeaders ()Ljava/util/Set; + public final fun setMaxBodyChars (I)V + public final fun setMaxCalls (I)V + public final fun setMaxTotalBodyChars (I)V + public final fun setRedactedFields (Ljava/util/Set;)V + public final fun setRedactedHeaders (Ljava/util/Set;)V + public final fun start (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;)J + public static synthetic fun start$default (Lio/devdeck/network/NetworkLogger;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;ILjava/lang/Object;)J +} + +public final class io/devdeck/network/NetworkView : java/lang/Enum { + public static final field ByEndpoint Lio/devdeck/network/NetworkView; + public static final field Timeline Lio/devdeck/network/NetworkView; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getLabel ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/network/NetworkView; + public static fun values ()[Lio/devdeck/network/NetworkView; +} + +public final class io/devdeck/performance/MemoryInfoKt { + public static final fun MemoryInfo (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/performance/MemorySnapshot { + public static final field $stable I + public fun (JJJJJZ)V + public final fun component1 ()J + public final fun component2 ()J + public final fun component3 ()J + public final fun component4 ()J + public final fun component5 ()J + public final fun component6 ()Z + public final fun copy (JJJJJZ)Lio/devdeck/performance/MemorySnapshot; + public static synthetic fun copy$default (Lio/devdeck/performance/MemorySnapshot;JJJJJZILjava/lang/Object;)Lio/devdeck/performance/MemorySnapshot; + public fun equals (Ljava/lang/Object;)Z + public final fun getHeapMaxBytes ()J + public final fun getHeapUsedBytes ()J + public final fun getNativeHeapBytes ()J + public final fun getSystemAvailableBytes ()J + public final fun getSystemTotalBytes ()J + public fun hashCode ()I + public final fun isLowMemory ()Z + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/performance/PerformanceMonitor { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/performance/PerformanceMonitor; + public final fun isOverlayEnabled ()Z + public final fun memory (Landroid/content/Context;)Lio/devdeck/performance/MemorySnapshot; + public final fun setOverlayEnabled (Z)V +} + +public final class io/devdeck/plugin/AppActionsPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (ZLjava/util/List;ZZZZZLjava/lang/String;)V + public synthetic fun (ZLjava/util/List;ZZZZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/AppearancePlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (ZLjava/util/List;ZLjava/lang/String;)V + public synthetic fun (ZLjava/util/List;ZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/BreadcrumbsPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (ZZZLjava/lang/String;Ljava/lang/String;)V + public synthetic fun (ZZZLjava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/CrashPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (ZLjava/lang/String;)V + public synthetic fun (ZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public abstract interface class io/devdeck/plugin/DeckPlugin { + public abstract fun Content (Landroidx/compose/runtime/Composer;I)V + public abstract fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/DeckPlugin$DefaultImpls { + public static fun getSection (Lio/devdeck/plugin/DeckPlugin;)Ljava/lang/String; + public static fun onAttach (Lio/devdeck/plugin/DeckPlugin;Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/DeckPluginContentKt { + public static final fun DeckPluginContent (Ljava/util/List;Landroidx/compose/ui/Modifier;ZZLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/plugin/DeckPluginKt { + public static final fun deckPlugin (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;)Lio/devdeck/plugin/DeckPlugin; + public static synthetic fun deckPlugin$default (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lio/devdeck/plugin/DeckPlugin; +} + +public final class io/devdeck/plugin/DeckPlugins { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/plugin/DeckPlugins; + public final fun defaults ()Ljava/util/List; +} + +public final class io/devdeck/plugin/DevicePlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (ZZZLjava/lang/String;)V + public synthetic fun (ZZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/Endpoint { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lio/devdeck/plugin/Endpoint; + public static synthetic fun copy$default (Lio/devdeck/plugin/Endpoint;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/devdeck/plugin/Endpoint; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getUrl ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/plugin/EndpointPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public static final field Companion Lio/devdeck/plugin/EndpointPlugin$Companion; + public static final field DEFAULT_KEY Ljava/lang/String; + public fun (Ljava/util/List;Lio/devdeck/plugin/Endpoint;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/util/List;Lio/devdeck/plugin/Endpoint;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public final fun current (Landroid/content/Context;)Lio/devdeck/plugin/Endpoint; + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/EndpointPlugin$Companion { + public final fun current (Landroid/content/Context;Ljava/util/List;Lio/devdeck/plugin/Endpoint;Ljava/lang/String;)Lio/devdeck/plugin/Endpoint; + public static synthetic fun current$default (Lio/devdeck/plugin/EndpointPlugin$Companion;Landroid/content/Context;Ljava/util/List;Lio/devdeck/plugin/Endpoint;Ljava/lang/String;ILjava/lang/Object;)Lio/devdeck/plugin/Endpoint; +} + +public final class io/devdeck/plugin/FeatureFlagsPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun (Ljava/util/List;Lio/devdeck/flags/FeatureFlagStore;Ljava/lang/String;Ljava/lang/String;Z)V + public synthetic fun (Ljava/util/List;Lio/devdeck/flags/FeatureFlagStore;Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public final fun exportJson ()Ljava/lang/String; + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public final fun importJson (Ljava/lang/String;)I + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/InfoPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (ZZZLjava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;)V + public synthetic fun (ZZZLjava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/LogcatPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;ZLio/devdeck/console/ConsoleView;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ZLio/devdeck/console/ConsoleView;Ljava/util/Set;Ljava/util/Set;Ljava/util/Set;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/LogsPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public static final field Companion Lio/devdeck/plugin/LogsPlugin$Companion; + public static final field LIFECYCLE_TAG Ljava/lang/String; + public fun ()V + public fun (Ljava/util/List;ZZLjava/lang/String;)V + public synthetic fun (Ljava/util/List;ZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/LogsPlugin$Companion { +} + +public final class io/devdeck/plugin/MemorySimulatorPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/NetworkLogsPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (Ljava/util/List;Ljava/lang/String;Lio/devdeck/network/NetworkView;Ljava/lang/String;)V + public synthetic fun (Ljava/util/List;Ljava/lang/String;Lio/devdeck/network/NetworkView;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/NetworkSimulatorPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/PerformancePlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (ZZLjava/lang/String;)V + public synthetic fun (ZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/StoragePlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (ZZLkotlin/jvm/functions/Function1;Ljava/lang/String;)V + public synthetic fun (ZZLkotlin/jvm/functions/Function1;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/UiToolsPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun ()V + public fun (ZZZLjava/lang/String;)V + public synthetic fun (ZZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/plugin/UpdatesPlugin : io/devdeck/plugin/DeckPlugin { + public static final field $stable I + public fun (Lio/devdeck/updates/UpdateSource;ZJZLjava/lang/String;)V + public synthetic fun (Lio/devdeck/updates/UpdateSource;ZJZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun Content (Landroidx/compose/runtime/Composer;I)V + public fun getId ()Ljava/lang/String; + public fun getSection ()Ljava/lang/String; + public fun onAttach (Landroid/content/Context;)V +} + +public final class io/devdeck/simulation/MemorySimulator { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/simulation/MemorySimulator; + public final fun allocate (I)J + public final fun fillHeap (I)J + public final fun getHeldBytes ()J + public final fun getKillOnBackground ()Z + public final fun init (Landroid/app/Application;)V + public final fun release ()V + public final fun send (Lio/devdeck/simulation/MemoryWarning;)V + public final fun setKillOnBackground (Z)V +} + +public final class io/devdeck/simulation/MemorySimulatorSettingsKt { + public static final fun MemorySimulatorSettings (Landroidx/compose/ui/Modifier;Ljava/util/List;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/simulation/MemoryWarning : java/lang/Enum { + public static final field Background Lio/devdeck/simulation/MemoryWarning; + public static final field Complete Lio/devdeck/simulation/MemoryWarning; + public static final field LowMemory Lio/devdeck/simulation/MemoryWarning; + public static final field Moderate Lio/devdeck/simulation/MemoryWarning; + public static final field RunningCritical Lio/devdeck/simulation/MemoryWarning; + public static final field RunningLow Lio/devdeck/simulation/MemoryWarning; + public static final field RunningModerate Lio/devdeck/simulation/MemoryWarning; + public static final field UiHidden Lio/devdeck/simulation/MemoryWarning; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getLabel ()Ljava/lang/String; + public final fun getLevel ()Ljava/lang/Integer; + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/simulation/MemoryWarning; + public static fun values ()[Lio/devdeck/simulation/MemoryWarning; +} + +public final class io/devdeck/simulation/MemoryWarningKt { + public static final fun getCommonMemoryWarnings ()Ljava/util/List; +} + +public final class io/devdeck/simulation/NetworkCondition : java/lang/Enum { + public static final field Failing Lio/devdeck/simulation/NetworkCondition; + public static final field Normal Lio/devdeck/simulation/NetworkCondition; + public static final field Slow Lio/devdeck/simulation/NetworkCondition; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getLabel ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/simulation/NetworkCondition; + public static fun values ()[Lio/devdeck/simulation/NetworkCondition; +} + +public abstract interface class io/devdeck/simulation/NetworkEffect { + public abstract fun getLabel ()Ljava/lang/String; +} + +public final class io/devdeck/simulation/NetworkEffect$Fail : io/devdeck/simulation/NetworkEffect { + public static final field $stable I + public fun (Lio/devdeck/simulation/NetworkFailure;)V + public final fun component1 ()Lio/devdeck/simulation/NetworkFailure; + public final fun copy (Lio/devdeck/simulation/NetworkFailure;)Lio/devdeck/simulation/NetworkEffect$Fail; + public static synthetic fun copy$default (Lio/devdeck/simulation/NetworkEffect$Fail;Lio/devdeck/simulation/NetworkFailure;ILjava/lang/Object;)Lio/devdeck/simulation/NetworkEffect$Fail; + public fun equals (Ljava/lang/Object;)Z + public final fun getFailure ()Lio/devdeck/simulation/NetworkFailure; + public fun getLabel ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/simulation/NetworkEffect$Slow : io/devdeck/simulation/NetworkEffect { + public static final field $stable I + public fun (JJ)V + public final fun component1 ()J + public final fun component2 ()J + public final fun copy (JJ)Lio/devdeck/simulation/NetworkEffect$Slow; + public static synthetic fun copy$default (Lio/devdeck/simulation/NetworkEffect$Slow;JJILjava/lang/Object;)Lio/devdeck/simulation/NetworkEffect$Slow; + public fun equals (Ljava/lang/Object;)Z + public final fun getBytesPerSecond ()J + public final fun getDelayMillis ()J + public fun getLabel ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/simulation/NetworkFailure : java/lang/Enum { + public static final field NoConnection Lio/devdeck/simulation/NetworkFailure; + public static final field ServerError Lio/devdeck/simulation/NetworkFailure; + public static final field Timeout Lio/devdeck/simulation/NetworkFailure; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getLabel ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Lio/devdeck/simulation/NetworkFailure; + public static fun values ()[Lio/devdeck/simulation/NetworkFailure; +} + +public final class io/devdeck/simulation/NetworkSimulation { + public static final field $stable I + public fun ()V + public fun (Lio/devdeck/simulation/NetworkCondition;JJLio/devdeck/simulation/NetworkFailure;ILjava/lang/String;)V + public synthetic fun (Lio/devdeck/simulation/NetworkCondition;JJLio/devdeck/simulation/NetworkFailure;ILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lio/devdeck/simulation/NetworkCondition; + public final fun component2 ()J + public final fun component3 ()J + public final fun component4 ()Lio/devdeck/simulation/NetworkFailure; + public final fun component5 ()I + public final fun component6 ()Ljava/lang/String; + public final fun copy (Lio/devdeck/simulation/NetworkCondition;JJLio/devdeck/simulation/NetworkFailure;ILjava/lang/String;)Lio/devdeck/simulation/NetworkSimulation; + public static synthetic fun copy$default (Lio/devdeck/simulation/NetworkSimulation;Lio/devdeck/simulation/NetworkCondition;JJLio/devdeck/simulation/NetworkFailure;ILjava/lang/String;ILjava/lang/Object;)Lio/devdeck/simulation/NetworkSimulation; + public fun equals (Ljava/lang/Object;)Z + public final fun getBytesPerSecond ()J + public final fun getCondition ()Lio/devdeck/simulation/NetworkCondition; + public final fun getDelayMillis ()J + public final fun getFailure ()Lio/devdeck/simulation/NetworkFailure; + public final fun getFailureRatePercent ()I + public final fun getSummary ()Ljava/lang/String; + public final fun getUrlFilter ()Ljava/lang/String; + public fun hashCode ()I + public final fun isActive ()Z + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/simulation/NetworkSimulator { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/simulation/NetworkSimulator; + public final fun effectFor (Ljava/lang/String;)Lio/devdeck/simulation/NetworkEffect; + public final fun getSimulation ()Lio/devdeck/simulation/NetworkSimulation; + public final fun init (Landroid/content/Context;)V + public final fun isActive ()Z + public final fun reset ()V + public final fun setSimulation (Lio/devdeck/simulation/NetworkSimulation;)V +} + +public final class io/devdeck/simulation/NetworkSimulatorSettingsKt { + public static final fun NetworkSimulatorSettings (Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/storage/SharedPreferencesBrowserKt { + public static final fun SharedPreferencesBrowser (Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun SharedPreferencesFile (Ljava/lang/String;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/storage/StorageUsageKt { + public static final fun StorageUsage (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/tools/AnimationSpeedSwitchKt { + public static final fun AnimationSpeedSwitch (Landroidx/compose/ui/Modifier;Ljava/lang/String;FLandroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/tools/KeylineOverlaySwitchKt { + public static final fun KeylineOverlaySwitch (Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + +public final class io/devdeck/tools/UiTools { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/tools/UiTools; + public final fun getKeylineGridSize-D9Ej5fM ()F + public final fun getKeylines ()Ljava/util/List; + public final fun isAnimationSlowedDown ()Z + public final fun isKeylineOverlayEnabled ()Z + public final fun setAnimationsSlowedDown (ZF)Z + public static synthetic fun setAnimationsSlowedDown$default (Lio/devdeck/tools/UiTools;ZFILjava/lang/Object;)Z + public final fun setKeylineGridSize-0680j_4 (F)V + public final fun setKeylineOverlayEnabled (Z)V + public final fun setKeylines (Ljava/util/List;)V +} + +public final class io/devdeck/updates/AvailableUpdate { + public static final field $stable I + public fun (Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/Long; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;)Lio/devdeck/updates/AvailableUpdate; + public static synthetic fun copy$default (Lio/devdeck/updates/AvailableUpdate;Ljava/lang/String;Ljava/lang/Long;Ljava/lang/String;ILjava/lang/Object;)Lio/devdeck/updates/AvailableUpdate; + public fun equals (Ljava/lang/Object;)Z + public final fun getLabel ()Ljava/lang/String; + public final fun getReleaseNotes ()Ljava/lang/String; + public final fun getVersionCode ()Ljava/lang/Long; + public final fun getVersionName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/updates/UpdateChecker { + public static final field $stable I + public static final field DEFAULT_CHECK_INTERVAL_MILLIS J + public static final field INSTANCE Lio/devdeck/updates/UpdateChecker; + public final fun attach (Landroid/content/Context;Lio/devdeck/updates/UpdateSource;J)V + public static synthetic fun attach$default (Lio/devdeck/updates/UpdateChecker;Landroid/content/Context;Lio/devdeck/updates/UpdateSource;JILjava/lang/Object;)V + public final fun check ()V + public final fun checkIfDue ()V + public final fun getAvailableUpdate ()Lio/devdeck/updates/AvailableUpdate; + public final fun getBuilds ()Ljava/util/List; + public final fun getLastCheckedAtMillis ()Ljava/lang/Long; + public final fun getSource ()Lio/devdeck/updates/UpdateSource; + public final fun getStatus ()Lio/devdeck/updates/UpdateStatus; + public final fun install ()V + public final fun install (Lio/devdeck/updates/AvailableUpdate;)V + public final fun isSignedIn ()Z + public final fun signIn ()V + public final fun signOut ()V +} + +public abstract interface class io/devdeck/updates/UpdateSource { + public fun availableBuilds (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun checkForUpdate (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getBrowseBuildsUrl ()Ljava/lang/String; + public abstract fun getName ()Ljava/lang/String; + public fun getRequiresSignIn ()Z + public abstract fun install (Lio/devdeck/updates/AvailableUpdate;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun isSignedIn ()Z + public fun signIn (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun signOut ()V +} + +public final class io/devdeck/updates/UpdateSource$DefaultImpls { + public static fun availableBuilds (Lio/devdeck/updates/UpdateSource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun getBrowseBuildsUrl (Lio/devdeck/updates/UpdateSource;)Ljava/lang/String; + public static fun getRequiresSignIn (Lio/devdeck/updates/UpdateSource;)Z + public static fun isSignedIn (Lio/devdeck/updates/UpdateSource;)Z + public static fun signIn (Lio/devdeck/updates/UpdateSource;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun signOut (Lio/devdeck/updates/UpdateSource;)V +} + +public abstract interface class io/devdeck/updates/UpdateStatus { +} + +public final class io/devdeck/updates/UpdateStatus$Available : io/devdeck/updates/UpdateStatus { + public static final field $stable I + public fun (Lio/devdeck/updates/AvailableUpdate;)V + public final fun component1 ()Lio/devdeck/updates/AvailableUpdate; + public final fun copy (Lio/devdeck/updates/AvailableUpdate;)Lio/devdeck/updates/UpdateStatus$Available; + public static synthetic fun copy$default (Lio/devdeck/updates/UpdateStatus$Available;Lio/devdeck/updates/AvailableUpdate;ILjava/lang/Object;)Lio/devdeck/updates/UpdateStatus$Available; + public fun equals (Ljava/lang/Object;)Z + public final fun getUpdate ()Lio/devdeck/updates/AvailableUpdate; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/updates/UpdateStatus$Checking : io/devdeck/updates/UpdateStatus { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/updates/UpdateStatus$Checking; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/updates/UpdateStatus$Failed : io/devdeck/updates/UpdateStatus { + public static final field $stable I + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/devdeck/updates/UpdateStatus$Failed; + public static synthetic fun copy$default (Lio/devdeck/updates/UpdateStatus$Failed;Ljava/lang/String;ILjava/lang/Object;)Lio/devdeck/updates/UpdateStatus$Failed; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/updates/UpdateStatus$Idle : io/devdeck/updates/UpdateStatus { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/updates/UpdateStatus$Idle; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/updates/UpdateStatus$Installing : io/devdeck/updates/UpdateStatus { + public static final field $stable I + public fun (Lio/devdeck/updates/AvailableUpdate;Ljava/lang/Float;)V + public final fun component1 ()Lio/devdeck/updates/AvailableUpdate; + public final fun component2 ()Ljava/lang/Float; + public final fun copy (Lio/devdeck/updates/AvailableUpdate;Ljava/lang/Float;)Lio/devdeck/updates/UpdateStatus$Installing; + public static synthetic fun copy$default (Lio/devdeck/updates/UpdateStatus$Installing;Lio/devdeck/updates/AvailableUpdate;Ljava/lang/Float;ILjava/lang/Object;)Lio/devdeck/updates/UpdateStatus$Installing; + public fun equals (Ljava/lang/Object;)Z + public final fun getProgress ()Ljava/lang/Float; + public final fun getUpdate ()Lio/devdeck/updates/AvailableUpdate; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/updates/UpdateStatus$UpToDate : io/devdeck/updates/UpdateStatus { + public static final field $stable I + public static final field INSTANCE Lio/devdeck/updates/UpdateStatus$UpToDate; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/devdeck/updates/UpdatesPanelKt { + public static final fun UpdatesPanel (Landroidx/compose/ui/Modifier;Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V +} + diff --git a/devdeck/consumer-rules.pro b/devdeck/consumer-rules.pro index e69de29..b1d5c47 100644 --- a/devdeck/consumer-rules.pro +++ b/devdeck/consumer-rules.pro @@ -0,0 +1,2 @@ +# DevDeck needs no keep rules: it doesn't use reflection on its own classes, and its +# components (file provider, screen recording service) are kept through the manifest. diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt index 0aba93d..dce0264 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt @@ -195,7 +195,7 @@ private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) { if (update != null) add(HeaderChip("Update · ${update.label}", updateColor)) if (network.isActive) add(HeaderChip(network.summary, warning)) if (heldBytes > 0) add(HeaderChip("Holding ${mb(heldBytes)}", warning)) - if (MemorySimulator.killOnBackground) add(HeaderChip("Kill on background", warning)) + if (MemorySimulator.killOnBackground) add(HeaderChip("Kill when backgrounded", warning)) } if (chips.isNotEmpty()) { Row( diff --git a/devdeck/src/main/kotlin/io/devdeck/actions/AppActions.kt b/devdeck/src/main/kotlin/io/devdeck/actions/AppActions.kt index 9c66b47..25bab86 100644 --- a/devdeck/src/main/kotlin/io/devdeck/actions/AppActions.kt +++ b/devdeck/src/main/kotlin/io/devdeck/actions/AppActions.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import kotlin.system.exitProcess +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext public object AppActions { @@ -26,6 +28,11 @@ public object AppActions { context.externalCacheDir?.deleteRecursively() } + /** Same as [clearCache], but deletes on [Dispatchers.IO] so it is safe to call from the main thread. */ + public suspend fun clearCacheInBackground(context: Context) { + withContext(Dispatchers.IO) { clearCache(context) } + } + public fun clearAppData(context: Context) { (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).clearApplicationUserData() } diff --git a/devdeck/src/main/kotlin/io/devdeck/appearance/Appearance.kt b/devdeck/src/main/kotlin/io/devdeck/appearance/Appearance.kt index 4569988..1dc6b4c 100644 --- a/devdeck/src/main/kotlin/io/devdeck/appearance/Appearance.kt +++ b/devdeck/src/main/kotlin/io/devdeck/appearance/Appearance.kt @@ -2,6 +2,7 @@ package io.devdeck.appearance import android.app.LocaleManager import android.app.UiModeManager +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.os.Build @@ -90,10 +91,17 @@ public fun DisplaySettingsButton(modifier: Modifier = Modifier, title: String = ButtonItem( title = title, modifier = modifier, - onClick = { context.startActivity(Intent(Settings.ACTION_DISPLAY_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }, + onClick = { openDisplaySettings(context) }, ) } +private fun openDisplaySettings(context: Context) { + try { + context.startActivity(Intent(Settings.ACTION_DISPLAY_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (_: ActivityNotFoundException) { + } +} + @RequiresApi(Build.VERSION_CODES.TIRAMISU) private fun currentLanguage(context: Context): String = context.getSystemService(LocaleManager::class.java).applicationLocales.toLanguageTags().ifEmpty { SYSTEM_LANGUAGE } diff --git a/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt b/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt index a42eb54..d29cbc6 100644 --- a/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt +++ b/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt @@ -7,10 +7,13 @@ import android.content.res.Configuration import android.os.Bundle import android.os.Handler import android.os.Looper +import android.text.Editable +import android.text.InputType import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup +import android.widget.EditText import android.widget.TextView import io.devdeck.DeckVisibility import io.devdeck.gesture.inputObserver @@ -137,13 +140,34 @@ internal object BreadcrumbCollector { } private fun describe(view: View): String { - val text = (view as? TextView)?.text?.toString()?.takeIf { it.isNotBlank() } + val text = (view as? TextView)?.let { textView -> + val editable = textView is EditText || textView.text is Editable || textView.onCheckIsTextEditor() + recordableText(textView.text, editable, textView.inputType) + } val description = view.contentDescription?.toString()?.takeIf { it.isNotBlank() } val id = if (view.id != View.NO_ID) runCatching { view.resources.getResourceEntryName(view.id) }.getOrNull() else null val name = text?.let { "\"$it\"" } ?: description?.let { "\"$it\"" } ?: id ?: "unnamed" return "${view.javaClass.simpleName} $name" + if (id != null && (text != null || description != null)) " (#$id)" else "" } + // Typed input (EditText, editable or password fields) is never recorded; only static labels are. + internal fun recordableText(text: CharSequence?, editable: Boolean, inputType: Int): String? { + if (editable || isPasswordInputType(inputType)) return null + return text?.toString()?.takeIf { it.isNotBlank() } + } + + private fun isPasswordInputType(inputType: Int): Boolean { + val variation = inputType and (InputType.TYPE_MASK_CLASS or InputType.TYPE_MASK_VARIATION) + return variation in PASSWORD_VARIATIONS + } + + private val PASSWORD_VARIATIONS = setOf( + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD, + InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD, + ) + private fun View.contains(x: Int, y: Int): Boolean { val location = IntArray(2) getLocationOnScreen(location) diff --git a/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbList.kt b/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbList.kt index f84e393..47c7b2b 100644 --- a/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbList.kt +++ b/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbList.kt @@ -26,12 +26,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.devdeck.components.ConfirmDeleteButton import io.devdeck.components.FilterPill import io.devdeck.components.FilterPillRow import io.devdeck.components.InfoGroup import io.devdeck.components.TextItem import io.devdeck.internal.DeckIcons +import io.devdeck.internal.DeckPalette +import io.devdeck.internal.isDeckDark import io.devdeck.internal.shareFiles import java.text.SimpleDateFormat import java.util.Date @@ -44,41 +48,40 @@ public fun BreadcrumbList(modifier: Modifier = Modifier, maxRendered: Int = 200) var filter by rememberSaveable { mutableStateOf(null) } val visible = remember(all, filter) { all.filter { filter == null || it.type == filter }.asReversed() } val formatter = remember { SimpleDateFormat("HH:mm:ss", Locale.US) } + val isDark = isDeckDark() Column(modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { Text( text = if (visible.size == 1) "1 step" else "${visible.size} steps", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) IconButton( onClick = { context.shareFiles(emptyList(), text = Breadcrumbs.format(visible.asReversed()), subject = "Breadcrumbs") }, enabled = visible.isNotEmpty(), ) { Icon(DeckIcons.Share, contentDescription = "Share breadcrumbs") } - IconButton(onClick = Breadcrumbs::clear, enabled = all.isNotEmpty()) { - Icon( - DeckIcons.Delete, - contentDescription = "Clear breadcrumbs", - tint = MaterialTheme.colorScheme.error.copy(alpha = if (all.isEmpty()) 0.38f else 1f), - ) - } + ConfirmDeleteButton(contentDescription = "Clear breadcrumbs", onConfirm = Breadcrumbs::clear, enabled = all.isNotEmpty()) } FilterPillRow { FilterPill(text = "All", selected = filter == null, onClick = { filter = null }) BreadcrumbType.entries.forEach { type -> - FilterPill(text = type.label, selected = filter == type, onClick = { filter = type }, color = type.color, showDot = true) + FilterPill(text = type.label, selected = filter == type, onClick = { + filter = type + }, color = type.color(isDark), showDot = true) } } if (visible.isEmpty()) TextItem("Nothing recorded yet. Open screens, tap around or send requests.") InfoGroup(dividerThreshold = 2) { - visible.take(maxRendered).forEach { BreadcrumbRow(it, formatter.format(Date(it.timestampMillis))) } + visible.take(maxRendered).forEach { BreadcrumbRow(it, formatter.format(Date(it.timestampMillis)), it.type.color(isDark)) } } } } @Composable -private fun BreadcrumbRow(crumb: Breadcrumb, time: String) { +private fun BreadcrumbRow(crumb: Breadcrumb, time: String, color: Color) { Row( Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.Top, @@ -90,19 +93,18 @@ private fun BreadcrumbRow(crumb: Breadcrumb, time: String) { color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 2.dp), ) - Box(Modifier.padding(top = 5.dp).size(8.dp).background(crumb.type.color, CircleShape)) + Box(Modifier.padding(top = 5.dp).size(8.dp).background(color, CircleShape)) Column(Modifier.weight(1f)) { - Text(crumb.type.label, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = crumb.type.color) + Text(crumb.type.label, style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, color = color) Text(crumb.message, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) } } } -internal val BreadcrumbType.color: Color - get() = when (this) { - BreadcrumbType.Screen -> Color(0xFF1E88E5) - BreadcrumbType.Tap -> Color(0xFF8E24AA) - BreadcrumbType.Network -> Color(0xFF00897B) - BreadcrumbType.App -> Color(0xFFF57C00) - BreadcrumbType.Custom -> Color(0xFF757575) - } +internal fun BreadcrumbType.color(isDark: Boolean): Color = when (this) { + BreadcrumbType.Screen -> DeckPalette.Blue + BreadcrumbType.Tap -> DeckPalette.Purple + BreadcrumbType.Network -> DeckPalette.Teal + BreadcrumbType.App -> DeckPalette.Orange + BreadcrumbType.Custom -> DeckPalette.Grey +}.pick(isDark) diff --git a/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt b/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt index 9f53a0a..1be0a52 100644 --- a/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt +++ b/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt @@ -15,7 +15,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -41,6 +43,9 @@ import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable public fun BugReportScreen(modifier: Modifier = Modifier, buildInfo: () -> Map = { emptyMap() }) { @@ -48,7 +53,10 @@ public fun BugReportScreen(modifier: Modifier = Modifier, buildInfo: () -> Map(), context, ScreenCapture.galleryVersion) { + value = withContext(Dispatchers.IO) { ScreenCapture.captures(context) } + } val selected = remember { mutableStateListOf() } Column(modifier.fillMaxWidth()) { @@ -84,12 +92,20 @@ public fun BugReportScreen(modifier: Modifier = Modifier, buildInfo: () -> Map val payload = entry.payload?.let { "\n $it" }.orEmpty() "${formatter.format(Date(entry.timestampMillis))} [${entry.tag}] ${entry.message}$payload" } val logcat = LogcatReader.buffer.lines.value.takeLast(LOGCAT_LINES_IN_REPORT) - file.writeText( - buildString { + return ReportFiles.write( + context, + name = "logs_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.txt", + text = buildString { appendLine("=== Recent steps ===") appendLine(Breadcrumbs.format(Breadcrumbs.recent(STEPS_IN_REPORT)).ifEmpty { "(none)" }) appendLine() @@ -133,7 +149,6 @@ private fun writeLogFile(context: Context): File { } }, ) - return file } private const val LOGCAT_LINES_IN_REPORT = 1_000 diff --git a/devdeck/src/main/kotlin/io/devdeck/bugreport/ReportFiles.kt b/devdeck/src/main/kotlin/io/devdeck/bugreport/ReportFiles.kt new file mode 100644 index 0000000..4dc3a9b --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/bugreport/ReportFiles.kt @@ -0,0 +1,23 @@ +package io.devdeck.bugreport + +import android.content.Context +import java.io.File + +/** Exported reports and log files live in the app cache and only the newest few are kept. */ +internal object ReportFiles { + + fun write(context: Context, name: String, text: String): File { + val directory = File(context.cacheDir, DIRECTORY).apply { mkdirs() } + val file = File(directory, name) + file.writeText(text) + directory.listFiles() + .orEmpty() + .sortedByDescending { it.lastModified() } + .drop(MAX_FILES) + .forEach { if (it != file) it.delete() } + return file + } + + private const val DIRECTORY = "devdeck-reports" + private const val MAX_FILES = 20 +} diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt index 4ac56e9..d380a62 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt @@ -13,14 +13,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.produceState -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -28,15 +29,21 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.devdeck.LocalDeckState import io.devdeck.components.BulletItem +import io.devdeck.components.ConfirmDeleteButton +import io.devdeck.components.InfoGroup import io.devdeck.components.TextItem +import io.devdeck.internal.DeckIcons import io.devdeck.internal.openFile import io.devdeck.internal.shareFiles import java.io.File import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @Composable @@ -48,15 +55,46 @@ public fun GalleryButton(title: String = "Open the gallery") { @Composable public fun CaptureGallery(modifier: Modifier = Modifier) { val context = LocalContext.current - val files = remember(ScreenCapture.galleryVersion) { ScreenCapture.captures(context) } + val scope = rememberCoroutineScope() + val loaded by produceState?>(null, ScreenCapture.galleryVersion) { + value = withContext(Dispatchers.IO) { ScreenCapture.captures(context) } + } + val files = loaded.orEmpty() + val delete: (List) -> Unit = { targets -> + scope.launch { + withContext(Dispatchers.IO) { targets.forEach { it.delete() } } + ScreenCapture.notifyGalleryChanged() + } + } Column(modifier.fillMaxWidth()) { - if (files.isEmpty()) TextItem("No screenshots or recordings yet") - files.forEach { file -> GalleryRow(file) } + Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + text = if (files.size == 1) "1 capture" else "${files.size} captures", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { context.shareFiles(files, subject = "Captures") }, enabled = files.isNotEmpty()) { + Icon(DeckIcons.Share, contentDescription = "Share all captures") + } + ConfirmDeleteButton( + contentDescription = "Delete all captures", + onConfirm = { delete(files) }, + enabled = files.isNotEmpty(), + confirmText = "Tap again to delete", + ) + } + if (loaded?.isEmpty() == true) TextItem("No screenshots or recordings yet") + InfoGroup(dividerThreshold = 2) { + files.forEach { file -> key(file.path) { GalleryRow(file, onDelete = { delete(listOf(file)) }) } } + } } } @Composable -private fun GalleryRow(file: File) { +private fun GalleryRow(file: File, onDelete: () -> Unit) { val context = LocalContext.current val thumbnail by produceState(null, file) { value = withContext(Dispatchers.IO) { loadThumbnail(file) } @@ -64,8 +102,8 @@ private fun GalleryRow(file: File) { Row( Modifier .fillMaxWidth() - .clickable { context.openFile(file) } - .padding(horizontal = 16.dp, vertical = 8.dp), + .clickable(role = Role.Button, onClickLabel = "Open") { context.openFile(file) } + .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -80,18 +118,25 @@ private fun GalleryRow(file: File) { if (image != null) { Image(image, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(56.dp)) } else { - Text(if (file.extension == "mp4") "▶" else "…", style = MaterialTheme.typography.titleLarge) + Text( + if (file.extension == "mp4") "▶" else "…", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.clearAndSetSemantics {}, + ) } } - Column(Modifier.weight(1f)) { - Text(file.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) - Row { - TextButton(onClick = { context.shareFiles(listOf(file)) }) { Text("Share") } - TextButton(onClick = { - file.delete() - ScreenCapture.notifyGalleryChanged() - }) { Text("Delete", color = MaterialTheme.colorScheme.error) } + Text( + file.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Row { + IconButton(onClick = { context.shareFiles(listOf(file)) }) { + Icon(DeckIcons.Share, contentDescription = "Share ${file.name}") } + ConfirmDeleteButton(contentDescription = "Delete ${file.name}", onConfirm = onDelete, confirmText = "Tap again to delete") } } } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt index de0c5a3..f2586a7 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt @@ -17,11 +17,15 @@ import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.graphics.createBitmap import androidx.core.view.drawToBitmap +import io.devdeck.logs.DeckLogger import java.io.File +import java.io.IOException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.concurrent.atomic.AtomicInteger import kotlin.coroutines.resume +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -35,7 +39,13 @@ public object ScreenCapture { internal var galleryVersion by mutableIntStateOf(0) - internal val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val captureCounter = AtomicInteger() + + internal val scope = CoroutineScope( + SupervisorJob() + Dispatchers.Main.immediate + CoroutineExceptionHandler { _, error -> + DeckLogger.log("Capture failed", tag = "Capture", payload = error.toString()) + }, + ) public fun captureDirectory(context: Context): File { val base = context.getExternalFilesDir(null) ?: context.filesDir @@ -50,8 +60,11 @@ public object ScreenCapture { public fun uriFor(context: Context, file: File): Uri = FileProvider.getUriForFile(context, "${context.packageName}.devdeck.files", file) internal fun newCaptureFile(context: Context, prefix: String, extension: String): File { - val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) - return File(captureDirectory(context), "${prefix}_$stamp.$extension") + val stamp = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) + val directory = captureDirectory(context) + var file = File(directory, "${prefix}_$stamp.$extension") + while (file.exists()) file = File(directory, "${prefix}_${stamp}_${captureCounter.incrementAndGet()}.$extension") + return file } internal fun notifyGalleryChanged() { @@ -61,9 +74,15 @@ public object ScreenCapture { public suspend fun takeScreenshot(activity: Activity): File? { val bitmap = captureWindow(activity) ?: return null val file = newCaptureFile(activity, "screenshot", "png") - withContext(Dispatchers.IO) { - file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + val written = withContext(Dispatchers.IO) { + try { + file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + } catch (_: IOException) { + file.delete() + false + } } + if (!written) return null notifyGalleryChanged() return file } @@ -73,8 +92,12 @@ public object ScreenCapture { if (decorView.width == 0 || decorView.height == 0) return null if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return runCatching { decorView.drawToBitmap() }.getOrNull() val bitmap = createBitmap(decorView.width, decorView.height) - val result = suspendCancellableCoroutine { continuation -> - PixelCopy.request(activity.window, bitmap, { continuation.resume(it) }, Handler(Looper.getMainLooper())) + val result = try { + suspendCancellableCoroutine { continuation -> + PixelCopy.request(activity.window, bitmap, { continuation.resume(it) }, Handler(Looper.getMainLooper())) + } + } catch (_: IllegalArgumentException) { + return null } return if (result == PixelCopy.SUCCESS) bitmap else null } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt index 55b884a..6790106 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt @@ -40,14 +40,17 @@ public class ScreenRecordingService : Service() { ACTION_START -> { val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, Activity.RESULT_CANCELED) val data = IntentCompat.getParcelableExtra(intent, EXTRA_DATA, Intent::class.java) - if (data == null || projection != null) { - if (projection == null) { + when { + // Already recording: satisfy the foreground-service contract and keep going. + projection != null -> startInForeground() + + data == null -> { startInForeground() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() } - stopSelf() - } else { - start(resultCode, data) + + else -> start(resultCode, data) } } diff --git a/devdeck/src/main/kotlin/io/devdeck/components/ActionPill.kt b/devdeck/src/main/kotlin/io/devdeck/components/ActionPill.kt index c1e90b0..4ecbe90 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/ActionPill.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/ActionPill.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -38,6 +39,7 @@ public fun ActionPill( fontWeight = FontWeight.SemiBold, color = accent, modifier = modifier + .minimumInteractiveComponentSize() .alpha(if (enabled) 1f else 0.4f) .clip(CircleShape) .background(accent.copy(alpha = 0.12f)) @@ -51,8 +53,7 @@ public fun ActionPill( @Composable public fun ActionPillRow(modifier: Modifier = Modifier, content: @Composable () -> Unit) { FlowRow( - modifier.fillMaxWidth().padding(horizontal = HorizontalPadding, vertical = 8.dp), + modifier.fillMaxWidth().padding(horizontal = HorizontalPadding, vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), ) { content() } } diff --git a/devdeck/src/main/kotlin/io/devdeck/components/BulletItem.kt b/devdeck/src/main/kotlin/io/devdeck/components/BulletItem.kt index 87cb9bb..0e52390 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/BulletItem.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/BulletItem.kt @@ -76,7 +76,7 @@ private fun InfoRow( modifier .fillMaxWidth() .then(if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier) - .heightIn(min = 36.dp) + .heightIn(min = if (onClick != null) 48.dp else 36.dp) .padding(horizontal = HorizontalPadding, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = horizontalArrangement, diff --git a/devdeck/src/main/kotlin/io/devdeck/components/CodeBlock.kt b/devdeck/src/main/kotlin/io/devdeck/components/CodeBlock.kt index 9b599e3..843f681 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/CodeBlock.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/CodeBlock.kt @@ -152,7 +152,7 @@ public fun CodeBlock( @Composable private fun ToolbarIcon(icon: ImageVector, description: String, onClick: () -> Unit) { - IconButton(onClick = onClick, modifier = Modifier.size(36.dp)) { + IconButton(onClick = onClick) { Icon(icon, contentDescription = description, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) } } diff --git a/devdeck/src/main/kotlin/io/devdeck/components/ConfirmDeleteButton.kt b/devdeck/src/main/kotlin/io/devdeck/components/ConfirmDeleteButton.kt new file mode 100644 index 0000000..508e9ef --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/components/ConfirmDeleteButton.kt @@ -0,0 +1,79 @@ +package io.devdeck.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.devdeck.internal.DeckIcons +import kotlinx.coroutines.delay + +/** A delete icon button that asks for a second tap within a few seconds before running [onConfirm]. */ +@Composable +internal fun ConfirmDeleteButton( + contentDescription: String, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + confirmText: String = "Tap again to clear", +) { + var armed by remember { mutableStateOf(false) } + LaunchedEffect(armed, enabled) { + if (armed && enabled) delay(CONFIRM_WINDOW_MILLIS) + armed = false + } + val error = MaterialTheme.colorScheme.error + Box( + modifier + .heightIn(min = 48.dp) + .widthIn(min = 48.dp) + .clip(CircleShape) + .clickable(enabled = enabled, role = Role.Button, onClickLabel = if (armed) confirmText else null) { + if (armed) { + armed = false + onConfirm() + } else { + armed = true + } + } + .semantics { if (!armed) this.contentDescription = contentDescription }, + contentAlignment = Alignment.Center, + ) { + if (armed) { + Text( + confirmText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = error, + maxLines = 1, + modifier = Modifier + .padding(horizontal = 4.dp) + .background(error.copy(alpha = 0.12f), CircleShape) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) + } else { + Icon(DeckIcons.Delete, contentDescription = null, tint = error.copy(alpha = if (enabled) 1f else 0.38f)) + } + } +} + +private const val CONFIRM_WINDOW_MILLIS = 3_000L diff --git a/devdeck/src/main/kotlin/io/devdeck/components/FilterPill.kt b/devdeck/src/main/kotlin/io/devdeck/components/FilterPill.kt index 4eae48d..a8abe48 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/FilterPill.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/FilterPill.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -26,6 +27,7 @@ import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import io.devdeck.internal.contentColorOn @Composable public fun FilterPill( @@ -38,9 +40,10 @@ public fun FilterPill( ) { val accent = if (color.isSpecified) color else MaterialTheme.colorScheme.primary val background by animateColorAsState(if (selected) accent else accent.copy(alpha = 0.12f), label = "pillBackground") - val content by animateColorAsState(if (selected) Color.White else accent, label = "pillContent") + val content by animateColorAsState(if (selected) contentColorOn(accent) else accent, label = "pillContent") Row( modifier + .minimumInteractiveComponentSize() .clip(CircleShape) .background(background) .selectable(selected = selected, role = Role.RadioButton, onClick = onClick) @@ -64,7 +67,7 @@ public fun FilterPillRow( modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 12.dp), + .padding(start = 16.dp, end = 16.dp, bottom = 4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = verticalAlignment, content = content, diff --git a/devdeck/src/main/kotlin/io/devdeck/components/InputField.kt b/devdeck/src/main/kotlin/io/devdeck/components/InputField.kt index 73283c7..29bd5f9 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/InputField.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/InputField.kt @@ -36,6 +36,7 @@ internal fun InputField( monospace: Boolean = false, validate: ((String) -> String?)? = null, format: ((String) -> String?)? = null, + subtitle: String? = null, ) { var draft by rememberSaveable(value) { mutableStateOf(value) } val error = validate?.invoke(draft) @@ -57,7 +58,7 @@ internal fun InputField( minLines = if (singleLine) 1 else 3, maxLines = if (singleLine) 1 else 10, isError = error != null, - supportingText = error?.let { { Text(it) } }, + supportingText = (error ?: subtitle)?.let { { Text(it) } }, textStyle = if (monospace) { MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) } else { diff --git a/devdeck/src/main/kotlin/io/devdeck/components/JsonInputItem.kt b/devdeck/src/main/kotlin/io/devdeck/components/JsonInputItem.kt index 83b1448..b5b7996 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/JsonInputItem.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/JsonInputItem.kt @@ -22,6 +22,7 @@ public fun JsonInputItem( modifier: Modifier = Modifier, enabled: Boolean = true, previewLines: Int = 6, + subtitle: String? = null, ) { val drawerState = LocalDeckState.current val error = remember(value) { Json.error(value) } @@ -38,7 +39,7 @@ public fun JsonInputItem( Column(Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.bodyLarge) Text( - text = error ?: "${value.length} chars", + text = error ?: listOfNotNull(subtitle, "${value.length} chars").joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = if (error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -54,3 +55,16 @@ public fun JsonInputItem( ) } } + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@Composable +public fun JsonInputItem( + title: String, + value: String, + onValueCommit: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + previewLines: Int = 6, +) { + JsonInputItem(title, value, onValueCommit, modifier, enabled, previewLines, subtitle = null) +} diff --git a/devdeck/src/main/kotlin/io/devdeck/components/NumberInputItem.kt b/devdeck/src/main/kotlin/io/devdeck/components/NumberInputItem.kt index 0ceb0bb..92077d7 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/NumberInputItem.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/NumberInputItem.kt @@ -13,6 +13,7 @@ public fun NumberInputItem( enabled: Boolean = true, allowDecimals: Boolean = true, range: ClosedFloatingPointRange? = null, + subtitle: String? = null, ) { val text = if (allowDecimals) value.toString().removeSuffix(".0") else value.toLong().toString() InputField( @@ -32,6 +33,7 @@ public fun NumberInputItem( else -> null } }, + subtitle = subtitle, ) } @@ -43,6 +45,7 @@ public fun NumberInputItem( modifier: Modifier = Modifier, enabled: Boolean = true, range: LongRange? = null, + subtitle: String? = null, ) { NumberInputItem( title = title, @@ -52,5 +55,33 @@ public fun NumberInputItem( enabled = enabled, allowDecimals = false, range = range?.let { it.first.toDouble()..it.last.toDouble() }, + subtitle = subtitle, ) } + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@Composable +public fun NumberInputItem( + title: String, + value: Double, + onValueCommit: (Double) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + allowDecimals: Boolean = true, + range: ClosedFloatingPointRange? = null, +) { + NumberInputItem(title, value, onValueCommit, modifier, enabled, allowDecimals, range, subtitle = null) +} + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@Composable +public fun NumberInputItem( + title: String, + value: Long, + onValueCommit: (Long) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + range: LongRange? = null, +) { + NumberInputItem(title, value, onValueCommit, modifier, enabled, range, subtitle = null) +} diff --git a/devdeck/src/main/kotlin/io/devdeck/components/Pill.kt b/devdeck/src/main/kotlin/io/devdeck/components/Pill.kt index d1ffff8..bd450a7 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/Pill.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/Pill.kt @@ -11,7 +11,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.devdeck.internal.contentColorOn @Composable internal fun Pill(text: String, color: Color, filled: Boolean = true) { @@ -20,7 +22,9 @@ internal fun Pill(text: String, color: Color, filled: Boolean = true) { text = text, style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), fontWeight = FontWeight.Bold, - color = if (filled) Color.White else color, + color = if (filled) contentColorOn(color) else color, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier .then(if (filled) Modifier.background(color, shape) else Modifier.border(1.dp, color, shape)) .padding(horizontal = 8.dp, vertical = 2.dp), diff --git a/devdeck/src/main/kotlin/io/devdeck/components/TextInputItem.kt b/devdeck/src/main/kotlin/io/devdeck/components/TextInputItem.kt index f1765e2..cda827a 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/TextInputItem.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/TextInputItem.kt @@ -16,6 +16,7 @@ public fun TextInputItem( commitOnEveryChange: Boolean = false, singleLine: Boolean = true, validate: ((String) -> String?)? = null, + subtitle: String? = null, ) { InputField( title = title, @@ -27,5 +28,22 @@ public fun TextInputItem( commitOnEveryChange = commitOnEveryChange, singleLine = singleLine, validate = validate, + subtitle = subtitle, ) } + +@Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) +@Composable +public fun TextInputItem( + title: String, + value: String, + onValueCommit: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + keyboardType: KeyboardType = KeyboardType.Text, + commitOnEveryChange: Boolean = false, + singleLine: Boolean = true, + validate: ((String) -> String?)? = null, +) { + TextInputItem(title, value, onValueCommit, modifier, enabled, keyboardType, commitOnEveryChange, singleLine, validate, subtitle = null) +} diff --git a/devdeck/src/main/kotlin/io/devdeck/components/TitleAndSubtitle.kt b/devdeck/src/main/kotlin/io/devdeck/components/TitleAndSubtitle.kt index 66ac920..2485e39 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/TitleAndSubtitle.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/TitleAndSubtitle.kt @@ -6,17 +6,26 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.text.style.TextOverflow @Composable internal fun TitleAndSubtitle(title: String, subtitle: String?, enabled: Boolean, modifier: Modifier = Modifier) { val alpha = if (enabled) 1f else 0.38f Column(modifier) { - Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha)) + Text( + title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) if (subtitle != null) { Text( subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha), + maxLines = 2, + overflow = TextOverflow.Ellipsis, ) } } diff --git a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleBuffer.kt b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleBuffer.kt index 5551c0c..8aed61e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleBuffer.kt +++ b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleBuffer.kt @@ -15,6 +15,7 @@ public class ConsoleBuffer(public val capacity: Int = 5_000) { private val pending = ArrayList() private var flushScheduled = false + private var generation = 0L public fun append(line: ConsoleLine) { synchronized(pending) { @@ -26,18 +27,30 @@ public class ConsoleBuffer(public val capacity: Int = 5_000) { } public fun clear() { - synchronized(pending) { pending.clear() } - _lines.value = emptyList() + synchronized(pending) { + generation++ + pending.clear() + _lines.value = emptyList() + } } - private fun flush() { - val batch = synchronized(pending) { - flushScheduled = false - pending.toList().also { pending.clear() } + private fun flush() = publish(takeBatch()) + + internal fun takeBatch(): Batch = synchronized(pending) { + flushScheduled = false + Batch(generation, pending.toList().also { pending.clear() }) + } + + // A batch taken before clear() belongs to the old generation and is dropped. + internal fun publish(batch: Batch) { + if (batch.lines.isEmpty()) return + synchronized(pending) { + if (batch.generation == generation) _lines.update { (it + batch.lines).takeLast(capacity) } } - if (batch.isNotEmpty()) _lines.update { (it + batch).takeLast(capacity) } } + internal class Batch(val generation: Long, val lines: List) + private companion object { const val FLUSH_DELAY_MILLIS = 250L diff --git a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleLevel.kt b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleLevel.kt index b88a862..6e50c6b 100644 --- a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleLevel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleLevel.kt @@ -2,16 +2,23 @@ package io.devdeck.console import android.util.Log import androidx.compose.ui.graphics.Color +import io.devdeck.internal.DeckPalette +import io.devdeck.internal.Tone -public enum class ConsoleLevel(public val letter: Char, public val label: String, internal val color: Color) { - Verbose('V', "Verbose", Color(0xFF9E9E9E)), - Debug('D', "Debug", Color(0xFF1E88E5)), - Info('I', "Info", Color(0xFF2E7D32)), - Warn('W', "Warn", Color(0xFFF57C00)), - Error('E', "Error", Color(0xFFD32F2F)), - Assert('A', "Assert", Color(0xFF8E24AA)), +public enum class ConsoleLevel(public val letter: Char, public val label: String, private val tone: Tone) { + Verbose('V', "Verbose", DeckPalette.Grey), + Debug('D', "Debug", DeckPalette.Blue), + Info('I', "Info", DeckPalette.Green), + Warn('W', "Warn", DeckPalette.Orange), + Error('E', "Error", DeckPalette.Red), + Assert('A', "Assert", DeckPalette.Purple), ; + /** Light-theme color, kept for existing callers. */ + internal val color: Color get() = tone.light + + internal fun color(isDark: Boolean): Color = tone.pick(isDark) + public companion object { public fun fromPriority(priority: Int): ConsoleLevel = when (priority) { Log.VERBOSE -> Verbose diff --git a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleLineRow.kt b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleLineRow.kt index b657e26..bbc5277 100644 --- a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleLineRow.kt +++ b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleLineRow.kt @@ -11,28 +11,43 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.devdeck.components.Pill +import io.devdeck.internal.isDeckDark @Composable internal fun ConsoleLineRow(line: ConsoleLine, showTag: Boolean) { var expanded by rememberSaveable(line.id) { mutableStateOf(false) } + var overflows by remember(line.id) { mutableStateOf(false) } + val expandable = expanded || overflows + val isDark = isDeckDark() Column( Modifier .fillMaxWidth() - .clickable { expanded = !expanded } + .then( + if (expandable) { + Modifier + .clickable(onClickLabel = if (expanded) "Collapse" else "Expand") { expanded = !expanded } + .semantics { stateDescription = if (expanded) "Expanded" else "Collapsed" } + } else { + Modifier + }, + ) .padding(horizontal = 16.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - Pill(line.level.letter.toString(), line.level.color) + Pill(line.level.letter.toString(), line.level.color(isDark)) Text( ConsoleFormat.time(line.timestampMillis), style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), @@ -51,9 +66,10 @@ internal fun ConsoleLineRow(line: ConsoleLine, showTag: Boolean) { Text( line.message, style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), - color = if (line.level >= ConsoleLevel.Error) line.level.color else MaterialTheme.colorScheme.onSurface, + color = if (line.level >= ConsoleLevel.Error) line.level.color(isDark) else MaterialTheme.colorScheme.onSurface, maxLines = if (expanded) Int.MAX_VALUE else COLLAPSED_LINES, overflow = TextOverflow.Ellipsis, + onTextLayout = { if (!expanded) overflows = it.hasVisualOverflow }, ) } } diff --git a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleTagRow.kt b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleTagRow.kt index 94e776d..93ce940 100644 --- a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleTagRow.kt +++ b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleTagRow.kt @@ -3,24 +3,26 @@ package io.devdeck.console import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.devdeck.components.Pill -import io.devdeck.network.CallCountColor +import io.devdeck.internal.isDeckDark +import io.devdeck.internal.plural +import io.devdeck.network.NetworkColors @Composable internal fun ConsoleTagRow(tag: String, lines: List, onClick: () -> Unit) { val errors = lines.count { it.level >= ConsoleLevel.Error } val warnings = lines.count { it.level == ConsoleLevel.Warn } + val isDark = isDeckDark() Column( Modifier .fillMaxWidth() @@ -28,10 +30,10 @@ internal fun ConsoleTagRow(tag: String, lines: List, onClick: () -> .padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(3.dp), ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - Pill("${lines.size} ${if (lines.size == 1) "line" else "lines"}", CallCountColor) - if (errors > 0) Pill("$errors E", ConsoleLevel.Error.color) - if (warnings > 0) Pill("$warnings W", ConsoleLevel.Warn.color) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Pill(plural(lines.size, "line"), NetworkColors.callCount(isDark)) + if (errors > 0) Pill("$errors E", ConsoleLevel.Error.color(isDark)) + if (warnings > 0) Pill("$warnings W", ConsoleLevel.Warn.color(isDark)) } Text( tag, diff --git a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleViewer.kt b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleViewer.kt index 8ad00dd..2ff445f 100644 --- a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleViewer.kt +++ b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleViewer.kt @@ -12,17 +12,21 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.devdeck.LocalDeckState -import io.devdeck.capture.ScreenCapture +import io.devdeck.bugreport.ReportFiles +import io.devdeck.components.ConfirmDeleteButton import io.devdeck.components.FilterPill import io.devdeck.components.FilterPillRow import io.devdeck.components.InfoGroup @@ -30,8 +34,13 @@ import io.devdeck.components.SearchField import io.devdeck.components.SegmentedPills import io.devdeck.components.TextItem import io.devdeck.internal.DeckIcons +import io.devdeck.internal.isDeckDark +import io.devdeck.internal.plural import io.devdeck.internal.shareFiles import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext public enum class ConsoleView(public val label: String) { All("All"), ByTag("By tag") } @@ -53,52 +62,64 @@ public fun ConsoleViewer( var minLevel by rememberSaveable { mutableStateOf(ConsoleLevel.Verbose) } var view by rememberSaveable { mutableStateOf(if (fixedTag != null) ConsoleView.All else initialView) } + val scope = rememberCoroutineScope() + val isDark = isDeckDark() + val visible = remember(source, query, minLevel, fixedTag) { + val needle = query.trim() source.filter { line -> line.level >= minLevel && (fixedTag == null || line.tag == fixedTag) && - ( - query.isBlank() || - line.tag.contains(query.trim(), ignoreCase = true) || - line.message.contains(query.trim(), ignoreCase = true) - ) + (needle.isEmpty() || line.tag.contains(needle, ignoreCase = true) || line.message.contains(needle, ignoreCase = true)) } } val errors = remember(visible) { visible.count { it.level >= ConsoleLevel.Error } } + val groups = remember(visible, view) { + if (view == + ConsoleView.ByTag + ) { + visible.groupBy { it.tag }.entries.sortedByDescending { it.value.last().timestampMillis } + } else { + emptyList() + } + } Column(modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { Text( - "${visible.size} lines · $errors errors${if (frozen != null) " · paused" else ""}", + "${plural(visible.size, "line")} · ${plural(errors, "error")}${if (frozen != null) " · paused" else ""}", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) IconButton(onClick = { frozen = if (frozen == null) live else null }) { Icon( - if (frozen == - null - ) { - DeckIcons.Pause - } else { - DeckIcons.PlayArrow - }, + if (frozen == null) DeckIcons.Pause else DeckIcons.PlayArrow, contentDescription = if (frozen == null) "Pause" else "Resume", ) } - IconButton(onClick = { shareLines(context, title, visible) }, enabled = visible.isNotEmpty()) { - Icon(DeckIcons.Share, contentDescription = "Share") - } - IconButton(onClick = { - buffer.clear() - frozen = null - }, enabled = source.isNotEmpty()) { - Icon( - DeckIcons.Delete, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.error.copy(alpha = if (source.isEmpty()) 0.38f else 1f), - ) + IconButton( + onClick = { + val lines = visible + scope.launch { + val file = withContext(Dispatchers.IO) { writeLines(context, title, lines) } + context.shareFiles(listOf(file), subject = title) + } + }, + enabled = visible.isNotEmpty(), + ) { + Icon(DeckIcons.Share, contentDescription = "Share logs") } + ConfirmDeleteButton( + contentDescription = "Clear logs", + onConfirm = { + buffer.clear() + frozen = null + }, + enabled = source.isNotEmpty(), + ) } if (fixedTag == null) { SegmentedPills( @@ -117,7 +138,7 @@ public fun ConsoleViewer( text = if (isAll) "All" else "${level.label}+", selected = minLevel == level, onClick = { minLevel = level }, - color = if (isAll) Color.Unspecified else level.color, + color = if (isAll) Color.Unspecified else level.color(isDark), showDot = !isAll, ) } @@ -126,11 +147,12 @@ public fun ConsoleViewer( visible.isEmpty() -> TextItem(if (source.isEmpty()) "Nothing logged yet" else "No lines match") view == ConsoleView.ByTag -> { - val groups = visible.groupBy { it.tag }.entries.sortedByDescending { it.value.last().timestampMillis } InfoGroup(dividerThreshold = LIST_DIVIDER_THRESHOLD) { groups.take(maxRendered).forEach { (tag, lines) -> - ConsoleTagRow(tag, lines) { - deckState?.navigateTo(tag) { ConsoleViewer(buffer, title = "$title · $tag", fixedTag = tag) } + key(tag) { + ConsoleTagRow(tag, lines) { + deckState?.navigateTo(tag) { ConsoleViewer(buffer, title = "$title · $tag", fixedTag = tag) } + } } } } @@ -138,11 +160,11 @@ public fun ConsoleViewer( else -> { InfoGroup(dividerThreshold = LIST_DIVIDER_THRESHOLD) { - visible.asReversed().take(maxRendered).forEach { line -> ConsoleLineRow(line, showTag = fixedTag == null) } + visible.asReversed().take(maxRendered).forEach { line -> + key(line.id) { ConsoleLineRow(line, showTag = fixedTag == null) } + } } - if (visible.size > - maxRendered - ) { + if (visible.size > maxRendered) { TextItem("Showing the latest $maxRendered of ${visible.size} lines. Search or filter to narrow down.") } } @@ -152,11 +174,10 @@ public fun ConsoleViewer( private val LEVEL_FILTERS = listOf(ConsoleLevel.Verbose, ConsoleLevel.Debug, ConsoleLevel.Info, ConsoleLevel.Warn, ConsoleLevel.Error) -private fun shareLines(context: Context, title: String, lines: List) { - val directory = File(ScreenCapture.captureDirectory(context), "reports").apply { mkdirs() } - val file = File(directory, "${title.filter { it.isLetterOrDigit() }.lowercase().ifEmpty { "logs" }}_${System.currentTimeMillis()}.txt") - file.writeText(ConsoleFormat.export(lines)) - context.shareFiles(listOf(file), subject = title) -} +private fun writeLines(context: Context, title: String, lines: List): File = ReportFiles.write( + context, + name = "${title.filter { it.isLetterOrDigit() }.lowercase().ifEmpty { "logs" }}_${System.currentTimeMillis()}.txt", + text = ConsoleFormat.export(lines), +) private const val LIST_DIVIDER_THRESHOLD = 2 diff --git a/devdeck/src/main/kotlin/io/devdeck/crash/CrashList.kt b/devdeck/src/main/kotlin/io/devdeck/crash/CrashList.kt index 153e181..9a7171d 100644 --- a/devdeck/src/main/kotlin/io/devdeck/crash/CrashList.kt +++ b/devdeck/src/main/kotlin/io/devdeck/crash/CrashList.kt @@ -9,13 +9,17 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.devdeck.components.BulletItem import io.devdeck.components.CodeBlock +import io.devdeck.components.ConfirmDeleteButton import io.devdeck.components.InfoGroup import io.devdeck.components.PageLinkItem import io.devdeck.components.TextItem @@ -24,37 +28,44 @@ import io.devdeck.internal.shareFiles import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable public fun CrashList(modifier: Modifier = Modifier) { val context = LocalContext.current - val crashes = remember(CrashReporter.version) { CrashReporter.crashes() } + val scope = rememberCoroutineScope() + val loaded by produceState?>(null, CrashReporter.version) { + value = withContext(Dispatchers.IO) { CrashReporter.crashes() } + } + val crashes = loaded.orEmpty() Column(modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { Text( text = if (crashes.size == 1) "1 crash" else "${crashes.size} crashes", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) IconButton( onClick = { context.shareFiles(crashes.map { it.file }, subject = "Crash logs") }, enabled = crashes.isNotEmpty(), ) { Icon(DeckIcons.Share, contentDescription = "Share crash logs") } - IconButton(onClick = CrashReporter::clear, enabled = crashes.isNotEmpty()) { - Icon( - DeckIcons.Delete, - contentDescription = "Clear crash logs", - tint = MaterialTheme.colorScheme.error.copy(alpha = if (crashes.isEmpty()) 0.38f else 1f), - ) - } + ConfirmDeleteButton( + contentDescription = "Clear crash logs", + onConfirm = { scope.launch(Dispatchers.IO) { CrashReporter.clear() } }, + enabled = crashes.isNotEmpty(), + ) } - if (crashes.isEmpty()) TextItem("No crashes recorded. Crashes are saved here and shown after the app restarts.") + if (loaded?.isEmpty() == true) TextItem("No crashes recorded. Crashes are saved here and shown after the app restarts.") InfoGroup(dividerThreshold = 2) { crashes.forEach { crash -> PageLinkItem( title = crash.title.substringBefore(':').substringAfterLast('.'), - subtitle = "${formatTime(crash.timestampMillis)} · ${crash.title.substringAfter(": ", "").take(80)}", + subtitle = "${formatTime(crash.timestampMillis)} · ${crash.title.substringAfter(": ", "")}", ) { CrashDetail(crash) } } } diff --git a/devdeck/src/main/kotlin/io/devdeck/device/PermissionsList.kt b/devdeck/src/main/kotlin/io/devdeck/device/PermissionsList.kt index 106c708..5b38a40 100644 --- a/devdeck/src/main/kotlin/io/devdeck/device/PermissionsList.kt +++ b/devdeck/src/main/kotlin/io/devdeck/device/PermissionsList.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -57,13 +58,15 @@ public fun PermissionsList(modifier: Modifier = Modifier) { return@Column } Row( - Modifier.fillMaxWidth().padding(horizontal = HorizontalPadding, vertical = 6.dp), + Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(start = HorizontalPadding, end = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( "${permissions.count { it.granted }} of ${permissions.size} granted", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) } diff --git a/devdeck/src/main/kotlin/io/devdeck/internal/DeckPalette.kt b/devdeck/src/main/kotlin/io/devdeck/internal/DeckPalette.kt new file mode 100644 index 0000000..74d3a60 --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/internal/DeckPalette.kt @@ -0,0 +1,39 @@ +package io.devdeck.internal + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import io.devdeck.LocalDeckColors + +/** A category color with a light-theme shade that reads on white and a dark-theme shade that reads on dark surfaces. */ +@Immutable +internal class Tone(val light: Color, val dark: Color) { + fun pick(isDark: Boolean): Color = if (isDark) dark else light +} + +internal object DeckPalette { + val Blue = Tone(Color(0xFF1565C0), Color(0xFF64B5F6)) + val Purple = Tone(Color(0xFF8E24AA), Color(0xFFCE93D8)) + val DeepPurple = Tone(Color(0xFF5E35B1), Color(0xFFB39DDB)) + val Teal = Tone(Color(0xFF00796B), Color(0xFF80CBC4)) + val Green = Tone(Color(0xFF2E7D32), Color(0xFF81C784)) + val Orange = Tone(Color(0xFFB45309), Color(0xFFFFB74D)) + val DeepOrange = Tone(Color(0xFFBF360C), Color(0xFFFFAB91)) + val Red = Tone(Color(0xFFC62828), Color(0xFFEF9A9A)) + val Grey = Tone(Color(0xFF616161), Color(0xFFBDBDBD)) +} + +@Composable +@ReadOnlyComposable +internal fun isDeckDark(): Boolean = LocalDeckColors.current?.isDark ?: isSystemInDarkTheme() + +/** White or near-black, whichever contrasts more with [fill]. */ +internal fun contentColorOn(fill: Color): Color = if (fill.luminance() > LUMINANCE_SPLIT) DarkContent else Color.White + +private val DarkContent = Color(0xFF1C1B1F) + +// Above this luminance black text has more contrast than white. +private const val LUMINANCE_SPLIT = 0.179f diff --git a/devdeck/src/main/kotlin/io/devdeck/internal/Plural.kt b/devdeck/src/main/kotlin/io/devdeck/internal/Plural.kt new file mode 100644 index 0000000..f69097f --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/internal/Plural.kt @@ -0,0 +1,5 @@ +package io.devdeck.internal + +/** "1 line", "2 lines". */ +internal fun plural(count: Int, singular: String, plural: String = "${singular}s"): String = + "$count ${if (count == 1) singular else plural}" diff --git a/devdeck/src/main/kotlin/io/devdeck/logcat/LogcatReader.kt b/devdeck/src/main/kotlin/io/devdeck/logcat/LogcatReader.kt index f9a58d3..89a7af2 100644 --- a/devdeck/src/main/kotlin/io/devdeck/logcat/LogcatReader.kt +++ b/devdeck/src/main/kotlin/io/devdeck/logcat/LogcatReader.kt @@ -94,19 +94,30 @@ public object LogcatReader { internal object LogcatParser { private val THREADTIME = Regex("""^(\d\d-\d\d \d\d:\d\d:\d\d\.\d{3})\s+(\d+)\s+(\d+)\s+([VDIWEFA])\s+(.*?)\s*: ?(.*)$""") - private val year = Calendar.getInstance().get(Calendar.YEAR) - private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + private val timeFormat = object : ThreadLocal() { + override fun initialValue() = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + } - fun parse(raw: String, pid: Int): ConsoleLine? { + fun parse(raw: String, pid: Int, nowMillis: Long = System.currentTimeMillis()): ConsoleLine? { val match = THREADTIME.matchEntire(raw) ?: return null val (time, linePid, tid, level, tag, message) = match.destructured if (linePid.toIntOrNull() != pid) return null + val year = yearFor(time.substring(0, 2).toIntOrNull() ?: 0, nowMillis) return ConsoleLine( level = ConsoleLevel.fromLetter(level.first()), tag = tag.ifBlank { "(no tag)" }, message = message, - timestampMillis = runCatching { timeFormat.parse("$year-$time")?.time }.getOrNull() ?: System.currentTimeMillis(), + timestampMillis = runCatching { timeFormat.get()?.parse("$year-$time")?.time }.getOrNull() ?: nowMillis, threadId = tid.toIntOrNull(), ) } + + // threadtime omits the year; a line from December read in January belongs to the previous year. + fun yearFor(month: Int, nowMillis: Long): Int { + val now = Calendar.getInstance().apply { timeInMillis = nowMillis } + val year = now.get(Calendar.YEAR) + return if (month - (now.get(Calendar.MONTH) + 1) > MONTHS_AHEAD_LIMIT) year - 1 else year + } + + private const val MONTHS_AHEAD_LIMIT = 6 } diff --git a/devdeck/src/main/kotlin/io/devdeck/logs/LogList.kt b/devdeck/src/main/kotlin/io/devdeck/logs/LogList.kt index 5a69c5f..d5a8628 100644 --- a/devdeck/src/main/kotlin/io/devdeck/logs/LogList.kt +++ b/devdeck/src/main/kotlin/io/devdeck/logs/LogList.kt @@ -12,47 +12,72 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.devdeck.bugreport.ReportFiles +import io.devdeck.components.ConfirmDeleteButton import io.devdeck.components.InfoGroup import io.devdeck.components.TextItem import io.devdeck.internal.DeckIcons +import io.devdeck.internal.shareFiles import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable public fun LogList(modifier: Modifier = Modifier, tag: String? = null, maxItems: Int = 100) { + val context = LocalContext.current val allEntries by DeckLogger.entries.collectAsState() val entries = remember(allEntries, tag, maxItems) { allEntries.asSequence().filter { tag == null || it.tag == tag }.take(maxItems).toList() } val formatter = remember { SimpleDateFormat("HH:mm:ss.SSS", Locale.US) } + val scope = rememberCoroutineScope() Column(modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { Text( text = if (entries.size == 1) "1 entry" else "${entries.size} entries", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - IconButton(onClick = { DeckLogger.clear(tag) }, enabled = entries.isNotEmpty()) { - Icon( - DeckIcons.Delete, - contentDescription = "Clear logs", - tint = MaterialTheme.colorScheme.error.copy(alpha = if (entries.isEmpty()) 0.38f else 1f), - ) - } + IconButton( + onClick = { + val subject = tag?.let { "$it logs" } ?: "Logs" + // Payloads can be large, so share a file rather than intent text. + scope.launch { + val file = withContext(Dispatchers.IO) { + ReportFiles.write(context, "logs_${System.currentTimeMillis()}.txt", format(entries)) + } + context.shareFiles(listOf(file), subject = subject) + } + }, + enabled = entries.isNotEmpty(), + ) { Icon(DeckIcons.Share, contentDescription = "Share logs") } + ConfirmDeleteButton(contentDescription = "Clear logs", onConfirm = { DeckLogger.clear(tag) }, enabled = entries.isNotEmpty()) } - if (entries.isEmpty()) TextItem("No logs yet") + if (entries.isEmpty()) TextItem("No entries yet") InfoGroup(dividerThreshold = 2) { - entries.forEach { entry -> LogRow(entry, formatter.format(Date(entry.timestampMillis)), showTag = tag == null) } + entries.forEach { entry -> + key(entry.id) { LogRow(entry, formatter.format(Date(entry.timestampMillis)), showTag = tag == null) } + } } } } @@ -63,7 +88,15 @@ private fun LogRow(entry: LogEntry, time: String, showTag: Boolean) { Column( Modifier .fillMaxWidth() - .clickable(enabled = entry.payload != null) { expanded = !expanded } + .then( + if (entry.payload != null) { + Modifier + .clickable(onClickLabel = if (expanded) "Collapse" else "Expand") { expanded = !expanded } + .semantics { stateDescription = if (expanded) "Expanded" else "Collapsed" } + } else { + Modifier + }, + ) .padding(horizontal = 16.dp, vertical = 10.dp), ) { Row { @@ -84,3 +117,11 @@ private fun LogRow(entry: LogEntry, time: String, showTag: Boolean) { } } } + +private fun format(entries: List): String { + val formatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + return entries.asReversed().joinToString("\n") { entry -> + val payload = entry.payload?.let { "\n $it" }.orEmpty() + "${formatter.format(Date(entry.timestampMillis))} [${entry.tag}] ${entry.message}$payload" + } +} diff --git a/devdeck/src/main/kotlin/io/devdeck/network/NetworkCallCard.kt b/devdeck/src/main/kotlin/io/devdeck/network/NetworkCallCard.kt index 9363a8b..a79562d 100644 --- a/devdeck/src/main/kotlin/io/devdeck/network/NetworkCallCard.kt +++ b/devdeck/src/main/kotlin/io/devdeck/network/NetworkCallCard.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -17,7 +18,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight @@ -25,20 +25,22 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.devdeck.LocalDeckState import io.devdeck.components.Pill +import io.devdeck.internal.isDeckDark @Composable internal fun NetworkCallCard(call: NetworkCall, highlights: List, repeatCount: Int = 1, showTitle: Boolean = true) { val deckState = LocalDeckState.current + val isDark = isDeckDark() val matched = remember(call, highlights) { highlights.filter { it.matches(call) } } - val accent = matched.firstOrNull()?.color ?: statusColor(call) + val accent = matched.firstOrNull()?.color ?: statusColor(call, isDark) CardRow( accent = accent, onClick = { deckState?.navigateTo(call.displayName) { NetworkCallDetail(call.id, highlights = highlights) } }, ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - Pill(call.method, methodColor(call.method)) - Pill(NetworkFormat.status(call), statusColor(call)) - if (call.simulation != null) Pill("Simulated", SimulatedColor, filled = false) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Pill(call.method, methodColor(call.method, isDark)) + Pill(NetworkFormat.status(call), statusColor(call, isDark)) + if (call.simulation != null) Pill("Simulated", NetworkColors.simulated(isDark), filled = false) if (repeatCount > 1) Pill("×$repeatCount", MaterialTheme.colorScheme.onSurfaceVariant, filled = false) matched.forEach { Pill(it.label, it.color, filled = false) } } diff --git a/devdeck/src/main/kotlin/io/devdeck/network/NetworkCallDetail.kt b/devdeck/src/main/kotlin/io/devdeck/network/NetworkCallDetail.kt index d2a1c71..1260f2e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/network/NetworkCallDetail.kt +++ b/devdeck/src/main/kotlin/io/devdeck/network/NetworkCallDetail.kt @@ -2,12 +2,11 @@ package io.devdeck.network import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -17,6 +16,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp +import io.devdeck.components.ActionPill +import io.devdeck.components.ActionPillRow import io.devdeck.components.BulletItem import io.devdeck.components.CodeBlock import io.devdeck.components.CodeLanguage @@ -26,6 +27,7 @@ import io.devdeck.components.Pill import io.devdeck.components.SectionDivider import io.devdeck.components.TextItem import io.devdeck.internal.copyToClipboard +import io.devdeck.internal.isDeckDark import io.devdeck.internal.shareFiles @Composable @@ -38,24 +40,31 @@ internal fun NetworkCallDetail(callId: Long, modifier: Modifier = Modifier, high } val context = LocalContext.current val matched = remember(call, highlights) { highlights.filter { it.matches(call) } } + val isDark = isDeckDark() Column(modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - Pill(call.method, methodColor(call.method)) - Pill(NetworkFormat.status(call), statusColor(call)) - if (call.simulation != null) Pill("Simulated", SimulatedColor, filled = false) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + Pill(call.method, methodColor(call.method, isDark)) + Pill(NetworkFormat.status(call), statusColor(call, isDark)) + if (call.simulation != null) Pill("Simulated", NetworkColors.simulated(isDark), filled = false) matched.forEach { Pill(it.label, it.color, filled = false) } Text(NetworkFormat.duration(call.durationMillis), style = MaterialTheme.typography.bodySmall) } Text(call.url, style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace)) - Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) { - TextButton(onClick = { context.copyToClipboard("URL", call.url) }) { Text("Copy URL") } - TextButton(onClick = { context.copyToClipboard("cURL", NetworkFormat.curl(call)) }) { Text("Copy cURL") } - TextButton(onClick = { context.shareFiles(emptyList(), text = NetworkFormat.summary(call), subject = call.displayName) }) { - Text("Share") - } - } + } + ActionPillRow { + ActionPill(text = "Copy URL", onClick = { context.copyToClipboard("URL", call.url) }, compact = true) + ActionPill(text = "Copy cURL", onClick = { context.copyToClipboard("cURL", NetworkFormat.curl(call)) }, compact = true) + ActionPill( + text = "Share", + onClick = { context.shareFiles(emptyList(), text = NetworkFormat.summary(call), subject = call.displayName) }, + compact = true, + ) } SectionDivider() diff --git a/devdeck/src/main/kotlin/io/devdeck/network/NetworkFormat.kt b/devdeck/src/main/kotlin/io/devdeck/network/NetworkFormat.kt index 43701dc..caf25a5 100644 --- a/devdeck/src/main/kotlin/io/devdeck/network/NetworkFormat.kt +++ b/devdeck/src/main/kotlin/io/devdeck/network/NetworkFormat.kt @@ -1,6 +1,7 @@ package io.devdeck.network import androidx.compose.ui.graphics.Color +import io.devdeck.internal.DeckPalette import io.devdeck.internal.Json import java.text.SimpleDateFormat import java.util.Date @@ -67,27 +68,39 @@ internal object NetworkFormat { } } + fun line(call: NetworkCall): String = + "${time(call.startedAtMillis)} ${call.method} ${status(call)} ${call.url} · ${duration(call.durationMillis)}" + private fun String.shellEscape(): String = replace("'", "'\\''") } -internal fun methodColor(method: String): Color = when (method) { - "GET" -> Color(0xFF1E88E5) - "POST" -> Color(0xFF43A047) - "PUT" -> Color(0xFFFB8C00) - "PATCH" -> Color(0xFF8E24AA) - "DELETE" -> Color(0xFFE53935) - else -> Color(0xFF757575) -} +internal fun methodColor(method: String, isDark: Boolean): Color = when (method) { + "GET" -> DeckPalette.Blue + "POST" -> DeckPalette.Green + "PUT" -> DeckPalette.Orange + "PATCH" -> DeckPalette.Purple + "DELETE" -> DeckPalette.Red + else -> DeckPalette.Grey +}.pick(isDark) + +/** Shared status colors for the network screens, picked for the current theme. */ +internal object NetworkColors { + fun error(isDark: Boolean): Color = DeckPalette.Red.pick(isDark) -internal val CallCountColor = Color(0xFF5E35B1) + fun success(isDark: Boolean): Color = DeckPalette.Green.pick(isDark) -internal val SimulatedColor = Color(0xFFEF6C00) + fun pending(isDark: Boolean): Color = DeckPalette.Grey.pick(isDark) + + fun callCount(isDark: Boolean): Color = DeckPalette.DeepPurple.pick(isDark) + + fun simulated(isDark: Boolean): Color = DeckPalette.DeepOrange.pick(isDark) +} -internal fun statusColor(call: NetworkCall): Color = when { - call.error != null -> Color(0xFFD32F2F) - call.responseCode == null -> Color(0xFF9E9E9E) - call.responseCode >= 500 -> Color(0xFFD32F2F) - call.responseCode >= 400 -> Color(0xFFF57C00) - call.responseCode >= 300 -> Color(0xFF1E88E5) - else -> Color(0xFF2E7D32) +internal fun statusColor(call: NetworkCall, isDark: Boolean): Color = when { + call.error != null -> NetworkColors.error(isDark) + call.responseCode == null -> NetworkColors.pending(isDark) + call.responseCode >= 500 -> NetworkColors.error(isDark) + call.responseCode >= 400 -> DeckPalette.Orange.pick(isDark) + call.responseCode >= 300 -> DeckPalette.Blue.pick(isDark) + else -> NetworkColors.success(isDark) } diff --git a/devdeck/src/main/kotlin/io/devdeck/network/NetworkGroupCard.kt b/devdeck/src/main/kotlin/io/devdeck/network/NetworkGroupCard.kt index 477586b..14ba9ad 100644 --- a/devdeck/src/main/kotlin/io/devdeck/network/NetworkGroupCard.kt +++ b/devdeck/src/main/kotlin/io/devdeck/network/NetworkGroupCard.kt @@ -1,33 +1,33 @@ package io.devdeck.network import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.FlowRow import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.devdeck.LocalDeckState import io.devdeck.components.Pill +import io.devdeck.internal.isDeckDark @Composable internal fun NetworkGroupCard(group: NetworkGroup, highlights: List) { val deckState = LocalDeckState.current + val isDark = isDeckDark() val matched = remember(group, highlights) { highlights.filter { highlight -> group.calls.any(highlight.matches) } } - val accent = matched.firstOrNull()?.color ?: if (group.failures > 0) Color(0xFFD32F2F) else Color(0xFF2E7D32) + val accent = matched.firstOrNull()?.color ?: if (group.failures > 0) NetworkColors.error(isDark) else NetworkColors.success(isDark) CardRow( accent = accent, onClick = { deckState?.navigateTo(group.title) { NetworkGroupDetail(group.key, highlights = highlights) } }, ) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { - Pill(group.method, methodColor(group.method)) - Pill("${group.count} ${if (group.count == 1) "call" else "calls"}", CallCountColor) - if (group.failures > 0) Pill("${group.failures} failed", Color(0xFFD32F2F)) - if (group.pending > 0) Pill("${group.pending} pending", Color(0xFF9E9E9E), filled = false) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Pill(group.method, methodColor(group.method, isDark)) + Pill("${group.count} ${if (group.count == 1) "call" else "calls"}", NetworkColors.callCount(isDark)) + if (group.failures > 0) Pill("${group.failures} failed", NetworkColors.error(isDark)) + if (group.pending > 0) Pill("${group.pending} pending", NetworkColors.pending(isDark), filled = false) matched.forEach { Pill(it.label, it.color, filled = false) } } Text( diff --git a/devdeck/src/main/kotlin/io/devdeck/network/NetworkInspector.kt b/devdeck/src/main/kotlin/io/devdeck/network/NetworkInspector.kt index 141cbd2..1e6d852 100644 --- a/devdeck/src/main/kotlin/io/devdeck/network/NetworkInspector.kt +++ b/devdeck/src/main/kotlin/io/devdeck/network/NetworkInspector.kt @@ -17,9 +17,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.devdeck.components.ConfirmDeleteButton import io.devdeck.components.FilterPill import io.devdeck.components.FilterPillRow import io.devdeck.components.InfoGroup @@ -27,6 +28,9 @@ import io.devdeck.components.SearchField import io.devdeck.components.SegmentedPills import io.devdeck.components.TextItem import io.devdeck.internal.DeckIcons +import io.devdeck.internal.isDeckDark +import io.devdeck.internal.plural +import io.devdeck.internal.shareFiles public enum class NetworkView(public val label: String) { Timeline("Timeline"), ByEndpoint("By endpoint") } @@ -37,11 +41,14 @@ public fun NetworkInspector( initialView: NetworkView = NetworkView.Timeline, maxItems: Int = 150, ) { + val context = LocalContext.current + val isDark = isDeckDark() val calls by NetworkLogger.calls.collectAsState() var query by rememberSaveable { mutableStateOf("") } var filter by rememberSaveable { mutableStateOf(FILTER_ALL) } var view by rememberSaveable { mutableStateOf(initialView) } val visible = remember(calls, query, filter, highlights) { + val needle = query.trim() calls.filter { call -> val matchesFilter = when (filter) { FILTER_ALL -> true @@ -52,7 +59,7 @@ public fun NetworkInspector( ( query.isBlank() || listOfNotNull(call.url, call.method, call.operationName, call.responseCode?.toString()) - .any { it.contains(query.trim(), ignoreCase = true) } + .any { it.contains(needle, ignoreCase = true) } ) } } @@ -64,20 +71,21 @@ public fun NetworkInspector( Column(modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { Text( - "${calls.size} requests · $endpoints endpoints · $failures failed", + "${plural(calls.size, "request")} · ${plural(endpoints, "endpoint")} · $failures failed", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - IconButton(onClick = NetworkLogger::clear, enabled = calls.isNotEmpty()) { - Icon( - DeckIcons.Delete, - contentDescription = "Clear requests", - tint = MaterialTheme.colorScheme.error.copy(alpha = if (calls.isEmpty()) 0.38f else 1f), - ) - } + IconButton( + onClick = { + val text = visible.take(maxItems).joinToString("\n") { NetworkFormat.line(it) } + context.shareFiles(emptyList(), text = text, subject = "Network requests") + }, + enabled = visible.isNotEmpty(), + ) { Icon(DeckIcons.Share, contentDescription = "Share requests") } + ConfirmDeleteButton(contentDescription = "Clear requests", onConfirm = NetworkLogger::clear, enabled = calls.isNotEmpty()) } SegmentedPills( options = NetworkView.entries, @@ -93,7 +101,7 @@ public fun NetworkInspector( text = "Errors $failures", selected = filter == FILTER_ERRORS, onClick = { filter = FILTER_ERRORS }, - color = ErrorColor, + color = NetworkColors.error(isDark), showDot = true, ) highlights.forEach { highlight -> @@ -122,5 +130,3 @@ public fun NetworkInspector( private const val FILTER_ALL = "__all" private const val FILTER_ERRORS = "__errors" - -private val ErrorColor = Color(0xFFD32F2F) diff --git a/devdeck/src/main/kotlin/io/devdeck/network/NetworkLogger.kt b/devdeck/src/main/kotlin/io/devdeck/network/NetworkLogger.kt index 8ca6893..e910f1e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/network/NetworkLogger.kt +++ b/devdeck/src/main/kotlin/io/devdeck/network/NetworkLogger.kt @@ -14,7 +14,20 @@ public object NetworkLogger { public var maxBodyChars: Int = 250_000 - public var redactedHeaders: Set = setOf("Authorization", "Cookie", "Set-Cookie", "Proxy-Authorization", "X-Api-Key") + /** Total request + response body chars kept across all calls; the oldest calls' bodies are dropped first. */ + public var maxTotalBodyChars: Int = 12_000_000 + + public var redactedHeaders: Set = setOf( + "Authorization", + "Cookie", + "Set-Cookie", + "Proxy-Authorization", + "X-Api-Key", + "X-Auth-Token", + "X-Access-Token", + "X-CSRF-Token", + "X-XSRF-Token", + ) public var redactedFields: Set = setOf( "password", @@ -55,7 +68,7 @@ public object NetworkLogger { operationName = GraphQl.operationName(url, headers, body), simulation = simulation, ) - _calls.update { (listOf(call) + it).take(maxCalls) } + _calls.update { (listOf(call) + it).take(maxCalls).withinBodyBudget() } return id } @@ -104,15 +117,37 @@ public object NetworkLogger { } private fun update(id: Long, transform: (NetworkCall) -> NetworkCall) { - _calls.update { calls -> calls.map { if (it.id == id) transform(it) else it } } + _calls.update { calls -> calls.map { if (it.id == id) transform(it) else it }.withinBodyBudget() } + } + + private fun List.withinBodyBudget(): List { + val budget = maxTotalBodyChars.toLong() + if (sumOf { it.bodyChars() } <= budget) return this + var total = 0L + var evicting = false + return map { call -> + total += call.bodyChars() + if (total > budget) evicting = true + if (evicting) call.withoutBodies() else call + } } + private fun NetworkCall.bodyChars(): Long = (requestBody?.length ?: 0).toLong() + (responseBody?.length ?: 0) + + private fun NetworkCall.withoutBodies(): NetworkCall = + if ((requestBody == null || requestBody == EVICTED_BODY) && (responseBody == null || responseBody == EVICTED_BODY)) { + this + } else { + copy(requestBody = requestBody?.let { EVICTED_BODY }, responseBody = responseBody?.let { EVICTED_BODY }) + } + private fun redact(headers: List>): List> = headers.map { (name, value) -> - if (redactedHeaders.any { it.equals(name, ignoreCase = true) }) name to REDACTED else name to value + if (NetworkRedaction.matches(name, redactedHeaders)) name to REDACTED else name to value } private fun String.truncate(): String = if (length <= maxBodyChars) this else take(maxBodyChars) + "\n… truncated (${length - maxBodyChars} more chars)" private const val REDACTED = "██ redacted ██" + private const val EVICTED_BODY = "(body dropped to stay within NetworkLogger.maxTotalBodyChars)" } diff --git a/devdeck/src/main/kotlin/io/devdeck/network/NetworkRedaction.kt b/devdeck/src/main/kotlin/io/devdeck/network/NetworkRedaction.kt index 688beba..5ba902f 100644 --- a/devdeck/src/main/kotlin/io/devdeck/network/NetworkRedaction.kt +++ b/devdeck/src/main/kotlin/io/devdeck/network/NetworkRedaction.kt @@ -6,32 +6,56 @@ internal object NetworkRedaction { fun url(url: String, keys: Set): String { if (keys.isEmpty() || '?' !in url) return url + val normalized = normalizeAll(keys) val base = url.substringBefore('?') val rest = url.substringAfter('?') val query = rest.substringBefore('#') val fragment = rest.substringAfter('#', "").let { if (it.isEmpty()) "" else "#$it" } val redacted = query.split('&').joinToString("&") { part -> val name = part.substringBefore('=') - if ('=' in part && keys.any { it.equals(decode(name), ignoreCase = true) }) "$name=$MASK" else part + if ('=' in part && normalize(decode(name)) in normalized) "$name=$MASK" else part } return "$base?$redacted$fragment" } fun body(body: String, keys: Set): String { if (keys.isEmpty() || body.isEmpty()) return body - var result = body - keys.forEach { key -> - val name = Regex.escape(key) - result = - Regex("(\"$name\"\\s*:\\s*)\"(?:[^\"\\\\]|\\\\.)*\"", RegexOption.IGNORE_CASE).replace(result) { - "${it.groupValues[1]}\"$MASK\"" - } - result = Regex("((?:^|&)$name=)[^&\\s]*", RegexOption.IGNORE_CASE).replace(result) { "${it.groupValues[1]}$MASK" } + val normalized = normalizeAll(keys) + val json = JSON_STRING.replace(body) { match -> + val separator = match.groups[2]?.value + if (separator != null && normalize(match.groupValues[1]) in normalized) { + "\"${match.groupValues[1]}\"$separator\"$MASK\"" + } else { + match.value + } } - return result + return FORM_FIELD.replace(json) { match -> + val name = match.groupValues[2] + if (normalize(decode(name)) in normalized) "${match.groupValues[1]}$name=$MASK" else match.value + } + } + + fun matches(name: String, keys: Set): Boolean { + val normalized = normalize(name) + return keys.any { normalize(it) == normalized } + } + + // `accessToken`, `access_token` and `Access-Token` all compare equal. + fun normalize(name: String): String = buildString(name.length) { + name.forEach { if (it != '_' && it != '-') append(it.lowercaseChar()) } } + private fun normalizeAll(keys: Set): Set = keys.mapTo(HashSet()) { normalize(it) } + private fun decode(value: String): String = runCatching { URLDecoder.decode(value, "UTF-8") }.getOrDefault(value) + // Consumes every JSON string in order so keys and values stay aligned; groups 2 and 3 match only for `"key": scalar`. + private const val STRING_BODY = "[^\"\\\\]*+(?:\\\\.[^\"\\\\]*+)*+" + + private val JSON_STRING = + Regex("\"($STRING_BODY)\"(?:(\\s*:\\s*)(\"$STRING_BODY\"|-?\\d[\\d.eE+-]*|true|false|null))?") + + private val FORM_FIELD = Regex("(^|&)([^=&\\s]+)=[^&\\s]*") + const val MASK = "██" } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt index 7ba44f2..68f08f1 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt @@ -2,6 +2,7 @@ package io.devdeck.plugin import android.widget.Toast import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext import io.devdeck.actions.AppActions import io.devdeck.actions.DeepLinkLauncher @@ -9,6 +10,7 @@ import io.devdeck.components.ButtonItem import io.devdeck.components.ConfirmButtonItem import io.devdeck.components.ExpandableItem import io.devdeck.components.SwitchItem +import kotlinx.coroutines.launch public class AppActionsPlugin( private val deepLinks: Boolean = true, @@ -26,6 +28,7 @@ public class AppActionsPlugin( @Composable override fun Content() { val context = LocalContext.current + val scope = rememberCoroutineScope() if (deepLinks) ExpandableItem("Open a deep link") { DeepLinkLauncher(suggestions = deepLinkSuggestions) } if (webViewDebugging) { SwitchItem( @@ -38,8 +41,10 @@ public class AppActionsPlugin( if (restart) ButtonItem(title = "Restart app", onClick = { AppActions.restart(context) }) if (clearCache) { ConfirmButtonItem(title = "Clear cache", onConfirm = { - AppActions.clearCache(context) - Toast.makeText(context, "Cache cleared", Toast.LENGTH_SHORT).show() + scope.launch { + AppActions.clearCacheInBackground(context) + Toast.makeText(context, "Cache cleared", Toast.LENGTH_SHORT).show() + } }) } if (clearData) { diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt index f454296..8b966fb 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt @@ -38,7 +38,8 @@ public class EndpointPlugin( selected = selected, onSelected = { endpoint -> selected = endpoint - save(context, key, endpoint) + // Apps may restart the process from onChanged too, so write synchronously before handing over. + save(context, key, endpoint, synchronous = restartOnChange || onChanged != null) DeckLogger.log("$title switched to ${endpoint.name}", payload = endpoint.url) onChanged?.invoke(endpoint) if (restartOnChange) AppActions.restart(context) @@ -60,8 +61,9 @@ public class EndpointPlugin( return endpoints.firstOrNull { it.url == url } ?: default } - private fun save(context: Context, key: String, endpoint: Endpoint) { - DeckSettings.of(context).edit(commit = true) { putString(prefKey(key), endpoint.url) } + // A restart kills the process right away, so only then must the write hit disk before returning. + private fun save(context: Context, key: String, endpoint: Endpoint, synchronous: Boolean) { + DeckSettings.of(context).edit(commit = synchronous) { putString(prefKey(key), endpoint.url) } } private fun prefKey(key: String) = if (key == DEFAULT_KEY) "endpoint.url" else "endpoint.url_$key" diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt index db36111..53c8b6b 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt @@ -4,7 +4,10 @@ import android.content.Context import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import io.devdeck.components.ActionPill +import io.devdeck.components.ActionPillRow import io.devdeck.components.ButtonItem +import io.devdeck.components.ConfirmButtonItem import io.devdeck.components.JsonInputItem import io.devdeck.components.NumberInputItem import io.devdeck.components.PageLinkItem @@ -40,17 +43,22 @@ public class FeatureFlagsPlugin( flags.groupBy { it.section }.forEach { (section, sectionFlags) -> Section(section) { sectionFlags.groupBy { it.category }.forEach { (category, categoryFlags) -> - PageLinkItem(title = category) { FlagList(categoryFlags) } + PageLinkItem(title = category.replaceFirstChar { it.titlecase() }) { FlagList(categoryFlags) } } } SectionDivider() } if (bulkEdit) { - Section("Bulk Edit ✏️") { - ButtonItem(title = "Reset", onClick = { - flags.forEach(store::reset) - DeckLogger.log("Feature flags reset") - }) + Section("Bulk edit ✏️") { + ConfirmButtonItem( + title = "Reset all flags", + subtitle = "Remove every override", + confirmTitle = "Tap again to reset all flags", + onConfirm = { + flags.forEach(store::reset) + DeckLogger.log("Feature flags reset") + }, + ) ButtonItem(title = "Import from clipboard", onClick = { importFromClipboard(context) }) ButtonItem(title = "Export to clipboard", onClick = { exportToClipboard(context) }) } @@ -60,7 +68,8 @@ public class FeatureFlagsPlugin( @Composable private fun FlagList(categoryFlags: List>) { categoryFlags.forEach { flag -> - val subtitle = if (store.isOverridden(flag)) "Overridden" else null + val overridden = store.isOverridden(flag) + val subtitle = if (overridden) "Overridden" else null when (flag) { is FeatureFlag.Toggle -> SwitchItem( title = flag.title, @@ -72,6 +81,7 @@ public class FeatureFlagsPlugin( is FeatureFlag.Text -> TextInputItem( title = flag.title, + subtitle = subtitle, value = store[flag], enabled = flag.editable, onValueCommit = { store[flag] = it }, @@ -79,6 +89,7 @@ public class FeatureFlagsPlugin( is FeatureFlag.Number -> NumberInputItem( title = flag.title, + subtitle = subtitle, value = store[flag], enabled = flag.editable, allowDecimals = !flag.wholeNumber, @@ -88,11 +99,15 @@ public class FeatureFlagsPlugin( is FeatureFlag.Json -> JsonInputItem( title = flag.title, + subtitle = subtitle, value = store[flag], enabled = flag.editable, onValueCommit = { store[flag] = it }, ) } + if (overridden && flag.editable) { + ActionPillRow { ActionPill(text = "Reset to default", onClick = { store.reset(flag) }, compact = true) } + } } } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/LogsPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/LogsPlugin.kt index 9910f04..d2cc78c 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/LogsPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/LogsPlugin.kt @@ -21,7 +21,7 @@ public class LogsPlugin( override val id: String = "logs" override fun onAttach(context: Context) { - if (lifecycleLogs) (context as? Application)?.registerActivityLifecycleCallbacks(LifecycleLogger) + (context as? Application)?.let { LifecycleLogger.setRegistered(it, lifecycleLogs) } } @Composable @@ -49,6 +49,20 @@ public class LogsPlugin( } private object LifecycleLogger : Application.ActivityLifecycleCallbacks { + private var registeredWith: Application? = null + + // Re-attaching a new LogsPlugin (install/updateConfig) must not register the callbacks twice. + @Synchronized + fun setRegistered(application: Application, registered: Boolean) { + if (registered == (registeredWith === application)) return + registeredWith?.unregisterActivityLifecycleCallbacks(this) + registeredWith = null + if (registered) { + application.registerActivityLifecycleCallbacks(this) + registeredWith = application + } + } + private fun log(activity: Activity, event: String) = DeckLogger.log("${activity.javaClass.simpleName}: $event", tag = LIFECYCLE_TAG) override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = log(activity, "onCreate") diff --git a/devdeck/src/main/kotlin/io/devdeck/simulation/SimulationBadge.kt b/devdeck/src/main/kotlin/io/devdeck/simulation/SimulationBadge.kt index 54a2c5c..f11b4f6 100644 --- a/devdeck/src/main/kotlin/io/devdeck/simulation/SimulationBadge.kt +++ b/devdeck/src/main/kotlin/io/devdeck/simulation/SimulationBadge.kt @@ -33,10 +33,11 @@ internal fun SimulationBadge(modifier: Modifier = Modifier) { modifier = Modifier .align(Alignment.BottomStart) .padding(8.dp) - .background(BadgeColor.copy(alpha = 0.85f), RoundedCornerShape(6.dp)) + .background(BadgeColor.copy(alpha = 0.92f), RoundedCornerShape(6.dp)) .padding(horizontal = 8.dp, vertical = 3.dp), ) } } -private val BadgeColor = Color(0xFFEF6C00) +// Dark enough for white text to pass contrast. +private val BadgeColor = Color(0xFFBF360C) diff --git a/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt b/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt index 3aa8723..e7a5c49 100644 --- a/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt +++ b/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt @@ -6,28 +6,27 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.edit import io.devdeck.LocalDeckState -import io.devdeck.components.ActionPill import io.devdeck.components.BulletItem import io.devdeck.components.ConfirmButtonItem +import io.devdeck.components.ConfirmDeleteButton import io.devdeck.components.InfoGroup import io.devdeck.components.JsonInputItem import io.devdeck.components.NumberInputItem @@ -36,31 +35,44 @@ import io.devdeck.components.SectionDivider import io.devdeck.components.SwitchItem import io.devdeck.components.TextInputItem import io.devdeck.components.TextItem -import io.devdeck.internal.DeckIcons import io.devdeck.internal.Json -import kotlinx.coroutines.delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable public fun SharedPreferencesBrowser(modifier: Modifier = Modifier, include: (String) -> Boolean = { true }) { val context = LocalContext.current - val names = remember(context) { StorageFiles.sharedPreferencesNames(context).filter(include) } + val scope = rememberCoroutineScope() var version by remember { mutableIntStateOf(0) } + val loaded by produceState?>(null, context, version) { + value = withContext(Dispatchers.IO) { StorageFiles.sharedPreferencesNames(context) } + } + val names = remember(loaded, include) { loaded.orEmpty().filter(include) } Column(modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { Text( text = if (names.size == 1) "1 file" else "${names.size} files", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - if (names.isNotEmpty()) { - ClearAllButton(onClear = { - names.forEach { context.getSharedPreferences(it, Context.MODE_PRIVATE).edit { clear() } } - version++ - }) - } + ConfirmDeleteButton( + contentDescription = "Clear all files", + onConfirm = { + scope.launch { + withContext(Dispatchers.IO) { + names.forEach { context.getSharedPreferences(it, Context.MODE_PRIVATE).edit(commit = true) { clear() } } + } + version++ + } + }, + enabled = names.isNotEmpty(), + ) } - if (names.isEmpty()) TextItem("No SharedPreferences files") + if (loaded != null && names.isEmpty()) TextItem("No SharedPreferences files") InfoGroup(dividerThreshold = 2) { names.forEach { name -> key(name, version) { PreferenceFileRow(name) } } } } } @@ -68,39 +80,20 @@ public fun SharedPreferencesBrowser(modifier: Modifier = Modifier, include: (Str @Composable private fun PreferenceFileRow(name: String) { val prefs = rememberObservedPreferences(name) - val count = prefs.values.all.size - PageLinkItem(title = name, subtitle = if (count == 1) "1 key" else "$count keys") { SharedPreferencesFile(name) } -} - -@Composable -private fun ClearAllButton(onClear: () -> Unit) { - var confirming by remember { mutableStateOf(false) } - LaunchedEffect(confirming) { - if (confirming) { - delay(CONFIRM_TIMEOUT_MILLIS) - confirming = false - } - } - if (confirming) { - ActionPill( - text = "Clear all files?", - onClick = { - confirming = false - onClear() - }, - color = MaterialTheme.colorScheme.error, - compact = true, - ) - } else { - IconButton(onClick = { confirming = true }) { - Icon(DeckIcons.Delete, contentDescription = "Clear all files", tint = MaterialTheme.colorScheme.error) - } - } + val count = prefs?.values?.all?.size + PageLinkItem( + title = name, + subtitle = when (count) { + null -> "…" + 1 -> "1 key" + else -> "$count keys" + }, + ) { SharedPreferencesFile(name) } } @Composable public fun SharedPreferencesFile(name: String, modifier: Modifier = Modifier) { - val prefs = rememberObservedPreferences(name) + val prefs = rememberObservedPreferences(name) ?: return val entries = prefs.values.all.toSortedMap() Column(modifier.fillMaxWidth()) { if (entries.isEmpty()) TextItem("This file is empty") @@ -119,7 +112,7 @@ public fun SharedPreferencesFile(name: String, modifier: Modifier = Modifier) { @Composable private fun PreferenceEditor(name: String, key: String) { - val prefs = rememberObservedPreferences(name) + val prefs = rememberObservedPreferences(name) ?: return val drawerState = LocalDeckState.current val editor = prefs.values when (val value = editor.all[key]) { @@ -154,17 +147,21 @@ private fun PreferenceEditor(name: String, key: String) { private class ObservedPreferences(val values: SharedPreferences, val version: Int, val refresh: () -> Unit) +/** Opens and loads the file off the main thread; null until it is ready. */ @Composable -private fun rememberObservedPreferences(name: String): ObservedPreferences { +private fun rememberObservedPreferences(name: String): ObservedPreferences? { val context = LocalContext.current - val prefs = remember(name) { context.getSharedPreferences(name, Context.MODE_PRIVATE) } + val prefs by produceState(null, name) { + value = withContext(Dispatchers.IO) { context.getSharedPreferences(name, Context.MODE_PRIVATE).also { it.all } } + } var version by remember { mutableIntStateOf(0) } DisposableEffect(prefs) { + val current = prefs val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> version++ } - prefs.registerOnSharedPreferenceChangeListener(listener) - onDispose { prefs.unregisterOnSharedPreferenceChangeListener(listener) } + current?.registerOnSharedPreferenceChangeListener(listener) + onDispose { current?.unregisterOnSharedPreferenceChangeListener(listener) } } - return ObservedPreferences(prefs, version) { version++ } + return prefs?.let { ObservedPreferences(it, version) { version++ } } } private fun Any?.typeName(): String = when (this) { @@ -177,6 +174,7 @@ private fun Any?.typeName(): String = when (this) { else -> "Unknown" } -private fun Any?.preview(): String = toString().replace('\n', ' ').let { if (it.length > 40) it.take(40) + "…" else it } +// Only bounds the text handed to layout; the subtitle's maxLines does the visible truncation. +private fun Any?.preview(): String = toString().take(MAX_PREVIEW_CHARS).replace('\n', ' ') -private const val CONFIRM_TIMEOUT_MILLIS = 3_000L +private const val MAX_PREVIEW_CHARS = 300 diff --git a/devdeck/src/main/kotlin/io/devdeck/updates/UpdateChecker.kt b/devdeck/src/main/kotlin/io/devdeck/updates/UpdateChecker.kt index 40c9db4..e19e365 100644 --- a/devdeck/src/main/kotlin/io/devdeck/updates/UpdateChecker.kt +++ b/devdeck/src/main/kotlin/io/devdeck/updates/UpdateChecker.kt @@ -72,9 +72,9 @@ public object UpdateChecker { public fun signOut() { if (job?.isActive == true) return val current = source ?: return - current.signOut() + val result = runCatching { current.signOut() } isSignedIn = current.isSignedIn() - status = UpdateStatus.Idle + status = result.fold(onSuccess = { UpdateStatus.Idle }, onFailure = { UpdateStatus.Failed(it.describe()) }) } public fun install() { diff --git a/devdeck/src/main/res/xml/devdeck_file_paths.xml b/devdeck/src/main/res/xml/devdeck_file_paths.xml index e7c5bb9..fcbe797 100644 --- a/devdeck/src/main/res/xml/devdeck_file_paths.xml +++ b/devdeck/src/main/res/xml/devdeck_file_paths.xml @@ -2,4 +2,5 @@ + diff --git a/devdeck/src/test/kotlin/io/devdeck/breadcrumbs/BreadcrumbDescribeTest.kt b/devdeck/src/test/kotlin/io/devdeck/breadcrumbs/BreadcrumbDescribeTest.kt new file mode 100644 index 0000000..4004a20 --- /dev/null +++ b/devdeck/src/test/kotlin/io/devdeck/breadcrumbs/BreadcrumbDescribeTest.kt @@ -0,0 +1,36 @@ +package io.devdeck.breadcrumbs + +import android.text.InputType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BreadcrumbDescribeTest { + + @Test + fun `records the label of static text views`() { + assertEquals("Place order", BreadcrumbCollector.recordableText("Place order", editable = false, inputType = InputType.TYPE_NULL)) + } + + @Test + fun `never records text typed into editable views`() { + assertNull(BreadcrumbCollector.recordableText("hunter2", editable = true, inputType = InputType.TYPE_CLASS_TEXT)) + } + + @Test + fun `never records password input even when not editable`() { + val passwords = listOf( + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD, + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD, + InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD, + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS, + ) + passwords.forEach { assertNull(BreadcrumbCollector.recordableText("1234", editable = false, inputType = it)) } + } + + @Test + fun `skips blank text`() { + assertNull(BreadcrumbCollector.recordableText(" ", editable = false, inputType = InputType.TYPE_NULL)) + } +} diff --git a/devdeck/src/test/kotlin/io/devdeck/console/ConsoleBufferTest.kt b/devdeck/src/test/kotlin/io/devdeck/console/ConsoleBufferTest.kt new file mode 100644 index 0000000..0ee1f42 --- /dev/null +++ b/devdeck/src/test/kotlin/io/devdeck/console/ConsoleBufferTest.kt @@ -0,0 +1,45 @@ +package io.devdeck.console + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConsoleBufferTest { + + private fun line(message: String) = ConsoleLine(level = ConsoleLevel.Info, tag = "Test", message = message) + + @Test + fun `flushes appended lines`() { + val buffer = ConsoleBuffer() + buffer.append(line("one")) + buffer.publish(buffer.takeBatch()) + + assertEquals(listOf("one"), buffer.lines.value.map { it.message }) + } + + @Test + fun `a batch taken before clear is dropped`() { + val buffer = ConsoleBuffer() + buffer.append(line("old")) + val inFlight = buffer.takeBatch() + + buffer.clear() + buffer.publish(inFlight) + + assertTrue(buffer.lines.value.isEmpty()) + } + + @Test + fun `lines appended after clear still show up`() { + val buffer = ConsoleBuffer() + buffer.append(line("old")) + val inFlight = buffer.takeBatch() + buffer.clear() + buffer.append(line("new")) + + buffer.publish(inFlight) + buffer.publish(buffer.takeBatch()) + + assertEquals(listOf("new"), buffer.lines.value.map { it.message }) + } +} diff --git a/devdeck/src/test/kotlin/io/devdeck/logcat/LogcatParserTest.kt b/devdeck/src/test/kotlin/io/devdeck/logcat/LogcatParserTest.kt index 0f106bd..11588ec 100644 --- a/devdeck/src/test/kotlin/io/devdeck/logcat/LogcatParserTest.kt +++ b/devdeck/src/test/kotlin/io/devdeck/logcat/LogcatParserTest.kt @@ -1,6 +1,7 @@ package io.devdeck.logcat import io.devdeck.console.ConsoleLevel +import java.util.Calendar import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -41,4 +42,26 @@ class LogcatParserTest { fun `maps fatal to assert`() { assertEquals(ConsoleLevel.Assert, LogcatParser.parse("10-24 04:45:22.358 1 1 F libc: abort", pid = 1)?.level) } + + @Test + fun `uses the previous year for December lines read in January`() { + val newYear = millis(2027, Calendar.JANUARY, 1) + val line = LogcatParser.parse("12-31 23:59:59.900 1 1 I Clock: tick", pid = 1, nowMillis = newYear) + + assertEquals(millis(2026, Calendar.DECEMBER, 31, 23, 59, 59, 900), line?.timestampMillis) + } + + @Test + fun `uses the current year for recent months`() { + assertEquals(2027, LogcatParser.yearFor(month = 1, nowMillis = millis(2027, Calendar.JANUARY, 1))) + assertEquals(2027, LogcatParser.yearFor(month = 6, nowMillis = millis(2027, Calendar.MARCH, 1))) + assertEquals(2026, LogcatParser.yearFor(month = 11, nowMillis = millis(2027, Calendar.FEBRUARY, 1))) + } + + private fun millis(year: Int, month: Int, day: Int, hour: Int = 0, minute: Int = 0, second: Int = 0, ms: Int = 0): Long = + Calendar.getInstance().apply { + clear() + set(year, month, day, hour, minute, second) + set(Calendar.MILLISECOND, ms) + }.timeInMillis } diff --git a/devdeck/src/test/kotlin/io/devdeck/network/NetworkLoggerBudgetTest.kt b/devdeck/src/test/kotlin/io/devdeck/network/NetworkLoggerBudgetTest.kt new file mode 100644 index 0000000..5fd24f2 --- /dev/null +++ b/devdeck/src/test/kotlin/io/devdeck/network/NetworkLoggerBudgetTest.kt @@ -0,0 +1,47 @@ +package io.devdeck.network + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class NetworkLoggerBudgetTest { + + @Before + fun setUp() { + NetworkLogger.clear() + NetworkLogger.maxTotalBodyChars = 1_000 + } + + @After + fun tearDown() { + NetworkLogger.clear() + NetworkLogger.maxTotalBodyChars = 12_000_000 + } + + @Test + fun `drops the oldest bodies first once over budget and keeps metadata`() { + val first = NetworkLogger.start("POST", "https://x.io/1", body = "a".repeat(300)) + NetworkLogger.complete(first, 200, body = "b".repeat(300)) + val second = NetworkLogger.start("POST", "https://x.io/2", body = "c".repeat(300)) + NetworkLogger.complete(second, 201, body = "d".repeat(300)) + + val (newest, oldest) = NetworkLogger.calls.value + assertEquals("c".repeat(300), newest.requestBody) + assertEquals("d".repeat(300), newest.responseBody) + assertNotEquals("a".repeat(300), oldest.requestBody) + assertNotEquals("b".repeat(300), oldest.responseBody) + assertEquals(200, oldest.responseCode) + assertEquals("https://x.io/1", oldest.url) + assertTrue(NetworkLogger.calls.value.sumOf { (it.requestBody?.length ?: 0) + (it.responseBody?.length ?: 0) } <= 1_000) + } + + @Test + fun `keeps every body while under budget`() { + repeat(3) { NetworkLogger.start("GET", "https://x.io/$it", body = "x".repeat(100)) } + + assertTrue(NetworkLogger.calls.value.all { it.requestBody == "x".repeat(100) }) + } +} diff --git a/devdeck/src/test/kotlin/io/devdeck/network/NetworkRedactionTest.kt b/devdeck/src/test/kotlin/io/devdeck/network/NetworkRedactionTest.kt index b86bb39..c942a87 100644 --- a/devdeck/src/test/kotlin/io/devdeck/network/NetworkRedactionTest.kt +++ b/devdeck/src/test/kotlin/io/devdeck/network/NetworkRedactionTest.kt @@ -32,6 +32,54 @@ class NetworkRedactionTest { assertEquals("user=sam&password=██&remember=true", NetworkRedaction.body("user=sam&password=s3cret&remember=true", keys)) } + @Test + fun `matches camelCase and dashed variants of secret keys`() { + val body = NetworkRedaction.body( + """{"accessToken":"a1","refreshToken":"r1","idToken":"i1","clientSecret":"c1","apiKey":"k1","user":"sam"}""", + NetworkLogger.redactedFields, + ) + + listOf("a1", "r1", "i1", "c1", "k1").forEach { assertFalse(body, body.contains("\"$it\"")) } + assertTrue(body.contains(""""accessToken":"██"""")) + assertTrue(body.contains(""""user":"sam"""")) + assertEquals("a=1&accessToken=██", NetworkRedaction.body("a=1&accessToken=xyz", keys)) + assertEquals("https://x.io/?Api-Key=██&q=1", NetworkRedaction.url("https://x.io/?Api-Key=k&q=1", keys)) + } + + @Test + fun `masks non-string JSON values`() { + val body = NetworkRedaction.body("""{"password": 123456, "api_key": true, "access_token": -1.5e3, "page": 2}""", keys) + + assertEquals("""{"password": "██", "api_key": "██", "access_token": "██", "page": 2}""", body) + } + + @Test + fun `does not treat string values as keys`() { + val body = NetworkRedaction.body("""{"note":"password","items":["password",":"],"x":{"password":"p"}}""", keys) + + assertEquals("""{"note":"password","items":["password",":"],"x":{"password":"██"}}""", body) + } + + @Test + fun `handles very long string values`() { + val long = "a".repeat(200_000) + val body = NetworkRedaction.body("""{"blob":"$long","password":"p"}""", keys) + + assertTrue(body.endsWith(""""password":"██"}""")) + } + + @Test + fun `redacts the default auth headers`() { + NetworkLogger.clear() + val headers = listOf("X-Auth-Token", "x-access-token", "X-CSRF-Token", "X-XSRF-TOKEN", "Authorization").map { it to "secret" } + NetworkLogger.start("GET", "https://x.io", headers = headers + ("Accept" to "json")) + + val logged = NetworkLogger.calls.value.single().requestHeaders + assertTrue(logged.filter { it.first != "Accept" }.none { it.second == "secret" }) + assertEquals("json", logged.single { it.first == "Accept" }.second) + NetworkLogger.clear() + } + @Test fun `leaves urls without secrets untouched`() { val url = "https://api.example.com/orders?page=1" diff --git a/gradle.properties b/gradle.properties index c2b8f25..56d3fda 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,7 @@ org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configuration-cache=true android.useAndroidX=true android.nonTransitiveRClass=true kotlin.code.style=official diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index fa4ed51..a2548e6 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,12 +1,12 @@ -#This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect -toolchainVersion=25 +#This file is generated by :updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/65c654f29b9eb65a9f970de37f9e9b63/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4656f2d04f7288a2f9e3e8e0b44ff5ad/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/65c654f29b9eb65a9f970de37f9e9b63/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4656f2d04f7288a2f9e3e8e0b44ff5ad/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/204f0dc4ae86bb7172e0e5fec2aae6fb/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/00d5230ba27fb94fafe62c630104eb6b/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/65c654f29b9eb65a9f970de37f9e9b63/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4656f2d04f7288a2f9e3e8e0b44ff5ad/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/59c4d289838e9b34cc46a2ec3baa0e12/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/c9869437c9afb245b55b746fc0c53348/redirect +toolchainVersion=17 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a06430..f89c102 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,8 @@ timber = "4.7.1" ktlint = "1.8.0" junit = "4.13.2" json = "20260814" +binaryCompatibilityValidator = "0.18.2" +asm = "9.6" [libraries] androidx-core = { group = "androidx.core", name = "core", version.ref = "core" } @@ -30,8 +32,17 @@ ktlint-cli = { group = "com.pinterest.ktlint", name = "ktlint-cli", version.ref junit = { group = "junit", name = "junit", version.ref = "junit" } json = { group = "org.json", name = "json", version.ref = "json" } +# Runtime of the binary-compatibility-validator tasks (see the root build.gradle.kts) +asm = { group = "org.ow2.asm", name = "asm", version.ref = "asm" } +asm-tree = { group = "org.ow2.asm", name = "asm-tree", version.ref = "asm" } +kotlin-metadata-jvm = { group = "org.jetbrains.kotlin", name = "kotlin-metadata-jvm", version.ref = "kotlin" } + +[bundles] +binary-compatibility-validator-runtime = ["asm", "asm-tree", "kotlin-metadata-jvm"] + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +binary-compatibility-validator = { id = "org.jetbrains.kotlinx.binary-compatibility-validator", version.ref = "binaryCompatibilityValidator" } diff --git a/sample/README.md b/sample/README.md index efb0941..bae2c98 100644 --- a/sample/README.md +++ b/sample/README.md @@ -73,7 +73,7 @@ fun samplePlugins(context: Context, flagStore: FeatureFlagStore): List