From 4ed80b79d286048debf4ffd98d069aed229e2c46 Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 02:51:00 +0530 Subject: [PATCH 1/7] Permissions page, flag restart actions, endpoint restart, richer app info --- CHANGELOG.md | 7 +- .../main/kotlin/io/devdeck/device/AppInfo.kt | 8 +- .../io/devdeck/device/SystemSnapshot.kt | 81 ++++++++++++++++++- .../io/devdeck/plugin/AppActionsPlugin.kt | 6 ++ .../kotlin/io/devdeck/plugin/DevicePlugin.kt | 3 +- .../io/devdeck/plugin/EndpointPlugin.kt | 15 ++++ .../io/devdeck/plugin/FeatureFlagsPlugin.kt | 27 ++++--- sample/build.gradle.kts | 2 + 8 files changed, 131 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f631fc8..aaddf0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,9 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - 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. +- `TextInputItem`, `NumberInputItem` and `JsonInputItem` take an optional `subtitle`. +- The feature flags page has *Restart screen* and *Restart app* buttons, so flag changes that are read once per screen or process take effect. +- After switching endpoints, `EndpointPlugin` shows a *Restart app* button so the app picks up the new endpoint (unless `restartOnChange` already restarts it). - 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. @@ -22,6 +24,9 @@ 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. +- Permissions open on their own page instead of expanding inside the System section. +- The App section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable, R8 obfuscated or shrunk, signed with the debug or a release key, where it was installed from, target and min SDK, and install and update times, plus a shortcut to the app's system settings. `AppInfo()` and the device & app snapshot show the same details. +- Toggle flags no longer show an *Overridden* subtitle. - 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. diff --git a/devdeck/src/main/kotlin/io/devdeck/device/AppInfo.kt b/devdeck/src/main/kotlin/io/devdeck/device/AppInfo.kt index 631dad7..b9813f0 100644 --- a/devdeck/src/main/kotlin/io/devdeck/device/AppInfo.kt +++ b/devdeck/src/main/kotlin/io/devdeck/device/AppInfo.kt @@ -7,14 +7,16 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import io.devdeck.components.BulletItem +import io.devdeck.components.InfoGroup @Composable public fun AppInfo(modifier: Modifier = Modifier, showSystemShortcuts: Boolean = false) { val context = LocalContext.current - val app = remember(context) { appVersionInfo(context) } + val rows = remember(context) { appRows(context) } Column(modifier.fillMaxWidth()) { - BulletItem(label = "Package", value = context.packageName) - if (app != null) BulletItem(label = "Version", value = "${app.versionName} (${app.versionCode})") + InfoGroup { + rows.forEach { (label, value) -> BulletItem(label = label, value = value) } + } if (showSystemShortcuts) { AppSettingsButton() DeveloperOptionsButton() diff --git a/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt b/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt index c79a709..0797388 100644 --- a/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt +++ b/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt @@ -2,12 +2,16 @@ package io.devdeck.device import android.content.Context import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager import android.hardware.display.DisplayManager import android.os.Build import android.util.DisplayMetrics import android.view.Display import androidx.compose.runtime.Immutable import androidx.core.os.ConfigurationCompat +import java.security.MessageDigest +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -45,15 +49,21 @@ public fun systemSnapshot(context: Context, buildInfo: Map = emp ) } -private fun appRows(context: Context): List> { +internal fun appRows(context: Context): List> { val packageInfo = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull() val version = appVersionInfo(context) val appInfo = context.applicationInfo val debuggable = appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + val buildConfig = buildConfigValues(context) return listOfNotNull( version?.let { "Version" to "${it.versionName} (${it.versionCode})" }, "Package" to context.packageName, - "Build type" to if (debuggable) "Debuggable" else "Release", + buildConfig["BUILD_TYPE"]?.let { "Build type" to it }, + buildConfig["FLAVOR"]?.takeIf { it.isNotEmpty() }?.let { "Flavor" to it }, + "Debuggable" to if (debuggable) "Yes" else "No", + "R8" to r8State(), + signingSummary(context)?.let { "Signed with" to it }, + "Installed from" to installerName(context), "Target SDK" to appInfo.targetSdkVersion.toString(), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) "Min SDK" to appInfo.minSdkVersion.toString() else null, packageInfo?.let { "Installed" to formatDateTime(it.firstInstallTime) }, @@ -61,6 +71,73 @@ private fun appRows(context: Context): List> { ) } +// BuildConfig lives in the app's namespace, which can differ from the application id (e.g. an `.qa` suffix), +// so try the id, its parent packages and the Application class's package. R8 usually removes it. +private fun buildConfigValues(context: Context): Map { + val packageName = context.packageName + val candidates = buildList { + var name = packageName + while (name.contains('.')) { + add(name) + name = name.substringBeforeLast('.') + } + context.applicationInfo.className?.substringBeforeLast('.', "")?.takeIf { it.isNotEmpty() }?.let { add(it) } + }.distinct() + val buildConfig = candidates.firstNotNullOfOrNull { runCatching { Class.forName("$it.BuildConfig") }.getOrNull() } ?: return emptyMap() + return listOf("BUILD_TYPE", "FLAVOR").mapNotNull { field -> + runCatching { buildConfig.getField(field).get(null) as? String }.getOrNull()?.let { field to it } + }.toMap() +} + +// R8 renames classes when it obfuscates and drops Kotlin metadata when it shrinks, unless the app keeps them. +private class R8Probe + +private fun r8State(): String { + val renamed = R8Probe::class.java.name != "io.devdeck.device.R8Probe" + val metadataStripped = !R8Probe::class.java.isAnnotationPresent(Metadata::class.java) + return when { + renamed -> "Obfuscated" + metadataStripped -> "Shrunk, not obfuscated" + else -> "Off" + } +} + +private fun signingSummary(context: Context): String? = runCatching { + val pm = context.packageManager + val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES).signingInfo?.apkContentsSigners + } else { + @Suppress("DEPRECATION") + pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures + } + val bytes = certificates?.firstOrNull()?.toByteArray() ?: return null + val certificate = CertificateFactory.getInstance("X.509").generateCertificate(bytes.inputStream()) as X509Certificate + val key = if (certificate.subjectX500Principal.name.contains("CN=Android Debug")) "Debug key" else "Release key" + val fingerprint = MessageDigest.getInstance("SHA-256").digest(bytes).take(FINGERPRINT_BYTES) + .joinToString(":") { "%02X".format(it) } + "$key · SHA-256 $fingerprint…" +}.getOrNull() + +private fun installerName(context: Context): String { + val installer = runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName + } else { + @Suppress("DEPRECATION") + context.packageManager.getInstallerPackageName(context.packageName) + } + }.getOrNull() + return when (installer) { + null -> "adb or sideloaded" + "com.android.vending" -> "Google Play" + "dev.firebase.appdistribution" -> "Firebase App Distribution" + "com.android.shell" -> "adb" + else -> installer + } +} + +private const val FINGERPRINT_BYTES = 8 + private fun deviceRows(context: Context): List> { val metrics = DisplayMetrics() @Suppress("DEPRECATION") diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt index 68f08f1..1895208 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt @@ -10,6 +10,8 @@ import io.devdeck.components.ButtonItem import io.devdeck.components.ConfirmButtonItem import io.devdeck.components.ExpandableItem import io.devdeck.components.SwitchItem +import io.devdeck.device.AppInfo +import io.devdeck.device.AppSettingsButton import kotlinx.coroutines.launch public class AppActionsPlugin( @@ -29,6 +31,10 @@ public class AppActionsPlugin( override fun Content() { val context = LocalContext.current val scope = rememberCoroutineScope() + ExpandableItem("App info") { + AppInfo() + AppSettingsButton(title = "Open in system settings") + } if (deepLinks) ExpandableItem("Open a deep link") { DeepLinkLauncher(suggestions = deepLinkSuggestions) } if (webViewDebugging) { SwitchItem( diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt index 4fee887..381073c 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt @@ -2,6 +2,7 @@ package io.devdeck.plugin import androidx.compose.runtime.Composable import io.devdeck.components.ExpandableItem +import io.devdeck.components.PageLinkItem import io.devdeck.device.DeveloperOptionsButton import io.devdeck.device.DeviceInfo import io.devdeck.device.PermissionsList @@ -18,7 +19,7 @@ public class DevicePlugin( @Composable override fun Content() { if (deviceInfo) ExpandableItem("Device info") { DeviceInfo() } - if (permissions) ExpandableItem("Permissions") { PermissionsList() } + if (permissions) PageLinkItem("Permissions") { PermissionsList() } if (developerOptions) DeveloperOptionsButton() } } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt index 8b966fb..514a5ee 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.content.edit import io.devdeck.actions.AppActions +import io.devdeck.components.ButtonItem import io.devdeck.components.DropdownItem import io.devdeck.internal.DeckSettings import io.devdeck.logs.DeckLogger @@ -31,6 +32,8 @@ public class EndpointPlugin( override fun Content() { val context = LocalContext.current var selected by remember { mutableStateOf(current(context)) } + // The endpoint this process started with; apps usually read it once, so a switch needs a restart. + val applied = remember { appliedUrls.getOrPut(key) { selected.url } } DropdownItem( title = title, subtitle = selected.url, @@ -46,11 +49,23 @@ public class EndpointPlugin( }, optionLabel = { it.name }, ) + if (!restartOnChange && selected.url != applied) { + ButtonItem( + title = "Restart app", + subtitle = "Apply ${selected.name}", + onClick = { + save(context, key, selected, synchronous = true) + AppActions.restart(context) + }, + ) + } } public companion object { public const val DEFAULT_KEY: String = "default" + private val appliedUrls = mutableMapOf() + public fun current( context: Context, endpoints: List, diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt index 53c8b6b..eff002e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt @@ -4,8 +4,7 @@ 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.actions.AppActions import io.devdeck.components.ButtonItem import io.devdeck.components.ConfirmButtonItem import io.devdeck.components.JsonInputItem @@ -18,6 +17,7 @@ import io.devdeck.components.TextInputItem import io.devdeck.flags.FeatureFlag import io.devdeck.flags.FeatureFlagStore import io.devdeck.internal.copyToClipboard +import io.devdeck.internal.findActivity import io.devdeck.internal.readClipboardText import io.devdeck.logs.DeckLogger import org.json.JSONObject @@ -48,6 +48,20 @@ public class FeatureFlagsPlugin( } SectionDivider() } + // Most apps read flags once per screen or process, so changes show up after a restart. + Section("Apply changes 🔄") { + ButtonItem( + title = "Restart screen", + subtitle = "Recreates the current screen", + onClick = { context.findActivity()?.recreate() }, + ) + ButtonItem( + title = "Restart app", + subtitle = "Relaunches the app in a new process", + onClick = { AppActions.restart(context) }, + ) + } + SectionDivider() if (bulkEdit) { Section("Bulk edit ✏️") { ConfirmButtonItem( @@ -68,12 +82,9 @@ public class FeatureFlagsPlugin( @Composable private fun FlagList(categoryFlags: List>) { categoryFlags.forEach { flag -> - val overridden = store.isOverridden(flag) - val subtitle = if (overridden) "Overridden" else null when (flag) { is FeatureFlag.Toggle -> SwitchItem( title = flag.title, - subtitle = subtitle, checked = store[flag], enabled = flag.editable, onCheckedChange = { store[flag] = it }, @@ -81,7 +92,6 @@ public class FeatureFlagsPlugin( is FeatureFlag.Text -> TextInputItem( title = flag.title, - subtitle = subtitle, value = store[flag], enabled = flag.editable, onValueCommit = { store[flag] = it }, @@ -89,7 +99,6 @@ public class FeatureFlagsPlugin( is FeatureFlag.Number -> NumberInputItem( title = flag.title, - subtitle = subtitle, value = store[flag], enabled = flag.editable, allowDecimals = !flag.wholeNumber, @@ -99,15 +108,11 @@ 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/sample/build.gradle.kts b/sample/build.gradle.kts index 97a4872..0c3dbbe 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -51,6 +51,8 @@ android { buildFeatures { compose = true + // Lets DevDeck's App info show the build type and flavor. + buildConfig = true } } From 79a3378de73b584316424b1fab3ca8186e9165ed Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 02:52:21 +0530 Subject: [PATCH 2/7] Drop app settings row from App info; the header has a settings button --- CHANGELOG.md | 2 +- .../src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaddf0a..1a7cb69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - 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. - Permissions open on their own page instead of expanding inside the System section. -- The App section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable, R8 obfuscated or shrunk, signed with the debug or a release key, where it was installed from, target and min SDK, and install and update times, plus a shortcut to the app's system settings. `AppInfo()` and the device & app snapshot show the same details. +- The App section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable, R8 obfuscated or shrunk, signed with the debug or a release key, where it was installed from, target and min SDK, and install and update times. `AppInfo()` and the device & app snapshot show the same details. - Toggle flags no longer show an *Overridden* subtitle. - 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. diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt index 1895208..72cece3 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt @@ -11,7 +11,6 @@ import io.devdeck.components.ConfirmButtonItem import io.devdeck.components.ExpandableItem import io.devdeck.components.SwitchItem import io.devdeck.device.AppInfo -import io.devdeck.device.AppSettingsButton import kotlinx.coroutines.launch public class AppActionsPlugin( @@ -31,10 +30,7 @@ public class AppActionsPlugin( override fun Content() { val context = LocalContext.current val scope = rememberCoroutineScope() - ExpandableItem("App info") { - AppInfo() - AppSettingsButton(title = "Open in system settings") - } + ExpandableItem("App info") { AppInfo() } if (deepLinks) ExpandableItem("Open a deep link") { DeepLinkLauncher(suggestions = deepLinkSuggestions) } if (webViewDebugging) { SwitchItem( From e594f669c3a072dddc0ef0ac606316065e48696b Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 02:58:17 +0530 Subject: [PATCH 3/7] Add system language & region button; trim App info to build details --- CHANGELOG.md | 3 +- devdeck/api/devdeck.api | 1 + .../io/devdeck/appearance/Appearance.kt | 17 ++++++- .../io/devdeck/device/SystemSnapshot.kt | 45 ------------------- .../io/devdeck/plugin/AppearancePlugin.kt | 6 ++- 5 files changed, 23 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a7cb69..246432f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - `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`. +- A *System language & region* button in the User Interface section opens Android's language and region settings (`LocaleSettingsButton()`). - The feature flags page has *Restart screen* and *Restart app* buttons, so flag changes that are read once per screen or process take effect. - After switching endpoints, `EndpointPlugin` shows a *Restart app* button so the app picks up the new endpoint (unless `restartOnChange` already restarts it). - Share buttons on the Logs and Network activity pages, and share-all / delete-all in the capture gallery. @@ -25,7 +26,7 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - 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. - Permissions open on their own page instead of expanding inside the System section. -- The App section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable, R8 obfuscated or shrunk, signed with the debug or a release key, where it was installed from, target and min SDK, and install and update times. `AppInfo()` and the device & app snapshot show the same details. +- The App section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable or R8 obfuscated or shrunk, and target and min SDK. `AppInfo()` and the device & app snapshot show the same details. - Toggle flags no longer show an *Overridden* subtitle. - 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. diff --git a/devdeck/api/devdeck.api b/devdeck/api/devdeck.api index 1a94f3b..3040a99 100644 --- a/devdeck/api/devdeck.api +++ b/devdeck/api/devdeck.api @@ -243,6 +243,7 @@ public final class io/devdeck/actions/DeepLinkLauncherKt { 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 LocaleSettingsButton (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 } diff --git a/devdeck/src/main/kotlin/io/devdeck/appearance/Appearance.kt b/devdeck/src/main/kotlin/io/devdeck/appearance/Appearance.kt index 1dc6b4c..fa32867 100644 --- a/devdeck/src/main/kotlin/io/devdeck/appearance/Appearance.kt +++ b/devdeck/src/main/kotlin/io/devdeck/appearance/Appearance.kt @@ -95,9 +95,22 @@ public fun DisplaySettingsButton(modifier: Modifier = Modifier, title: String = ) } -private fun openDisplaySettings(context: Context) { +@Composable +public fun LocaleSettingsButton(modifier: Modifier = Modifier, title: String = "System language & region") { + val context = LocalContext.current + ButtonItem( + title = title, + subtitle = "Changes the whole device", + modifier = modifier, + onClick = { openSettings(context, Settings.ACTION_LOCALE_SETTINGS) }, + ) +} + +private fun openDisplaySettings(context: Context) = openSettings(context, Settings.ACTION_DISPLAY_SETTINGS) + +private fun openSettings(context: Context, action: String) { try { - context.startActivity(Intent(Settings.ACTION_DISPLAY_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + context.startActivity(Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } catch (_: ActivityNotFoundException) { } } diff --git a/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt b/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt index 0797388..45ccbd2 100644 --- a/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt +++ b/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt @@ -2,16 +2,12 @@ package io.devdeck.device import android.content.Context import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager import android.hardware.display.DisplayManager import android.os.Build import android.util.DisplayMetrics import android.view.Display import androidx.compose.runtime.Immutable import androidx.core.os.ConfigurationCompat -import java.security.MessageDigest -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -50,7 +46,6 @@ public fun systemSnapshot(context: Context, buildInfo: Map = emp } internal fun appRows(context: Context): List> { - val packageInfo = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull() val version = appVersionInfo(context) val appInfo = context.applicationInfo val debuggable = appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 @@ -62,12 +57,8 @@ internal fun appRows(context: Context): List> { buildConfig["FLAVOR"]?.takeIf { it.isNotEmpty() }?.let { "Flavor" to it }, "Debuggable" to if (debuggable) "Yes" else "No", "R8" to r8State(), - signingSummary(context)?.let { "Signed with" to it }, - "Installed from" to installerName(context), "Target SDK" to appInfo.targetSdkVersion.toString(), if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) "Min SDK" to appInfo.minSdkVersion.toString() else null, - packageInfo?.let { "Installed" to formatDateTime(it.firstInstallTime) }, - packageInfo?.let { "Updated" to formatDateTime(it.lastUpdateTime) }, ) } @@ -102,42 +93,6 @@ private fun r8State(): String { } } -private fun signingSummary(context: Context): String? = runCatching { - val pm = context.packageManager - val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES).signingInfo?.apkContentsSigners - } else { - @Suppress("DEPRECATION") - pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures - } - val bytes = certificates?.firstOrNull()?.toByteArray() ?: return null - val certificate = CertificateFactory.getInstance("X.509").generateCertificate(bytes.inputStream()) as X509Certificate - val key = if (certificate.subjectX500Principal.name.contains("CN=Android Debug")) "Debug key" else "Release key" - val fingerprint = MessageDigest.getInstance("SHA-256").digest(bytes).take(FINGERPRINT_BYTES) - .joinToString(":") { "%02X".format(it) } - "$key · SHA-256 $fingerprint…" -}.getOrNull() - -private fun installerName(context: Context): String { - val installer = runCatching { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - context.packageManager.getInstallSourceInfo(context.packageName).installingPackageName - } else { - @Suppress("DEPRECATION") - context.packageManager.getInstallerPackageName(context.packageName) - } - }.getOrNull() - return when (installer) { - null -> "adb or sideloaded" - "com.android.vending" -> "Google Play" - "dev.firebase.appdistribution" -> "Firebase App Distribution" - "com.android.shell" -> "adb" - else -> installer - } -} - -private const val FINGERPRINT_BYTES = 8 - private fun deviceRows(context: Context): List> { val metrics = DisplayMetrics() @Suppress("DEPRECATION") diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/AppearancePlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/AppearancePlugin.kt index 10dab26..4a6f227 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/AppearancePlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/AppearancePlugin.kt @@ -3,6 +3,7 @@ package io.devdeck.plugin import androidx.compose.runtime.Composable import io.devdeck.appearance.DisplaySettingsButton import io.devdeck.appearance.LanguageSelector +import io.devdeck.appearance.LocaleSettingsButton import io.devdeck.appearance.NightModeSelector public class AppearancePlugin( @@ -18,6 +19,9 @@ public class AppearancePlugin( override fun Content() { if (nightMode) NightModeSelector() if (languageTags.isNotEmpty()) LanguageSelector(languageTags) - if (displaySettings) DisplaySettingsButton() + if (displaySettings) { + LocaleSettingsButton() + DisplaySettingsButton() + } } } From 2a8a25fa66c676ed4eb5096843b570f7f4c08a56 Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 03:20:28 +0530 Subject: [PATCH 4/7] Recording controls, update chip focus, persistent floating button, tighter spacing and text --- CHANGELOG.md | 6 + devdeck/api/devdeck.api | 2 + .../src/main/kotlin/io/devdeck/DeckPanel.kt | 29 +++- .../src/main/kotlin/io/devdeck/DeckState.kt | 3 + .../src/main/kotlin/io/devdeck/DeckTheme.kt | 15 ++- .../main/kotlin/io/devdeck/DevDeckOverlay.kt | 77 +++++++++-- .../io/devdeck/actions/DeepLinkLauncher.kt | 6 +- .../io/devdeck/capture/CaptureGallery.kt | 6 +- .../io/devdeck/capture/RecordingControls.kt | 126 ++++++++++++++++++ .../io/devdeck/capture/ScreenCapture.kt | 22 +++ .../io/devdeck/capture/ScreenRecordButton.kt | 8 +- .../devdeck/capture/ScreenRecordingService.kt | 48 ++++++- .../io/devdeck/capture/ScreenshotButton.kt | 8 +- .../kotlin/io/devdeck/components/Dimens.kt | 6 +- .../kotlin/io/devdeck/components/Section.kt | 4 +- .../io/devdeck/components/SectionCard.kt | 7 +- .../io/devdeck/components/SectionDivider.kt | 2 +- .../kotlin/io/devdeck/internal/DeckIcons.kt | 3 + .../io/devdeck/plugin/DeckPluginContent.kt | 7 +- .../io/devdeck/plugin/FeatureFlagsPlugin.kt | 2 +- .../kotlin/io/devdeck/plugin/UpdatesPlugin.kt | 4 +- .../kotlin/io/devdeck/updates/UpdatesPanel.kt | 20 ++- 22 files changed, 365 insertions(+), 46 deletions(-) create mode 100644 devdeck/src/main/kotlin/io/devdeck/capture/RecordingControls.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 246432f..0259b2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - `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`. +- While recording the screen, a small floating bar shows the time with pause/resume (Android 7+) and stop, so you don't have to reopen the drawer; the drawer header shows a *Recording* chip, and the notification has Pause/Resume too. - A *System language & region* button in the User Interface section opens Android's language and region settings (`LocaleSettingsButton()`). - The feature flags page has *Restart screen* and *Restart app* buttons, so flag changes that are read once per screen or process take effect. - After switching endpoints, `EndpointPlugin` shows a *Restart app* button so the app picks up the new endpoint (unless `restartOnChange` already restarts it). @@ -26,6 +27,11 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - 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. - Permissions open on their own page instead of expanding inside the System section. +- The update chip in the header reads *New version* and opens the App section at *Test build updates*. +- Drawer text is a little smaller, list rows use Material's 48dp minimum height, and section titles and dividers take less space, so more fits on small phones. +- The header's settings and close buttons sit closer together. +- *Take a screenshot*, *Record a video*, *Open the gallery* and deep link suggestions use the standard button and page rows instead of bullet points. +- The floating button stays where you drag it, across screens, opening the drawer and app restarts. - The App section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable or R8 obfuscated or shrunk, and target and min SDK. `AppInfo()` and the device & app snapshot show the same details. - Toggle flags no longer show an *Overridden* subtitle. - Clearing logs, crash logs, breadcrumbs, requests, console lines, captures or shared preferences, and resetting all feature flags, now ask for a second tap. diff --git a/devdeck/api/devdeck.api b/devdeck/api/devdeck.api index 3040a99..284d248 100644 --- a/devdeck/api/devdeck.api +++ b/devdeck/api/devdeck.api @@ -348,6 +348,8 @@ public final class io/devdeck/capture/ScreenRecordButtonKt { public final class io/devdeck/capture/ScreenRecordingService : android/app/Service { public static final field $stable I + public static final field ACTION_PAUSE Ljava/lang/String; + public static final field ACTION_RESUME Ljava/lang/String; 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; diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt index dce0264..921e51f 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -49,15 +50,22 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.devdeck.capture.RecordingRed +import io.devdeck.capture.ScreenCapture +import io.devdeck.capture.formatRecordingTime +import io.devdeck.capture.rememberRecordingElapsed import io.devdeck.components.DeckCard import io.devdeck.device.appVersionInfo import io.devdeck.internal.DeckIcons +import io.devdeck.plugin.UPDATES_PLUGIN_ID import io.devdeck.simulation.MemorySimulator import io.devdeck.simulation.NetworkSimulator import io.devdeck.simulation.mb @@ -190,9 +198,17 @@ private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) { } val update = UpdateChecker.availableUpdate val updateColor = updateAvailableColor(LocalDeckColors.current?.isDark == true) + val deckState = LocalDeckState.current + val recordingElapsed = if (ScreenCapture.isRecording) rememberRecordingElapsed() else 0L val chips = buildList { + if (ScreenCapture.isRecording) { + val label = if (ScreenCapture.isRecordingPaused) "Recording paused" else "● Recording" + add(HeaderChip("$label · ${formatRecordingTime(recordingElapsed)}", RecordingRed)) + } version?.let { add(HeaderChip("v$it", null)) } - if (update != null) add(HeaderChip("Update · ${update.label}", updateColor)) + if (update != null) { + add(HeaderChip("New version · ${update.label}", updateColor, onClick = { deckState?.pendingFocus = UPDATES_PLUGIN_ID })) + } if (network.isActive) add(HeaderChip(network.summary, warning)) if (heldBytes > 0) add(HeaderChip("Holding ${mb(heldBytes)}", warning)) if (MemorySimulator.killOnBackground) add(HeaderChip("Kill when backgrounded", warning)) @@ -230,7 +246,7 @@ private fun DeckPageBar(title: String, onBack: () -> Unit, onClose: () -> Unit) @Composable private fun HeaderIconButton(icon: ImageVector, description: String, onClick: () -> Unit) { val card = LocalDeckColors.current?.card ?: MaterialTheme.colorScheme.surfaceVariant - IconButton(onClick = onClick) { + IconButton(onClick = onClick, modifier = Modifier.size(HeaderButtonSize)) { Box(Modifier.size(32.dp).background(card, CircleShape), contentAlignment = Alignment.Center) { Icon(icon, contentDescription = description, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) } @@ -248,7 +264,7 @@ private fun openAppSettings(context: Context) { @Composable private fun CloseButton(onClose: () -> Unit) { val card = LocalDeckColors.current?.card ?: MaterialTheme.colorScheme.surfaceVariant - IconButton(onClick = onClose) { + IconButton(onClick = onClose, modifier = Modifier.size(HeaderButtonSize)) { Box(Modifier.size(32.dp).background(card, CircleShape), contentAlignment = Alignment.Center) { Icon( DeckIcons.Close, @@ -260,7 +276,10 @@ private fun CloseButton(onClose: () -> Unit) { } } -private data class HeaderChip(val text: String, val accent: Color?) +// 40dp keeps the touch target close to the guideline while pulling the settings and close buttons together. +private val HeaderButtonSize = 40.dp + +private data class HeaderChip(val text: String, val accent: Color?, val onClick: (() -> Unit)? = null) @Composable private fun Chip(chip: HeaderChip) { @@ -276,6 +295,8 @@ private fun Chip(chip: HeaderChip) { modifier = Modifier .background(accent?.copy(alpha = 0.14f) ?: card, shape) .then(if (accent == null) Modifier.border(BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shape) else Modifier) + .clip(shape) + .then(chip.onClick?.let { Modifier.clickable(role = Role.Button, onClick = it) } ?: Modifier) .padding(horizontal = 10.dp, vertical = 4.dp), ) } diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckState.kt b/devdeck/src/main/kotlin/io/devdeck/DeckState.kt index c975d87..8a36e1b 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckState.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckState.kt @@ -42,6 +42,9 @@ public class DeckState(initialValue: DeckValue = DeckValue.Closed) { internal val expandedRows = mutableStateMapOf() + // A plugin id the drawer should open and scroll to, e.g. "updates" from the header chip. Cleared once shown. + internal var pendingFocus by mutableStateOf(null) + public fun open() { targetValue = DeckValue.Open } diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckTheme.kt b/devdeck/src/main/kotlin/io/devdeck/DeckTheme.kt index 3b4ebd7..04dda31 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckTheme.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckTheme.kt @@ -2,6 +2,7 @@ package io.devdeck import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable @@ -10,6 +11,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.isSpecified +import androidx.compose.ui.unit.sp @Immutable internal data class DeckColors(val panel: Color, val card: Color, val warning: Color, val isDark: Boolean) @@ -34,11 +36,22 @@ internal fun DeckTheme(style: DeckStyle, content: @Composable () -> Unit) { warning = if (dark) DarkWarning else LightWarning, isDark = dark, ) - MaterialTheme(colorScheme = colorScheme) { + MaterialTheme(colorScheme = colorScheme, typography = DeckTypography) { CompositionLocalProvider(LocalDeckColors provides colors, content = content) } } +// A notch smaller than Material's defaults: the drawer is narrow, and labels and descriptions should fit on one line more often. +private val DeckTypography = Typography().run { + copy( + titleLarge = titleLarge.copy(fontSize = 20.sp, lineHeight = 26.sp), + titleMedium = titleMedium.copy(fontSize = 15.sp, lineHeight = 22.sp), + bodyLarge = bodyLarge.copy(fontSize = 15.sp, lineHeight = 21.sp), + bodyMedium = bodyMedium.copy(fontSize = 13.sp, lineHeight = 18.sp), + bodySmall = bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + ) +} + private val LightPanel = Color(0xFFF2F3F7) private val LightWarning = Color(0xFFE65100) private val DarkWarning = Color(0xFFFFB74D) diff --git a/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt b/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt index 950ce93..94f1bf7 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt @@ -1,5 +1,6 @@ package io.devdeck +import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.animation.core.animate import androidx.compose.animation.core.tween @@ -38,13 +39,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min +import androidx.core.content.edit +import io.devdeck.capture.RecordingControls import io.devdeck.internal.DeckIcons +import io.devdeck.internal.DeckSettings import io.devdeck.performance.PerformanceMonitor import io.devdeck.performance.PerformanceOverlay import io.devdeck.simulation.SimulationBadge @@ -117,6 +123,8 @@ internal fun DevDeckOverlay( SimulationBadge() + if (!isVisible) RecordingControls() + if (isVisible) { Box( Modifier @@ -153,13 +161,24 @@ internal fun DevDeckOverlay( @Composable private fun FloatingTrigger(trigger: DeckTrigger.FloatingButton, onClick: () -> Unit) { - var offset by remember { mutableStateOf(Offset.Zero) } - Box( + val context = LocalContext.current + val layoutDirection = LocalLayoutDirection.current + BoxWithConstraints( Modifier .fillMaxSize() .windowInsetsPadding(WindowInsets.safeDrawing) .padding(12.dp), ) { + val sizePx = with(LocalDensity.current) { TriggerSize.roundToPx() } + val rangeX = (constraints.maxWidth - sizePx).coerceAtLeast(1).toFloat() + val rangeY = (constraints.maxHeight - sizePx).coerceAtLeast(1).toFloat() + val default = remember(trigger.alignment, layoutDirection) { + val spot = trigger.alignment.align(IntSize.Zero, IntSize(FRACTION_SCALE, FRACTION_SCALE), layoutDirection) + Offset(spot.x.toFloat() / FRACTION_SCALE, spot.y.toFloat() / FRACTION_SCALE) + } + // Stored as a fraction of the free area, so the button keeps its place across screens, rotation and restarts. + val fraction = TriggerPosition.get(context) ?: default + val position = IntOffset((fraction.x * rangeX).roundToInt(), (fraction.y * rangeY).roundToInt()) Surface( onClick = onClick, shape = CircleShape, @@ -167,33 +186,33 @@ private fun FloatingTrigger(trigger: DeckTrigger.FloatingButton, onClick: () -> contentColor = MaterialTheme.colorScheme.onPrimaryContainer, shadowElevation = 4.dp, modifier = Modifier - .align(trigger.alignment) - .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) } + .offset { position } .then( if (trigger.draggable) { - Modifier.pointerInput(Unit) { - detectDragGestures { change, dragAmount -> + Modifier.pointerInput(rangeX, rangeY, default) { + detectDragGestures(onDragEnd = { TriggerPosition.save(context) }) { change, dragAmount -> change.consume() - offset += dragAmount + val current = TriggerPosition.get(context) ?: default + TriggerPosition.set( + Offset( + (current.x + dragAmount.x / rangeX).coerceIn(0f, 1f), + (current.y + dragAmount.y / rangeY).coerceIn(0f, 1f), + ), + ) } } } else { Modifier }, ) - .size(44.dp), + .size(TriggerSize), ) { Box(contentAlignment = Alignment.Center) { trigger.icon?.invoke() ?: Icon(DeckIcons.BugReport, contentDescription = "Open debug menu") } } if (UpdateChecker.availableUpdate != null) { - Box( - Modifier - .align(trigger.alignment) - .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) } - .size(44.dp), - ) { + Box(Modifier.offset { position }.size(TriggerSize)) { Box( Modifier .align(Alignment.TopEnd) @@ -206,3 +225,33 @@ private fun FloatingTrigger(trigger: DeckTrigger.FloatingButton, onClick: () -> } } } + +/** Where the user dragged the floating button, shared by every screen and kept in DevDeck's settings. */ +private object TriggerPosition { + private var position by mutableStateOf(null) + private var loaded = false + + fun get(context: Context): Offset? { + if (!loaded) { + loaded = true + val prefs = DeckSettings.of(context) + if (prefs.contains(KEY_X)) position = Offset(prefs.getFloat(KEY_X, 0f), prefs.getFloat(KEY_Y, 0f)) + } + return position + } + + fun set(value: Offset) { + position = value + } + + fun save(context: Context) { + val value = position ?: return + DeckSettings.of(context).edit { putFloat(KEY_X, value.x).putFloat(KEY_Y, value.y) } + } + + private const val KEY_X = "trigger.x" + private const val KEY_Y = "trigger.y" +} + +private val TriggerSize = 44.dp +private const val FRACTION_SCALE = 10_000 diff --git a/devdeck/src/main/kotlin/io/devdeck/actions/DeepLinkLauncher.kt b/devdeck/src/main/kotlin/io/devdeck/actions/DeepLinkLauncher.kt index af11c15..36bb1a7 100644 --- a/devdeck/src/main/kotlin/io/devdeck/actions/DeepLinkLauncher.kt +++ b/devdeck/src/main/kotlin/io/devdeck/actions/DeepLinkLauncher.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.edit import androidx.core.net.toUri -import io.devdeck.components.BulletItem +import io.devdeck.components.ButtonItem import io.devdeck.components.SectionTitle import io.devdeck.internal.DeckIcons import io.devdeck.internal.DeckSettings @@ -62,7 +62,7 @@ public fun DeepLinkLauncher(modifier: Modifier = Modifier, suggestions: List - BulletItem(link, onClick = { + ButtonItem(title = link, onClick = { uri = link open(link) }) @@ -71,7 +71,7 @@ public fun DeepLinkLauncher(modifier: Modifier = Modifier, suggestions: List - BulletItem(link, onClick = { + ButtonItem(title = link, onClick = { uri = link open(link) }) diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt index d380a62..abad48e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt @@ -33,10 +33,9 @@ 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.PageLinkItem import io.devdeck.components.TextItem import io.devdeck.internal.DeckIcons import io.devdeck.internal.openFile @@ -48,8 +47,7 @@ import kotlinx.coroutines.withContext @Composable public fun GalleryButton(title: String = "Open the gallery") { - val drawerState = LocalDeckState.current - BulletItem(title) { drawerState?.navigateTo("Gallery") { CaptureGallery() } } + PageLinkItem(title = title, pageTitle = "Gallery") { CaptureGallery() } } @Composable diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/RecordingControls.kt b/devdeck/src/main/kotlin/io/devdeck/capture/RecordingControls.kt new file mode 100644 index 0000000..8c1552e --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/capture/RecordingControls.kt @@ -0,0 +1,126 @@ +package io.devdeck.capture + +import android.os.SystemClock +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import io.devdeck.LocalDeckColors +import io.devdeck.internal.DeckIcons +import java.util.Locale +import kotlin.math.roundToInt +import kotlinx.coroutines.delay + +/** Small floating bar with the recording time, pause/resume and stop, so testers don't have to reopen the drawer. */ +@Composable +internal fun RecordingControls(modifier: Modifier = Modifier) { + if (!ScreenCapture.isRecording) return + val context = LocalContext.current + val elapsed = rememberRecordingElapsed() + val paused = ScreenCapture.isRecordingPaused + var drag by remember { mutableStateOf(Offset.Zero) } + val isDark = LocalDeckColors.current?.isDark == true + val background = if (isDark) Color(0xE6202124) else Color(0xEBFFFFFF) + val content = if (isDark) Color(0xFFE8EAED) else Color(0xFF202124) + Box(modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).padding(top = 8.dp)) { + Row( + Modifier + .align(Alignment.TopCenter) + .offset { IntOffset(drag.x.roundToInt(), drag.y.roundToInt()) } + .pointerInput(Unit) { + detectDragGestures { change, amount -> + change.consume() + drag += amount + } + } + .shadow(4.dp, CircleShape) + .background(background, CircleShape) + .padding(start = 12.dp, end = 4.dp) + .semantics { contentDescription = if (paused) "Recording paused" else "Recording" }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box(Modifier.size(8.dp).background(if (paused) content.copy(alpha = 0.4f) else RecordingRed, CircleShape)) + Text( + formatRecordingTime(elapsed), + style = MaterialTheme.typography.labelMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = content, + ) + if (ScreenCapture.canPauseRecording) { + IconButton( + onClick = { if (paused) ScreenCapture.resumeRecording(context) else ScreenCapture.pauseRecording(context) }, + modifier = Modifier.size(ControlSize), + ) { + Icon( + if (paused) DeckIcons.PlayArrow else DeckIcons.Pause, + contentDescription = if (paused) "Resume recording" else "Pause recording", + tint = content, + modifier = Modifier.size(18.dp), + ) + } + } + IconButton(onClick = { ScreenCapture.stopRecording(context) }, modifier = Modifier.size(ControlSize)) { + Icon(DeckIcons.Stop, contentDescription = "Stop recording", tint = RecordingRed, modifier = Modifier.size(18.dp)) + } + } + } +} + +/** Recording time in millis, ticking while a recording is running. */ +@Composable +internal fun rememberRecordingElapsed(): Long { + var now by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } + val ticking = ScreenCapture.isRecording && !ScreenCapture.isRecordingPaused + LaunchedEffect(ticking) { + while (ticking) { + now = SystemClock.elapsedRealtime() + delay(TICK_MILLIS) + } + } + return ScreenCapture.recordingElapsedMillis(now) +} + +internal fun formatRecordingTime(millis: Long): String { + val seconds = (millis / 1000).coerceAtLeast(0) + return String.format(Locale.US, "%d:%02d", seconds / 60, seconds % 60) +} + +internal val RecordingRed = Color(0xFFE53935) + +private val ControlSize = 36.dp +private const val TICK_MILLIS = 500L diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt index f2586a7..82b0733 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt @@ -8,9 +8,11 @@ import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.view.PixelCopy import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.content.ContextCompat @@ -37,6 +39,18 @@ public object ScreenCapture { public var isRecording: Boolean by mutableStateOf(false) internal set + internal var isRecordingPaused by mutableStateOf(false) + + // Recording time is the time before the last resume plus the time since it, so pauses don't count. + internal var recordedMillisBeforeResume by mutableLongStateOf(0L) + internal var resumedAtMillis by mutableLongStateOf(0L) + + // MediaRecorder.pause() arrived in Android 7.0. + internal val canPauseRecording: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + + internal fun recordingElapsedMillis(now: Long = SystemClock.elapsedRealtime()): Long = + recordedMillisBeforeResume + if (isRecordingPaused) 0 else now - resumedAtMillis + internal var galleryVersion by mutableIntStateOf(0) private val captureCounter = AtomicInteger() @@ -110,6 +124,14 @@ public object ScreenCapture { ContextCompat.startForegroundService(context, intent) } + internal fun pauseRecording(context: Context) = sendRecordingAction(context, ScreenRecordingService.ACTION_PAUSE) + + internal fun resumeRecording(context: Context) = sendRecordingAction(context, ScreenRecordingService.ACTION_RESUME) + + private fun sendRecordingAction(context: Context, action: String) { + context.startService(Intent(context, ScreenRecordingService::class.java).setAction(action)) + } + internal fun stopRecording(context: Context) { context.startService( Intent(context, ScreenRecordingService::class.java).setAction(ScreenRecordingService.ACTION_STOP), diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt index b0ed307..a2e1261 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt @@ -9,7 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalContext import io.devdeck.LocalDeckState -import io.devdeck.components.BulletItem +import io.devdeck.components.ButtonItem import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -27,11 +27,11 @@ public fun ScreenRecordButton(title: String = "Record a video", stopTitle: Strin } } if (ScreenCapture.isRecording) { - BulletItem(stopTitle) { ScreenCapture.stopRecording(context) } + ButtonItem(title = stopTitle, destructive = true, onClick = { ScreenCapture.stopRecording(context) }) } else { - BulletItem(title) { + ButtonItem(title = title, onClick = { val manager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager launcher.launch(manager.createScreenCaptureIntent()) - } + }) } } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt index 6790106..6c0c8cc 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordingService.kt @@ -17,6 +17,7 @@ import android.os.Build import android.os.Handler import android.os.IBinder import android.os.Looper +import android.os.SystemClock import android.util.DisplayMetrics import android.view.Display import android.widget.Toast @@ -54,6 +55,10 @@ public class ScreenRecordingService : Service() { } } + ACTION_PAUSE -> pause() + + ACTION_RESUME -> resume() + ACTION_STOP -> finish() } return START_NOT_STICKY @@ -122,6 +127,9 @@ public class ScreenRecordingService : Service() { mediaRecorder.start() } if (started.isSuccess) { + ScreenCapture.recordedMillisBeforeResume = 0 + ScreenCapture.resumedAtMillis = SystemClock.elapsedRealtime() + ScreenCapture.isRecordingPaused = false ScreenCapture.isRecording = true DeckLogger.log("Screen recording started", tag = LOG_TAG) } else { @@ -133,6 +141,28 @@ public class ScreenRecordingService : Service() { } } + private fun pause() { + val mediaRecorder = recorder ?: return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || ScreenCapture.isRecordingPaused) return + if (runCatching { mediaRecorder.pause() }.isFailure) return + ScreenCapture.recordedMillisBeforeResume = ScreenCapture.recordingElapsedMillis() + ScreenCapture.isRecordingPaused = true + updateNotification() + } + + private fun resume() { + val mediaRecorder = recorder ?: return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || !ScreenCapture.isRecordingPaused) return + if (runCatching { mediaRecorder.resume() }.isFailure) return + ScreenCapture.resumedAtMillis = SystemClock.elapsedRealtime() + ScreenCapture.isRecordingPaused = false + updateNotification() + } + + private fun updateNotification() { + (getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, buildNotification()) + } + private fun finish() { val file = outputFile val saved = recorder?.let { runCatching { it.stop() }.isSuccess } == true @@ -153,6 +183,7 @@ public class ScreenRecordingService : Service() { ScreenCapture.notifyGalleryChanged() } ScreenCapture.isRecording = false + ScreenCapture.isRecordingPaused = false ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() } @@ -175,11 +206,24 @@ public class ScreenRecordingService : Service() { Intent(this, ScreenRecordingService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) + val paused = ScreenCapture.isRecordingPaused NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(android.R.drawable.presence_video_online) - .setContentTitle("Recording screen") + .setContentTitle(if (paused) "Recording paused" else "Recording screen") .setContentText("Tap Stop to save the recording") .setOngoing(true) + .apply { + if (ScreenCapture.canPauseRecording) { + val action = if (paused) ACTION_RESUME else ACTION_PAUSE + val intent = PendingIntent.getService( + this@ScreenRecordingService, + 1, + Intent(this@ScreenRecordingService, ScreenRecordingService::class.java).setAction(action), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + addAction(0, if (paused) "Resume" else "Pause", intent) + } + } .addAction(0, "Stop", stopIntent) .build() } @@ -187,6 +231,8 @@ public class ScreenRecordingService : Service() { internal companion object { const val ACTION_START = "io.devdeck.action.START_RECORDING" const val ACTION_STOP = "io.devdeck.action.STOP_RECORDING" + const val ACTION_PAUSE = "io.devdeck.action.PAUSE_RECORDING" + const val ACTION_RESUME = "io.devdeck.action.RESUME_RECORDING" const val EXTRA_RESULT_CODE = "resultCode" const val EXTRA_DATA = "data" private const val CHANNEL_ID = "devdeck_recording" diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt index 6ab9a2f..6ee7fcc 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.platform.LocalContext import io.devdeck.LocalDeckState -import io.devdeck.components.BulletItem +import io.devdeck.components.ButtonItem import io.devdeck.internal.findActivity import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -15,8 +15,8 @@ import kotlinx.coroutines.launch public fun ScreenshotButton(title: String = "Take a screenshot") { val context = LocalContext.current val drawerState = LocalDeckState.current - BulletItem(title) { - val activity = context.findActivity() ?: return@BulletItem + ButtonItem(title = title, onClick = { + val activity = context.findActivity() ?: return@ButtonItem drawerState?.close() ScreenCapture.scope.launch { if (drawerState != null) snapshotFlow { drawerState.isVisible }.first { !it } @@ -25,5 +25,5 @@ public fun ScreenshotButton(title: String = "Take a screenshot") { val message = if (file != null) "Screenshot saved" else "Screenshot failed" Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() } - } + }) } diff --git a/devdeck/src/main/kotlin/io/devdeck/components/Dimens.kt b/devdeck/src/main/kotlin/io/devdeck/components/Dimens.kt index a3d2e2a..454afa4 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/Dimens.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/Dimens.kt @@ -6,5 +6,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp internal val HorizontalPadding = 16.dp -internal val RowMinHeight = 52.dp -internal val RowPadding = Modifier.heightIn(min = RowMinHeight).padding(horizontal = HorizontalPadding, vertical = 12.dp) + +// Material's 48dp minimum touch target; rows grow with a subtitle. +internal val RowMinHeight = 48.dp +internal val RowPadding = Modifier.heightIn(min = RowMinHeight).padding(horizontal = HorizontalPadding, vertical = 8.dp) diff --git a/devdeck/src/main/kotlin/io/devdeck/components/Section.kt b/devdeck/src/main/kotlin/io/devdeck/components/Section.kt index a50be2a..36fb69b 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/Section.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/Section.kt @@ -3,7 +3,6 @@ package io.devdeck.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -21,8 +20,7 @@ public fun SectionTitle(text: String, modifier: Modifier = Modifier) { color = MaterialTheme.colorScheme.onSurface, modifier = modifier .fillMaxWidth() - .heightIn(min = RowMinHeight) - .padding(horizontal = HorizontalPadding, vertical = 14.dp), + .padding(start = HorizontalPadding, end = HorizontalPadding, top = 12.dp, bottom = 4.dp), ) } diff --git a/devdeck/src/main/kotlin/io/devdeck/components/SectionCard.kt b/devdeck/src/main/kotlin/io/devdeck/components/SectionCard.kt index 1c29a4d..740c034 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/SectionCard.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/SectionCard.kt @@ -15,6 +15,7 @@ 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 @@ -34,6 +35,7 @@ internal fun SectionCard( collapsible: Boolean, modifier: Modifier = Modifier, closeOthersOnOpen: Boolean = false, + pluginIds: List = emptyList(), content: @Composable () -> Unit, ) { val drawerState = LocalDeckState.current @@ -54,6 +56,9 @@ internal fun SectionCard( } if (!expanded) reveal.request() } + // Opens this section when something asks the drawer to show one of its plugins. + val focusHere = drawerState?.pendingFocus?.let { it in pluginIds } == true + LaunchedEffect(focusHere) { if (focusHere && !expanded) toggle() } val (emoji, name) = remember(title) { splitSectionTitle(title) } val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "sectionChevron") @@ -62,7 +67,7 @@ internal fun SectionCard( Modifier .fillMaxWidth() .then(if (collapsible) Modifier.clickable(onClick = toggle) else Modifier) - .padding(horizontal = 12.dp, vertical = 10.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { SectionBadge(name, emoji) diff --git a/devdeck/src/main/kotlin/io/devdeck/components/SectionDivider.kt b/devdeck/src/main/kotlin/io/devdeck/components/SectionDivider.kt index 4026244..a9b816f 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/SectionDivider.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/SectionDivider.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.unit.dp @Composable public fun SectionDivider(modifier: Modifier = Modifier) { HorizontalDivider( - modifier = modifier.padding(horizontal = HorizontalPadding, vertical = 8.dp), + modifier = modifier.padding(horizontal = HorizontalPadding, vertical = 4.dp), thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant, ) diff --git a/devdeck/src/main/kotlin/io/devdeck/internal/DeckIcons.kt b/devdeck/src/main/kotlin/io/devdeck/internal/DeckIcons.kt index db5f69f..7c69f9c 100644 --- a/devdeck/src/main/kotlin/io/devdeck/internal/DeckIcons.kt +++ b/devdeck/src/main/kotlin/io/devdeck/internal/DeckIcons.kt @@ -22,6 +22,9 @@ internal object DeckIcons { val Pause: ImageVector by lazy { icon("Pause", "M6,19h4V5H6v14zM14,5v14h4V5h-4z") } + val Stop: ImageVector by lazy { + icon("Stop", "M6,6h12v12H6z") + } val PlayArrow: ImageVector by lazy { icon("PlayArrow", "M8,5v14l11,-7z") } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/DeckPluginContent.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/DeckPluginContent.kt index 044008d..f11ee2c 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/DeckPluginContent.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/DeckPluginContent.kt @@ -37,7 +37,12 @@ public fun DeckPluginContent( DeckCard { pluginsContent() } } else { key(section) { - SectionCard(section, collapsible = collapsibleSections, closeOthersOnOpen = oneSectionAtATime) { pluginsContent() } + SectionCard( + section, + collapsible = collapsibleSections, + closeOthersOnOpen = oneSectionAtATime, + pluginIds = sectionPlugins.map { it.id }, + ) { pluginsContent() } } } } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt index eff002e..9a7d9be 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/FeatureFlagsPlugin.kt @@ -57,7 +57,7 @@ public class FeatureFlagsPlugin( ) ButtonItem( title = "Restart app", - subtitle = "Relaunches the app in a new process", + subtitle = "Starts the app again", onClick = { AppActions.restart(context) }, ) } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/UpdatesPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/UpdatesPlugin.kt index c473b38..71cb7a2 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/UpdatesPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/UpdatesPlugin.kt @@ -7,6 +7,8 @@ import io.devdeck.updates.UpdateNotifier import io.devdeck.updates.UpdateSource import io.devdeck.updates.UpdatesPanel +internal const val UPDATES_PLUGIN_ID = "updates" + public class UpdatesPlugin( private val source: UpdateSource, private val checkOnStart: Boolean = true, @@ -15,7 +17,7 @@ public class UpdatesPlugin( override val section: String? = "App 🛠️", ) : DeckPlugin { - override val id: String = "updates" + override val id: String = UPDATES_PLUGIN_ID override fun onAttach(context: Context) { val app = context.applicationContext diff --git a/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt b/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt index 0932f58..21676df 100644 --- a/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -21,6 +23,7 @@ import androidx.compose.material3.LinearProgressIndicator 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 @@ -32,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.net.toUri import io.devdeck.LocalDeckColors +import io.devdeck.LocalDeckState import io.devdeck.components.ActionPill import io.devdeck.components.ActionPillRow import io.devdeck.components.DropdownItem @@ -39,9 +43,11 @@ import io.devdeck.components.HorizontalPadding import io.devdeck.components.TitleAndSubtitle import io.devdeck.device.appVersionInfo import io.devdeck.internal.DeckIcons +import io.devdeck.plugin.UPDATES_PLUGIN_ID import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import kotlinx.coroutines.delay @Composable public fun UpdatesPanel(modifier: Modifier = Modifier, title: String = "Test build updates") { @@ -50,7 +56,17 @@ public fun UpdatesPanel(modifier: Modifier = Modifier, title: String = "Test bui val update = UpdateChecker.availableUpdate val success = updateAvailableColor(LocalDeckColors.current?.isDark == true) val context = LocalContext.current - Column(modifier.fillMaxWidth()) { + val deckState = LocalDeckState.current + val bringIntoView = remember { BringIntoViewRequester() } + LaunchedEffect(deckState?.pendingFocus) { + if (deckState?.pendingFocus == UPDATES_PLUGIN_ID) { + // Wait for the section to finish expanding so the scroll lands on the final position. + delay(FOCUS_DELAY_MILLIS) + bringIntoView.bringIntoView() + deckState.pendingFocus = null + } + } + Column(modifier.fillMaxWidth().bringIntoViewRequester(bringIntoView)) { Row( Modifier.fillMaxWidth().padding(start = HorizontalPadding, end = 4.dp, top = 6.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, @@ -170,3 +186,5 @@ private fun statusText(status: UpdateStatus, source: UpdateSource): String = whe } private fun time(millis: Long): String = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(millis)) + +private const val FOCUS_DELAY_MILLIS = 350L From 6e4de1bae9813c9e0565428fe40111845541c9f5 Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 04:05:58 +0530 Subject: [PATCH 5/7] Version under subtitle; recording chip opens capture tools and has a stop button --- CHANGELOG.md | 3 +- .../src/main/kotlin/io/devdeck/DeckPanel.kt | 73 +++++++++++++++---- .../io/devdeck/capture/ScreenCaptureTools.kt | 7 ++ .../io/devdeck/capture/ScreenRecordButton.kt | 31 +++++++- .../io/devdeck/components/ExpandableItem.kt | 5 +- .../io/devdeck/components/SectionCard.kt | 3 +- .../kotlin/io/devdeck/plugin/UiToolsPlugin.kt | 4 +- 7 files changed, 105 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0259b2e..4c9b18e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - `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`. -- While recording the screen, a small floating bar shows the time with pause/resume (Android 7+) and stop, so you don't have to reopen the drawer; the drawer header shows a *Recording* chip, and the notification has Pause/Resume too. +- While recording the screen, a small floating bar shows the time with pause/resume (Android 7+) and stop, so you don't have to reopen the drawer; the drawer header shows a *Recording* chip with a red stop button (tapping the chip opens the screen capture tools), and the notification has Pause/Resume too. - A *System language & region* button in the User Interface section opens Android's language and region settings (`LocaleSettingsButton()`). - The feature flags page has *Restart screen* and *Restart app* buttons, so flag changes that are read once per screen or process take effect. - After switching endpoints, `EndpointPlugin` shows a *Restart app* button so the app picks up the new endpoint (unless `restartOnChange` already restarts it). @@ -30,6 +30,7 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - The update chip in the header reads *New version* and opens the App section at *Test build updates*. - Drawer text is a little smaller, list rows use Material's 48dp minimum height, and section titles and dividers take less space, so more fits on small phones. - The header's settings and close buttons sit closer together. +- The app version shows under the drawer subtitle (*Version 1.0.0 (10000)*) instead of as a chip. - *Take a screenshot*, *Record a video*, *Open the gallery* and deep link suggestions use the standard button and page rows instead of bullet points. - The floating button stays where you drag it, across screens, opening the drawer and app restarts. - The App section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable or R8 obfuscated or shrunk, and target and min SDK. `AppInfo()` and the device & app snapshot show the same details. diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt index 921e51f..76d75a8 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import io.devdeck.capture.RECORDING_FOCUS import io.devdeck.capture.RecordingRed import io.devdeck.capture.ScreenCapture import io.devdeck.capture.formatRecordingTime @@ -71,6 +72,7 @@ import io.devdeck.simulation.NetworkSimulator import io.devdeck.simulation.mb import io.devdeck.updates.UpdateChecker import io.devdeck.updates.updateAvailableColor +import kotlinx.coroutines.delay @Composable internal fun DeckPanel( @@ -91,6 +93,13 @@ internal fun DeckPanel( Surface(modifier = modifier, shape = shape, color = colors?.panel ?: MaterialTheme.colorScheme.surface, shadowElevation = 12.dp) { CompositionLocalProvider(LocalDeckState provides state) { + // Whatever asked to be shown has had time to scroll into view; drop the request so it can't linger. + LaunchedEffect(state.pendingFocus) { + if (state.pendingFocus != null) { + delay(FOCUS_TIMEOUT_MILLIS) + state.pendingFocus = null + } + } val page = state.pages.lastOrNull() AnimatedContent( targetState = PageEntry(page, state.pages.size), @@ -157,11 +166,12 @@ private fun DeckPageContent( } private const val PAGE_TRANSITION_MILLIS = 280 +private const val FOCUS_TIMEOUT_MILLIS = 2_000L @Composable private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) { val context = LocalContext.current - val version = remember(context) { appVersionInfo(context)?.versionName } + val version = remember(context) { appVersionInfo(context)?.let { "${it.versionName} (${it.versionCode})" } } val warning = LocalDeckColors.current?.warning ?: MaterialTheme.colorScheme.error LaunchedEffect(Unit) { UpdateChecker.checkIfDue() } val network = NetworkSimulator.simulation @@ -192,6 +202,15 @@ private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) { overflow = TextOverflow.Ellipsis, ) } + if (version != null) { + Text( + text = "Version $version", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } HeaderIconButton(DeckIcons.Settings, "Open app settings") { openAppSettings(context) } CloseButton(onClose) @@ -203,9 +222,15 @@ private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) { val chips = buildList { if (ScreenCapture.isRecording) { val label = if (ScreenCapture.isRecordingPaused) "Recording paused" else "● Recording" - add(HeaderChip("$label · ${formatRecordingTime(recordingElapsed)}", RecordingRed)) + add( + HeaderChip( + "$label · ${formatRecordingTime(recordingElapsed)}", + RecordingRed, + onClick = { deckState?.pendingFocus = RECORDING_FOCUS }, + stop = { ScreenCapture.stopRecording(context) }, + ), + ) } - version?.let { add(HeaderChip("v$it", null)) } if (update != null) { add(HeaderChip("New version · ${update.label}", updateColor, onClick = { deckState?.pendingFocus = UPDATES_PLUGIN_ID })) } @@ -279,24 +304,42 @@ private fun CloseButton(onClose: () -> Unit) { // 40dp keeps the touch target close to the guideline while pulling the settings and close buttons together. private val HeaderButtonSize = 40.dp -private data class HeaderChip(val text: String, val accent: Color?, val onClick: (() -> Unit)? = null) +private data class HeaderChip(val text: String, val accent: Color?, val onClick: (() -> Unit)? = null, val stop: (() -> Unit)? = null) @Composable private fun Chip(chip: HeaderChip) { val shape = RoundedCornerShape(50) val card = LocalDeckColors.current?.card ?: MaterialTheme.colorScheme.surface val accent = chip.accent - Text( - text = chip.text, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium, - color = accent ?: MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier + Row( + Modifier .background(accent?.copy(alpha = 0.14f) ?: card, shape) .then(if (accent == null) Modifier.border(BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shape) else Modifier) - .clip(shape) - .then(chip.onClick?.let { Modifier.clickable(role = Role.Button, onClick = it) } ?: Modifier) - .padding(horizontal = 10.dp, vertical = 4.dp), - ) + .clip(shape), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = chip.text, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = accent ?: MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier + .then(chip.onClick?.let { Modifier.clickable(role = Role.Button, onClick = it) } ?: Modifier) + .padding(start = 10.dp, end = if (chip.stop != null) 4.dp else 10.dp, top = 4.dp, bottom = 4.dp), + ) + chip.stop?.let { stop -> + Box( + Modifier + .padding(end = 4.dp) + .size(20.dp) + .clip(CircleShape) + .background(RecordingRed) + .clickable(role = Role.Button, onClickLabel = "Stop recording", onClick = stop), + contentAlignment = Alignment.Center, + ) { + Icon(DeckIcons.Stop, contentDescription = "Stop recording", tint = Color.White, modifier = Modifier.size(12.dp)) + } + } + } } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCaptureTools.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCaptureTools.kt index 4f3d485..517a130 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCaptureTools.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCaptureTools.kt @@ -1,11 +1,18 @@ package io.devdeck.capture import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import io.devdeck.LocalDeckState import io.devdeck.components.ExpandableItem +import io.devdeck.components.expandableKey @Composable public fun ScreenCaptureTools(modifier: Modifier = Modifier, title: String = "Screen capture tools") { + val drawerState = LocalDeckState.current + LaunchedEffect(drawerState?.pendingFocus) { + if (drawerState?.pendingFocus == RECORDING_FOCUS) drawerState.expandedRows[expandableKey(drawerState, title)] = true + } ExpandableItem(title = title, modifier = modifier) { ScreenshotButton() ScreenRecordButton() diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt index a2e1261..dda951d 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt @@ -5,11 +5,18 @@ import android.content.Context import android.media.projection.MediaProjectionManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import io.devdeck.LocalDeckState import io.devdeck.components.ButtonItem +import io.devdeck.plugin.UI_TOOLS_PLUGIN_ID +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -26,12 +33,32 @@ public fun ScreenRecordButton(title: String = "Record a video", stopTitle: Strin ScreenCapture.startRecording(context.applicationContext, result.resultCode, data) } } + // The header's recording chip asks the drawer to show this button. + val bringIntoView = remember { BringIntoViewRequester() } + LaunchedEffect(drawerState?.pendingFocus) { + if (drawerState?.pendingFocus == RECORDING_FOCUS) { + delay(FOCUS_DELAY_MILLIS) + bringIntoView.bringIntoView() + drawerState.pendingFocus = null + } + } + val focusModifier = Modifier.bringIntoViewRequester(bringIntoView) if (ScreenCapture.isRecording) { - ButtonItem(title = stopTitle, destructive = true, onClick = { ScreenCapture.stopRecording(context) }) + ButtonItem( + title = stopTitle, + destructive = true, + modifier = focusModifier, + onClick = { ScreenCapture.stopRecording(context) }, + ) } else { - ButtonItem(title = title, onClick = { + ButtonItem(title = title, modifier = focusModifier, onClick = { val manager = context.getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager launcher.launch(manager.createScreenCaptureIntent()) }) } } + +/** Asks the drawer to open the screen capture tools and show the record button. */ +internal const val RECORDING_FOCUS = "$UI_TOOLS_PLUGIN_ID/recording" + +private const val FOCUS_DELAY_MILLIS = 450L diff --git a/devdeck/src/main/kotlin/io/devdeck/components/ExpandableItem.kt b/devdeck/src/main/kotlin/io/devdeck/components/ExpandableItem.kt index 178ffea..0e60250 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/ExpandableItem.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/ExpandableItem.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import io.devdeck.DeckState import io.devdeck.LocalDeckState import io.devdeck.internal.DeckIcons @@ -29,7 +30,7 @@ public fun ExpandableItem( content: @Composable ColumnScope.() -> Unit, ) { val drawerState = LocalDeckState.current - val key = "${drawerState?.pages?.lastOrNull()?.title.orEmpty()}/$title" + val key = expandableKey(drawerState, title) var localExpanded by rememberSaveable(title) { mutableStateOf(initiallyExpanded) } val expanded = drawerState?.expandedRows?.get(key) ?: localExpanded val reveal = rememberRevealOnExpand() @@ -57,3 +58,5 @@ public fun ExpandableItem( AnimatedVisibility(expanded) { Column(content = content) } } } + +internal fun expandableKey(drawerState: DeckState?, title: String): String = "${drawerState?.pages?.lastOrNull()?.title.orEmpty()}/$title" diff --git a/devdeck/src/main/kotlin/io/devdeck/components/SectionCard.kt b/devdeck/src/main/kotlin/io/devdeck/components/SectionCard.kt index 740c034..fb8d0d3 100644 --- a/devdeck/src/main/kotlin/io/devdeck/components/SectionCard.kt +++ b/devdeck/src/main/kotlin/io/devdeck/components/SectionCard.kt @@ -57,7 +57,8 @@ internal fun SectionCard( if (!expanded) reveal.request() } // Opens this section when something asks the drawer to show one of its plugins. - val focusHere = drawerState?.pendingFocus?.let { it in pluginIds } == true + // A request is a plugin id, optionally followed by "/" and something inside that plugin. + val focusHere = drawerState?.pendingFocus?.let { focus -> pluginIds.any { focus == it || focus.startsWith("$it/") } } == true LaunchedEffect(focusHere) { if (focusHere && !expanded) toggle() } val (emoji, name) = remember(title) { splitSectionTitle(title) } val rotation by animateFloatAsState(if (expanded) 180f else 0f, label = "sectionChevron") diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/UiToolsPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/UiToolsPlugin.kt index f52abcb..1aee353 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/UiToolsPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/UiToolsPlugin.kt @@ -5,6 +5,8 @@ import io.devdeck.capture.ScreenCaptureTools import io.devdeck.tools.AnimationSpeedSwitch import io.devdeck.tools.KeylineOverlaySwitch +internal const val UI_TOOLS_PLUGIN_ID = "ui_tools" + public class UiToolsPlugin( private val animationSpeed: Boolean = true, private val keylineOverlay: Boolean = true, @@ -12,7 +14,7 @@ public class UiToolsPlugin( override val section: String? = "User Interface 🎨", ) : DeckPlugin { - override val id: String = "ui_tools" + override val id: String = UI_TOOLS_PLUGIN_ID @Composable override fun Content() { From 4dad1b7dba4a8a2bfdeda56eba73a653b9a7a3f0 Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 04:12:46 +0530 Subject: [PATCH 6/7] Fixed stop button before header chips; App and Device info in Info section --- CHANGELOG.md | 5 +- README.md | 4 +- .../src/main/kotlin/io/devdeck/DeckPanel.kt | 71 +++++++++---------- .../io/devdeck/plugin/AppActionsPlugin.kt | 2 - .../kotlin/io/devdeck/plugin/DevicePlugin.kt | 6 +- .../kotlin/io/devdeck/plugin/InfoPlugin.kt | 5 ++ sample/README.md | 4 +- 7 files changed, 49 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9b18e..29a6617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - `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`. -- While recording the screen, a small floating bar shows the time with pause/resume (Android 7+) and stop, so you don't have to reopen the drawer; the drawer header shows a *Recording* chip with a red stop button (tapping the chip opens the screen capture tools), and the notification has Pause/Resume too. +- While recording the screen, a small floating bar shows the time with pause/resume (Android 7+) and stop, so you don't have to reopen the drawer; the drawer header shows a *Recording* chip with a red stop button that stays in place while the chips scroll (tapping the chip opens the screen capture tools), and the notification has Pause/Resume too. - A *System language & region* button in the User Interface section opens Android's language and region settings (`LocaleSettingsButton()`). - The feature flags page has *Restart screen* and *Restart app* buttons, so flag changes that are read once per screen or process take effect. - After switching endpoints, `EndpointPlugin` shows a *Restart app* button so the app picks up the new endpoint (unless `restartOnChange` already restarts it). @@ -30,10 +30,11 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - The update chip in the header reads *New version* and opens the App section at *Test build updates*. - Drawer text is a little smaller, list rows use Material's 48dp minimum height, and section titles and dividers take less space, so more fits on small phones. - The header's settings and close buttons sit closer together. +- *Device info* moved from the System section to the Info section, next to *App info*. `DevicePlugin(deviceInfo = …)` still compiles but no longer has an effect. - The app version shows under the drawer subtitle (*Version 1.0.0 (10000)*) instead of as a chip. - *Take a screenshot*, *Record a video*, *Open the gallery* and deep link suggestions use the standard button and page rows instead of bullet points. - The floating button stays where you drag it, across screens, opening the drawer and app restarts. -- The App section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable or R8 obfuscated or shrunk, and target and min SDK. `AppInfo()` and the device & app snapshot show the same details. +- The Info section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable or R8 obfuscated or shrunk, and target and min SDK. `AppInfo()` and the device & app snapshot show the same details. - Toggle flags no longer show an *Overridden* subtitle. - 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. diff --git a/README.md b/README.md index 2d88070..f4d3984 100644 --- a/README.md +++ b/README.md @@ -162,9 +162,9 @@ DevDeck.install( | `AppearancePlugin` | User Interface | Dark mode (Android 12+) and app language (Android 13+) override | ✓ | | `PerformancePlugin` | System | FPS / jank / memory overlay and memory details | ✓ | | `StoragePlugin` | System | SharedPreferences browser/editor and storage usage | ✓ | -| `DevicePlugin` | System | Device info, permissions (request, revoke, open settings), developer options | ✓ | +| `DevicePlugin` | System | Permissions (request, revoke, open settings), developer options | ✓ | | `UpdatesPlugin` | App | New test build check, release notes and install (module `devdeck-appdistribution` for Firebase) | — needs a source | -| `InfoPlugin` | Info | Device & app snapshot image, bug report, version | ✓ | +| `InfoPlugin` | Info | App info (version, build type, R8, SDK levels), device info, device & app snapshot image, bug report, version | ✓ | Every plugin takes a `section` (or `null` for no header) and switches to hide individual rows, e.g. `UiToolsPlugin(screenCapture = false)`. diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt index 76d75a8..05e53a5 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt @@ -227,7 +227,6 @@ private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) { "$label · ${formatRecordingTime(recordingElapsed)}", RecordingRed, onClick = { deckState?.pendingFocus = RECORDING_FOCUS }, - stop = { ScreenCapture.stopRecording(context) }, ), ) } @@ -239,11 +238,15 @@ private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) { if (MemorySimulator.killOnBackground) add(HeaderChip("Kill when backgrounded", warning)) } if (chips.isNotEmpty()) { - Row( - Modifier.padding(top = 12.dp, end = 8.dp).horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - chips.forEach { Chip(it) } + Row(Modifier.padding(top = 12.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically) { + // Stays put while the chips scroll, so stopping a recording is always one tap. + if (ScreenCapture.isRecording) { + StopRecordingButton(onClick = { ScreenCapture.stopRecording(context) }) + Spacer(Modifier.width(6.dp)) + } + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + chips.forEach { Chip(it) } + } } } } @@ -304,42 +307,38 @@ private fun CloseButton(onClose: () -> Unit) { // 40dp keeps the touch target close to the guideline while pulling the settings and close buttons together. private val HeaderButtonSize = 40.dp -private data class HeaderChip(val text: String, val accent: Color?, val onClick: (() -> Unit)? = null, val stop: (() -> Unit)? = null) +private data class HeaderChip(val text: String, val accent: Color?, val onClick: (() -> Unit)? = null) + +@Composable +private fun StopRecordingButton(onClick: () -> Unit) { + Box( + Modifier + .size(28.dp) + .clip(CircleShape) + .background(RecordingRed) + .clickable(role = Role.Button, onClickLabel = "Stop recording", onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon(DeckIcons.Stop, contentDescription = "Stop recording", tint = Color.White, modifier = Modifier.size(14.dp)) + } +} @Composable private fun Chip(chip: HeaderChip) { val shape = RoundedCornerShape(50) val card = LocalDeckColors.current?.card ?: MaterialTheme.colorScheme.surface val accent = chip.accent - Row( - Modifier + Text( + text = chip.text, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = accent ?: MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + modifier = Modifier .background(accent?.copy(alpha = 0.14f) ?: card, shape) .then(if (accent == null) Modifier.border(BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), shape) else Modifier) - .clip(shape), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = chip.text, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium, - color = accent ?: MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier - .then(chip.onClick?.let { Modifier.clickable(role = Role.Button, onClick = it) } ?: Modifier) - .padding(start = 10.dp, end = if (chip.stop != null) 4.dp else 10.dp, top = 4.dp, bottom = 4.dp), - ) - chip.stop?.let { stop -> - Box( - Modifier - .padding(end = 4.dp) - .size(20.dp) - .clip(CircleShape) - .background(RecordingRed) - .clickable(role = Role.Button, onClickLabel = "Stop recording", onClick = stop), - contentAlignment = Alignment.Center, - ) { - Icon(DeckIcons.Stop, contentDescription = "Stop recording", tint = Color.White, modifier = Modifier.size(12.dp)) - } - } - } + .clip(shape) + .then(chip.onClick?.let { Modifier.clickable(role = Role.Button, onClick = it) } ?: Modifier) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt index 72cece3..68f08f1 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/AppActionsPlugin.kt @@ -10,7 +10,6 @@ import io.devdeck.components.ButtonItem import io.devdeck.components.ConfirmButtonItem import io.devdeck.components.ExpandableItem import io.devdeck.components.SwitchItem -import io.devdeck.device.AppInfo import kotlinx.coroutines.launch public class AppActionsPlugin( @@ -30,7 +29,6 @@ public class AppActionsPlugin( override fun Content() { val context = LocalContext.current val scope = rememberCoroutineScope() - ExpandableItem("App info") { AppInfo() } if (deepLinks) ExpandableItem("Open a deep link") { DeepLinkLauncher(suggestions = deepLinkSuggestions) } if (webViewDebugging) { SwitchItem( diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt index 381073c..4807a03 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt @@ -1,14 +1,13 @@ package io.devdeck.plugin import androidx.compose.runtime.Composable -import io.devdeck.components.ExpandableItem import io.devdeck.components.PageLinkItem import io.devdeck.device.DeveloperOptionsButton -import io.devdeck.device.DeviceInfo import io.devdeck.device.PermissionsList public class DevicePlugin( - private val deviceInfo: Boolean = true, + // Device info moved to InfoPlugin; kept so existing calls still compile. + @Suppress("unused") private val deviceInfo: Boolean = true, private val permissions: Boolean = true, private val developerOptions: Boolean = true, override val section: String? = "System 📱", @@ -18,7 +17,6 @@ public class DevicePlugin( @Composable override fun Content() { - if (deviceInfo) ExpandableItem("Device info") { DeviceInfo() } if (permissions) PageLinkItem("Permissions") { PermissionsList() } if (developerOptions) DeveloperOptionsButton() } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/InfoPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/InfoPlugin.kt index e772b6a..d8eccd4 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/InfoPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/InfoPlugin.kt @@ -2,8 +2,11 @@ package io.devdeck.plugin import androidx.compose.runtime.Composable import io.devdeck.bugreport.BugReportButton +import io.devdeck.components.ExpandableItem import io.devdeck.components.PageLinkItem +import io.devdeck.device.AppInfo import io.devdeck.device.AppVersionItem +import io.devdeck.device.DeviceInfo import io.devdeck.device.DeviceSnapshotCard public class InfoPlugin( @@ -19,6 +22,8 @@ public class InfoPlugin( @Composable override fun Content() { + ExpandableItem("App info") { AppInfo() } + ExpandableItem("Device info") { DeviceInfo() } if (snapshot) { PageLinkItem(title = "Device & app snapshot", subtitle = "Share versions and device details as an image") { DeviceSnapshotCard(buildInfo = buildInfo) diff --git a/sample/README.md b/sample/README.md index bae2c98..eb56b1b 100644 --- a/sample/README.md +++ b/sample/README.md @@ -88,10 +88,10 @@ fun samplePlugins(context: Context, flagStore: FeatureFlagStore): List Date: Sun, 25 Oct 2026 04:30:17 +0530 Subject: [PATCH 7/7] Address review: RTL trigger, focus cleanup, plugin switches, BuildConfig check, clamped recording bar, docs --- CHANGELOG.md | 8 +++--- README.md | 9 +++--- devdeck/api/devdeck.api | 8 ++++-- devdeck/consumer-rules.pro | 3 ++ .../src/main/kotlin/io/devdeck/DeckPanel.kt | 2 +- .../src/main/kotlin/io/devdeck/DeckState.kt | 4 +++ .../main/kotlin/io/devdeck/DevDeckOverlay.kt | 10 +++++-- .../io/devdeck/capture/RecordingControls.kt | 28 +++++++++++++++---- .../io/devdeck/capture/ScreenCapture.kt | 2 +- .../io/devdeck/capture/ScreenRecordButton.kt | 5 ++-- .../io/devdeck/device/SystemSnapshot.kt | 6 +++- .../io/devdeck/plugin/AppearancePlugin.kt | 15 +++++++--- .../kotlin/io/devdeck/plugin/DevicePlugin.kt | 4 +-- .../io/devdeck/plugin/EndpointPlugin.kt | 3 +- .../kotlin/io/devdeck/plugin/InfoPlugin.kt | 16 +++++++++-- .../kotlin/io/devdeck/updates/UpdatesPanel.kt | 5 ++-- 16 files changed, 91 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a6617..88d99c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,9 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - `AppActions.clearCacheInBackground()` clears the cache off the main thread. - `TextInputItem`, `NumberInputItem` and `JsonInputItem` take an optional `subtitle`. - While recording the screen, a small floating bar shows the time with pause/resume (Android 7+) and stop, so you don't have to reopen the drawer; the drawer header shows a *Recording* chip with a red stop button that stays in place while the chips scroll (tapping the chip opens the screen capture tools), and the notification has Pause/Resume too. -- A *System language & region* button in the User Interface section opens Android's language and region settings (`LocaleSettingsButton()`). +- A *System language & region* button in the User Interface section opens Android's language and region settings (`LocaleSettingsButton()`; hide it with `AppearancePlugin(localeSettings = false)`). - The feature flags page has *Restart screen* and *Restart app* buttons, so flag changes that are read once per screen or process take effect. -- After switching endpoints, `EndpointPlugin` shows a *Restart app* button so the app picks up the new endpoint (unless `restartOnChange` already restarts it). +- After switching endpoints, `EndpointPlugin` shows a *Restart app* button so the app picks up the new endpoint, unless `restartOnChange` or `onChanged` already applies it. - 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. @@ -30,11 +30,11 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - The update chip in the header reads *New version* and opens the App section at *Test build updates*. - Drawer text is a little smaller, list rows use Material's 48dp minimum height, and section titles and dividers take less space, so more fits on small phones. - The header's settings and close buttons sit closer together. -- *Device info* moved from the System section to the Info section, next to *App info*. `DevicePlugin(deviceInfo = …)` still compiles but no longer has an effect. +- *Device info* moved from the System section to the Info section, next to *App info*; hide either with `InfoPlugin(appInfo = false, deviceInfo = false)`. `DevicePlugin(deviceInfo = …)` still compiles but no longer has an effect. - The app version shows under the drawer subtitle (*Version 1.0.0 (10000)*) instead of as a chip. - *Take a screenshot*, *Record a video*, *Open the gallery* and deep link suggestions use the standard button and page rows instead of bullet points. - The floating button stays where you drag it, across screens, opening the drawer and app restarts. -- The Info section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable or R8 obfuscated or shrunk, and target and min SDK. `AppInfo()` and the device & app snapshot show the same details. +- The Info section starts with *App info*: version, package, build type and flavor (from the app's `BuildConfig` when it exists), whether the build is debuggable or R8 obfuscated or shrunk, and target and min SDK. `AppInfo()` and the device & app snapshot show the same details; the snapshot no longer lists install and update times, and shows *Debuggable* rather than a guessed build type when the app has no `BuildConfig`. - Toggle flags no longer show an *Overridden* subtitle. - 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. diff --git a/README.md b/README.md index f4d3984..95d7350 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ OkHttpClient.Builder().apply { DebugTools.interceptors().forEach(::addIntercepto |---|---|---| | Two-finger swipe toward the drawer, anywhere on screen | On | `gestures = DeckGestures(twoFingerSwipe = false)` | | **Ctrl + Shift + D** (emulator or hardware keyboard) | On | `DeckGestures(keyboardShortcut = DeckShortcut(keyCode = KeyEvent.KEYCODE_F12, ctrl = false, shift = false))`, or `null` to turn off | -| Floating bug button (draggable) | On | `trigger = DeckTrigger.None` | +| Floating bug button (draggable; it stays where you leave it) | On | `trigger = DeckTrigger.None` | | Code | — | `DevDeck.open()`, `DevDeck.close()`, `DevDeck.toggle()` | - DevDeck never covers your controls. The swipe is detected by watching touches without taking them, so taps, scrolls and system back keep working. @@ -243,7 +243,7 @@ The memory rows under **Simulate 🧪** reproduce what the app sees on a low-mem ### Test build updates -`UpdatesPlugin` tells testers when a newer test build is available. It shows a green *Update · v1.4.0 (412)* chip in the drawer header and a dot on the floating button. The plugin row shows the release notes, an *Install latest* button with download progress, a refresh icon to check again, and tester sign-in. +`UpdatesPlugin` tells testers when a newer test build is available. It shows a green *New version · v1.4.0 (412)* chip in the drawer header, which jumps to the plugin row, and a dot on the floating button. The plugin row shows the release notes, an *Install latest* button with download progress, a refresh icon to check again, and tester sign-in. With Firebase App Distribution: @@ -306,11 +306,12 @@ The last 50 steps are saved with every crash and shown as *Steps before the cras ### Screen capture, bug reports and device info -- **Screenshot / screen recording / gallery:** under User Interface. Recording asks for Android's screen-capture permission and shows a Stop notification. +- **Screenshot / screen recording / gallery:** under User Interface. Recording asks for Android's screen-capture permission. While it runs, a small floating bar shows the time with pause/resume (Android 7+) and stop, the drawer header shows a *Recording* chip and a red stop button, and the notification has the same controls. The floating bar is drawn in your app's window, so it appears in the video. +- **App info and device info:** under Info. App info shows the version, package, build type and flavor (read from your app's `BuildConfig` when it exists and R8 hasn't removed it), whether the build is debuggable, R8 state and SDK levels. - **Device & app snapshot:** under Info. One image with app version, build type, SDKs, device, Android version, screen and locale, plus your `buildInfo`. Share it, save it, or copy it as text from the icons on the card; the icons are left out of the image. - **App settings:** the ⚙ button next to the drawer's close button opens the app's system settings page. - **Bug report:** a description, recent steps, your logs, recent logcat, device info and chosen captures, sent through the share sheet. -- **Permissions:** under System. Each permission shows Granted or Denied with what you can do about it. *Request* shows the system dialog, or opens the app's settings once Android stops asking. *Revoke* (Android 13+) removes a runtime permission when the app closes; until then the row shows *Revoked when the app restarts*. The list refreshes when you come back from a permission dialog or settings screen. *Open settings* handles special permissions such as drawing over other apps. Install-time permissions can't change, so they are only listed. To grant or revoke silently, use `adb shell pm grant` / `pm revoke`. +- **Permissions:** under System, on their own page. Each permission shows Granted or Denied with what you can do about it. *Request* shows the system dialog, or opens the app's settings once Android stops asking. *Revoke* (Android 13+) removes a runtime permission when the app closes; until then the row shows *Revoked when the app restarts*. The list refreshes when you come back from a permission dialog or settings screen. *Open settings* handles special permissions such as drawing over other apps. Install-time permissions can't change, so they are only listed. To grant or revoke silently, use `adb shell pm grant` / `pm revoke`. --- diff --git a/devdeck/api/devdeck.api b/devdeck/api/devdeck.api index 284d248..3ab51c3 100644 --- a/devdeck/api/devdeck.api +++ b/devdeck/api/devdeck.api @@ -972,8 +972,10 @@ public final class io/devdeck/plugin/AppActionsPlugin : io/devdeck/plugin/DeckPl 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;)V public synthetic fun (ZLjava/util/List;ZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/List;ZLjava/lang/String;Z)V + public synthetic fun (ZLjava/util/List;ZLjava/lang/String;ZILkotlin/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; @@ -1087,8 +1089,10 @@ public final class io/devdeck/plugin/FeatureFlagsPlugin : io/devdeck/plugin/Deck 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;)V public synthetic fun (ZZZLjava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZZZLjava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;ZZ)V + public synthetic fun (ZZZLjava/lang/String;Lkotlin/jvm/functions/Function0;Ljava/lang/String;ZZILkotlin/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; diff --git a/devdeck/consumer-rules.pro b/devdeck/consumer-rules.pro index b1d5c47..968adb9 100644 --- a/devdeck/consumer-rules.pro +++ b/devdeck/consumer-rules.pro @@ -1,2 +1,5 @@ # 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. +# +# Don't add a blanket -keep for io.devdeck: App info detects R8 by checking whether +# io.devdeck.device.R8Probe was renamed or stripped. diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt index 05e53a5..f97f458 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt @@ -221,7 +221,7 @@ private fun DeckHeader(title: String, subtitle: String?, onClose: () -> Unit) { val recordingElapsed = if (ScreenCapture.isRecording) rememberRecordingElapsed() else 0L val chips = buildList { if (ScreenCapture.isRecording) { - val label = if (ScreenCapture.isRecordingPaused) "Recording paused" else "● Recording" + val label = if (ScreenCapture.isRecordingPaused) "Recording paused" else "Recording" add( HeaderChip( "$label · ${formatRecordingTime(recordingElapsed)}", diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckState.kt b/devdeck/src/main/kotlin/io/devdeck/DeckState.kt index 8a36e1b..62590cd 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckState.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckState.kt @@ -51,6 +51,7 @@ public class DeckState(initialValue: DeckValue = DeckValue.Closed) { public fun close() { targetValue = DeckValue.Closed + pendingFocus = null } public fun toggle() { @@ -106,3 +107,6 @@ public class DeckState(initialValue: DeckValue = DeckValue.Closed) { @Composable public fun rememberDeckState(initialValue: DeckValue = DeckValue.Closed): DeckState = rememberSaveable(saver = DeckState.saver()) { DeckState(initialValue) } + +// Long enough for a section to finish expanding before a pendingFocus target scrolls into view. +internal const val FOCUS_SCROLL_DELAY_MILLIS = 400L diff --git a/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt b/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt index 94f1bf7..008cb08 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset @@ -34,6 +35,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.AbsoluteAlignment import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -177,7 +179,7 @@ private fun FloatingTrigger(trigger: DeckTrigger.FloatingButton, onClick: () -> Offset(spot.x.toFloat() / FRACTION_SCALE, spot.y.toFloat() / FRACTION_SCALE) } // Stored as a fraction of the free area, so the button keeps its place across screens, rotation and restarts. - val fraction = TriggerPosition.get(context) ?: default + val fraction = (if (trigger.draggable) TriggerPosition.get(context) else null) ?: default val position = IntOffset((fraction.x * rangeX).roundToInt(), (fraction.y * rangeY).roundToInt()) Surface( onClick = onClick, @@ -185,8 +187,10 @@ private fun FloatingTrigger(trigger: DeckTrigger.FloatingButton, onClick: () -> color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f), contentColor = MaterialTheme.colorScheme.onPrimaryContainer, shadowElevation = 4.dp, + // Absolute (left-based) placement: the default spot is already mirrored for RTL and drags are physical. modifier = Modifier - .offset { position } + .align(AbsoluteAlignment.TopLeft) + .absoluteOffset { position } .then( if (trigger.draggable) { Modifier.pointerInput(rangeX, rangeY, default) { @@ -212,7 +216,7 @@ private fun FloatingTrigger(trigger: DeckTrigger.FloatingButton, onClick: () -> } } if (UpdateChecker.availableUpdate != null) { - Box(Modifier.offset { position }.size(TriggerSize)) { + Box(Modifier.align(AbsoluteAlignment.TopLeft).absoluteOffset { position }.size(TriggerSize)) { Box( Modifier .align(Alignment.TopEnd) diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/RecordingControls.kt b/devdeck/src/main/kotlin/io/devdeck/capture/RecordingControls.kt index 8c1552e..0283363 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/RecordingControls.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/RecordingControls.kt @@ -5,10 +5,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.absoluteOffset import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size @@ -31,12 +32,14 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import io.devdeck.LocalDeckColors import io.devdeck.internal.DeckIcons @@ -51,19 +54,27 @@ internal fun RecordingControls(modifier: Modifier = Modifier) { val context = LocalContext.current val elapsed = rememberRecordingElapsed() val paused = ScreenCapture.isRecordingPaused - var drag by remember { mutableStateOf(Offset.Zero) } val isDark = LocalDeckColors.current?.isDark == true val background = if (isDark) Color(0xE6202124) else Color(0xEBFFFFFF) val content = if (isDark) Color(0xFFE8EAED) else Color(0xFF202124) - Box(modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).padding(top = 8.dp)) { + BoxWithConstraints(modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing).padding(top = 8.dp)) { + var barSize by remember { mutableStateOf(IntSize.Zero) } + // Keep the bar fully on screen: it starts top-centre, so it can move half the spare width either way. + val maxX = ((constraints.maxWidth - barSize.width) / 2f).coerceAtLeast(0f) + val maxY = (constraints.maxHeight - barSize.height).toFloat().coerceAtLeast(0f) + fun clamp(offset: Offset) = Offset(offset.x.coerceIn(-maxX, maxX), offset.y.coerceIn(0f, maxY)) Row( Modifier .align(Alignment.TopCenter) - .offset { IntOffset(drag.x.roundToInt(), drag.y.roundToInt()) } - .pointerInput(Unit) { + .onSizeChanged { barSize = it } + .absoluteOffset { + val offset = clamp(ControlsPosition.offset) + IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) + } + .pointerInput(maxX, maxY) { detectDragGestures { change, amount -> change.consume() - drag += amount + ControlsPosition.offset = clamp(ControlsPosition.offset + amount) } } .shadow(4.dp, CircleShape) @@ -101,6 +112,11 @@ internal fun RecordingControls(modifier: Modifier = Modifier) { } } +// Shared across screens and drawer openings, so the bar stays where the tester moved it. +private object ControlsPosition { + var offset by mutableStateOf(Offset.Zero) +} + /** Recording time in millis, ticking while a recording is running. */ @Composable internal fun rememberRecordingElapsed(): Long { diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt index 82b0733..3a494ea 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt @@ -49,7 +49,7 @@ public object ScreenCapture { internal val canPauseRecording: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N internal fun recordingElapsedMillis(now: Long = SystemClock.elapsedRealtime()): Long = - recordedMillisBeforeResume + if (isRecordingPaused) 0 else now - resumedAtMillis + recordedMillisBeforeResume + if (isRecordingPaused) 0 else (now - resumedAtMillis).coerceAtLeast(0) internal var galleryVersion by mutableIntStateOf(0) diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt index dda951d..df7489f 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import io.devdeck.FOCUS_SCROLL_DELAY_MILLIS import io.devdeck.LocalDeckState import io.devdeck.components.ButtonItem import io.devdeck.plugin.UI_TOOLS_PLUGIN_ID @@ -37,7 +38,7 @@ public fun ScreenRecordButton(title: String = "Record a video", stopTitle: Strin val bringIntoView = remember { BringIntoViewRequester() } LaunchedEffect(drawerState?.pendingFocus) { if (drawerState?.pendingFocus == RECORDING_FOCUS) { - delay(FOCUS_DELAY_MILLIS) + delay(FOCUS_SCROLL_DELAY_MILLIS) bringIntoView.bringIntoView() drawerState.pendingFocus = null } @@ -60,5 +61,3 @@ public fun ScreenRecordButton(title: String = "Record a video", stopTitle: Strin /** Asks the drawer to open the screen capture tools and show the record button. */ internal const val RECORDING_FOCUS = "$UI_TOOLS_PLUGIN_ID/recording" - -private const val FOCUS_DELAY_MILLIS = 450L diff --git a/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt b/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt index 45ccbd2..e03e50e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt +++ b/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt @@ -74,7 +74,11 @@ private fun buildConfigValues(context: Context): Map { } context.applicationInfo.className?.substringBeforeLast('.', "")?.takeIf { it.isNotEmpty() }?.let { add(it) } }.distinct() - val buildConfig = candidates.firstNotNullOfOrNull { runCatching { Class.forName("$it.BuildConfig") }.getOrNull() } ?: return emptyMap() + // Library modules have their own BuildConfig; only accept the one generated for this application id. + val buildConfig = candidates.firstNotNullOfOrNull { name -> + runCatching { Class.forName("$name.BuildConfig") }.getOrNull() + ?.takeIf { runCatching { it.getField("APPLICATION_ID").get(null) }.getOrNull() == packageName } + } ?: return emptyMap() return listOf("BUILD_TYPE", "FLAVOR").mapNotNull { field -> runCatching { buildConfig.getField(field).get(null) as? String }.getOrNull()?.let { field to it } }.toMap() diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/AppearancePlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/AppearancePlugin.kt index 4a6f227..49fde40 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/AppearancePlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/AppearancePlugin.kt @@ -11,17 +11,24 @@ public class AppearancePlugin( private val languageTags: List = emptyList(), private val displaySettings: Boolean = true, override val section: String? = "User Interface 🎨", + private val localeSettings: Boolean = true, ) : DeckPlugin { + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + public constructor( + nightMode: Boolean = true, + languageTags: List = emptyList(), + displaySettings: Boolean = true, + section: String? = "User Interface 🎨", + ) : this(nightMode, languageTags, displaySettings, section, localeSettings = true) + override val id: String = "appearance" @Composable override fun Content() { if (nightMode) NightModeSelector() if (languageTags.isNotEmpty()) LanguageSelector(languageTags) - if (displaySettings) { - LocaleSettingsButton() - DisplaySettingsButton() - } + if (localeSettings) LocaleSettingsButton() + if (displaySettings) DisplaySettingsButton() } } diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt index 4807a03..537cdfa 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt @@ -6,8 +6,8 @@ import io.devdeck.device.DeveloperOptionsButton import io.devdeck.device.PermissionsList public class DevicePlugin( - // Device info moved to InfoPlugin; kept so existing calls still compile. - @Suppress("unused") private val deviceInfo: Boolean = true, + // Device info moved to InfoPlugin (see its deviceInfo switch); kept so existing calls still compile. + @Suppress("UNUSED_PARAMETER") deviceInfo: Boolean = true, private val permissions: Boolean = true, private val developerOptions: Boolean = true, override val section: String? = "System 📱", diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt index 514a5ee..a6c6b00 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/EndpointPlugin.kt @@ -49,7 +49,8 @@ public class EndpointPlugin( }, optionLabel = { it.name }, ) - if (!restartOnChange && selected.url != applied) { + // Apps that apply the switch themselves (restartOnChange or onChanged) don't need the prompt. + if (!restartOnChange && onChanged == null && selected.url != applied) { ButtonItem( title = "Restart app", subtitle = "Apply ${selected.name}", diff --git a/devdeck/src/main/kotlin/io/devdeck/plugin/InfoPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/InfoPlugin.kt index d8eccd4..e0b9847 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/InfoPlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/InfoPlugin.kt @@ -16,14 +16,26 @@ public class InfoPlugin( private val versionText: String? = null, private val buildInfo: () -> Map = { emptyMap() }, override val section: String? = "Info ℹ️", + private val appInfo: Boolean = true, + private val deviceInfo: Boolean = true, ) : DeckPlugin { + @Deprecated("Kept for binary compatibility", level = DeprecationLevel.HIDDEN) + public constructor( + snapshot: Boolean = true, + bugReport: Boolean = true, + version: Boolean = true, + versionText: String? = null, + buildInfo: () -> Map = { emptyMap() }, + section: String? = "Info ℹ️", + ) : this(snapshot, bugReport, version, versionText, buildInfo, section, appInfo = true, deviceInfo = true) + override val id: String = "info" @Composable override fun Content() { - ExpandableItem("App info") { AppInfo() } - ExpandableItem("Device info") { DeviceInfo() } + if (appInfo) ExpandableItem("App info") { AppInfo() } + if (deviceInfo) ExpandableItem("Device info") { DeviceInfo() } if (snapshot) { PageLinkItem(title = "Device & app snapshot", subtitle = "Share versions and device details as an image") { DeviceSnapshotCard(buildInfo = buildInfo) diff --git a/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt b/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt index 21676df..4b73fa3 100644 --- a/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/updates/UpdatesPanel.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import io.devdeck.FOCUS_SCROLL_DELAY_MILLIS import io.devdeck.LocalDeckColors import io.devdeck.LocalDeckState import io.devdeck.components.ActionPill @@ -61,7 +62,7 @@ public fun UpdatesPanel(modifier: Modifier = Modifier, title: String = "Test bui LaunchedEffect(deckState?.pendingFocus) { if (deckState?.pendingFocus == UPDATES_PLUGIN_ID) { // Wait for the section to finish expanding so the scroll lands on the final position. - delay(FOCUS_DELAY_MILLIS) + delay(FOCUS_SCROLL_DELAY_MILLIS) bringIntoView.bringIntoView() deckState.pendingFocus = null } @@ -186,5 +187,3 @@ private fun statusText(status: UpdateStatus, source: UpdateSource): String = whe } private fun time(millis: Long): String = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(millis)) - -private const val FOCUS_DELAY_MILLIS = 350L