From 424f986a96f5b00ef2c99b6d77550d8ec62f8712 Mon Sep 17 00:00:00 2001 From: Mansi Singh Date: Wed, 24 Dec 2025 21:11:38 +0530 Subject: [PATCH] Add support for capturing screenshot of multiple windows with hardware bitmap enabled Issue: - Falcon library fails when processing hardware-accelerated bitmaps with error: "Software rendering doesn't support hardware bitmaps". - The fallback method is not capable of capturing screenshots of multiple window surfaces (dialogs, bottom sheets, Compose overlays). Cause: - Falcon library requires software bitmaps for processing but modern Android uses hardware bitmaps for performance, hence failing to capture screenshot of some screens. - The fallback approach used PixelCopy API that supports hardware bitmaps but could capture the screenshot of only the root window provided and not any additional windows layered on top (dialogs, bottom sheets, etc.). Fix: - Created new `ScreenshotCapture.java` utility class to handle hardware-accelerated bitmap capture using `PixelCopy` API. - Implemented `PixelCopy` API for Android O+ to capture hardware-accelerated bitmaps correctly. - Implemented `getRootViews()` to detect all active windows using reflection. - Added `captureAsync()` method with dual capture strategy: - Primary: Capture from Surface (via reflection on ViewRoot's mSurface) for per-window isolation. - Fallback: Capture from Window object when Surface unavailable - Created `ViewRootData` class to encapsulate window hierarchy information: - View reference, window frame bounds, layout parameters, ViewRoot object - Window type detection (activity vs dialog/bottom sheet) - Window association for parent-child relationships - Created dedicated utility class `ScreenshotCaptureUtils.java` for bitmap manipulation and merging. --- shaky/build.gradle | 1 - .../android/shaky/ScreenshotCapture.java | 456 ++++++++++++++++++ .../android/shaky/ScreenshotCaptureUtils.java | 365 ++++++++++++++ .../com/linkedin/android/shaky/Shaky.java | 31 +- 4 files changed, 836 insertions(+), 17 deletions(-) create mode 100644 shaky/src/main/java/com/linkedin/android/shaky/ScreenshotCapture.java create mode 100644 shaky/src/main/java/com/linkedin/android/shaky/ScreenshotCaptureUtils.java diff --git a/shaky/build.gradle b/shaky/build.gradle index 66c0fce..fbef538 100644 --- a/shaky/build.gradle +++ b/shaky/build.gradle @@ -37,7 +37,6 @@ android { dependencies { api 'com.squareup:seismic:1.0.3' - implementation 'com.jraska:falcon:2.2.0' implementation "androidx.appcompat:appcompat:1.7.0" implementation "com.google.android.material:material:1.12.0" implementation "androidx.recyclerview:recyclerview:1.3.2" diff --git a/shaky/src/main/java/com/linkedin/android/shaky/ScreenshotCapture.java b/shaky/src/main/java/com/linkedin/android/shaky/ScreenshotCapture.java new file mode 100644 index 0000000..d15167f --- /dev/null +++ b/shaky/src/main/java/com/linkedin/android/shaky/ScreenshotCapture.java @@ -0,0 +1,456 @@ +/** + * Copyright (C) 2016 LinkedIn Corp. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.linkedin.android.shaky; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.PixelCopy; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Utility class for capturing screenshots of Android applications. + * Handles both traditional Android Views and Jetpack Compose screens. + *

+ * Uses PixelCopy API (Android O+) to capture hardware-accelerated bitmaps, + * with Canvas-based fallback for older API levels. + */ +final class ScreenshotCapture { + private static final String TAG = "ScreenshotCapture"; + + private ScreenshotCapture() { + } + + /** + * Captures a screenshot of a view using the associated window. + * Uses PixelCopy for hardware bitmap support (Android O+). + * + * @param viewRootData Information about the view root to capture + * @param window The window containing the view + * @param callback Callback to receive the captured bitmap + */ + static void captureAsync(@NonNull ViewRootData viewRootData, @Nullable Window window, + @NonNull CaptureCallback callback) { + final View view = viewRootData._view.getRootView(); + + if (view.getWidth() == 0 || view.getHeight() == 0) { + callback.onCaptureComplete(null); + return; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && window != null) { + final Bitmap bitmap = Bitmap.createBitmap(view.getWidth(), view.getHeight(), + Bitmap.Config.ARGB_8888); + + // Try to get the Surface from the ViewRoot if available + // This allows us to capture each window separately (activity, dialog, etc.) + Surface surface = null; + if (viewRootData._viewRoot != null) { + try { + Object surfaceObj = getFieldValueSafe("mSurface", viewRootData._viewRoot); + if (surfaceObj instanceof Surface) { + surface = (Surface) surfaceObj; + } + } catch (Exception e) { + // Surface not available, will fallback to Window + } + } + + if (surface != null && surface.isValid()) { + // Use the Surface directly - this captures the specific window + PixelCopy.request(surface, bitmap, copyResult -> { + if (copyResult == PixelCopy.SUCCESS) { + // Return full bitmap - cropping/positioning handled during merge + callback.onCaptureComplete(bitmap); + } else { + Log.e(TAG, "PixelCopy from Surface failed with result: " + copyResult); + callback.onCaptureComplete(null); + } + }, new Handler(Looper.getMainLooper())); + } else { + // Fallback to using Window + PixelCopy.request(window, bitmap, copyResult -> { + if (copyResult == PixelCopy.SUCCESS) { + // Return full bitmap - cropping/positioning handled during merge + callback.onCaptureComplete(bitmap); + } else { + Log.e(TAG, "PixelCopy from Window failed with result: " + copyResult); + callback.onCaptureComplete(null); + } + }, new Handler(Looper.getMainLooper())); + } + } else { + Log.e(TAG, "PixelCopy not available (API < 26) or window is null"); + callback.onCaptureComplete(null); + } + } + + + //endregion + + //region View Root Detection + + /** + * Gets all root views currently attached to the window manager. + * This includes the main activity view and any dialogs/bottom sheets. + * + * @param activity The activity to get root views from + * @return List of ViewRootData containing view hierarchy information + */ + @SuppressWarnings("unchecked") + static List getRootViews(@NonNull Activity activity) { + // Get the global window manager (available since API 17+, safe for minSdk 21) + Object globalWindowManager = getFieldValueSafe("mGlobal", activity.getWindowManager()); + + Object rootObjects = getFieldValueSafe("mRoots", globalWindowManager); + Object paramsObject = getFieldValueSafe("mParams", globalWindowManager); + + // Since API 19+, these are ArrayLists (safe for minSdk 21) + Object[] roots = ((List) rootObjects).toArray(); + List paramsList = + (List) paramsObject; + WindowManager.LayoutParams[] params = paramsList.toArray(new WindowManager.LayoutParams[0]); + + List rootViews = extractViewRootData(roots, params); + if (rootViews.isEmpty()) { + return Collections.emptyList(); + } + + offsetRootsTopLeft(rootViews); + ensureDialogsAreAfterActivities(rootViews); + + return rootViews; + } + + /** + * Extracts view root data from WindowManager's internal arrays. + */ + private static List extractViewRootData(Object[] roots, + WindowManager.LayoutParams[] params) { + List rootViews = new ArrayList<>(); + + for (int i = 0; i < roots.length; i++) { + Object root = roots[i]; + View rootView = (View) getFieldValueSafe("mView", root); + + if (rootView == null || !rootView.isShown()) { + continue; + } + + WindowManager.LayoutParams layoutParams = params[i]; + int[] location = new int[2]; + rootView.getLocationOnScreen(location); + + int left = location[0]; + int top = location[1]; + int width = rootView.getWidth(); + int height = rootView.getHeight(); + + // For dialogs/bottom sheets, find actual content bounds + if (layoutParams.type == WindowManager.LayoutParams.TYPE_APPLICATION) { + View contentView = findBottomSheetContent(rootView, 0); + if (contentView != null && contentView != rootView) { + int[] childLocation = new int[2]; + contentView.getLocationOnScreen(childLocation); + left = childLocation[0]; + top = childLocation[1]; + width = contentView.getWidth(); + height = contentView.getHeight(); + } + } + + Rect area = new Rect(left, top, left + width, top + height); + rootViews.add(new ViewRootData(rootView, area, layoutParams, root)); + } + + return rootViews; + } + + /** + * Recursively searches for bottom sheet content view. + * Bottom sheets typically have a non-zero top position from screen. + */ + private static View findBottomSheetContent(View view, int depth) { + if (depth > 10) return null; // Prevent deep recursion + + int[] location = new int[2]; + view.getLocationOnScreen(location); + + if (location[1] > 100) { // Bottom sheets typically start below y=100 + return view; + } + + if (view instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) view; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + View child = viewGroup.getChildAt(i); + if (child != null && child.getVisibility() == View.VISIBLE) { + View result = findBottomSheetContent(child, depth + 1); + if (result != null) { + return result; + } + } + } + } + + return null; + } + + /** + * Offsets all root views so the top-left is at (0,0). + */ + private static void offsetRootsTopLeft(List rootViews) { + int minTop = Integer.MAX_VALUE; + int minLeft = Integer.MAX_VALUE; + + for (ViewRootData rootView : rootViews) { + minTop = Math.min(minTop, rootView._winFrame.top); + minLeft = Math.min(minLeft, rootView._winFrame.left); + } + + for (ViewRootData rootView : rootViews) { + rootView._winFrame.offset(-minLeft, -minTop); + } + } + + /** + * Ensures dialogs are positioned after their parent activities in the list. + * This ensures proper rendering order. + */ + private static void ensureDialogsAreAfterActivities(List viewRoots) { + if (viewRoots.size() <= 1) { + return; + } + + for (int dialogIndex = 0; dialogIndex < viewRoots.size() - 1; dialogIndex++) { + ViewRootData viewRoot = viewRoots.get(dialogIndex); + if (!viewRoot.isDialogType() || viewRoot.getWindowToken() == null) { + continue; + } + + for (int parentIndex = dialogIndex + 1; parentIndex < viewRoots.size(); parentIndex++) { + ViewRootData possibleParent = viewRoots.get(parentIndex); + if (possibleParent.isActivityType() + && possibleParent.getWindowToken() == viewRoot.getWindowToken()) { + viewRoots.remove(possibleParent); + viewRoots.add(dialogIndex, possibleParent); + break; + } + } + } + } + + //endregion + + //region Bitmap Utilities + + /** + * Crops a bitmap to the specified region. + * Handles regions that may have Y/X offsets (e.g., bottom sheets positioned at bottom of screen). + * + * @param source The source bitmap (full screen capture) + * @param region The region to crop to (may have offset from screen top/left) + * @return The cropped bitmap, or the original if no cropping is needed + */ + private static Bitmap cropBitmap(Bitmap source, Rect region) { + // Calculate the width and height of the desired region + int regionWidth = region.right - region.left; + int regionHeight = region.bottom - region.top; + + if (region.left == 0 && region.top == 0 && + regionWidth == source.getWidth() && regionHeight == source.getHeight()) { + // No cropping needed, return original + return source; + } + + // For regions with Y offset (like bottom sheets), we need to crop from that position + // in the full-screen captured bitmap + // Example: region=Rect(0, 2208 - 1080, 2340) means crop from y=2208 with height=132 + int cropLeft = region.left; + int cropTop = region.top; + int cropWidth = regionWidth; + int cropHeight = regionHeight; + + // Ensure crop is within bitmap bounds + if (cropLeft < 0 || cropTop < 0 || + cropLeft + cropWidth > source.getWidth() || + cropTop + cropHeight > source.getHeight()) { + + // Clamp to bitmap bounds + cropLeft = Math.max(0, cropLeft); + cropTop = Math.max(0, cropTop); + cropWidth = Math.min(cropWidth, source.getWidth() - cropLeft); + cropHeight = Math.min(cropHeight, source.getHeight() - cropTop); + } + + if (cropWidth <= 0 || cropHeight <= 0) { + Log.e(TAG, "Invalid crop region after bounds check, returning original bitmap"); + return source; + } + + Bitmap cropped = Bitmap.createBitmap(source, cropLeft, cropTop, cropWidth, cropHeight); + + // Recycle the original to save memory + if (cropped != source) { + source.recycle(); + } + return cropped; + } + + //endregion + + //region Reflection Helpers + + /** + * Safely gets a field value using reflection, returns null on failure. + */ + private static Object getFieldValueSafe(String fieldName, Object target) { + try { + Field field = findField(fieldName, target.getClass()); + field.setAccessible(true); + return field.get(target); + } catch (Exception e) { + Log.w(TAG, "Failed to get field " + fieldName, e); + return null; + } + } + + /** + * Finds a field in a class hierarchy. + */ + private static Field findField(String name, Class clazz) throws NoSuchFieldException { + Class currentClass = clazz; + while (currentClass != Object.class) { + for (Field field : currentClass.getDeclaredFields()) { + if (name.equals(field.getName())) { + return field; + } + } + currentClass = currentClass.getSuperclass(); + } + throw new NoSuchFieldException("Field " + name + " not found for class " + clazz); + } + + //endregion + + //region ViewRootData - Window Hierarchy Information + + /** + * Contains information about a view root in the window hierarchy. + * Each window (activity, dialog, bottom sheet) has its own ViewRoot. + */ + static class ViewRootData { + final View _view; + final Rect _winFrame; + final Rect _originalWinFrame; + final WindowManager.LayoutParams _layoutParams; + final Object _viewRoot; + + ViewRootData(View view, Rect winFrame, WindowManager.LayoutParams layoutParams, + Object viewRoot) { + _view = view; + _winFrame = winFrame; + _originalWinFrame = new Rect(winFrame); + _layoutParams = layoutParams; + _viewRoot = viewRoot; + } + + /** + * Returns true if this is a dialog or bottom sheet window. + */ + boolean isDialogType() { + return _layoutParams.type == WindowManager.LayoutParams.TYPE_APPLICATION; + } + + /** + * Returns true if this is the main activity window. + */ + boolean isActivityType() { + return _layoutParams.type == WindowManager.LayoutParams.TYPE_BASE_APPLICATION; + } + + /** + * Returns the window token for matching dialogs to their parent activities. + */ + android.os.IBinder getWindowToken() { + return _layoutParams.token; + } + + /** + * Returns the layout parameters for this window. + */ + WindowManager.LayoutParams getLayoutParams() { + return _layoutParams; + } + + /** + * Gets the Window associated with this view root. + * Tries multiple approaches to find the window. + */ + @Nullable + Window getWindow() { + // Try to get from context + Context context = _view.getContext(); + + if (context instanceof Activity) { + return ((Activity) context).getWindow(); + } + + // Unwrap ContextWrapper to find Activity + Context ctx = context; + while (ctx instanceof ContextWrapper && !(ctx instanceof Activity)) { + ctx = ((ContextWrapper) ctx).getBaseContext(); + if (ctx == null) break; + } + + if (ctx instanceof Activity) { + return ((Activity) ctx).getWindow(); + } + + return null; + } + } + + /** + * Callback interface for asynchronous screenshot capture. + */ + interface CaptureCallback { + /** + * Called when screenshot capture completes. + * + * @param bitmap The captured bitmap, or null if capture failed + */ + void onCaptureComplete(@Nullable Bitmap bitmap); + } +} diff --git a/shaky/src/main/java/com/linkedin/android/shaky/ScreenshotCaptureUtils.java b/shaky/src/main/java/com/linkedin/android/shaky/ScreenshotCaptureUtils.java new file mode 100644 index 0000000..8a2440b --- /dev/null +++ b/shaky/src/main/java/com/linkedin/android/shaky/ScreenshotCaptureUtils.java @@ -0,0 +1,365 @@ +/** + * Copyright (C) 2016 LinkedIn Corp. + *

+ * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + *

+ * http://www.apache.org/licenses/LICENSE-2.0 + *

+ * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.linkedin.android.shaky; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.util.Log; +import android.view.Window; +import android.view.WindowManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Utility class for advanced screenshot capture operations. + * Handles bitmap merging, positioning, and rendering of complex window hierarchies. + * + * Package-private - internal use only. + */ +final class ScreenshotCaptureUtils { + private static final String TAG = "ScreenshotCaptureUtils"; + + // Prevent instantiation + private ScreenshotCaptureUtils() { + } + + /** + * Captures screenshots of all visible windows (activity, dialogs, bottom sheets) asynchronously + * and merges them into a single bitmap representing the complete screen state. + */ + @UiThread + static void captureAndMergeAsync(@NonNull Activity activity, @NonNull final ScreenshotCapture.CaptureCallback callback) { + final List rootViews = ScreenshotCapture.getRootViews(activity); + + if (rootViews.isEmpty()) { + callback.onCaptureComplete(null); + return; + } + + // Each DecorView represents a separate rendering surface (activity, dialog, bottom sheet, etc.) + // Capture each surface separately, then merge them to create the final screenshot + final Bitmap[] bitmaps = new Bitmap[rootViews.size()]; + final AtomicInteger completedCount = new AtomicInteger(0); + + for (int i = 0; i < rootViews.size(); i++) { + final int index = i; + final ScreenshotCapture.ViewRootData rootView = rootViews.get(i); + + // Try to get the specific window for this root view (dialog/bottom sheet may have their own) + // Fall back to activity window if not available + Window windowForView = rootView.getWindow(); + if (windowForView == null) { + windowForView = activity.getWindow(); + } + + final Window finalWindow = windowForView; + + // Capture each window asynchronously + ScreenshotCapture.captureAsync(rootView, finalWindow, new ScreenshotCapture.CaptureCallback() { + @Override + public void onCaptureComplete(Bitmap bitmap) { + bitmaps[index] = bitmap; + int completed = completedCount.incrementAndGet(); + + if (bitmap == null) { + Log.e(TAG, "Failed to capture view " + index); + } + + // When all captures complete, merge them into final screenshot + if (completed == rootViews.size()) { + Bitmap mergedBitmap = mergeBitmaps(bitmaps, rootViews); + callback.onCaptureComplete(mergedBitmap); + } + } + }); + } + } + + /** + * Merges multiple bitmaps by drawing them at their correct screen positions. + * @param bitmaps array of bitmaps to merge + * @param rootViews list of ViewRootData containing position information for each bitmap + * @return a single bitmap with all bitmaps drawn at their correct positions, or null if all bitmaps are null + */ + @Nullable + static Bitmap mergeBitmaps(@NonNull Bitmap[] bitmaps, @NonNull List rootViews) { + if (bitmaps.length == 0) { + return null; + } + + // Find the first non-null bitmap + Bitmap firstBitmap = null; + for (Bitmap bitmap : bitmaps) { + if (bitmap != null) { + firstBitmap = bitmap; + break; + } + } + + if (firstBitmap == null) { + return null; + } + + // Calculate canvas size - determine screen height to distinguish full-screen vs sized bitmaps + int screenHeight = getScreenHeight(bitmaps, rootViews); + int maxWidth = 0; + int maxHeight = 0; + + for (int i = 0; i < rootViews.size(); i++) { + if (bitmaps[i] != null) { + Rect originalFrame = rootViews.get(i)._originalWinFrame; + int bitmapWidth = bitmaps[i].getWidth(); + int bitmapHeight = bitmaps[i].getHeight(); + + maxWidth = Math.max(maxWidth, bitmapWidth); + + // Full-screen bitmaps use screen height, sized bitmaps use position + height + if (bitmapHeight == screenHeight) { + maxHeight = Math.max(maxHeight, screenHeight); + } else { + maxHeight = Math.max(maxHeight, originalFrame.top + bitmapHeight); + } + } + } + + // Create merged bitmap + Bitmap mergedBitmap = Bitmap.createBitmap(maxWidth, maxHeight, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(mergedBitmap); + + // Check if we need to apply dim overlay (for Traditional View dialogs) + DimOverlayInfo dimInfo = getDimOverlayInfo(bitmaps, rootViews); + + // Draw each bitmap at its correct position + for (int i = 0; i < bitmaps.length; i++) { + Bitmap bitmap = bitmaps[i]; + if (bitmap == null || i >= rootViews.size()) { + continue; + } + + Rect originalFrame = rootViews.get(i)._originalWinFrame; + if (originalFrame == null) { + Log.e(TAG, "Null frame for bitmap " + i + ", skipping"); + continue; + } + + drawBitmapAtPosition(canvas, bitmap, originalFrame, screenHeight); + + // Add dim overlay after activity but before dialog (Traditional Views only) + if (dimInfo.shouldApplyDim && i == dimInfo.dialogIndex - 1 && rootViews.get(i).isActivityType()) { + applyDimOverlay(canvas, dimInfo, bitmaps[i + 1], screenHeight); + } + } + + return mergedBitmap; + } + + /** + * Determines screen height from activity bitmap or first available bitmap. + */ + private static int getScreenHeight(@NonNull Bitmap[] bitmaps, @NonNull List rootViews) { + // Try to find activity bitmap first + for (int i = 0; i < rootViews.size(); i++) { + if (bitmaps[i] != null && rootViews.get(i).isActivityType()) { + return bitmaps[i].getHeight(); + } + } + + // Fallback to first non-null bitmap + for (Bitmap bitmap : bitmaps) { + if (bitmap != null) { + return bitmap.getHeight(); + } + } + + return 0; + } + + /** + * Analyzes view hierarchy to determine if dim overlay should be applied. + */ + private static DimOverlayInfo getDimOverlayInfo(@NonNull Bitmap[] bitmaps, @NonNull List rootViews) { + DimOverlayInfo info = new DimOverlayInfo(); + + for (int i = 0; i < rootViews.size(); i++) { + if (i < bitmaps.length && bitmaps[i] != null && !rootViews.get(i).isActivityType()) { + info.shouldApplyDim = true; + info.dialogIndex = i; + + // Get actual dim amount from Android's LayoutParams (proper Android way) + WindowManager.LayoutParams layoutParams = rootViews.get(i).getLayoutParams(); + if ((layoutParams.flags & WindowManager.LayoutParams.FLAG_DIM_BEHIND) == WindowManager.LayoutParams.FLAG_DIM_BEHIND) { + info.dimAmount = layoutParams.dimAmount; + } + break; + } + } + + return info; + } + + /** + * Draws a bitmap at its correct position on the canvas. + * Handles both full-screen Compose bitmaps and sized Traditional View bitmaps. + */ + private static void drawBitmapAtPosition(@NonNull Canvas canvas, @NonNull Bitmap bitmap, + @NonNull Rect originalFrame, int screenHeight) { + int left = originalFrame.left; + int top = originalFrame.top; + int frameWidth = originalFrame.right - originalFrame.left; + int frameHeight = originalFrame.bottom - originalFrame.top; + + // Check if this is a Compose full-screen bitmap + if (bitmap.getHeight() == screenHeight && originalFrame.top > 0) { + // Full-screen Compose bitmap: draw at (0,0) for overlay effect + canvas.drawBitmap(bitmap, 0, 0, null); + return; + } + + // Check if this is a bottom sheet (positioned at screen bottom) + boolean isBottomSheet = isBottomSheetPosition(originalFrame, screenHeight); + + // Handle traditional bottom sheets with transparent left/right edges + if (isBottomSheet) { + // Try to crop transparent edges and scale to fill width + if (handleTransparentEdges(canvas, bitmap, left, top, frameWidth, frameHeight)) { + return; // Already drawn + } + } + + // For dialogs or views without transparent edges: draw at natural size and position + canvas.drawBitmap(bitmap, left, top, null); + } + + /** + * Determines if a view is positioned like a bottom sheet (at the bottom of the screen). + */ + private static boolean isBottomSheetPosition(@NonNull Rect frame, int screenHeight) { + boolean extendsToBottom = frame.bottom >= screenHeight - 10; + int height = frame.bottom - frame.top; + boolean isSignificantHeight = height > screenHeight * 0.1f; + return extendsToBottom && isSignificantHeight; + } + + /** + * Handles traditional bottom sheets with transparent left/right edges. + * Crops the transparent edges and scales to fill the frame width while preserving aspect ratio. + */ + private static boolean handleTransparentEdges(@NonNull Canvas canvas, @NonNull Bitmap bitmap, + int left, int top, int frameWidth, int frameHeight) { + int leftEdge = findLeftEdge(bitmap); + int rightEdge = findRightEdge(bitmap); + int actualContentWidth = rightEdge - leftEdge; + + // Validate edge detection + if (leftEdge < 0 || rightEdge <= leftEdge || rightEdge > bitmap.getWidth() || actualContentWidth <= 0) { + return false; // Invalid, use normal drawing + } + + // Check if there are transparent edges + if (leftEdge > 0 || rightEdge < bitmap.getWidth()) { + // Crop to content and scale to fill frame width, preserving aspect ratio + Rect srcRect = new Rect(leftEdge, 0, rightEdge, bitmap.getHeight()); + + // Scale to fill width, calculate new height to preserve aspect ratio + float scaleX = (float) frameWidth / actualContentWidth; + int scaledHeight = (int) (bitmap.getHeight() * scaleX); + + Rect destRect = new Rect(left, top, left + frameWidth, top + scaledHeight); + canvas.drawBitmap(bitmap, srcRect, destRect, null); + return true; // Bitmap was drawn + } + + return false; // No transparent edges, use normal drawing + } + + /** + * Applies dim overlay for Traditional View dialogs. + * Compose views handle dimming internally, so we skip them. + */ + private static void applyDimOverlay(@NonNull Canvas canvas, + @NonNull DimOverlayInfo dimInfo, @Nullable Bitmap dialogBitmap, + int screenHeight) { + if (dialogBitmap == null || dimInfo.dimAmount <= 0) { + return; + } + + boolean isComposeLikeView = dialogBitmap.getHeight() == screenHeight; + + if (!isComposeLikeView) { + // Traditional View: apply dim overlay using Android's dimAmount + int alpha = (int) (255 * dimInfo.dimAmount); + canvas.drawARGB(alpha, 0, 0, 0); + } + } + + /** + * Find the leftmost column with non-transparent pixels. + * Samples pixels for performance (checks every 10th row). + */ + private static int findLeftEdge(Bitmap bitmap) { + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + int sampleStep = Math.max(1, height / 10); // Sample ~10 rows + + for (int x = 0; x < width; x++) { + for (int y = 0; y < height; y += sampleStep) { + int pixel = bitmap.getPixel(x, y); + int alpha = (pixel >> 24) & 0xff; + if (alpha > 0) { + return x; + } + } + } + return width; // All transparent + } + + /** + * Find the rightmost column with non-transparent pixels. + * Samples pixels for performance (checks every 10th row). + */ + private static int findRightEdge(Bitmap bitmap) { + int width = bitmap.getWidth(); + int height = bitmap.getHeight(); + int sampleStep = Math.max(1, height / 10); // Sample ~10 rows + + for (int x = width - 1; x >= 0; x--) { + for (int y = 0; y < height; y += sampleStep) { + int pixel = bitmap.getPixel(x, y); + int alpha = (pixel >> 24) & 0xff; + if (alpha > 0) { + return x + 1; // Return exclusive right edge + } + } + } + return 0; // All transparent + } + + /** + * Helper class to store dim overlay information. + */ + private static class DimOverlayInfo { + boolean shouldApplyDim = false; + int dialogIndex = -1; + float dimAmount = 0.0f; + } +} diff --git a/shaky/src/main/java/com/linkedin/android/shaky/Shaky.java b/shaky/src/main/java/com/linkedin/android/shaky/Shaky.java index 33db569..5e04a95 100644 --- a/shaky/src/main/java/com/linkedin/android/shaky/Shaky.java +++ b/shaky/src/main/java/com/linkedin/android/shaky/Shaky.java @@ -26,19 +26,18 @@ import android.hardware.SensorManager; import android.net.Uri; import android.os.Bundle; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.UiThread; import androidx.annotation.VisibleForTesting; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.bottomsheet.BottomSheetDialog; -import com.jraska.falcon.Falcon; import com.squareup.seismic.ShakeDetector; import java.util.ArrayList; @@ -191,7 +190,20 @@ private void doStartFeedbackFlow() { shakyFlowCallback.onCollectingData(); } collectDataTask = new CollectDataTask(activity, delegate, createCallback()); - collectDataTask.execute(getScreenshotBitmap()); + + // Capture screenshot asynchronously, then execute the task + ScreenshotCaptureUtils.captureAndMergeAsync(activity, new ScreenshotCapture.CaptureCallback() { + @Override + public void onCaptureComplete(Bitmap bitmap) { + if (bitmap != null) { + collectDataTask.execute(bitmap); + } else { + Log.e("Shaky", "Failed to capture screenshot"); + // Dismiss the dialog if screenshot capture failed + dismissCollectFeedbackDialogIfNecessary(); + } + } + }); } /** @@ -373,19 +385,6 @@ boolean canStartFeedbackFlow() { return canStart; } - @Nullable - @UiThread - private Bitmap getScreenshotBitmap() { - try { - // Attempt to use Falcon to take the screenshot - return Falcon.takeScreenshotBitmap(activity); - } catch (Falcon.UnableToTakeScreenshotException exception) { - // Fallback to using the default screenshot capture mechanism if Falcon does not work (e.g. if it has not - // been updated to work on newer versions of Android yet) - View view = activity.getWindow().getDecorView().getRootView(); - return Utils.capture(view, activity.getWindow()); - } - } private void dismissCollectFeedbackDialogIfNecessary() { if (collectDataTask != null || activity == null) {