diff --git a/shaky/src/main/java/com/linkedin/android/shaky/CollectDataTask.java b/shaky/src/main/java/com/linkedin/android/shaky/CollectDataTask.java
index 00caa76..9253e76 100644
--- a/shaky/src/main/java/com/linkedin/android/shaky/CollectDataTask.java
+++ b/shaky/src/main/java/com/linkedin/android/shaky/CollectDataTask.java
@@ -67,15 +67,33 @@ protected Result doInBackground(Bitmap... params) {
}
}
- Uri screenshotUri = null;
- Bitmap bitmap = params != null && params.length != 0 ? params[0] : null;
- if (bitmap != null) {
- File screenshotFile = Utils.writeBitmapToDirectory(bitmap, screenshotDirectory);
- screenshotUri = Uri.fromFile(screenshotFile);
+ Result result = new Result();
+
+ // Process all bitmaps
+ if (params != null && params.length > 0) {
+ for (int i = 0; i < params.length; i++) {
+ Bitmap bitmap = params[i];
+ if (bitmap != null) {
+ File screenshotFile = Utils.writeBitmapToDirectory(bitmap, screenshotDirectory);
+
+ if (screenshotFile != null) {
+ Uri screenshotUri = Uri.fromFile(screenshotFile);
+
+ // First bitmap becomes the main screenshot (for UI preview)
+ if (i == 0) {
+ result.setScreenshotUri(screenshotUri);
+ } else {
+ // Subsequent screenshots (dialogs/bottom sheets) are only attachments
+ result.getAttachments().add(screenshotUri);
+ }
+ } else {
+ Log.e(TAG, "Failed to write bitmap " + i + " to file");
+ }
+ }
+ }
+ Log.d(TAG, "Saved " + result.getAttachments().size() + " screenshot(s) total");
}
- Result result = new Result();
- result.setScreenshotUri(screenshotUri);
delegate.collectData(activity, result);
return result;
}
diff --git a/shaky/src/main/java/com/linkedin/android/shaky/MultiWindowScreenshotCapture.java b/shaky/src/main/java/com/linkedin/android/shaky/MultiWindowScreenshotCapture.java
new file mode 100644
index 0000000..6c84475
--- /dev/null
+++ b/shaky/src/main/java/com/linkedin/android/shaky/MultiWindowScreenshotCapture.java
@@ -0,0 +1,477 @@
+/**
+ * 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.IBinder;
+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;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Multi-window screenshot capture for Android applications.
+ *
+ * Uses PixelCopy API (Android O+) to capture hardware-accelerated bitmaps that cannot be
+ * accessed via traditional Canvas rendering. This is required for Jetpack Compose screens
+ * and Coil-loaded images which create GPU-stored bitmaps.
+ *
+ * Captures each window separately (activity, dialogs, bottom sheets) by reflecting into
+ * WindowManager's internal data structures to enumerate all root views.
+ *
+ * Requirements: Android API 26+ (PixelCopy API)
+ */
+final class MultiWindowScreenshotCapture {
+ private static final String TAG = "MultiWindowCapture";
+
+ private MultiWindowScreenshotCapture() {
+ }
+
+ /**
+ * Captures screenshots of all visible windows asynchronously.
+ *
+ * Uses reflection to enumerate all windows, then captures each via PixelCopy.
+ * Callback is invoked on main thread when all captures complete.
+ *
+ * @param activity the activity to capture screenshots from
+ * @param callback receives list of bitmaps (one per window), or null if all captures failed
+ */
+ static void captureMultipleAsync(@NonNull Activity activity,
+ @NonNull MultiBitmapCallback callback
+ ) {
+ final List rootViews = getRootViews(activity);
+
+ if (rootViews.isEmpty()) {
+ callback.onCaptureComplete(null);
+ return;
+ }
+
+ Log.d(TAG, "Found " + rootViews.size() + " window(s) to capture");
+
+ 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 ViewRootData rootView = rootViews.get(i);
+
+ Window windowForView = rootView.getWindow();
+ if (windowForView == null) {
+ windowForView = activity.getWindow();
+ }
+
+ captureAsync(rootView, windowForView, new CaptureCallback() {
+ @Override
+ public void onCaptureComplete(Bitmap bitmap) {
+ bitmaps[index] = bitmap;
+ int completed = completedCount.incrementAndGet();
+
+ if (bitmap == null) {
+ Log.e(TAG, "Failed to capture window " + index);
+ }
+
+ if (completed == rootViews.size()) {
+ List bitmapList = new ArrayList<>();
+ for (Bitmap bmp : bitmaps) {
+ if (bmp != null) {
+ bitmapList.add(bmp);
+ }
+ }
+
+ callback.onCaptureComplete(bitmapList.isEmpty() ? null : bitmapList);
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Gets all root views using reflection to access WindowManager internals.
+ * Includes activity, dialogs, and bottom sheets currently visible.
+ *
+ * @param activity the activity context
+ * @return List of ViewRootData, or empty list if reflection fails
+ */
+ @SuppressWarnings("unchecked")
+ static List getRootViews(@NonNull Activity activity) {
+ Object globalWindowManager = getFieldValueSafe("mGlobal", activity.getWindowManager());
+ if (globalWindowManager == null) {
+ return Collections.emptyList();
+ }
+
+ Object rootObjects = getFieldValueSafe("mRoots", globalWindowManager);
+ Object paramsObject = getFieldValueSafe("mParams", globalWindowManager);
+
+ if (rootObjects == null || paramsObject == null) {
+ return Collections.emptyList();
+ }
+
+ 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;
+ }
+
+ /**
+ * Captures a screenshot of a view using the associated window.
+ * Uses PixelCopy API (Android O+) for hardware bitmap support.
+ *
+ * @param viewRootData information about the view root to capture
+ * @param window the window containing the view
+ * @param callback callback to receive the captured bitmap
+ */
+ private 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) {
+ Log.e(TAG, "PixelCopy not available (API < 26) or window is null");
+ callback.onCaptureComplete(null);
+ return;
+ }
+
+ final Bitmap bitmap =
+ Bitmap.createBitmap(view.getWidth(), view.getHeight(), Bitmap.Config.ARGB_8888);
+
+ // Try to get the Surface from ViewRoot for per-window capture
+ Surface surface = getSurfaceFromViewRoot(viewRootData._viewRoot);
+
+ if (surface != null && surface.isValid()) {
+ // Use Surface directly - captures specific window (activity/dialog/bottom sheet)
+ PixelCopy.request(surface, bitmap, copyResult -> {
+ if (copyResult == PixelCopy.SUCCESS) {
+ callback.onCaptureComplete(bitmap);
+ } else {
+ Log.e(TAG, "PixelCopy from Surface failed with result: " + copyResult);
+ callback.onCaptureComplete(null);
+ }
+ }, new Handler(Looper.getMainLooper()));
+ } else {
+ // Fallback to Window
+ PixelCopy.request(window, bitmap, copyResult -> {
+ if (copyResult == PixelCopy.SUCCESS) {
+ callback.onCaptureComplete(bitmap);
+ } else {
+ Log.e(TAG, "PixelCopy from Window failed with result: " + copyResult);
+ callback.onCaptureComplete(null);
+ }
+ }, new Handler(Looper.getMainLooper()));
+ }
+ }
+
+ /**
+ * Safely extracts Surface from ViewRootImpl using reflection.
+ *
+ * @param viewRoot the ViewRootImpl object
+ * @return Surface if available and valid, null otherwise
+ */
+ @Nullable
+ private static Surface getSurfaceFromViewRoot(@Nullable Object viewRoot) {
+ if (viewRoot == null) {
+ return null;
+ }
+
+ Object surfaceObj = getFieldValueSafe("mSurface", viewRoot);
+ return surfaceObj instanceof Surface ? (Surface) surfaceObj : null;
+ }
+
+ /**
+ * Extracts view root data from WindowManager's internal arrays. Filters out hidden views and
+ * calculates content bounds for dialogs/bottom sheets.
+ *
+ * @param roots ViewRootImpl objects from WindowManager
+ * @param params corresponding LayoutParams for each root
+ * @return List of ViewRootData with screen positions
+ */
+ 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;
+ }
+
+ /**
+ * Normalizes all root view coordinates so top-left starts at (0,0).
+ * Required for consistent positioning across different screen configurations.
+ */
+ 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);
+ }
+ }
+
+ /**
+ * Sorts view roots so activities come before their dialogs.
+ * Ensures correct capture order when multiple windows belong to same activity.
+ */
+ 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;
+ }
+ }
+ }
+ }
+
+ /**
+ * 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 != null && 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);
+ }
+
+ //region Data Classes & Interfaces
+
+ /**
+ * 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.
+ */
+ IBinder getWindowToken() {
+ return _layoutParams.token;
+ }
+
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Callback interface for receiving multiple captured bitmaps.
+ */
+ interface MultiBitmapCallback {
+ /**
+ * Called when all screenshots have been captured.
+ *
+ * @param bitmaps list of captured bitmaps, one per window, or null if capture failed
+ */
+ void onCaptureComplete(@Nullable List bitmaps);
+ }
+
+ //endregion
+}
diff --git a/shaky/src/main/java/com/linkedin/android/shaky/ShakeDelegate.java b/shaky/src/main/java/com/linkedin/android/shaky/ShakeDelegate.java
index 791ff2e..99fe129 100644
--- a/shaky/src/main/java/com/linkedin/android/shaky/ShakeDelegate.java
+++ b/shaky/src/main/java/com/linkedin/android/shaky/ShakeDelegate.java
@@ -183,6 +183,19 @@ public boolean shouldUseBottomSheet() {
return true;
}
+ /**
+ * Controls whether multi-window screenshot capture is enabled.
+ * When enabled, uses PixelCopy API to capture multiple windows (activity, dialogs, bottom sheets)
+ * separately. This handles hardware bitmaps correctly but may result in multiple screenshot attachments.
+ * When disabled (default), uses the original single-screenshot fallback behavior for backward
+ * compatibility.
+ *
+ * @return true to enable multi-window capture, false to use single-screenshot fallback (default)
+ */
+ public boolean enableMultiWindowCapture() {
+ return false;
+ }
+
/**
* Called when the user submits the Feedback form. Creates and starts an email Intent.
* This method can be overridden to send data to a custom URL endpoint, etc.
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..0b114bf 100644
--- a/shaky/src/main/java/com/linkedin/android/shaky/Shaky.java
+++ b/shaky/src/main/java/com/linkedin/android/shaky/Shaky.java
@@ -26,12 +26,12 @@
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;
@@ -42,6 +42,7 @@
import com.squareup.seismic.ShakeDetector;
import java.util.ArrayList;
+import java.util.List;
import java.util.concurrent.TimeUnit;
/**
@@ -53,6 +54,7 @@
*/
public class Shaky implements ShakeDetector.Listener {
+ private static final String TAG = "Shaky";
private static final String SEND_FEEDBACK_TAG = "SendFeedback";
private static final String COLLECT_DATA_TAG = "CollectFeedbackData";
private static final String CUSTOM_DIALOG_TAG = "CustomDialog";
@@ -191,7 +193,7 @@ private void doStartFeedbackFlow() {
shakyFlowCallback.onCollectingData();
}
collectDataTask = new CollectDataTask(activity, delegate, createCallback());
- collectDataTask.execute(getScreenshotBitmap());
+ getScreenshotBitmap();
}
/**
@@ -373,18 +375,62 @@ boolean canStartFeedbackFlow() {
return canStart;
}
- @Nullable
- @UiThread
- private Bitmap getScreenshotBitmap() {
+ /**
+ * Captures screenshot(s) as a Bitmap. This method implements a multi-step approach to capture
+ * screenshots:
+ * 1. `Falcon` - Fast single-bitmap capture of the entire screen (Canvas-based). However, fails
+ * if UI contains hardware bitmaps.
+ * 2. If {@link ShakeDelegate#enableMultiWindowCapture()} is enabled:
+ * - `PixelCopy` multi-window - Captures each window separately to handle hardware bitmaps.
+ * - Falls back to {@link Utils#capture} if PixelCopy fails.
+ * 3. If multi-window capture is disabled (default):
+ * - {@link Utils#capture} fallback - Main activity only.
+ */
+ private void 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());
+ Bitmap bitmap = Falcon.takeScreenshotBitmap(activity);
+ if (bitmap != null) {
+ collectDataTask.execute(bitmap);
+ return;
+ }
+ } catch (Exception exception) {
+ Log.e(TAG, "Falcon failed: " + exception.getMessage() + ", falling back");
+ }
+
+ // Falcon failed - check if multi-window capture is enabled
+ if (delegate.enableMultiWindowCapture()) {
+ // Use PixelCopy to capture all windows separately
+ MultiWindowScreenshotCapture.captureMultipleAsync(activity, (List bitmaps) -> {
+ if (bitmaps != null && !bitmaps.isEmpty()) {
+ Log.i(TAG, "PixelCopy captured " + bitmaps.size() + " screenshot(s)");
+ // Convert List to array for CollectDataTask
+ Bitmap[] bitmapArray = bitmaps.toArray(new Bitmap[0]);
+ // Pass the captured bitmaps as params to CollectDataTask
+ collectDataTask.execute(bitmapArray);
+ } else {
+ // PixelCopy failed - try final fallback
+ Log.e(TAG, "PixelCopy failed, falling back to Canvas");
+ captureWithCanvas();
+ }
+ });
+ } else {
+ // Multi-window capture disabled - use single-screenshot fallback
+ captureWithCanvas();
+ }
+ }
+
+ /**
+ * Final fallback when all other screenshot methods fail.
+ * Attempts to capture at least the main window via {@link Utils#capture}.
+ */
+ private void captureWithCanvas() {
+ View view = activity.getWindow().getDecorView().getRootView();
+ Bitmap bitmap = Utils.capture(view, activity.getWindow());
+ if (bitmap == null) {
+ Log.e(TAG, "Screenshot capture failed");
}
+ // Execute task regardless - user can still submit feedback without screenshot
+ collectDataTask.execute(bitmap);
}
private void dismissCollectFeedbackDialogIfNecessary() {