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) {