diff --git a/CHANGELOG.md b/CHANGELOG.md index f631fc8..88d99c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,11 @@ 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`. +- 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()`; 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` 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. @@ -22,6 +26,16 @@ 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 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*; 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 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. - Category, level and status colors have dark-theme variants, and labels on colored fills pick a readable text color. diff --git a/README.md b/README.md index 2d88070..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. @@ -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)`. @@ -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 1a94f3b..3ab51c3 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 } @@ -347,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; @@ -969,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; @@ -1084,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 dce0264..f97f458 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,20 +50,29 @@ 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.RECORDING_FOCUS +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 import io.devdeck.updates.UpdateChecker import io.devdeck.updates.updateAvailableColor +import kotlinx.coroutines.delay @Composable internal fun DeckPanel( @@ -83,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), @@ -149,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 @@ -184,25 +202,51 @@ 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) } 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 { - version?.let { add(HeaderChip("v$it", null)) } - if (update != null) add(HeaderChip("Update · ${update.label}", updateColor)) + if (ScreenCapture.isRecording) { + val label = if (ScreenCapture.isRecordingPaused) "Recording paused" else "Recording" + add( + HeaderChip( + "$label · ${formatRecordingTime(recordingElapsed)}", + RecordingRed, + onClick = { deckState?.pendingFocus = RECORDING_FOCUS }, + ), + ) + } + 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)) } 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) } + } } } } @@ -230,7 +274,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 +292,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 +304,24 @@ 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 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) { @@ -276,6 +337,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..62590cd 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckState.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckState.kt @@ -42,12 +42,16 @@ 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 } public fun close() { targetValue = DeckValue.Closed + pendingFocus = null } public fun toggle() { @@ -103,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/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..008cb08 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 @@ -13,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 @@ -33,18 +35,24 @@ 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 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 +125,8 @@ internal fun DevDeckOverlay( SimulationBadge() + if (!isVisible) RecordingControls() + if (isVisible) { Box( Modifier @@ -153,47 +163,60 @@ 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 = (if (trigger.draggable) TriggerPosition.get(context) else null) ?: default + val position = IntOffset((fraction.x * rangeX).roundToInt(), (fraction.y * rangeY).roundToInt()) Surface( onClick = onClick, shape = CircleShape, 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 - .align(trigger.alignment) - .offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) } + .align(AbsoluteAlignment.TopLeft) + .absoluteOffset { 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.align(AbsoluteAlignment.TopLeft).absoluteOffset { position }.size(TriggerSize)) { Box( Modifier .align(Alignment.TopEnd) @@ -206,3 +229,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/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/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..0283363 --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/capture/RecordingControls.kt @@ -0,0 +1,142 @@ +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.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.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.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 +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 + val isDark = LocalDeckColors.current?.isDark == true + val background = if (isDark) Color(0xE6202124) else Color(0xEBFFFFFF) + val content = if (isDark) Color(0xFFE8EAED) else Color(0xFF202124) + 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) + .onSizeChanged { barSize = it } + .absoluteOffset { + val offset = clamp(ControlsPosition.offset) + IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) + } + .pointerInput(maxX, maxY) { + detectDragGestures { change, amount -> + change.consume() + ControlsPosition.offset = clamp(ControlsPosition.offset + 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)) + } + } + } +} + +// 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 { + 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..3a494ea 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).coerceAtLeast(0) + 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/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 b0ed307..df7489f 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenRecordButton.kt @@ -5,11 +5,19 @@ 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.FOCUS_SCROLL_DELAY_MILLIS import io.devdeck.LocalDeckState -import io.devdeck.components.BulletItem +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 +34,30 @@ 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_SCROLL_DELAY_MILLIS) + bringIntoView.bringIntoView() + drawerState.pendingFocus = null + } + } + val focusModifier = Modifier.bringIntoViewRequester(bringIntoView) if (ScreenCapture.isRecording) { - BulletItem(stopTitle) { ScreenCapture.stopRecording(context) } + ButtonItem( + title = stopTitle, + destructive = true, + modifier = focusModifier, + onClick = { ScreenCapture.stopRecording(context) }, + ) } else { - BulletItem(title) { + 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" 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/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/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..fb8d0d3 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,10 @@ internal fun SectionCard( } if (!expanded) reveal.request() } + // Opens this section when something asks the drawer to show one of its plugins. + // 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") @@ -62,7 +68,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/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..e03e50e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt +++ b/devdeck/src/main/kotlin/io/devdeck/device/SystemSnapshot.kt @@ -45,22 +45,58 @@ public fun systemSnapshot(context: Context, buildInfo: Map = emp ) } -private fun appRows(context: Context): List> { - val packageInfo = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull() +internal fun appRows(context: Context): List> { 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(), "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) }, ) } +// 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() + // 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() +} + +// 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 deviceRows(context: Context): List> { val metrics = DisplayMetrics() @Suppress("DEPRECATION") 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/AppearancePlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/AppearancePlugin.kt index 10dab26..49fde40 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( @@ -10,14 +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 (localeSettings) LocaleSettingsButton() if (displaySettings) DisplaySettingsButton() } } 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/DevicePlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt index 4fee887..537cdfa 100644 --- a/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt +++ b/devdeck/src/main/kotlin/io/devdeck/plugin/DevicePlugin.kt @@ -1,13 +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 (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 📱", @@ -17,8 +17,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..a6c6b00 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,24 @@ public class EndpointPlugin( }, optionLabel = { it.name }, ) + // 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}", + 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..9a7d9be 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 = "Starts the app again", + 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/devdeck/src/main/kotlin/io/devdeck/plugin/InfoPlugin.kt b/devdeck/src/main/kotlin/io/devdeck/plugin/InfoPlugin.kt index e772b6a..e0b9847 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( @@ -13,12 +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() { + 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/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() { 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..4b73fa3 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 @@ -31,7 +34,9 @@ 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 import io.devdeck.components.ActionPillRow import io.devdeck.components.DropdownItem @@ -39,9 +44,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 +57,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_SCROLL_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, 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