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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ 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 that stays in place while the chips scroll (tapping the chip opens the screen capture tools), and the notification has Pause/Resume too.
- *Take a long screenshot* (Android 12+) captures the scrolling screen in one image from the current position down, up to 5 screens or 8 seconds in total, or until nothing new appears, so endless feeds stay bounded (`LongScreenshotButton()`, `ScreenCapture.takeLongScreenshot()`).
- Screenshots leave out DevDeck's own floating button, simulation badge and recording bar.
- The gallery also lists JPEG, WebP, GIF, WebM and other common image and video files in its folder.
- 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.
- Share buttons on the Logs and Network activity pages, and delete-all in the capture gallery.
- Consumer R8 rules for devdeck-okhttp, devdeck-timber and devdeck-appdistribution, so minified builds don't fail when OkHttp, Timber or Firebase isn't on the classpath.

### Changed
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,9 @@ 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. 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.
- **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.
- **Screenshots leave DevDeck out:** the floating button, simulation badge and recording bar are hidden while a screenshot is taken. The keyline and frame rate overlays stay, since you turn those on to capture them.
- **Long screenshot (Android 12+):** *Take a long screenshot* captures the screen's main scrolling list or page in one tall image, using the same scroll capture support as Android's *Capture more* (ScrollView, RecyclerView, ListView and Compose scrolling layouts). It starts at what's on screen and goes down, stopping at the end of the content, after 5 screens (or about 15 megapixels), after 8 seconds in total, or when scrolling shows nothing new, so endless feeds stay bounded; they may still load their next page while it scrolls. Screens with nothing to scroll show a message instead.
- **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.
Expand Down
2 changes: 2 additions & 0 deletions devdeck/api/devdeck.api
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ public final class io/devdeck/capture/ScreenCapture {
public final fun captureDirectory (Landroid/content/Context;)Ljava/io/File;
public final fun captures (Landroid/content/Context;)Ljava/util/List;
public final fun isRecording ()Z
public final fun takeLongScreenshot (Landroid/app/Activity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun takeScreenshot (Landroid/app/Activity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun uriFor (Landroid/content/Context;Ljava/io/File;)Landroid/net/Uri;
}
Expand Down Expand Up @@ -361,6 +362,7 @@ public final class io/devdeck/capture/ScreenRecordingService : android/app/Servi
}

public final class io/devdeck/capture/ScreenshotButtonKt {
public static final fun LongScreenshotButton (Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V
public static final fun ScreenshotButton (Ljava/lang/String;Landroidx/compose/runtime/Composer;II)V
}

Expand Down
26 changes: 20 additions & 6 deletions devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ 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.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
Expand Down Expand Up @@ -311,15 +314,26 @@ private data class HeaderChip(val text: String, val accent: Color?, val onClick:

@Composable
private fun StopRecordingButton(onClick: () -> Unit) {
Box(
val shape = RoundedCornerShape(50)
Row(
Modifier
.size(28.dp)
.clip(CircleShape)
.clip(shape)
.background(RecordingRed)
.clickable(role = Role.Button, onClickLabel = "Stop recording", onClick = onClick),
contentAlignment = Alignment.Center,
.clickable(role = Role.Button, onClickLabel = "Stop recording", onClick = onClick)
.semantics { contentDescription = "Stop recording" }
.padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Icon(DeckIcons.Stop, contentDescription = "Stop recording", tint = Color.White, modifier = Modifier.size(14.dp))
Box(Modifier.size(10.dp).background(Color.White, RoundedCornerShape(2.dp)))
Text(
"Stop",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White,
maxLines = 1,
modifier = Modifier.clearAndSetSemantics {},
)
}
}

Expand Down
11 changes: 6 additions & 5 deletions devdeck/src/main/kotlin/io/devdeck/DevDeck.kt
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@ public object DevDeck {
if (activity !is ComponentActivity) return
if (DeckExclusions.isExcluded(activity, current.excludedActivityPrefixes) || !current.includeActivity(activity)) return
val decorView = activity.window.decorView as? ViewGroup ?: return
if (decorView.findViewWithTag<View>(VIEW_TAG) != null || HostedActivities.contains(activity)) return
if (decorView.findViewWithTag<View>(DECK_OVERLAY_TAG) != null || HostedActivities.contains(activity)) return

val overlay = ComposeView(activity).apply {
tag = VIEW_TAG
tag = DECK_OVERLAY_TAG
setViewTreeLifecycleOwner(activity)
setViewTreeViewModelStoreOwner(activity)
setViewTreeSavedStateRegistryOwner(activity)
Expand Down Expand Up @@ -172,8 +172,9 @@ public object DevDeck {

internal fun removeOverlay(activity: Activity) {
val decorView = activity.window?.decorView as? ViewGroup ?: return
decorView.findViewWithTag<View>(VIEW_TAG)?.let(decorView::removeView)
decorView.findViewWithTag<View>(DECK_OVERLAY_TAG)?.let(decorView::removeView)
}

private const val VIEW_TAG = "io.devdeck.overlay"
}

/** Tag of the view DevDeck adds to each activity, so captures and tap tracking can skip it. */
internal const val DECK_OVERLAY_TAG = "io.devdeck.overlay"
7 changes: 4 additions & 3 deletions devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ 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.capture.ScreenCapture
import io.devdeck.internal.DeckIcons
import io.devdeck.internal.DeckSettings
import io.devdeck.performance.PerformanceMonitor
Expand Down Expand Up @@ -123,9 +124,9 @@ internal fun DevDeckOverlay(
PerformanceOverlay()
}

SimulationBadge()
if (!ScreenCapture.isCapturing) SimulationBadge()

if (!isVisible) RecordingControls()
if (!isVisible && !ScreenCapture.isCapturing) RecordingControls()

if (isVisible) {
Box(
Expand Down Expand Up @@ -155,7 +156,7 @@ internal fun DevDeckOverlay(
)
}
} else if (trigger is DeckTrigger.FloatingButton) {
FloatingTrigger(trigger = trigger, onClick = state::open)
if (!ScreenCapture.isCapturing) FloatingTrigger(trigger = trigger, onClick = state::open)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import android.view.ViewConfiguration
import android.view.ViewGroup
import android.widget.EditText
import android.widget.TextView
import io.devdeck.DECK_OVERLAY_TAG
import io.devdeck.DeckVisibility
import io.devdeck.gesture.inputObserver
import java.util.Collections
Expand Down Expand Up @@ -176,5 +177,4 @@ internal object BreadcrumbCollector {

private const val DRAWER_CHECK_DELAY_MILLIS = 150L
private const val COMPOSE_VIEW_PREFIX = "androidx.compose.ui.platform.AndroidComposeView"
private const val DECK_OVERLAY_TAG = "io.devdeck.overlay"
}
19 changes: 19 additions & 0 deletions devdeck/src/main/kotlin/io/devdeck/capture/CaptureFileTypes.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.devdeck.capture

import android.webkit.MimeTypeMap
import java.io.File

internal val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "webp", "gif")
internal val VIDEO_EXTENSIONS = setOf("mp4", "webm", "3gp", "mkv", "mov")

internal val File.isCaptureImage: Boolean get() = extension.lowercase() in IMAGE_EXTENSIONS
internal val File.isCaptureVideo: Boolean get() = extension.lowercase() in VIDEO_EXTENSIONS

/** MIME type from the file extension, for intents. */
internal val File.mediaType: String
get() = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase())
?: when {
isCaptureVideo -> "video/*"
isCaptureImage -> "image/*"
else -> "application/octet-stream"
}
34 changes: 15 additions & 19 deletions devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,6 @@ public fun CaptureGallery(modifier: Modifier = Modifier) {
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { context.shareFiles(files, subject = "Captures") }, enabled = files.isNotEmpty()) {
Icon(DeckIcons.Share, contentDescription = "Share all captures")
}
ConfirmDeleteButton(
contentDescription = "Delete all captures",
onConfirm = { delete(files) },
Expand All @@ -86,7 +83,9 @@ public fun CaptureGallery(modifier: Modifier = Modifier) {
}
if (loaded?.isEmpty() == true) TextItem("No screenshots or recordings yet")
InfoGroup(dividerThreshold = 2) {
files.forEach { file -> key(file.path) { GalleryRow(file, onDelete = { delete(listOf(file)) }) } }
files.forEach { file ->
key(file.path) { GalleryRow(file, onDelete = { delete(listOf(file)) }) }
}
}
}
}
Expand Down Expand Up @@ -117,38 +116,35 @@ private fun GalleryRow(file: File, onDelete: () -> Unit) {
Image(image, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(56.dp))
} else {
Text(
if (file.extension == "mp4") "▶" else "…",
if (file.isCaptureVideo) "▶" else "…",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.clearAndSetSemantics {},
)
}
}
Text(
file.name,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Row {
IconButton(onClick = { context.shareFiles(listOf(file)) }) {
Icon(DeckIcons.Share, contentDescription = "Share ${file.name}")
// Actions sit under the name so the name keeps the full width on narrow phones.
Column(Modifier.weight(1f)) {
Text(file.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = { context.shareFiles(listOf(file)) }) {
Icon(DeckIcons.Share, contentDescription = "Share ${file.name}", modifier = Modifier.size(20.dp))
}
ConfirmDeleteButton(contentDescription = "Delete ${file.name}", onConfirm = onDelete, confirmText = "Tap again to delete")
}
ConfirmDeleteButton(contentDescription = "Delete ${file.name}", onConfirm = onDelete, confirmText = "Tap again to delete")
}
}
}

private fun loadThumbnail(file: File): ImageBitmap? = runCatching {
when (file.extension) {
"png" -> {
when {
file.isCaptureImage -> {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.path, bounds)
val sample = (maxOf(bounds.outWidth, bounds.outHeight) / THUMBNAIL_SIZE_PX).coerceAtLeast(1)
BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })?.asImageBitmap()
}

"mp4" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
file.isCaptureVideo -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
android.media.ThumbnailUtils.createVideoThumbnail(file, android.util.Size(THUMBNAIL_SIZE_PX, THUMBNAIL_SIZE_PX), null)
.asImageBitmap()
} else {
Expand Down
Loading
Loading