Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
20 changes: 17 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<module>/api/<module>.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`.
Expand Down
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

<p align="center">
<img src="docs/devdeck-demo.gif" width="300" alt="DevDeck sample app: feature flag, slow and failing network, network inspector, frame rate and keyline overlays, dark mode, breadcrumbs, test build update and permissions" />
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
83 changes: 76 additions & 7 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -17,19 +22,83 @@ dependencies {

val ktlintSources = listOf("**/src/**/*.kt", "**/*.gradle.kts", "!**/build/**")

val ktlintFormat = tasks.register<JavaExec>("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<JavaExec>("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<JavaExec>("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 <module>/api/<module>.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<KotlinApiBuildTask>("apiBuild") {
description = "Builds the public API dump of the release variant."
val classesDir = tasks.named<KotlinCompile>("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<KotlinApiCompareTask>("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<Copy>("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) }
}
}
15 changes: 15 additions & 0 deletions devdeck-appdistribution/api/devdeck-appdistribution.api
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
public final class io/devdeck/appdistribution/FirebaseAppDistributionSource : io/devdeck/updates/UpdateSource {
public fun <init> ()V
public fun <init> (Lkotlin/jvm/functions/Function0;)V
public synthetic fun <init> (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
}

7 changes: 7 additions & 0 deletions devdeck-appdistribution/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ android {

defaultConfig {
minSdk = 23
consumerProguardFiles("consumer-rules.pro")
}

lint {
Expand All @@ -28,6 +29,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17
}

testOptions {
unitTests.isReturnDefaultValues = true
}

publishing {
singleVariant("release") {
withSourcesJar()
Expand All @@ -45,6 +50,8 @@ kotlin {
dependencies {
api(project(":devdeck"))
compileOnly(libs.firebase.appdistribution.api)
testImplementation(libs.firebase.appdistribution.api)
testImplementation(libs.junit)
}

afterEvaluate {
Expand Down
4 changes: 4 additions & 0 deletions devdeck-appdistribution/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -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.**
Loading
Loading