From 82e230f8ec0a07a4181267199a243d1cfbea9bd6 Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 05:13:51 +0530 Subject: [PATCH 1/4] Save captures to Downloads, drag captures out of the gallery, drop media onto bug reports --- CHANGELOG.md | 3 + README.md | 3 +- .../io/devdeck/bugreport/BugReportScreen.kt | 70 ++++++++++++ .../io/devdeck/capture/CaptureGallery.kt | 83 +++++++++++--- .../io/devdeck/capture/CaptureTransfer.kt | 104 ++++++++++++++++++ .../io/devdeck/capture/ScreenCapture.kt | 2 +- .../kotlin/io/devdeck/internal/FileIntents.kt | 11 +- 7 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 devdeck/src/main/kotlin/io/devdeck/capture/CaptureTransfer.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d99c0..70d08ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ 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`. +- The capture gallery can save one capture or all of them to `Download/DevDeck` (Android 10+), so they're easy to copy to a computer, and captures can be long-pressed and dragged into other apps or onto a desktop showing the device (ChromeOS, DeX, Phone Link, split screen). +- Images and videos dropped onto *Generate a bug report* are copied to the gallery and attached (Android 7+). +- 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. diff --git a/README.md b/README.md index 95d7350..4fcac1f 100644 --- a/README.md +++ b/README.md @@ -307,10 +307,11 @@ 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. +- **Getting captures to a computer:** in the gallery, *Save to Downloads* (one capture or all) copies them to `Download/DevDeck`, where a computer sees them over USB or `adb pull /sdcard/Download/DevDeck` (Android 10+; older versions use the share sheet). Where the device's screen is shown on a computer (ChromeOS, Samsung DeX, Windows Phone Link) or in split screen, long-press a capture to drag it into another app or onto the desktop. - **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. +- **Bug report:** a description, recent steps, your logs, recent logcat, device info and chosen captures, sent through the share sheet. Images and videos dragged in from another app or a computer (Android 7+) are copied to the gallery and attached. - **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/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt b/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt index 1be0a52..5b5e28d 100644 --- a/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt +++ b/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt @@ -1,12 +1,17 @@ package io.devdeck.bugreport import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -22,11 +27,17 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.toAndroidDragEvent +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.devdeck.breadcrumbs.Breadcrumbs +import io.devdeck.capture.CaptureTransfer import io.devdeck.capture.ScreenCapture import io.devdeck.components.ButtonItem import io.devdeck.components.SectionDivider @@ -36,6 +47,7 @@ import io.devdeck.components.TextItem import io.devdeck.console.ConsoleFormat import io.devdeck.device.appVersionInfo import io.devdeck.device.deviceInfo +import io.devdeck.internal.findActivity import io.devdeck.internal.shareFiles import io.devdeck.logcat.LogcatReader import io.devdeck.logs.DeckLogger @@ -72,6 +84,9 @@ public fun BugReportScreen(modifier: Modifier = Modifier, buildInfo: () -> Map selected.addAll(files.filterNot { it in selected }) }) + } if (captures.isEmpty()) TextItem("Take a screenshot or recording first to attach it") captures.forEach { file -> val checked = file in selected @@ -111,6 +126,61 @@ public fun BugReportScreen(modifier: Modifier = Modifier, buildInfo: () -> Map) -> Unit) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var hovering by remember { mutableStateOf(false) } + var dragging by remember { mutableStateOf(false) } + val target = remember { + object : DragAndDropTarget { + override fun onStarted(event: DragAndDropEvent) { + dragging = true + } + + override fun onEntered(event: DragAndDropEvent) { + hovering = true + } + + override fun onExited(event: DragAndDropEvent) { + hovering = false + } + + override fun onEnded(event: DragAndDropEvent) { + hovering = false + dragging = false + } + + override fun onDrop(event: DragAndDropEvent): Boolean { + val activity = context.findActivity() ?: return false + val dragEvent = event.toAndroidDragEvent() + scope.launch { + val files = CaptureTransfer.importDrop(activity, dragEvent) + if (files.isEmpty()) Toast.makeText(context, "Couldn't read the dropped file", Toast.LENGTH_SHORT).show() + onDropped(files) + } + return true + } + } + } + val colors = MaterialTheme.colorScheme + val shape = RoundedCornerShape(12.dp) + Text( + text = if (hovering) "Release to attach" else "Drop images or videos here to attach them", + style = MaterialTheme.typography.bodySmall, + color = if (dragging) colors.primary else colors.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .dragAndDropTarget(shouldStartDragAndDrop = { CaptureTransfer.isMediaDrag(it.toAndroidDragEvent()) }, target = target) + .background(if (hovering) colors.primary.copy(alpha = 0.12f) else Color.Transparent, shape) + .border(1.dp, if (dragging) colors.primary else colors.outlineVariant, shape) + .padding(vertical = 14.dp, horizontal = 12.dp), + ) +} + private fun buildReport(context: Context, description: String, includeDeviceInfo: Boolean, buildInfo: Map): String = buildString { appendLine(description.ifBlank { "(no description)" }) diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt index abad48e..f9ba469 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt @@ -2,9 +2,11 @@ package io.devdeck.capture import android.graphics.BitmapFactory import android.os.Build +import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -24,6 +26,7 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap @@ -58,6 +61,24 @@ public fun CaptureGallery(modifier: Modifier = Modifier) { value = withContext(Dispatchers.IO) { ScreenCapture.captures(context) } } val files = loaded.orEmpty() + val save: (List) -> Unit = { targets -> + if (CaptureTransfer.canSaveToDownloads) { + scope.launch { + val saved = CaptureTransfer.saveToDownloads(context, targets) + val message = if (saved == + targets.size + ) { + "Saved to Download/DevDeck" + } else { + "Saved $saved of ${targets.size} to Download/DevDeck" + } + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } else { + // Before Android 10, saving to Downloads needs a storage permission DevDeck doesn't ask for. + context.shareFiles(targets, subject = "Captures") + } + } val delete: (List) -> Unit = { targets -> scope.launch { withContext(Dispatchers.IO) { targets.forEach { it.delete() } } @@ -77,6 +98,11 @@ public fun CaptureGallery(modifier: Modifier = Modifier) { IconButton(onClick = { context.shareFiles(files, subject = "Captures") }, enabled = files.isNotEmpty()) { Icon(DeckIcons.Share, contentDescription = "Share all captures") } + if (CaptureTransfer.canSaveToDownloads) { + IconButton(onClick = { save(files) }, enabled = files.isNotEmpty()) { + Icon(DeckIcons.Download, contentDescription = "Save all captures to Downloads") + } + } ConfirmDeleteButton( contentDescription = "Delete all captures", onConfirm = { delete(files) }, @@ -85,14 +111,24 @@ public fun CaptureGallery(modifier: Modifier = Modifier) { ) } if (loaded?.isEmpty() == true) TextItem("No screenshots or recordings yet") + if (files.isNotEmpty() && CaptureTransfer.canDragBetweenApps) { + Text( + "Long-press a capture to drag it into another app, or to your computer on ChromeOS, DeX or Phone Link.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } InfoGroup(dividerThreshold = 2) { - files.forEach { file -> key(file.path) { GalleryRow(file, onDelete = { delete(listOf(file)) }) } } + files.forEach { file -> + key(file.path) { GalleryRow(file, onSave = { save(listOf(file)) }, onDelete = { delete(listOf(file)) }) } + } } } } @Composable -private fun GalleryRow(file: File, onDelete: () -> Unit) { +private fun GalleryRow(file: File, onSave: () -> Unit, onDelete: () -> Unit) { val context = LocalContext.current val thumbnail by produceState(null, file) { value = withContext(Dispatchers.IO) { loadThumbnail(file) } @@ -100,6 +136,15 @@ private fun GalleryRow(file: File, onDelete: () -> Unit) { Row( Modifier .fillMaxWidth() + .then( + if (CaptureTransfer.canDragBetweenApps) { + Modifier.dragAndDropSource { _ -> + DragAndDropTransferData(CaptureTransfer.dragClip(context, file), flags = CaptureTransfer.dragFlags()) + } + } else { + Modifier + }, + ) .clickable(role = Role.Button, onClickLabel = "Open") { context.openFile(file) } .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, @@ -117,38 +162,40 @@ 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)) }, modifier = Modifier.size(ActionSize)) { + Icon(DeckIcons.Share, contentDescription = "Share ${file.name}", modifier = Modifier.size(20.dp)) + } + if (CaptureTransfer.canSaveToDownloads) { + IconButton(onClick = onSave, modifier = Modifier.size(ActionSize)) { + Icon(DeckIcons.Download, contentDescription = "Save ${file.name} to Downloads", 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 { @@ -158,3 +205,5 @@ private fun loadThumbnail(file: File): ImageBitmap? = runCatching { else -> null } }.getOrNull() + +private val ActionSize = 40.dp diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureTransfer.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureTransfer.kt new file mode 100644 index 0000000..bd274c1 --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureTransfer.kt @@ -0,0 +1,104 @@ +package io.devdeck.capture + +import android.app.Activity +import android.content.ClipData +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import android.view.DragEvent +import android.view.View +import android.webkit.MimeTypeMap +import androidx.annotation.ChecksSdkIntAtLeast +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** Moving captures in and out of DevDeck: Downloads export, drag out of the gallery and drops onto a bug report. */ +internal object CaptureTransfer { + + // MediaStore.Downloads needs no storage permission, but only exists from Android 10. + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) + val canSaveToDownloads: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + // Global drag and drop (to other apps and a mirrored desktop) arrived in Android 7.0. + @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N) + val canDragBetweenApps: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N + + /** Copies [files] to Download/DevDeck, where a computer sees them over USB. Returns how many were saved. */ + suspend fun saveToDownloads(context: Context, files: List): Int = withContext(Dispatchers.IO) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@withContext 0 + val resolver = context.contentResolver + files.count { file -> + runCatching { + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, file.name) + put(MediaStore.MediaColumns.MIME_TYPE, file.mediaType) + put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/$DOWNLOADS_FOLDER") + } + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: return@runCatching false + resolver.openOutputStream(uri)?.use { output -> file.inputStream().use { it.copyTo(output) } } ?: return@runCatching false + true + }.getOrDefault(false) + } + } + + /** Drag flags that let another app, or a desktop showing this device, read the dragged file. */ + fun dragFlags(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ else 0 + + fun dragClip(context: Context, file: File): ClipData = + ClipData.newUri(context.contentResolver, file.name, ScreenCapture.uriFor(context, file)) + + fun isMediaDrag(event: DragEvent): Boolean { + val description = event.clipDescription ?: return false + return description.hasMimeType("image/*") || description.hasMimeType("video/*") + } + + /** Copies dropped images and videos into the capture gallery and returns the new files. */ + suspend fun importDrop(activity: Activity, event: DragEvent): List { + val clip = event.clipData ?: return emptyList() + // Content from another app is only readable while these permissions are held. + val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) activity.requestDragAndDropPermissions(event) else null + val uris = (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri } + return try { + withContext(Dispatchers.IO) { uris.mapNotNull { uri -> copyIntoGallery(activity, uri) } } + } finally { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) permissions?.release() + ScreenCapture.notifyGalleryChanged() + } + } + + private fun copyIntoGallery(context: Context, uri: Uri): File? = runCatching { + val type = context.contentResolver.getType(uri).orEmpty() + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(type) + ?: if (type.startsWith("video/")) "mp4" else "png" + val stamp = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) + val file = File(ScreenCapture.captureDirectory(context), "dropped_$stamp.$extension") + context.contentResolver.openInputStream(uri)?.use { input -> file.outputStream().use { input.copyTo(it) } } ?: return null + file + }.getOrNull() + + private const val DOWNLOADS_FOLDER = "DevDeck" +} + +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 and MediaStore. */ +internal val File.mediaType: String + get() = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) + ?: when { + isCaptureVideo -> "video/*" + isCaptureImage -> "image/*" + extension == "txt" -> "text/plain" + else -> "application/octet-stream" + } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt index 3a494ea..898563f 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt @@ -67,7 +67,7 @@ public object ScreenCapture { } public fun captures(context: Context): List = captureDirectory(context) - .listFiles { file -> file.isFile && (file.extension == "png" || file.extension == "mp4") } + .listFiles { file -> file.isFile && (file.isCaptureImage || file.isCaptureVideo) } .orEmpty() .sortedByDescending { it.lastModified() } diff --git a/devdeck/src/main/kotlin/io/devdeck/internal/FileIntents.kt b/devdeck/src/main/kotlin/io/devdeck/internal/FileIntents.kt index 5dbdbf7..5445814 100644 --- a/devdeck/src/main/kotlin/io/devdeck/internal/FileIntents.kt +++ b/devdeck/src/main/kotlin/io/devdeck/internal/FileIntents.kt @@ -5,11 +5,14 @@ import android.content.Context import android.content.Intent import android.widget.Toast import io.devdeck.capture.ScreenCapture +import io.devdeck.capture.isCaptureImage +import io.devdeck.capture.isCaptureVideo +import io.devdeck.capture.mediaType import java.io.File internal fun Context.openFile(file: File) { val uri = ScreenCapture.uriFor(this, file) - val type = if (file.extension == "mp4") "video/mp4" else "image/png" + val type = file.mediaType val intent = Intent(Intent.ACTION_VIEW) .setDataAndType(uri, type) .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) @@ -33,9 +36,9 @@ internal fun Context.shareFiles(files: List, text: String? = null, subject Intent(Intent.ACTION_SEND_MULTIPLE).putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris) } intent.type = when { - files.all { it.extension == "png" } -> "image/png" - files.all { it.extension == "mp4" } -> "video/mp4" - files.all { it.extension == "txt" } -> "text/plain" + files.map { it.mediaType }.distinct().size == 1 -> files.first().mediaType + files.all { it.isCaptureImage } -> "image/*" + files.all { it.isCaptureVideo } -> "video/*" else -> "*/*" } text?.let { intent.putExtra(Intent.EXTRA_TEXT, it) } From 03a619cde229e3b7b4fc8418be320fa2e0146d79 Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 05:56:03 +0530 Subject: [PATCH 2/4] Long screenshots, Stop pill, hide DevDeck overlays in captures; drop drag and drop and Downloads export --- CHANGELOG.md | 6 +- README.md | 6 +- devdeck/api/devdeck.api | 2 + .../src/main/kotlin/io/devdeck/DeckPanel.kt | 23 ++- .../main/kotlin/io/devdeck/DevDeckOverlay.kt | 7 +- .../io/devdeck/bugreport/BugReportScreen.kt | 70 -------- .../io/devdeck/capture/CaptureFileTypes.kt | 20 +++ .../io/devdeck/capture/CaptureGallery.kt | 55 +----- .../io/devdeck/capture/CaptureTransfer.kt | 104 ----------- .../io/devdeck/capture/LongScreenshot.kt | 168 ++++++++++++++++++ .../io/devdeck/capture/ScreenCapture.kt | 38 +++- .../io/devdeck/capture/ScreenCaptureTools.kt | 1 + .../io/devdeck/capture/ScreenshotButton.kt | 19 ++ 13 files changed, 275 insertions(+), 244 deletions(-) create mode 100644 devdeck/src/main/kotlin/io/devdeck/capture/CaptureFileTypes.kt delete mode 100644 devdeck/src/main/kotlin/io/devdeck/capture/CaptureTransfer.kt create mode 100644 devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 70d08ce..476da4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,10 @@ 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`. -- The capture gallery can save one capture or all of them to `Download/DevDeck` (Android 10+), so they're easy to copy to a computer, and captures can be long-pressed and dragged into other apps or onto a desktop showing the device (ChromeOS, DeX, Phone Link, split screen). -- Images and videos dropped onto *Generate a bug report* are copied to the gallery and attached (Android 7+). +- *Take a long screenshot* (Android 12+) captures the whole scrolling screen in one image (`LongScreenshotButton()`, `ScreenCapture.takeLongScreenshot()`). +- The gallery's *Share all* button is gone; each capture keeps its own share button. - 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. +- 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. diff --git a/README.md b/README.md index 4fcac1f..3e51795 100644 --- a/README.md +++ b/README.md @@ -306,12 +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. 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. -- **Getting captures to a computer:** in the gallery, *Save to Downloads* (one capture or all) copies them to `Download/DevDeck`, where a computer sees them over USB or `adb pull /sdcard/Download/DevDeck` (Android 10+; older versions use the share sheet). Where the device's screen is shown on a computer (ChromeOS, Samsung DeX, Windows Phone Link) or in split screen, long-press a capture to drag it into another app or onto the desktop. +- **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. +- **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). 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. -- **Bug report:** a description, recent steps, your logs, recent logcat, device info and chosen captures, sent through the share sheet. Images and videos dragged in from another app or a computer (Android 7+) are copied to the gallery and attached. +- **Bug report:** a description, recent steps, your logs, recent logcat, device info and chosen captures, sent through the share sheet. - **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 3ab51c3..b43f2c9 100644 --- a/devdeck/api/devdeck.api +++ b/devdeck/api/devdeck.api @@ -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; } @@ -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 } diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt index f97f458..1864815 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt @@ -55,6 +55,7 @@ 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.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -311,15 +312,25 @@ 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) + .padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp) + .semantics(mergeDescendants = true) {}, + 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, + ) } } diff --git a/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt b/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt index 008cb08..e36e203 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DevDeckOverlay.kt @@ -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 @@ -123,9 +124,9 @@ internal fun DevDeckOverlay( PerformanceOverlay() } - SimulationBadge() + if (!ScreenCapture.isCapturing) SimulationBadge() - if (!isVisible) RecordingControls() + if (!isVisible && !ScreenCapture.isCapturing) RecordingControls() if (isVisible) { Box( @@ -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) } } } diff --git a/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt b/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt index 5b5e28d..1be0a52 100644 --- a/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt +++ b/devdeck/src/main/kotlin/io/devdeck/bugreport/BugReportScreen.kt @@ -1,17 +1,12 @@ package io.devdeck.bugreport import android.content.Context -import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.toggleable -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -27,17 +22,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draganddrop.DragAndDropEvent -import androidx.compose.ui.draganddrop.DragAndDropTarget -import androidx.compose.ui.draganddrop.toAndroidDragEvent -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.devdeck.breadcrumbs.Breadcrumbs -import io.devdeck.capture.CaptureTransfer import io.devdeck.capture.ScreenCapture import io.devdeck.components.ButtonItem import io.devdeck.components.SectionDivider @@ -47,7 +36,6 @@ import io.devdeck.components.TextItem import io.devdeck.console.ConsoleFormat import io.devdeck.device.appVersionInfo import io.devdeck.device.deviceInfo -import io.devdeck.internal.findActivity import io.devdeck.internal.shareFiles import io.devdeck.logcat.LogcatReader import io.devdeck.logs.DeckLogger @@ -84,9 +72,6 @@ public fun BugReportScreen(modifier: Modifier = Modifier, buildInfo: () -> Map selected.addAll(files.filterNot { it in selected }) }) - } if (captures.isEmpty()) TextItem("Take a screenshot or recording first to attach it") captures.forEach { file -> val checked = file in selected @@ -126,61 +111,6 @@ public fun BugReportScreen(modifier: Modifier = Modifier, buildInfo: () -> Map) -> Unit) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var hovering by remember { mutableStateOf(false) } - var dragging by remember { mutableStateOf(false) } - val target = remember { - object : DragAndDropTarget { - override fun onStarted(event: DragAndDropEvent) { - dragging = true - } - - override fun onEntered(event: DragAndDropEvent) { - hovering = true - } - - override fun onExited(event: DragAndDropEvent) { - hovering = false - } - - override fun onEnded(event: DragAndDropEvent) { - hovering = false - dragging = false - } - - override fun onDrop(event: DragAndDropEvent): Boolean { - val activity = context.findActivity() ?: return false - val dragEvent = event.toAndroidDragEvent() - scope.launch { - val files = CaptureTransfer.importDrop(activity, dragEvent) - if (files.isEmpty()) Toast.makeText(context, "Couldn't read the dropped file", Toast.LENGTH_SHORT).show() - onDropped(files) - } - return true - } - } - } - val colors = MaterialTheme.colorScheme - val shape = RoundedCornerShape(12.dp) - Text( - text = if (hovering) "Release to attach" else "Drop images or videos here to attach them", - style = MaterialTheme.typography.bodySmall, - color = if (dragging) colors.primary else colors.onSurfaceVariant, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .dragAndDropTarget(shouldStartDragAndDrop = { CaptureTransfer.isMediaDrag(it.toAndroidDragEvent()) }, target = target) - .background(if (hovering) colors.primary.copy(alpha = 0.12f) else Color.Transparent, shape) - .border(1.dp, if (dragging) colors.primary else colors.outlineVariant, shape) - .padding(vertical = 14.dp, horizontal = 12.dp), - ) -} - private fun buildReport(context: Context, description: String, includeDeviceInfo: Boolean, buildInfo: Map): String = buildString { appendLine(description.ifBlank { "(no description)" }) diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureFileTypes.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureFileTypes.kt new file mode 100644 index 0000000..7f7ecef --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureFileTypes.kt @@ -0,0 +1,20 @@ +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/*" + extension == "txt" -> "text/plain" + else -> "application/octet-stream" + } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt index f9ba469..88db2ad 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt @@ -2,11 +2,9 @@ package io.devdeck.capture import android.graphics.BitmapFactory import android.os.Build -import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.draganddrop.dragAndDropSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,7 +24,6 @@ import androidx.compose.runtime.produceState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draganddrop.DragAndDropTransferData import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap @@ -61,24 +58,6 @@ public fun CaptureGallery(modifier: Modifier = Modifier) { value = withContext(Dispatchers.IO) { ScreenCapture.captures(context) } } val files = loaded.orEmpty() - val save: (List) -> Unit = { targets -> - if (CaptureTransfer.canSaveToDownloads) { - scope.launch { - val saved = CaptureTransfer.saveToDownloads(context, targets) - val message = if (saved == - targets.size - ) { - "Saved to Download/DevDeck" - } else { - "Saved $saved of ${targets.size} to Download/DevDeck" - } - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } - } else { - // Before Android 10, saving to Downloads needs a storage permission DevDeck doesn't ask for. - context.shareFiles(targets, subject = "Captures") - } - } val delete: (List) -> Unit = { targets -> scope.launch { withContext(Dispatchers.IO) { targets.forEach { it.delete() } } @@ -95,14 +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") - } - if (CaptureTransfer.canSaveToDownloads) { - IconButton(onClick = { save(files) }, enabled = files.isNotEmpty()) { - Icon(DeckIcons.Download, contentDescription = "Save all captures to Downloads") - } - } ConfirmDeleteButton( contentDescription = "Delete all captures", onConfirm = { delete(files) }, @@ -111,24 +82,16 @@ public fun CaptureGallery(modifier: Modifier = Modifier) { ) } if (loaded?.isEmpty() == true) TextItem("No screenshots or recordings yet") - if (files.isNotEmpty() && CaptureTransfer.canDragBetweenApps) { - Text( - "Long-press a capture to drag it into another app, or to your computer on ChromeOS, DeX or Phone Link.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } InfoGroup(dividerThreshold = 2) { files.forEach { file -> - key(file.path) { GalleryRow(file, onSave = { save(listOf(file)) }, onDelete = { delete(listOf(file)) }) } + key(file.path) { GalleryRow(file, onDelete = { delete(listOf(file)) }) } } } } } @Composable -private fun GalleryRow(file: File, onSave: () -> Unit, onDelete: () -> Unit) { +private fun GalleryRow(file: File, onDelete: () -> Unit) { val context = LocalContext.current val thumbnail by produceState(null, file) { value = withContext(Dispatchers.IO) { loadThumbnail(file) } @@ -136,15 +99,6 @@ private fun GalleryRow(file: File, onSave: () -> Unit, onDelete: () -> Unit) { Row( Modifier .fillMaxWidth() - .then( - if (CaptureTransfer.canDragBetweenApps) { - Modifier.dragAndDropSource { _ -> - DragAndDropTransferData(CaptureTransfer.dragClip(context, file), flags = CaptureTransfer.dragFlags()) - } - } else { - Modifier - }, - ) .clickable(role = Role.Button, onClickLabel = "Open") { context.openFile(file) } .padding(start = 16.dp, end = 4.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, @@ -175,11 +129,6 @@ private fun GalleryRow(file: File, onSave: () -> Unit, onDelete: () -> Unit) { IconButton(onClick = { context.shareFiles(listOf(file)) }, modifier = Modifier.size(ActionSize)) { Icon(DeckIcons.Share, contentDescription = "Share ${file.name}", modifier = Modifier.size(20.dp)) } - if (CaptureTransfer.canSaveToDownloads) { - IconButton(onClick = onSave, modifier = Modifier.size(ActionSize)) { - Icon(DeckIcons.Download, contentDescription = "Save ${file.name} to Downloads", modifier = Modifier.size(20.dp)) - } - } ConfirmDeleteButton(contentDescription = "Delete ${file.name}", onConfirm = onDelete, confirmText = "Tap again to delete") } } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureTransfer.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureTransfer.kt deleted file mode 100644 index bd274c1..0000000 --- a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureTransfer.kt +++ /dev/null @@ -1,104 +0,0 @@ -package io.devdeck.capture - -import android.app.Activity -import android.content.ClipData -import android.content.ContentValues -import android.content.Context -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import android.view.DragEvent -import android.view.View -import android.webkit.MimeTypeMap -import androidx.annotation.ChecksSdkIntAtLeast -import java.io.File -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** Moving captures in and out of DevDeck: Downloads export, drag out of the gallery and drops onto a bug report. */ -internal object CaptureTransfer { - - // MediaStore.Downloads needs no storage permission, but only exists from Android 10. - @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.Q) - val canSaveToDownloads: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - - // Global drag and drop (to other apps and a mirrored desktop) arrived in Android 7.0. - @get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.N) - val canDragBetweenApps: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N - - /** Copies [files] to Download/DevDeck, where a computer sees them over USB. Returns how many were saved. */ - suspend fun saveToDownloads(context: Context, files: List): Int = withContext(Dispatchers.IO) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@withContext 0 - val resolver = context.contentResolver - files.count { file -> - runCatching { - val values = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, file.name) - put(MediaStore.MediaColumns.MIME_TYPE, file.mediaType) - put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/$DOWNLOADS_FOLDER") - } - val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: return@runCatching false - resolver.openOutputStream(uri)?.use { output -> file.inputStream().use { it.copyTo(output) } } ?: return@runCatching false - true - }.getOrDefault(false) - } - } - - /** Drag flags that let another app, or a desktop showing this device, read the dragged file. */ - fun dragFlags(): Int = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ else 0 - - fun dragClip(context: Context, file: File): ClipData = - ClipData.newUri(context.contentResolver, file.name, ScreenCapture.uriFor(context, file)) - - fun isMediaDrag(event: DragEvent): Boolean { - val description = event.clipDescription ?: return false - return description.hasMimeType("image/*") || description.hasMimeType("video/*") - } - - /** Copies dropped images and videos into the capture gallery and returns the new files. */ - suspend fun importDrop(activity: Activity, event: DragEvent): List { - val clip = event.clipData ?: return emptyList() - // Content from another app is only readable while these permissions are held. - val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) activity.requestDragAndDropPermissions(event) else null - val uris = (0 until clip.itemCount).mapNotNull { clip.getItemAt(it).uri } - return try { - withContext(Dispatchers.IO) { uris.mapNotNull { uri -> copyIntoGallery(activity, uri) } } - } finally { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) permissions?.release() - ScreenCapture.notifyGalleryChanged() - } - } - - private fun copyIntoGallery(context: Context, uri: Uri): File? = runCatching { - val type = context.contentResolver.getType(uri).orEmpty() - val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(type) - ?: if (type.startsWith("video/")) "mp4" else "png" - val stamp = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.US).format(Date()) - val file = File(ScreenCapture.captureDirectory(context), "dropped_$stamp.$extension") - context.contentResolver.openInputStream(uri)?.use { input -> file.outputStream().use { input.copyTo(it) } } ?: return null - file - }.getOrNull() - - private const val DOWNLOADS_FOLDER = "DevDeck" -} - -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 and MediaStore. */ -internal val File.mediaType: String - get() = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) - ?: when { - isCaptureVideo -> "video/*" - isCaptureImage -> "image/*" - extension == "txt" -> "text/plain" - else -> "application/octet-stream" - } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt b/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt new file mode 100644 index 0000000..561160a --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt @@ -0,0 +1,168 @@ +package io.devdeck.capture + +import android.app.Activity +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.PixelFormat +import android.graphics.Point +import android.graphics.Rect +import android.media.Image +import android.media.ImageReader +import android.os.Build +import android.os.CancellationSignal +import android.os.Handler +import android.os.Looper +import android.view.ScrollCaptureCallback +import android.view.ScrollCaptureSession +import android.view.ScrollCaptureTarget +import android.view.View +import androidx.annotation.RequiresApi +import androidx.core.graphics.createBitmap +import androidx.core.graphics.get +import kotlin.coroutines.resume +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Captures the screen's main scrolling content as one tall image, the way Android's own "Capture more" does: + * it asks the app's scrollable view (ScrollView, RecyclerView, ListView or Compose) for tiles through the + * scroll capture API and stitches them between the parts of the window above and below the list. + */ +@RequiresApi(Build.VERSION_CODES.S) +internal object LongScreenshot { + + /** Returns null when the screen has nothing to scroll. Call on the main thread. */ + suspend fun capture(activity: Activity, window: Bitmap): Bitmap? { + val decorView = activity.window?.decorView ?: return null + val target = findTarget(decorView) ?: return null + val callback = target.callback + val signal = CancellationSignal() + + val scrollBounds = step { done -> callback.onScrollCaptureSearch(signal) { done(it) } } + if (scrollBounds == null || scrollBounds.isEmpty) return null + target.updatePositionInWindow() + val position = target.positionInWindow + + val width = scrollBounds.width() + val tileHeight = scrollBounds.height() + val reader = ImageReader.newInstance(width, tileHeight, PixelFormat.RGBA_8888, 2) + val session = ScrollCaptureSession(reader.surface, scrollBounds, position) + val tiles = mutableListOf>() + try { + step { done -> callback.onScrollCaptureStart(session, signal) { done(Unit) } } ?: return null + // The visible area first, then upwards to the top, then downwards to the end, within a height limit. + captureTile(callback, session, signal, reader, Rect(scrollBounds))?.let { tiles += it } + var top = scrollBounds.top + while (tiles.span() < tileHeight * MAX_SCREENS) { + val request = Rect(scrollBounds.left, top - tileHeight, scrollBounds.right, top) + val tile = captureTile(callback, session, signal, reader, request) ?: break + tiles += tile + if (tile.second.height < tileHeight) break + top -= tileHeight + } + var bottom = scrollBounds.bottom + while (tiles.span() < tileHeight * MAX_SCREENS) { + val request = Rect(scrollBounds.left, bottom, scrollBounds.right, bottom + tileHeight) + val tile = captureTile(callback, session, signal, reader, request) ?: break + tiles += tile + if (tile.second.height < tileHeight) break + bottom += tileHeight + } + } finally { + // Lets the app scroll back to where it was. + step { done -> callback.onScrollCaptureEnd { done(Unit) } } + reader.close() + } + if (tiles.isEmpty()) return null + return stitch( + window, + tiles, + top = position.y + scrollBounds.top, + bottom = position.y + scrollBounds.bottom, + left = + position.x + scrollBounds.left, + ) + } + + private fun findTarget(decorView: View): ScrollCaptureTarget? { + val targets = mutableListOf() + decorView.dispatchScrollCaptureSearch(Rect(0, 0, decorView.width, decorView.height), Point(0, 0)) { targets += it } + return targets + .filterNot { it.containingView.isInsideDeckOverlay() || it.hint == View.SCROLL_CAPTURE_HINT_EXCLUDE } + .maxWithOrNull( + compareBy { it.hint == View.SCROLL_CAPTURE_HINT_INCLUDE } + .thenBy { it.localVisibleRect.width() * it.localVisibleRect.height() }, + ) + } + + private suspend fun captureTile( + callback: ScrollCaptureCallback, + session: ScrollCaptureSession, + signal: CancellationSignal, + reader: ImageReader, + request: Rect, + ): Pair? { + // Drop any stale frame so the next one is this request's. + reader.acquireLatestImage()?.close() + val captured = step { done -> callback.onScrollCaptureImageRequest(session, signal, request) { done(it) } } + if (captured == null || captured.isEmpty) return null + val image = step { done -> + reader.acquireLatestImage()?.let(done) + ?: reader.setOnImageAvailableListener({ it.acquireLatestImage()?.let(done) }, Handler(Looper.getMainLooper())) + } ?: return null + reader.setOnImageAvailableListener(null, null) + return image.use { + val plane = it.planes[0] + val full = createBitmap(plane.rowStride / plane.pixelStride, it.height) + full.copyPixelsFromBuffer(plane.buffer) + // The captured area is drawn at the top-left of the buffer, even when it's smaller than the request. + val height = captured.height().coerceAtMost(it.height) + captured.top to Bitmap.createBitmap(full, 0, 0, captured.width().coerceAtMost(it.width), height) + } + } + + private fun stitch(window: Bitmap, tiles: List>, top: Int, bottom: Int, left: Int): Bitmap { + val contentTop = tiles.minOf { it.first } + val contentHeight = tiles.maxOf { it.first + it.second.height } - contentTop + val above = top.coerceIn(0, window.height) + val below = (window.height - bottom).coerceAtLeast(0) + val result = createBitmap(window.width, above + contentHeight + below) + val canvas = Canvas(result) + // Toolbar and anything else above the list, then the stitched list, then what's below it (e.g. a bottom bar). + // Side margins next to a narrower list take the screen's background colour. + canvas.drawColor(window[0, top.coerceIn(0, window.height - 1)]) + canvas.drawBitmap(window, Rect(0, 0, window.width, above), Rect(0, 0, window.width, above), null) + tiles.forEach { (y, bitmap) -> canvas.drawBitmap(bitmap, left.toFloat(), (above + y - contentTop).toFloat(), null) } + if (below > 0) { + canvas.drawBitmap( + window, + Rect(0, bottom, window.width, window.height), + Rect(0, above + contentHeight, window.width, above + contentHeight + below), + null, + ) + } + return result + } + + private fun List>.span(): Int = if (isEmpty()) 0 else maxOf { it.first + it.second.height } - minOf { it.first } + + private fun View.isInsideDeckOverlay(): Boolean { + var view: View? = this + while (view != null) { + if (view.tag == DECK_OVERLAY_TAG) return true + view = view.parent as? View + } + return false + } + + /** Runs a callback-style step on the main thread and waits for its single result, giving up after a timeout. */ + private suspend fun step(block: (done: (T) -> Unit) -> Unit): T? = withTimeoutOrNull(STEP_TIMEOUT_MILLIS) { + suspendCancellableCoroutine { continuation -> + block { value -> if (continuation.isActive) continuation.resume(value) } + } + } + + private const val DECK_OVERLAY_TAG = "io.devdeck.overlay" + private const val MAX_SCREENS = 12 + private const val STEP_TIMEOUT_MILLIS = 5_000L +} diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt index 898563f..da21cdf 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -39,6 +40,9 @@ public object ScreenCapture { public var isRecording: Boolean by mutableStateOf(false) internal set + // True while a screenshot is being taken, so DevDeck's floating button, badges and recording bar stay out of it. + internal var isCapturing by mutableStateOf(false) + 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. @@ -86,8 +90,36 @@ public object ScreenCapture { } public suspend fun takeScreenshot(activity: Activity): File? { - val bitmap = captureWindow(activity) ?: return null - val file = newCaptureFile(activity, "screenshot", "png") + val bitmap = withOverlaysHidden { captureWindow(activity) } ?: return null + return savePng(activity, bitmap, "screenshot") + } + + /** + * Captures the screen's main scrolling content in one tall image (Android 12+). Returns null when there is + * nothing to scroll; use [takeScreenshot] then. + */ + public suspend fun takeLongScreenshot(activity: Activity): File? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null + val bitmap = withOverlaysHidden { + val window = captureWindow(activity) ?: return@withOverlaysHidden null + runCatching { LongScreenshot.capture(activity, window) }.getOrNull() + } ?: return null + return savePng(activity, bitmap, "long_screenshot") + } + + private suspend fun withOverlaysHidden(block: suspend () -> T): T { + isCapturing = true + return try { + // Give the overlay a frame to disappear before the window is copied. + delay(OVERLAY_HIDE_MILLIS) + block() + } finally { + isCapturing = false + } + } + + private suspend fun savePng(activity: Activity, bitmap: Bitmap, prefix: String): File? { + val file = newCaptureFile(activity, prefix, "png") val written = withContext(Dispatchers.IO) { try { file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } @@ -138,3 +170,5 @@ public object ScreenCapture { ) } } + +private const val OVERLAY_HIDE_MILLIS = 100L diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCaptureTools.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCaptureTools.kt index 517a130..dd3a69b 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCaptureTools.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCaptureTools.kt @@ -15,6 +15,7 @@ public fun ScreenCaptureTools(modifier: Modifier = Modifier, title: String = "Sc } ExpandableItem(title = title, modifier = modifier) { ScreenshotButton() + LongScreenshotButton() ScreenRecordButton() GalleryButton() } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt index 6ee7fcc..fc50a62 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt @@ -1,5 +1,6 @@ package io.devdeck.capture +import android.os.Build import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.snapshotFlow @@ -27,3 +28,21 @@ public fun ScreenshotButton(title: String = "Take a screenshot") { } }) } + +@Composable +public fun LongScreenshotButton(title: String = "Take a long screenshot") { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + val context = LocalContext.current + val drawerState = LocalDeckState.current + ButtonItem(title = title, subtitle = "The whole scrolling screen in one image", onClick = { + val activity = context.findActivity() ?: return@ButtonItem + drawerState?.close() + ScreenCapture.scope.launch { + if (drawerState != null) snapshotFlow { drawerState.isVisible }.first { !it } + delay(CAPTURE_SETTLE_MILLIS) + val file = ScreenCapture.takeLongScreenshot(activity) + val message = if (file != null) "Long screenshot saved" else "Nothing to scroll on this screen" + Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() + } + }) +} From 0e0e60dbe763befabb5d3ecde09426f786a30eb9 Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 06:08:25 +0530 Subject: [PATCH 3/4] Bound long screenshots: start on screen, 5 screens, 8 s, stop on repeats, stitch in place --- CHANGELOG.md | 2 +- README.md | 2 +- .../io/devdeck/capture/LongScreenshot.kt | 115 +++++++++--------- .../io/devdeck/capture/ScreenCapture.kt | 4 +- 4 files changed, 62 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 476da4f..519801e 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`. -- *Take a long screenshot* (Android 12+) captures the whole scrolling screen in one image (`LongScreenshotButton()`, `ScreenCapture.takeLongScreenshot()`). +- *Take a long screenshot* (Android 12+) captures the scrolling screen in one image from the current position down, up to 5 screens, 8 seconds, or until nothing new appears, so endless feeds stay bounded (`LongScreenshotButton()`, `ScreenCapture.takeLongScreenshot()`). - The gallery's *Share all* button is gone; each capture keeps its own share button. - 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. diff --git a/README.md b/README.md index 3e51795..8e1ae6a 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,7 @@ 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. -- **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). Screens with nothing to scroll show a message instead. +- **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, 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. diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt b/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt index 561160a..5d928e9 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt @@ -27,6 +27,8 @@ import kotlinx.coroutines.withTimeoutOrNull * Captures the screen's main scrolling content as one tall image, the way Android's own "Capture more" does: * it asks the app's scrollable view (ScrollView, RecyclerView, ListView or Compose) for tiles through the * scroll capture API and stitches them between the parts of the window above and below the list. + * It starts at what's on screen and stops at the end of the content, after a few screens, after a time limit, + * or when scrolling brings nothing new, so endless feeds can't run it out of memory. */ @RequiresApi(Build.VERSION_CODES.S) internal object LongScreenshot { @@ -43,45 +45,67 @@ internal object LongScreenshot { target.updatePositionInWindow() val position = target.positionInWindow - val width = scrollBounds.width() val tileHeight = scrollBounds.height() - val reader = ImageReader.newInstance(width, tileHeight, PixelFormat.RGBA_8888, 2) + // Window parts above and below the list (toolbar, bottom bar) frame the stitched content. + val above = (position.y + scrollBounds.top).coerceIn(0, window.height) + val listBottom = (position.y + scrollBounds.bottom).coerceIn(above, window.height) + val below = window.height - listBottom + val left = position.x + scrollBounds.left + // Bounded for endless feeds: a few screens and a pixel budget, so the image stays small enough to keep in memory. + val maxContent = minOf(tileHeight * MAX_SCREENS, MAX_PIXELS / window.width).coerceAtLeast(tileHeight) + + // Each tile is drawn straight into the final image, so only one or two tiles are held at a time. + val result = createBitmap(window.width, above + maxContent + below) + val canvas = Canvas(result) + // Side margins next to a narrower list take the screen's background colour. + canvas.drawColor(window[0, above.coerceAtMost(window.height - 1)]) + canvas.drawBitmap(window, Rect(0, 0, window.width, above), Rect(0, 0, window.width, above), null) + + val reader = ImageReader.newInstance(scrollBounds.width(), tileHeight, PixelFormat.RGBA_8888, 2) val session = ScrollCaptureSession(reader.surface, scrollBounds, position) - val tiles = mutableListOf>() + var contentHeight = 0 try { step { done -> callback.onScrollCaptureStart(session, signal) { done(Unit) } } ?: return null - // The visible area first, then upwards to the top, then downwards to the end, within a height limit. - captureTile(callback, session, signal, reader, Rect(scrollBounds))?.let { tiles += it } - var top = scrollBounds.top - while (tiles.span() < tileHeight * MAX_SCREENS) { - val request = Rect(scrollBounds.left, top - tileHeight, scrollBounds.right, top) - val tile = captureTile(callback, session, signal, reader, request) ?: break - tiles += tile - if (tile.second.height < tileHeight) break - top -= tileHeight - } - var bottom = scrollBounds.bottom - while (tiles.span() < tileHeight * MAX_SCREENS) { - val request = Rect(scrollBounds.left, bottom, scrollBounds.right, bottom + tileHeight) - val tile = captureTile(callback, session, signal, reader, request) ?: break - tiles += tile - if (tile.second.height < tileHeight) break - bottom += tileHeight + // From what's on screen downwards only, so it doesn't load everything above a deep scroll position. + withTimeoutOrNull(TOTAL_TIME_MILLIS) { + // Requests are relative to the scroll bounds: 0 is the top of what's on screen now. + var requestTop = 0 + var previous: Bitmap? = null + while (contentHeight < maxContent) { + val request = Rect(0, requestTop, scrollBounds.width(), requestTop + tileHeight) + val tile = captureTile(callback, session, signal, reader, request) ?: break + // Nothing new arrived (e.g. a feed still loading its next page): stop instead of repeating it. + if (previous != null && tile.sameAs(previous)) { + tile.recycle() + break + } + val height = minOf(tile.height, maxContent - contentHeight) + val y = above + contentHeight + canvas.drawBitmap(tile, Rect(0, 0, tile.width, height), Rect(left, y, left + tile.width, y + height), null) + contentHeight += height + previous?.recycle() + previous = tile + if (tile.height < tileHeight) break + requestTop += tileHeight + } + previous?.recycle() } } finally { // Lets the app scroll back to where it was. step { done -> callback.onScrollCaptureEnd { done(Unit) } } reader.close() } - if (tiles.isEmpty()) return null - return stitch( - window, - tiles, - top = position.y + scrollBounds.top, - bottom = position.y + scrollBounds.bottom, - left = - position.x + scrollBounds.left, - ) + if (contentHeight == 0) { + result.recycle() + return null + } + if (below > 0) { + val y = above + contentHeight + canvas.drawBitmap(window, Rect(0, listBottom, window.width, window.height), Rect(0, y, window.width, y + below), null) + } + // Trim to what was captured without copying: the rows on top keep their place in the same allocation. + result.reconfigure(window.width, above + contentHeight + below, Bitmap.Config.ARGB_8888) + return result } private fun findTarget(decorView: View): ScrollCaptureTarget? { @@ -101,7 +125,7 @@ internal object LongScreenshot { signal: CancellationSignal, reader: ImageReader, request: Rect, - ): Pair? { + ): Bitmap? { // Drop any stale frame so the next one is this request's. reader.acquireLatestImage()?.close() val captured = step { done -> callback.onScrollCaptureImageRequest(session, signal, request) { done(it) } } @@ -117,35 +141,10 @@ internal object LongScreenshot { full.copyPixelsFromBuffer(plane.buffer) // The captured area is drawn at the top-left of the buffer, even when it's smaller than the request. val height = captured.height().coerceAtMost(it.height) - captured.top to Bitmap.createBitmap(full, 0, 0, captured.width().coerceAtMost(it.width), height) + Bitmap.createBitmap(full, 0, 0, captured.width().coerceAtMost(it.width), height).also { full.recycle() } } } - private fun stitch(window: Bitmap, tiles: List>, top: Int, bottom: Int, left: Int): Bitmap { - val contentTop = tiles.minOf { it.first } - val contentHeight = tiles.maxOf { it.first + it.second.height } - contentTop - val above = top.coerceIn(0, window.height) - val below = (window.height - bottom).coerceAtLeast(0) - val result = createBitmap(window.width, above + contentHeight + below) - val canvas = Canvas(result) - // Toolbar and anything else above the list, then the stitched list, then what's below it (e.g. a bottom bar). - // Side margins next to a narrower list take the screen's background colour. - canvas.drawColor(window[0, top.coerceIn(0, window.height - 1)]) - canvas.drawBitmap(window, Rect(0, 0, window.width, above), Rect(0, 0, window.width, above), null) - tiles.forEach { (y, bitmap) -> canvas.drawBitmap(bitmap, left.toFloat(), (above + y - contentTop).toFloat(), null) } - if (below > 0) { - canvas.drawBitmap( - window, - Rect(0, bottom, window.width, window.height), - Rect(0, above + contentHeight, window.width, above + contentHeight + below), - null, - ) - } - return result - } - - private fun List>.span(): Int = if (isEmpty()) 0 else maxOf { it.first + it.second.height } - minOf { it.first } - private fun View.isInsideDeckOverlay(): Boolean { var view: View? = this while (view != null) { @@ -163,6 +162,8 @@ internal object LongScreenshot { } private const val DECK_OVERLAY_TAG = "io.devdeck.overlay" - private const val MAX_SCREENS = 12 + private const val MAX_SCREENS = 5 + private const val MAX_PIXELS = 15_000_000 + private const val TOTAL_TIME_MILLIS = 8_000L private const val STEP_TIMEOUT_MILLIS = 5_000L } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt index da21cdf..4a15e02 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt @@ -95,8 +95,8 @@ public object ScreenCapture { } /** - * Captures the screen's main scrolling content in one tall image (Android 12+). Returns null when there is - * nothing to scroll; use [takeScreenshot] then. + * Captures the screen's main scrolling content in one tall image (Android 12+), from what's on screen down, + * within a few screens and seconds. Returns null when there is nothing to scroll; use [takeScreenshot] then. */ public suspend fun takeLongScreenshot(activity: Activity): File? { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null From 4e9cad036b0f37676d1d60fbacc0002d171a9a9b Mon Sep 17 00:00:00 2001 From: Abhishek Bansal Date: Sun, 25 Oct 2026 06:17:30 +0530 Subject: [PATCH 4/4] Address review: cancel capture steps, safe tile copy, one time budget, honest outcomes, capture counter, docs --- CHANGELOG.md | 6 +- README.md | 3 +- .../src/main/kotlin/io/devdeck/DeckPanel.kt | 7 +- devdeck/src/main/kotlin/io/devdeck/DevDeck.kt | 11 +- .../breadcrumbs/BreadcrumbCollector.kt | 2 +- .../io/devdeck/capture/CaptureFileTypes.kt | 1 - .../io/devdeck/capture/CaptureGallery.kt | 4 +- .../io/devdeck/capture/LongScreenshot.kt | 174 ++++++++++++------ .../io/devdeck/capture/ScreenCapture.kt | 52 ++++-- .../io/devdeck/capture/ScreenshotButton.kt | 47 +++-- 10 files changed, 201 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 519801e..0f3941b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,14 +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`. -- *Take a long screenshot* (Android 12+) captures the scrolling screen in one image from the current position down, up to 5 screens, 8 seconds, or until nothing new appears, so endless feeds stay bounded (`LongScreenshotButton()`, `ScreenCapture.takeLongScreenshot()`). -- The gallery's *Share all* button is gone; each capture keeps its own share button. +- *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 diff --git a/README.md b/README.md index 8e1ae6a..bc3699c 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,8 @@ 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. -- **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, 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. +- **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. diff --git a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt index 1864815..b6bf470 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt @@ -55,6 +55,8 @@ 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 @@ -318,8 +320,8 @@ private fun StopRecordingButton(onClick: () -> Unit) { .clip(shape) .background(RecordingRed) .clickable(role = Role.Button, onClickLabel = "Stop recording", onClick = onClick) - .padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp) - .semantics(mergeDescendants = true) {}, + .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), ) { @@ -330,6 +332,7 @@ private fun StopRecordingButton(onClick: () -> Unit) { fontWeight = FontWeight.SemiBold, color = Color.White, maxLines = 1, + modifier = Modifier.clearAndSetSemantics {}, ) } } diff --git a/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt b/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt index 2216bf6..2fedb8b 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DevDeck.kt @@ -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_TAG) != null || HostedActivities.contains(activity)) return + if (decorView.findViewWithTag(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) @@ -172,8 +172,9 @@ public object DevDeck { internal fun removeOverlay(activity: Activity) { val decorView = activity.window?.decorView as? ViewGroup ?: return - decorView.findViewWithTag(VIEW_TAG)?.let(decorView::removeView) + decorView.findViewWithTag(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" diff --git a/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt b/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt index d29cbc6..c079109 100644 --- a/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt +++ b/devdeck/src/main/kotlin/io/devdeck/breadcrumbs/BreadcrumbCollector.kt @@ -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 @@ -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" } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureFileTypes.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureFileTypes.kt index 7f7ecef..a3ac681 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureFileTypes.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureFileTypes.kt @@ -15,6 +15,5 @@ internal val File.mediaType: String ?: when { isCaptureVideo -> "video/*" isCaptureImage -> "image/*" - extension == "txt" -> "text/plain" else -> "application/octet-stream" } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt index 88db2ad..bcedb4e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt @@ -126,7 +126,7 @@ private fun GalleryRow(file: File, onDelete: () -> Unit) { 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)) }, modifier = Modifier.size(ActionSize)) { + 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") @@ -154,5 +154,3 @@ private fun loadThumbnail(file: File): ImageBitmap? = runCatching { else -> null } }.getOrNull() - -private val ActionSize = 40.dp diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt b/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt index 5d928e9..7c290ff 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt @@ -12,6 +12,7 @@ import android.os.Build import android.os.CancellationSignal import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.view.ScrollCaptureCallback import android.view.ScrollCaptureSession import android.view.ScrollCaptureTarget @@ -19,32 +20,46 @@ import android.view.View import androidx.annotation.RequiresApi import androidx.core.graphics.createBitmap import androidx.core.graphics.get +import io.devdeck.DECK_OVERLAY_TAG +import java.nio.ByteBuffer import kotlin.coroutines.resume +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** * Captures the screen's main scrolling content as one tall image, the way Android's own "Capture more" does: * it asks the app's scrollable view (ScrollView, RecyclerView, ListView or Compose) for tiles through the * scroll capture API and stitches them between the parts of the window above and below the list. - * It starts at what's on screen and stops at the end of the content, after a few screens, after a time limit, - * or when scrolling brings nothing new, so endless feeds can't run it out of memory. + * It starts at what's on screen and stops at the end of the content, after a few screens, after a time limit for + * the whole capture, or when scrolling brings nothing new, so endless feeds can't run it out of memory. */ @RequiresApi(Build.VERSION_CODES.S) internal object LongScreenshot { - /** Returns null when the screen has nothing to scroll. Call on the main thread. */ - suspend fun capture(activity: Activity, window: Bitmap): Bitmap? { - val decorView = activity.window?.decorView ?: return null - val target = findTarget(decorView) ?: return null + sealed interface Result { + class Captured(val bitmap: Bitmap) : Result + + data object NothingToScroll : Result + + data object Failed : Result + } + + /** Captures from what's on screen downwards, within [TOTAL_TIME_MILLIS] for the whole capture. Call on the main thread. */ + suspend fun capture(activity: Activity, window: Bitmap): Result { + val decorView = activity.window?.decorView ?: return Result.Failed + val target = findTarget(decorView) ?: return Result.NothingToScroll val callback = target.callback - val signal = CancellationSignal() + val deadline = SystemClock.uptimeMillis() + TOTAL_TIME_MILLIS - val scrollBounds = step { done -> callback.onScrollCaptureSearch(signal) { done(it) } } - if (scrollBounds == null || scrollBounds.isEmpty) return null + val scrollBounds = step(deadline) { signal, done -> callback.onScrollCaptureSearch(signal) { done(it) } } + ?: return Result.Failed + if (scrollBounds.isEmpty) return Result.NothingToScroll target.updatePositionInWindow() val position = target.positionInWindow + val tileWidth = scrollBounds.width() val tileHeight = scrollBounds.height() // Window parts above and below the list (toolbar, bottom bar) frame the stitched content. val above = (position.y + scrollBounds.top).coerceIn(0, window.height) @@ -55,57 +70,66 @@ internal object LongScreenshot { val maxContent = minOf(tileHeight * MAX_SCREENS, MAX_PIXELS / window.width).coerceAtLeast(tileHeight) // Each tile is drawn straight into the final image, so only one or two tiles are held at a time. - val result = createBitmap(window.width, above + maxContent + below) + val result = try { + createBitmap(window.width, above + maxContent + below) + } catch (_: OutOfMemoryError) { + return Result.Failed + } val canvas = Canvas(result) // Side margins next to a narrower list take the screen's background colour. canvas.drawColor(window[0, above.coerceAtMost(window.height - 1)]) canvas.drawBitmap(window, Rect(0, 0, window.width, above), Rect(0, 0, window.width, above), null) - val reader = ImageReader.newInstance(scrollBounds.width(), tileHeight, PixelFormat.RGBA_8888, 2) + val reader = ImageReader.newInstance(tileWidth, tileHeight, PixelFormat.RGBA_8888, 2) val session = ScrollCaptureSession(reader.surface, scrollBounds, position) - var contentHeight = 0 + var contentBottom = 0 try { - step { done -> callback.onScrollCaptureStart(session, signal) { done(Unit) } } ?: return null - // From what's on screen downwards only, so it doesn't load everything above a deep scroll position. - withTimeoutOrNull(TOTAL_TIME_MILLIS) { - // Requests are relative to the scroll bounds: 0 is the top of what's on screen now. - var requestTop = 0 - var previous: Bitmap? = null - while (contentHeight < maxContent) { - val request = Rect(0, requestTop, scrollBounds.width(), requestTop + tileHeight) - val tile = captureTile(callback, session, signal, reader, request) ?: break - // Nothing new arrived (e.g. a feed still loading its next page): stop instead of repeating it. - if (previous != null && tile.sameAs(previous)) { - tile.recycle() - break - } - val height = minOf(tile.height, maxContent - contentHeight) - val y = above + contentHeight + step(deadline) { signal, done -> callback.onScrollCaptureStart(session, signal) { done(Unit) } } + ?: return Result.Failed.also { result.recycle() } + // Requests are relative to the scroll bounds: 0 is the top of what's on screen now. Only downwards, + // so a deep scroll position doesn't load everything above it. + var requestTop = 0 + var previous: Bitmap? = null + while (requestTop < maxContent) { + val request = Rect(0, requestTop, tileWidth, requestTop + tileHeight) + val (captured, tile) = captureTile(callback, session, reader, request, deadline) ?: break + // Nothing new arrived (e.g. a feed still loading its next page): stop instead of repeating it. + if (previous != null && tile.sameAs(previous)) { + tile.recycle() + break + } + val top = captured.top.coerceAtLeast(0) + val height = minOf(tile.height, maxContent - top) + if (height > 0) { + val y = above + top canvas.drawBitmap(tile, Rect(0, 0, tile.width, height), Rect(left, y, left + tile.width, y + height), null) - contentHeight += height - previous?.recycle() - previous = tile - if (tile.height < tileHeight) break - requestTop += tileHeight + contentBottom = maxOf(contentBottom, top + height) } previous?.recycle() + previous = tile + // A short tile means the content ended. + if (captured.bottom < request.bottom || height <= 0) break + requestTop = captured.bottom } + previous?.recycle() } finally { - // Lets the app scroll back to where it was. - step { done -> callback.onScrollCaptureEnd { done(Unit) } } + // Always end the session, even after a timeout, so the app scrolls back to where it was. + withContext(NonCancellable) { + step(SystemClock.uptimeMillis() + END_TIMEOUT_MILLIS) { _, done -> callback.onScrollCaptureEnd { done(Unit) } } + } reader.close() } - if (contentHeight == 0) { + if (contentBottom == 0) { result.recycle() - return null + return Result.Failed } if (below > 0) { - val y = above + contentHeight + val y = above + contentBottom canvas.drawBitmap(window, Rect(0, listBottom, window.width, window.height), Rect(0, y, window.width, y + below), null) } // Trim to what was captured without copying: the rows on top keep their place in the same allocation. - result.reconfigure(window.width, above + contentHeight + below, Bitmap.Config.ARGB_8888) - return result + result.reconfigure(window.width, above + contentBottom + below, Bitmap.Config.ARGB_8888) + return Result.Captured(result) } private fun findTarget(decorView: View): ScrollCaptureTarget? { @@ -122,27 +146,45 @@ internal object LongScreenshot { private suspend fun captureTile( callback: ScrollCaptureCallback, session: ScrollCaptureSession, - signal: CancellationSignal, reader: ImageReader, request: Rect, - ): Bitmap? { + deadline: Long, + ): Pair? { // Drop any stale frame so the next one is this request's. reader.acquireLatestImage()?.close() - val captured = step { done -> callback.onScrollCaptureImageRequest(session, signal, request) { done(it) } } + val captured = step(deadline) { signal, done -> callback.onScrollCaptureImageRequest(session, signal, request) { done(it) } } if (captured == null || captured.isEmpty) return null - val image = step { done -> - reader.acquireLatestImage()?.let(done) - ?: reader.setOnImageAvailableListener({ it.acquireLatestImage()?.let(done) }, Handler(Looper.getMainLooper())) + val image = try { + step(deadline) { _, done -> + // Listen first, then check, so a frame that lands in between isn't missed; extra frames are closed. + reader.setOnImageAvailableListener({ + it.acquireLatestImage()?.let { image -> if (!done(image)) image.close() } + }, mainHandler) + reader.acquireLatestImage()?.let { image -> if (!done(image)) image.close() } + } + } finally { + reader.setOnImageAvailableListener(null, null) } ?: return null - reader.setOnImageAvailableListener(null, null) - return image.use { - val plane = it.planes[0] - val full = createBitmap(plane.rowStride / plane.pixelStride, it.height) - full.copyPixelsFromBuffer(plane.buffer) - // The captured area is drawn at the top-left of the buffer, even when it's smaller than the request. - val height = captured.height().coerceAtMost(it.height) - Bitmap.createBitmap(full, 0, 0, captured.width().coerceAtMost(it.width), height).also { full.recycle() } + return image.use { captured to it.toBitmap(captured.width(), captured.height()) } + } + + // The captured area is drawn at the top-left of the buffer. Rows are copied one by one because the row stride + // can be wider than the image and some devices don't pad the last row. + private fun Image.toBitmap(capturedWidth: Int, capturedHeight: Int): Bitmap { + val plane = planes[0] + val buffer = plane.buffer + val width = capturedWidth.coerceAtMost(this.width) + val height = capturedHeight.coerceAtMost(this.height) + val rowBytes = width * plane.pixelStride + val packed = ByteBuffer.allocateDirect(rowBytes * height) + val row = ByteArray(rowBytes) + for (y in 0 until height) { + buffer.position(y * plane.rowStride) + buffer.get(row, 0, rowBytes) + packed.put(row) } + packed.rewind() + return createBitmap(width, height).apply { copyPixelsFromBuffer(packed) } } private fun View.isInsideDeckOverlay(): Boolean { @@ -154,16 +196,30 @@ internal object LongScreenshot { return false } - /** Runs a callback-style step on the main thread and waits for its single result, giving up after a timeout. */ - private suspend fun step(block: (done: (T) -> Unit) -> Unit): T? = withTimeoutOrNull(STEP_TIMEOUT_MILLIS) { - suspendCancellableCoroutine { continuation -> - block { value -> if (continuation.isActive) continuation.resume(value) } + /** + * Runs one callback-style step on the main thread and waits for its result until [deadline]. On timeout the + * step's CancellationSignal is cancelled, as the system does, so the app stops scrolling for it. + * `done` returns false when the step already finished, so late results can be cleaned up. + */ + private suspend fun step(deadline: Long, block: (signal: CancellationSignal, done: (T) -> Boolean) -> Unit): T? { + val remaining = (deadline - SystemClock.uptimeMillis()).coerceAtMost(STEP_TIMEOUT_MILLIS) + if (remaining <= 0) return null + val signal = CancellationSignal() + return withTimeoutOrNull(remaining) { + suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { signal.cancel() } + block(signal) { value -> + continuation.isActive.also { active -> if (active) continuation.resume(value) } + } + } } } - private const val DECK_OVERLAY_TAG = "io.devdeck.overlay" + private val mainHandler = Handler(Looper.getMainLooper()) + private const val MAX_SCREENS = 5 private const val MAX_PIXELS = 15_000_000 private const val TOTAL_TIME_MILLIS = 8_000L private const val STEP_TIMEOUT_MILLIS = 5_000L + private const val END_TIMEOUT_MILLIS = 2_000L } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt index 4a15e02..c9c204e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt @@ -26,6 +26,7 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.resume import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -40,8 +41,18 @@ public object ScreenCapture { public var isRecording: Boolean by mutableStateOf(false) internal set - // True while a screenshot is being taken, so DevDeck's floating button, badges and recording bar stay out of it. - internal var isCapturing by mutableStateOf(false) + // Screenshots in progress; while any are, DevDeck's floating button, badges and recording bar stay out of them. + private var capturesInProgress by mutableIntStateOf(0) + + internal val isCapturing: Boolean get() = capturesInProgress > 0 + + internal sealed interface LongScreenshotOutcome { + class Saved(val file: File) : LongScreenshotOutcome + + data object NothingToScroll : LongScreenshotOutcome + + data object Failed : LongScreenshotOutcome + } internal var isRecordingPaused by mutableStateOf(false) @@ -96,25 +107,42 @@ public object ScreenCapture { /** * Captures the screen's main scrolling content in one tall image (Android 12+), from what's on screen down, - * within a few screens and seconds. Returns null when there is nothing to scroll; use [takeScreenshot] then. + * within a few screens and seconds. Returns null when there is nothing to scroll or the capture failed; use + * [takeScreenshot] then. Safe to call from any thread. */ - public suspend fun takeLongScreenshot(activity: Activity): File? { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null - val bitmap = withOverlaysHidden { - val window = captureWindow(activity) ?: return@withOverlaysHidden null - runCatching { LongScreenshot.capture(activity, window) }.getOrNull() - } ?: return null - return savePng(activity, bitmap, "long_screenshot") + public suspend fun takeLongScreenshot(activity: Activity): File? = (longScreenshot(activity) as? LongScreenshotOutcome.Saved)?.file + + internal suspend fun longScreenshot(activity: Activity): LongScreenshotOutcome = withContext(Dispatchers.Main.immediate) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return@withContext LongScreenshotOutcome.NothingToScroll + val result = withOverlaysHidden { + val window = captureWindow(activity) ?: return@withOverlaysHidden LongScreenshot.Result.Failed + try { + LongScreenshot.capture(activity, window) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + DeckLogger.log("Long screenshot failed", tag = "Capture", payload = error.toString()) + LongScreenshot.Result.Failed + } + } + when (result) { + is LongScreenshot.Result.Captured -> + savePng(activity, result.bitmap, "long_screenshot")?.let { LongScreenshotOutcome.Saved(it) } ?: LongScreenshotOutcome.Failed + + LongScreenshot.Result.NothingToScroll -> LongScreenshotOutcome.NothingToScroll + + LongScreenshot.Result.Failed -> LongScreenshotOutcome.Failed + } } private suspend fun withOverlaysHidden(block: suspend () -> T): T { - isCapturing = true + capturesInProgress++ return try { // Give the overlay a frame to disappear before the window is copied. delay(OVERLAY_HIDE_MILLIS) block() } finally { - isCapturing = false + capturesInProgress-- } } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt index fc50a62..4828fb0 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenshotButton.kt @@ -1,5 +1,6 @@ package io.devdeck.capture +import android.app.Activity import android.os.Build import android.widget.Toast import androidx.compose.runtime.Composable @@ -14,17 +15,11 @@ import kotlinx.coroutines.launch @Composable public fun ScreenshotButton(title: String = "Take a screenshot") { - val context = LocalContext.current - val drawerState = LocalDeckState.current + val capture = rememberCaptureAfterClosing() ButtonItem(title = title, onClick = { - val activity = context.findActivity() ?: return@ButtonItem - drawerState?.close() - ScreenCapture.scope.launch { - if (drawerState != null) snapshotFlow { drawerState.isVisible }.first { !it } - delay(CAPTURE_SETTLE_MILLIS) + capture { activity -> val file = ScreenCapture.takeScreenshot(activity) - val message = if (file != null) "Screenshot saved" else "Screenshot failed" - Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() + if (file != null) "Screenshot saved" else "Screenshot failed" } }) } @@ -32,17 +27,31 @@ public fun ScreenshotButton(title: String = "Take a screenshot") { @Composable public fun LongScreenshotButton(title: String = "Take a long screenshot") { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return - val context = LocalContext.current - val drawerState = LocalDeckState.current + val capture = rememberCaptureAfterClosing() ButtonItem(title = title, subtitle = "The whole scrolling screen in one image", onClick = { - val activity = context.findActivity() ?: return@ButtonItem - drawerState?.close() - ScreenCapture.scope.launch { - if (drawerState != null) snapshotFlow { drawerState.isVisible }.first { !it } - delay(CAPTURE_SETTLE_MILLIS) - val file = ScreenCapture.takeLongScreenshot(activity) - val message = if (file != null) "Long screenshot saved" else "Nothing to scroll on this screen" - Toast.makeText(activity, message, Toast.LENGTH_SHORT).show() + capture { activity -> + when (ScreenCapture.longScreenshot(activity)) { + is ScreenCapture.LongScreenshotOutcome.Saved -> "Long screenshot saved" + ScreenCapture.LongScreenshotOutcome.NothingToScroll -> "Nothing to scroll on this screen" + ScreenCapture.LongScreenshotOutcome.Failed -> "Long screenshot failed" + } } }) } + +/** Closes the drawer, waits for it to leave the screen, runs the capture and shows the message it returns. */ +@Composable +private fun rememberCaptureAfterClosing(): (suspend (Activity) -> String) -> Unit { + val context = LocalContext.current + val drawerState = LocalDeckState.current + return { block -> + context.findActivity()?.let { activity -> + drawerState?.close() + ScreenCapture.scope.launch { + if (drawerState != null) snapshotFlow { drawerState.isVisible }.first { !it } + delay(CAPTURE_SETTLE_MILLIS) + Toast.makeText(activity, block(activity), Toast.LENGTH_SHORT).show() + } + } + } +}