diff --git a/CHANGELOG.md b/CHANGELOG.md index 88d99c0..0f3941b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,14 @@ Add entries under **Unreleased** in each pull request. The *Bump version* workfl - `NetworkLogger.maxTotalBodyChars` (default 12M characters) caps the bodies kept across all calls; the oldest bodies are dropped first, their calls stay listed. - `AppActions.clearCacheInBackground()` clears the cache off the main thread. - `TextInputItem`, `NumberInputItem` and `JsonInputItem` take an optional `subtitle`. -- While recording the screen, a small floating bar shows the time with pause/resume (Android 7+) and stop, so you don't have to reopen the drawer; the drawer header shows a *Recording* chip with a red stop button that stays in place while the chips scroll (tapping the chip opens the screen capture tools), and the notification has Pause/Resume too. +- *Take a long screenshot* (Android 12+) captures the scrolling screen in one image from the current position down, up to 5 screens or 8 seconds in total, or until nothing new appears, so endless feeds stay bounded (`LongScreenshotButton()`, `ScreenCapture.takeLongScreenshot()`). +- Screenshots leave out DevDeck's own floating button, simulation badge and recording bar. +- The gallery also lists JPEG, WebP, GIF, WebM and other common image and video files in its folder. +- While recording the screen, a small floating bar shows the time with pause/resume (Android 7+) and stop, so you don't have to reopen the drawer; the drawer header shows a *Recording* chip with a red *Stop* button that stays in place while the chips scroll (tapping the chip opens the screen capture tools), and the notification has Pause/Resume too. - A *System language & region* button in the User Interface section opens Android's language and region settings (`LocaleSettingsButton()`; hide it with `AppearancePlugin(localeSettings = false)`). - The feature flags page has *Restart screen* and *Restart app* buttons, so flag changes that are read once per screen or process take effect. - After switching endpoints, `EndpointPlugin` shows a *Restart app* button so the app picks up the new endpoint, unless `restartOnChange` or `onChanged` already applies it. -- Share buttons on the Logs and Network activity pages, and share-all / delete-all in the capture gallery. +- Share buttons on the Logs and Network activity pages, and delete-all in the capture gallery. - Consumer R8 rules for devdeck-okhttp, devdeck-timber and devdeck-appdistribution, so minified builds don't fail when OkHttp, Timber or Firebase isn't on the classpath. ### Changed diff --git a/README.md b/README.md index 95d7350..bc3699c 100644 --- a/README.md +++ b/README.md @@ -306,7 +306,9 @@ The last 50 steps are saved with every crash and shown as *Steps before the cras ### Screen capture, bug reports and device info -- **Screenshot / screen recording / gallery:** under User Interface. Recording asks for Android's screen-capture permission. While it runs, a small floating bar shows the time with pause/resume (Android 7+) and stop, the drawer header shows a *Recording* chip and a red stop button, and the notification has the same controls. The floating bar is drawn in your app's window, so it appears in the video. +- **Screenshot / screen recording / gallery:** under User Interface. Recording asks for Android's screen-capture permission. While it runs, a small floating bar shows the time with pause/resume (Android 7+) and stop, the drawer header shows a *Recording* chip and a red *Stop* button, and the notification has the same controls. The floating bar is drawn in your app's window, so it appears in the video. +- **Screenshots leave DevDeck out:** the floating button, simulation badge and recording bar are hidden while a screenshot is taken. The keyline and frame rate overlays stay, since you turn those on to capture them. +- **Long screenshot (Android 12+):** *Take a long screenshot* captures the screen's main scrolling list or page in one tall image, using the same scroll capture support as Android's *Capture more* (ScrollView, RecyclerView, ListView and Compose scrolling layouts). It starts at what's on screen and goes down, stopping at the end of the content, after 5 screens (or about 15 megapixels), after 8 seconds in total, or when scrolling shows nothing new, so endless feeds stay bounded; they may still load their next page while it scrolls. Screens with nothing to scroll show a message instead. - **App info and device info:** under Info. App info shows the version, package, build type and flavor (read from your app's `BuildConfig` when it exists and R8 hasn't removed it), whether the build is debuggable, R8 state and SDK levels. - **Device & app snapshot:** under Info. One image with app version, build type, SDKs, device, Android version, screen and locale, plus your `buildInfo`. Share it, save it, or copy it as text from the icons on the card; the icons are left out of the image. - **App settings:** the ⚙ button next to the drawer's close button opens the app's system settings page. 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..b6bf470 100644 --- a/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt +++ b/devdeck/src/main/kotlin/io/devdeck/DeckPanel.kt @@ -55,6 +55,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -311,15 +314,26 @@ private data class HeaderChip(val text: String, val accent: Color?, val onClick: @Composable private fun StopRecordingButton(onClick: () -> Unit) { - Box( + val shape = RoundedCornerShape(50) + Row( Modifier - .size(28.dp) - .clip(CircleShape) + .clip(shape) .background(RecordingRed) - .clickable(role = Role.Button, onClickLabel = "Stop recording", onClick = onClick), - contentAlignment = Alignment.Center, + .clickable(role = Role.Button, onClickLabel = "Stop recording", onClick = onClick) + .semantics { contentDescription = "Stop recording" } + .padding(start = 8.dp, end = 10.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - Icon(DeckIcons.Stop, contentDescription = "Stop recording", tint = Color.White, modifier = Modifier.size(14.dp)) + Box(Modifier.size(10.dp).background(Color.White, RoundedCornerShape(2.dp))) + Text( + "Stop", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = Color.White, + maxLines = 1, + modifier = Modifier.clearAndSetSemantics {}, + ) } } 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/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/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 new file mode 100644 index 0000000..a3ac681 --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureFileTypes.kt @@ -0,0 +1,19 @@ +package io.devdeck.capture + +import android.webkit.MimeTypeMap +import java.io.File + +internal val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "webp", "gif") +internal val VIDEO_EXTENSIONS = setOf("mp4", "webm", "3gp", "mkv", "mov") + +internal val File.isCaptureImage: Boolean get() = extension.lowercase() in IMAGE_EXTENSIONS +internal val File.isCaptureVideo: Boolean get() = extension.lowercase() in VIDEO_EXTENSIONS + +/** MIME type from the file extension, for intents. */ +internal val File.mediaType: String + get() = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension.lowercase()) + ?: when { + isCaptureVideo -> "video/*" + isCaptureImage -> "image/*" + else -> "application/octet-stream" + } diff --git a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt index abad48e..bcedb4e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/CaptureGallery.kt @@ -74,9 +74,6 @@ public fun CaptureGallery(modifier: Modifier = Modifier) { overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - IconButton(onClick = { context.shareFiles(files, subject = "Captures") }, enabled = files.isNotEmpty()) { - Icon(DeckIcons.Share, contentDescription = "Share all captures") - } ConfirmDeleteButton( contentDescription = "Delete all captures", onConfirm = { delete(files) }, @@ -86,7 +83,9 @@ public fun CaptureGallery(modifier: Modifier = Modifier) { } if (loaded?.isEmpty() == true) TextItem("No screenshots or recordings yet") InfoGroup(dividerThreshold = 2) { - files.forEach { file -> key(file.path) { GalleryRow(file, onDelete = { delete(listOf(file)) }) } } + files.forEach { file -> + key(file.path) { GalleryRow(file, onDelete = { delete(listOf(file)) }) } + } } } } @@ -117,38 +116,35 @@ private fun GalleryRow(file: File, onDelete: () -> Unit) { Image(image, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.size(56.dp)) } else { Text( - if (file.extension == "mp4") "▶" else "…", + if (file.isCaptureVideo) "▶" else "…", style = MaterialTheme.typography.titleLarge, modifier = Modifier.clearAndSetSemantics {}, ) } } - Text( - file.name, - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - Row { - IconButton(onClick = { context.shareFiles(listOf(file)) }) { - Icon(DeckIcons.Share, contentDescription = "Share ${file.name}") + // Actions sit under the name so the name keeps the full width on narrow phones. + Column(Modifier.weight(1f)) { + Text(file.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { context.shareFiles(listOf(file)) }) { + Icon(DeckIcons.Share, contentDescription = "Share ${file.name}", modifier = Modifier.size(20.dp)) + } + ConfirmDeleteButton(contentDescription = "Delete ${file.name}", onConfirm = onDelete, confirmText = "Tap again to delete") } - ConfirmDeleteButton(contentDescription = "Delete ${file.name}", onConfirm = onDelete, confirmText = "Tap again to delete") } } } private fun loadThumbnail(file: File): ImageBitmap? = runCatching { - when (file.extension) { - "png" -> { + when { + file.isCaptureImage -> { val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(file.path, bounds) val sample = (maxOf(bounds.outWidth, bounds.outHeight) / THUMBNAIL_SIZE_PX).coerceAtLeast(1) BitmapFactory.decodeFile(file.path, BitmapFactory.Options().apply { inSampleSize = sample })?.asImageBitmap() } - "mp4" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + file.isCaptureVideo -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { android.media.ThumbnailUtils.createVideoThumbnail(file, android.util.Size(THUMBNAIL_SIZE_PX, THUMBNAIL_SIZE_PX), null) .asImageBitmap() } else { 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..7c290ff --- /dev/null +++ b/devdeck/src/main/kotlin/io/devdeck/capture/LongScreenshot.kt @@ -0,0 +1,225 @@ +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.os.SystemClock +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 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 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 { + + 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 deadline = SystemClock.uptimeMillis() + TOTAL_TIME_MILLIS + + 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) + 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 = 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(tileWidth, tileHeight, PixelFormat.RGBA_8888, 2) + val session = ScrollCaptureSession(reader.surface, scrollBounds, position) + var contentBottom = 0 + try { + 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) + 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 { + // 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 (contentBottom == 0) { + result.recycle() + return Result.Failed + } + if (below > 0) { + 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 + contentBottom + below, Bitmap.Config.ARGB_8888) + return Result.Captured(result) + } + + 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, + reader: ImageReader, + request: Rect, + deadline: Long, + ): Pair? { + // Drop any stale frame so the next one is this request's. + reader.acquireLatestImage()?.close() + val captured = step(deadline) { signal, done -> callback.onScrollCaptureImageRequest(session, signal, request) { done(it) } } + if (captured == null || captured.isEmpty) return null + 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 + 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 { + var view: View? = this + while (view != null) { + if (view.tag == DECK_OVERLAY_TAG) return true + view = view.parent as? View + } + return false + } + + /** + * 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 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 3a494ea..c9c204e 100644 --- a/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt +++ b/devdeck/src/main/kotlin/io/devdeck/capture/ScreenCapture.kt @@ -26,11 +26,13 @@ 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 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -39,6 +41,19 @@ public object ScreenCapture { public var isRecording: Boolean by mutableStateOf(false) internal set + // 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) // Recording time is the time before the last resume plus the time since it, so pauses don't count. @@ -67,7 +82,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() } @@ -86,8 +101,53 @@ 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+), from what's on screen down, + * 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? = (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 { + capturesInProgress++ + return try { + // Give the overlay a frame to disappear before the window is copied. + delay(OVERLAY_HIDE_MILLIS) + block() + } finally { + capturesInProgress-- + } + } + + 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 +198,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..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,7 @@ package io.devdeck.capture +import android.app.Activity +import android.os.Build import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.snapshotFlow @@ -13,17 +15,43 @@ 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" + } + }) +} + +@Composable +public fun LongScreenshotButton(title: String = "Take a long screenshot") { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + val capture = rememberCaptureAfterClosing() + ButtonItem(title = title, subtitle = "The whole scrolling screen in one image", onClick = { + 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() + } + } + } +} 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) }