From a75d352bdcfa3cc7a7457439857621257871a578 Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sat, 24 Oct 2026 12:57:43 +0530 Subject: [PATCH] Update ktlint, fix timeout rounding, run changelog check on label changes, limit Dependabot to compatible updates --- .github/dependabot.yml | 11 ++++++ .github/workflows/changelog.yml | 37 +++++++++++++++++++ .github/workflows/ci.yml | 25 ------------- .../okhttp/DevDeckOkHttpInterceptor.kt | 12 ++++-- .../breadcrumbs/BreadcrumbCollector.kt | 1 + .../io/devdeck/capture/CaptureGallery.kt | 2 + .../devdeck/capture/ScreenRecordingService.kt | 1 + .../io/devdeck/console/ConsoleViewer.kt | 2 + .../io/devdeck/device/AppPermissions.kt | 10 +++++ .../io/devdeck/device/PermissionsList.kt | 5 +++ .../devdeck/gesture/TwoFingerSwipeTracker.kt | 1 + .../io/devdeck/internal/JsonHighlighter.kt | 5 +++ .../io/devdeck/network/NetworkInspector.kt | 2 + .../io/devdeck/plugin/FeatureFlagsPlugin.kt | 3 ++ .../io/devdeck/simulation/NetworkSimulator.kt | 2 + .../storage/SharedPreferencesBrowser.kt | 7 ++++ .../kotlin/io/devdeck/updates/UpdatesPanel.kt | 5 +++ gradle/libs.versions.toml | 2 +- 18 files changed, 104 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/changelog.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c17b561..e18f736 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,17 @@ updates: interval: weekly open-pull-requests-limit: 5 labels: [dependencies, skip-changelog] + ignore: + - dependency-name: "androidx.*" + 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.squareup.okhttp3:*" + update-types: ["version-update:semver-major"] + - dependency-name: "com.jakewharton.timber:*" + update-types: ["version-update:semver-major"] + - dependency-name: "com.google.firebase:*" + update-types: ["version-update:semver-major"] groups: androidx-and-compose: patterns: ["androidx.*"] diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..01835f5 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,37 @@ +name: Changelog + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +concurrency: + group: changelog-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + changelog: + name: Changelog + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Require a CHANGELOG.md entry + env: + SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} + BASE_REF: ${{ github.base_ref }} + run: | + if [ "$SKIP" = "true" ]; then + echo "skip-changelog label set" + exit 0 + fi + if git diff --name-only "origin/${BASE_REF}...HEAD" | grep -qx 'CHANGELOG.md'; then + echo "CHANGELOG.md updated" + else + echo "::error::Add an entry under [Unreleased] in CHANGELOG.md, or add the skip-changelog label." + exit 1 + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e7afd0..3ff1c75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,28 +58,3 @@ jobs: name: test-reports path: '*/build/reports/tests/' if-no-files-found: ignore - - changelog: - name: Changelog - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - fetch-depth: 0 - persist-credentials: false - - name: Require a CHANGELOG.md entry - env: - SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} - BASE_REF: ${{ github.base_ref }} - run: | - if [ "$SKIP" = "true" ]; then - echo "skip-changelog label set" - exit 0 - fi - if git diff --name-only "origin/${BASE_REF}...HEAD" | grep -qx 'CHANGELOG.md'; then - echo "CHANGELOG.md updated" - else - echo "::error::Add an entry under [Unreleased] in CHANGELOG.md, or add the skip-changelog label." - exit 1 - fi 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 adef963..c6f928a 100644 --- a/devdeck-okhttp/src/main/kotlin/io/devdeck/okhttp/DevDeckOkHttpInterceptor.kt +++ b/devdeck-okhttp/src/main/kotlin/io/devdeck/okhttp/DevDeckOkHttpInterceptor.kt @@ -50,10 +50,12 @@ public class DevDeckOkHttpInterceptor( val response = try { when (effect) { is NetworkEffect.Fail -> simulateFailure(chain, effect.failure) + is NetworkEffect.Slow -> { pause(chain, effect.delayMillis) chain.proceed(request) } + null -> chain.proceed(request) } } catch (error: Exception) { @@ -89,10 +91,12 @@ public class DevDeckOkHttpInterceptor( when (failure) { NetworkFailure.NoConnection -> throw UnknownHostException("Unable to resolve host \"${request.url.host}\": No address associated with hostname $SIMULATED") + NetworkFailure.Timeout -> { pause(chain, chain.readTimeoutMillis().takeIf { it > 0 }?.toLong() ?: DEFAULT_TIMEOUT_MILLIS) throw SocketTimeoutException("timeout $SIMULATED") } + NetworkFailure.ServerError -> return serverError(request) } } @@ -115,10 +119,11 @@ public class DevDeckOkHttpInterceptor( val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis) while (true) { if (chain.call().isCanceled()) throw IOException("Canceled") - val remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()) - if (remaining <= 0) return + val remainingNanos = deadline - System.nanoTime() + if (remainingNanos <= 0) return + val step = minOf(remainingNanos, TimeUnit.MILLISECONDS.toNanos(PAUSE_STEP_MILLIS)) try { - Thread.sleep(minOf(remaining, PAUSE_STEP_MILLIS)) + Thread.sleep(step / NANOS_PER_MILLI, (step % NANOS_PER_MILLI).toInt()) } catch (error: InterruptedException) { Thread.currentThread().interrupt() throw InterruptedIOException("Interrupted").apply { initCause(error) } @@ -179,6 +184,7 @@ public class DevDeckOkHttpInterceptor( const val HTTP_UNAVAILABLE = 503 const val DEFAULT_TIMEOUT_MILLIS = 10_000L const val PAUSE_STEP_MILLIS = 50L + const val NANOS_PER_MILLI = 1_000_000L const val LARGE_BODY_FACTOR = 4 const val THROTTLE_CHUNK_BYTES = 8_192L val STREAMING_SUBTYPES = listOf("event-stream", "x-mixed-replace", "x-ndjson") diff --git a/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt b/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt index 2431267..a42eb54 100644 --- a/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt +++ b/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt @@ -96,6 +96,7 @@ internal object BreadcrumbCollector { downX = event.rawX downY = event.rawY } + MotionEvent.ACTION_UP -> if (taps && abs(event.rawX - downX) < slop && abs(event.rawY - downY) < slop) { val label = describeTap(activity, event.rawX.toInt(), event.rawY.toInt()) if (label != diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt index c44d433..4ac56e9 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt @@ -104,12 +104,14 @@ private fun loadThumbnail(file: File): ImageBitmap? = runCatching { val sample = (maxOf(bounds.outWidth, bounds.outHeight) / THUMBNAIL_SIZE_PX).coerceAtLeast(1) BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })?.asImageBitmap() } + "mp4" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { android.media.ThumbnailUtils.createVideoThumbnail(file, android.util.Size(THUMBNAIL_SIZE_PX, THUMBNAIL_SIZE_PX), null) .asImageBitmap() } else { null } + else -> null } }.getOrNull() diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt index c32f182..55b884a 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt @@ -50,6 +50,7 @@ public class ScreenRecordingService : Service() { start(resultCode, data) } } + ACTION_STOP -> finish() } return START_NOT_STICKY diff --git a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleViewer.kt b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleViewer.kt index 0686ab6..8ad00dd 100644 --- a/devdeck/src/main/kotlin/io/devdeck/console/ConsoleViewer.kt +++ b/devdeck/src/main/kotlin/io/devdeck/console/ConsoleViewer.kt @@ -124,6 +124,7 @@ public fun ConsoleViewer( } when { 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) { @@ -134,6 +135,7 @@ public fun ConsoleViewer( } } } + else -> { InfoGroup(dividerThreshold = LIST_DIVIDER_THRESHOLD) { visible.asReversed().take(maxRendered).forEach { line -> ConsoleLineRow(line, showTag = fixedTag == null) } diff --git a/devdeck/src/main/kotlin/io/devdeck/device/AppPermissions.kt b/devdeck/src/main/kotlin/io/devdeck/device/AppPermissions.kt index bfee3a6..780e869 100644 --- a/devdeck/src/main/kotlin/io/devdeck/device/AppPermissions.kt +++ b/devdeck/src/main/kotlin/io/devdeck/device/AppPermissions.kt @@ -103,23 +103,33 @@ internal object AppPermissions { private fun specialGranted(context: Context, name: String): Boolean? = when { name == SYSTEM_ALERT_WINDOW -> Settings.canDrawOverlays(context) + name == WRITE_SETTINGS -> Settings.System.canWrite(context) + name == REQUEST_INSTALL_PACKAGES && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> context.packageManager.canRequestPackageInstalls() + name == MANAGE_EXTERNAL_STORAGE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Environment.isExternalStorageManager() + name == SCHEDULE_EXACT_ALARM && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> (context.getSystemService(Context.ALARM_SERVICE) as AlarmManager).canScheduleExactAlarms() + else -> null } private fun specialSettingsIntent(context: Context, name: String): Intent? { val action = when { name == SYSTEM_ALERT_WINDOW -> Settings.ACTION_MANAGE_OVERLAY_PERMISSION + name == WRITE_SETTINGS -> Settings.ACTION_MANAGE_WRITE_SETTINGS + name == REQUEST_INSTALL_PACKAGES && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES + name == MANAGE_EXTERNAL_STORAGE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION + name == SCHEDULE_EXACT_ALARM && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM + else -> return null } return Intent(action, packageUri(context)) diff --git a/devdeck/src/main/kotlin/io/devdeck/device/PermissionsList.kt b/devdeck/src/main/kotlin/io/devdeck/device/PermissionsList.kt index f5b0b35..29e7537 100644 --- a/devdeck/src/main/kotlin/io/devdeck/device/PermissionsList.kt +++ b/devdeck/src/main/kotlin/io/devdeck/device/PermissionsList.kt @@ -100,11 +100,15 @@ private fun PermissionAction(permission: AppPermission) { val muted = MaterialTheme.colorScheme.onSurfaceVariant when { permission.kind == PermissionKind.InstallTime -> Note("Always granted at install") + permission.kind == PermissionKind.Unavailable -> Note("Not on this Android version") + permission.kind == PermissionKind.Special -> ActionPill(text = "Open settings", onClick = { AppPermissions.openSettings(context, permission) }, compact = true) + !permission.granted && activity != null -> ActionPill(text = "Request", onClick = { AppPermissions.request(activity, permission) }, compact = true) + AppPermissions.canRevoke(permission) && activity != null -> RevokePill(onRevoke = { if (AppPermissions.revoke(activity, permission)) { Toast.makeText( @@ -114,6 +118,7 @@ private fun PermissionAction(permission: AppPermission) { ).show() } }) + permission.granted -> Note("Revoke needs Android 13+ or App settings", muted) } } diff --git a/devdeck/src/main/kotlin/io/devdeck/gesture/TwoFingerSwipeTracker.kt b/devdeck/src/main/kotlin/io/devdeck/gesture/TwoFingerSwipeTracker.kt index 079a3d7..6a2985c 100644 --- a/devdeck/src/main/kotlin/io/devdeck/gesture/TwoFingerSwipeTracker.kt +++ b/devdeck/src/main/kotlin/io/devdeck/gesture/TwoFingerSwipeTracker.kt @@ -16,6 +16,7 @@ internal class TwoFingerSwipeTracker(private val minDistancePx: Float) { consumed = false return false } + pointers.size != FINGERS -> { start = null if (pointers.size > FINGERS) consumed = true diff --git a/devdeck/src/main/kotlin/io/devdeck/internal/JsonHighlighter.kt b/devdeck/src/main/kotlin/io/devdeck/internal/JsonHighlighter.kt index 3f37b69..669541f 100644 --- a/devdeck/src/main/kotlin/io/devdeck/internal/JsonHighlighter.kt +++ b/devdeck/src/main/kotlin/io/devdeck/internal/JsonHighlighter.kt @@ -47,24 +47,29 @@ internal fun highlightJson(text: String, colors: JsonColors): AnnotatedString = addStyle(SpanStyle(color = color), index, end) index = end } + char == '-' || char.isDigit() -> { var end = index + 1 while (end < text.length && (text[end].isDigit() || text[end] in ".eE+-")) end++ addStyle(SpanStyle(color = colors.number), index, end) index = end } + text.startsWith("true", index) || text.startsWith("null", index) -> { addStyle(SpanStyle(color = colors.literal), index, index + 4) index += 4 } + text.startsWith("false", index) -> { addStyle(SpanStyle(color = colors.literal), index, index + 5) index += 5 } + char in "{}[]:," -> { addStyle(SpanStyle(color = colors.punctuation), index, index + 1) index++ } + else -> index++ } } diff --git a/devdeck/src/main/kotlin/io/devdeck/network/NetworkInspector.kt b/devdeck/src/main/kotlin/io/devdeck/network/NetworkInspector.kt index 1dbb009..f37f74c 100644 --- a/devdeck/src/main/kotlin/io/devdeck/network/NetworkInspector.kt +++ b/devdeck/src/main/kotlin/io/devdeck/network/NetworkInspector.kt @@ -102,11 +102,13 @@ public fun NetworkInspector( } when { visible.isEmpty() -> TextItem(if (calls.isEmpty()) "No requests yet" else "No requests match") + view == NetworkView.Timeline -> InfoGroup(dividerThreshold = 2) { visible.take(maxItems).forEach { call -> NetworkCallCard(call, highlights, repeatCount = repeatCounts[call.groupKey] ?: 1) } } + else -> InfoGroup(dividerThreshold = 2) { groups.take(maxItems).forEach { group -> NetworkGroupCard(group, highlights) } } } } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt index 8e16a25..db36111 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt @@ -69,12 +69,14 @@ public class FeatureFlagsPlugin( enabled = flag.editable, onCheckedChange = { store[flag] = it }, ) + is FeatureFlag.Text -> TextInputItem( title = flag.title, value = store[flag], enabled = flag.editable, onValueCommit = { store[flag] = it }, ) + is FeatureFlag.Number -> NumberInputItem( title = flag.title, value = store[flag], @@ -83,6 +85,7 @@ public class FeatureFlagsPlugin( range = flag.range, onValueCommit = { store[flag] = it }, ) + is FeatureFlag.Json -> JsonInputItem( title = flag.title, value = store[flag], diff --git a/devdeck/src/main/kotlin/io/devdeck/simulation/NetworkSimulator.kt b/devdeck/src/main/kotlin/io/devdeck/simulation/NetworkSimulator.kt index 202a994..0fcebf7 100644 --- a/devdeck/src/main/kotlin/io/devdeck/simulation/NetworkSimulator.kt +++ b/devdeck/src/main/kotlin/io/devdeck/simulation/NetworkSimulator.kt @@ -48,7 +48,9 @@ public object NetworkSimulator { if (current.urlFilter.isNotEmpty() && !url.contains(current.urlFilter, ignoreCase = true)) return null return when (current.condition) { NetworkCondition.Normal -> null + NetworkCondition.Slow -> NetworkEffect.Slow(current.delayMillis, current.bytesPerSecond) + NetworkCondition.Failing -> if (random.nextInt(100) < current.failureRatePercent) NetworkEffect.Fail(current.failure) else null } diff --git a/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt b/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt index 2feecee..3aa8723 100644 --- a/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt +++ b/devdeck/src/main/kotlin/io/devdeck/storage/SharedPreferencesBrowser.kt @@ -124,18 +124,25 @@ private fun PreferenceEditor(name: String, key: String) { val editor = prefs.values when (val value = editor.all[key]) { null -> TextItem("This key no longer exists") + is Boolean -> SwitchItem(key, checked = value, subtitle = "Boolean", onCheckedChange = { editor.edit { putBoolean(key, it) } }) + is Int -> NumberInputItem(key, value = value.toLong(), onValueCommit = { editor.edit { putInt(key, it.toInt()) } }) + is Long -> NumberInputItem(key, value = value, onValueCommit = { editor.edit { putLong(key, it) } }) + is Float -> NumberInputItem(key, value = value.toDouble(), onValueCommit = { editor.edit { putFloat(key, it.toFloat()) } }) + is String -> if (Json.isJson(value)) { JsonInputItem(key, value = value, onValueCommit = { editor.edit { putString(key, it) } }) } else { TextInputItem(key, value = value, singleLine = false, onValueCommit = { editor.edit { putString(key, it) } }) } + is Set<*> -> value.forEach { BulletItem(it.toString()) } + else -> BulletItem(key, value.toString()) } SectionDivider() diff --git a/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt b/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt index df0da84..0932f58 100644 --- a/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt @@ -156,11 +156,16 @@ internal fun updateAvailableColor(isDark: Boolean): Color = if (isDark) Color(0x private fun statusText(status: UpdateStatus, source: UpdateSource): String = when (status) { UpdateStatus.Idle -> if (source.requiresSignIn && !UpdateChecker.isSignedIn) "Sign in to check ${source.name}" else "Not checked yet" + UpdateStatus.Checking -> "Checking ${source.name}…" + UpdateStatus.UpToDate -> "You're on the latest version · checked ${UpdateChecker.lastCheckedAtMillis?.let(::time).orEmpty()}" + is UpdateStatus.Available -> "${status.update.label} is available" + is UpdateStatus.Installing -> status.progress?.let { "Downloading ${status.update.label} · ${(it * 100).toInt()}%" } ?: "Preparing ${status.update.label}…" + is UpdateStatus.Failed -> "Check failed: ${status.message}" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d82812d..9e4200e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,7 @@ core = "1.17.0" okhttp = "4.12.0" appdistribution = "16.0.0-beta21" timber = "4.7.1" -ktlint = "1.3.1" +ktlint = "1.8.0" junit = "4.13.2" json = "20231013"