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