diff --git a/CHANGELOG.md b/CHANGELOG.md index 418e317c8..877a5cf0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [13.4.0](https://github.com/Instabug/Instabug-Flutter/compare/v13.3.0...v13.4.0) (September 29, 2024) +## [Unreleased](https://github.com/Instabug/Instabug-Flutter/compare/v13.4.0...dev) ### Added diff --git a/android/build.gradle b/android/build.gradle index 92654b91b..359e68917 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -22,7 +22,7 @@ rootProject.allprojects { apply plugin: 'com.android.library' android { - compileSdkVersion 28 + compileSdkVersion 33 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -41,10 +41,10 @@ android { } dependencies { - api 'com.instabug.library:instabug:13.4.1' - + api 'com.instabug.library:instabug:13.4.1.6295791-SNAPSHOT' + testImplementation 'org.robolectric:robolectric:4.12.2' testImplementation 'junit:junit:4.13.2' - testImplementation "org.mockito:mockito-inline:3.12.1" + testImplementation "org.mockito:mockito-inline:5.0.0" } // add upload_symbols task diff --git a/android/src/main/java/com/instabug/flutter/InstabugFlutterPlugin.java b/android/src/main/java/com/instabug/flutter/InstabugFlutterPlugin.java index cd1e018cb..ec6f3045a 100644 --- a/android/src/main/java/com/instabug/flutter/InstabugFlutterPlugin.java +++ b/android/src/main/java/com/instabug/flutter/InstabugFlutterPlugin.java @@ -10,6 +10,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.instabug.flutter.generated.InstabugPrivateViewPigeon; import com.instabug.flutter.modules.ApmApi; import com.instabug.flutter.modules.BugReportingApi; import com.instabug.flutter.modules.CrashReportingApi; @@ -19,15 +20,17 @@ import com.instabug.flutter.modules.RepliesApi; import com.instabug.flutter.modules.SessionReplayApi; import com.instabug.flutter.modules.SurveysApi; - -import java.util.concurrent.Callable; +import com.instabug.flutter.util.privateViews.BoundryCaptureManager; +import com.instabug.flutter.util.privateViews.PixelCopyCaptureManager; +import com.instabug.flutter.util.privateViews.PrivateViewManager; +import com.instabug.library.internal.crossplatform.InternalCore; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.embedding.engine.plugins.activity.ActivityAware; import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding; import io.flutter.embedding.engine.renderer.FlutterRenderer; import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.plugin.common.PluginRegistry.Registrar; +import io.flutter.plugin.common.PluginRegistry; public class InstabugFlutterPlugin implements FlutterPlugin, ActivityAware { private static final String TAG = InstabugFlutterPlugin.class.getName(); @@ -35,15 +38,18 @@ public class InstabugFlutterPlugin implements FlutterPlugin, ActivityAware { @SuppressLint("StaticFieldLeak") private static Activity activity; + PrivateViewManager privateViewManager; + /** * Embedding v1 */ @SuppressWarnings("deprecation") - public static void registerWith(Registrar registrar) { + public static void registerWith(PluginRegistry.Registrar registrar) { activity = registrar.activity(); register(registrar.context().getApplicationContext(), registrar.messenger(), (FlutterRenderer) registrar.textures()); } + @Override public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { register(binding.getApplicationContext(), binding.getBinaryMessenger(), (FlutterRenderer) binding.getTextureRegistry()); @@ -57,55 +63,44 @@ public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { @Override public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { activity = binding.getActivity(); + if (privateViewManager != null) { + privateViewManager.setActivity(activity); + } + } @Override public void onDetachedFromActivityForConfigChanges() { activity = null; + privateViewManager.setActivity(null); + } @Override public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) { activity = binding.getActivity(); + privateViewManager.setActivity(activity); + } @Override public void onDetachedFromActivity() { activity = null; + privateViewManager.setActivity(null); + } private static void register(Context context, BinaryMessenger messenger, FlutterRenderer renderer) { - final Callable screenshotProvider = new Callable() { - @Override - public Bitmap call() { - return takeScreenshot(renderer); - } - }; - ApmApi.init(messenger); BugReportingApi.init(messenger); CrashReportingApi.init(messenger); FeatureRequestsApi.init(messenger); - InstabugApi.init(messenger, context, screenshotProvider); + privateViewManager = new PrivateViewManager(new InstabugPrivateViewPigeon.InstabugPrivateViewApi(messenger), new PixelCopyCaptureManager(), new BoundryCaptureManager(renderer)); + InstabugApi.init(messenger, context, privateViewManager, InternalCore.INSTANCE); InstabugLogApi.init(messenger); RepliesApi.init(messenger); SessionReplayApi.init(messenger); SurveysApi.init(messenger); } - @Nullable - private static Bitmap takeScreenshot(FlutterRenderer renderer) { - try { - final View view = activity.getWindow().getDecorView().getRootView(); - - view.setDrawingCacheEnabled(true); - final Bitmap bitmap = renderer.getBitmap(); - view.setDrawingCacheEnabled(false); - - return bitmap; - } catch (Exception e) { - Log.e(TAG, "Failed to take screenshot using " + renderer.toString() + ". Cause: " + e); - return null; - } - } } diff --git a/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java b/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java index 74c4e4ba4..a8fb98050 100644 --- a/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java +++ b/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java @@ -13,6 +13,7 @@ import com.instabug.flutter.util.ArgsRegistry; import com.instabug.flutter.util.Reflection; import com.instabug.flutter.util.ThreadManager; +import com.instabug.flutter.util.privateViews.PrivateViewManager; import com.instabug.library.Feature; import com.instabug.library.Instabug; import com.instabug.library.InstabugColorTheme; @@ -21,13 +22,14 @@ import com.instabug.library.Platform; import com.instabug.library.ReproConfigurations; import com.instabug.library.featuresflags.model.IBGFeatureFlag; +import com.instabug.library.internal.crossplatform.InternalCore; import com.instabug.library.internal.module.InstabugLocale; import com.instabug.library.invocation.InstabugInvocationEvent; import com.instabug.library.model.NetworkLog; +import com.instabug.library.screenshot.ScreenshotCaptor; +import com.instabug.library.screenshot.instacapture.ScreenshotRequest; import com.instabug.library.ui.onboarding.WelcomeMessage; -import io.flutter.FlutterInjector; -import io.flutter.embedding.engine.loader.FlutterLoader; -import io.flutter.plugin.common.BinaryMessenger; + import org.jetbrains.annotations.NotNull; import org.json.JSONObject; @@ -40,22 +42,27 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.concurrent.Callable; + +import io.flutter.FlutterInjector; +import io.flutter.embedding.engine.loader.FlutterLoader; +import io.flutter.plugin.common.BinaryMessenger; public class InstabugApi implements InstabugPigeon.InstabugHostApi { private final String TAG = InstabugApi.class.getName(); private final Context context; - private final Callable screenshotProvider; private final InstabugCustomTextPlaceHolder placeHolder = new InstabugCustomTextPlaceHolder(); + private final PrivateViewManager privateViewManager; + private final InternalCore internalCore; - public static void init(BinaryMessenger messenger, Context context, Callable screenshotProvider) { - final InstabugApi api = new InstabugApi(context, screenshotProvider); + public static void init(BinaryMessenger messenger, Context context, PrivateViewManager privateViewManager, InternalCore internalCore) { + final InstabugApi api = new InstabugApi(context, privateViewManager, internalCore); InstabugPigeon.InstabugHostApi.setup(messenger, api); } - public InstabugApi(Context context, Callable screenshotProvider) { + public InstabugApi(Context context, PrivateViewManager privateViewManager, InternalCore internalCore) { this.context = context; - this.screenshotProvider = screenshotProvider; + this.privateViewManager = privateViewManager; + this.internalCore = internalCore; } @VisibleForTesting @@ -112,7 +119,13 @@ public void init(@NonNull String token, @NonNull List invocationEvents, .setSdkDebugLogsLevel(parsedLogLevel) .build(); - Instabug.setScreenshotProvider(screenshotProvider); + internalCore._setScreenshotCaptor(new ScreenshotCaptor() { + @Override + public void capture(@NonNull ScreenshotRequest screenshotRequest) { + privateViewManager.mask(screenshotRequest.getListener()); + } + }); + } @Override diff --git a/android/src/main/java/com/instabug/flutter/util/privateViews/BoundryCaptureManager.java b/android/src/main/java/com/instabug/flutter/util/privateViews/BoundryCaptureManager.java new file mode 100644 index 000000000..6d6653a59 --- /dev/null +++ b/android/src/main/java/com/instabug/flutter/util/privateViews/BoundryCaptureManager.java @@ -0,0 +1,42 @@ +package com.instabug.flutter.util.privateViews; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.util.DisplayMetrics; +import android.view.View; + +import com.instabug.flutter.util.ThreadManager; + +import io.flutter.embedding.engine.renderer.FlutterRenderer; + +public class BoundryCaptureManager implements CaptureManager { + FlutterRenderer renderer; + + public BoundryCaptureManager(FlutterRenderer renderer) { + this.renderer = renderer; + } + + @Override + public void capture(Activity activity, ScreenshotResultCallback screenshotResultCallback) { + ThreadManager.runOnMainThread(new Runnable() { + @Override + public void run() { + try { + if (activity == null) { + screenshotResultCallback.onError(); + return; + } + View rootView = activity.getWindow().getDecorView().getRootView(); + rootView.setDrawingCacheEnabled(true); + Bitmap bitmap = renderer.getBitmap(); + rootView.setDrawingCacheEnabled(false); + DisplayMetrics displayMetrics = activity.getResources().getDisplayMetrics(); + screenshotResultCallback.onScreenshotResult(new ScreenshotResult(displayMetrics.density, bitmap)); + + } catch (Exception e) { + screenshotResultCallback.onError(); + } + } + }); + } +} diff --git a/android/src/main/java/com/instabug/flutter/util/privateViews/CaptureManager.java b/android/src/main/java/com/instabug/flutter/util/privateViews/CaptureManager.java new file mode 100644 index 000000000..a179cf2db --- /dev/null +++ b/android/src/main/java/com/instabug/flutter/util/privateViews/CaptureManager.java @@ -0,0 +1,7 @@ +package com.instabug.flutter.util.privateViews; + +import android.app.Activity; + +public interface CaptureManager { + void capture(Activity activity, ScreenshotResultCallback screenshotResultCallback); +} diff --git a/android/src/main/java/com/instabug/flutter/util/privateViews/PixelCopyCaptureManager.java b/android/src/main/java/com/instabug/flutter/util/privateViews/PixelCopyCaptureManager.java new file mode 100644 index 000000000..12b88be85 --- /dev/null +++ b/android/src/main/java/com/instabug/flutter/util/privateViews/PixelCopyCaptureManager.java @@ -0,0 +1,85 @@ +package com.instabug.flutter.util.privateViews; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.DisplayMetrics; +import android.view.PixelCopy; +import android.view.SurfaceView; + +import androidx.annotation.RequiresApi; + +import com.instabug.library.util.memory.MemoryUtils; + +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.android.FlutterFragment; +import io.flutter.embedding.android.FlutterView; + +public class PixelCopyCaptureManager implements CaptureManager { + + @RequiresApi(api = Build.VERSION_CODES.N) + @Override + public void capture(Activity activity, ScreenshotResultCallback screenshotResultCallback) { + FlutterView flutterView = getFlutterView(activity); + if (flutterView == null || !isValidFlutterView(flutterView)) { + screenshotResultCallback.onError(); + return; + } + + SurfaceView surfaceView = (SurfaceView) flutterView.getChildAt(0); + Bitmap bitmap = createBitmapFromSurface(surfaceView); + + if (bitmap == null) { + screenshotResultCallback.onError(); + return; + } + + PixelCopy.request(surfaceView, bitmap, copyResult -> { + if (copyResult == PixelCopy.SUCCESS) { + DisplayMetrics displayMetrics = activity.getResources().getDisplayMetrics(); + screenshotResultCallback.onScreenshotResult(new ScreenshotResult(displayMetrics.density, bitmap)); + } else { + screenshotResultCallback.onError(); + } + }, new Handler(Looper.getMainLooper())); + } + + private FlutterView getFlutterView(Activity activity) { + FlutterView flutterViewInActivity = activity.findViewById(FlutterActivity.FLUTTER_VIEW_ID); + FlutterView flutterViewInFragment = activity.findViewById(FlutterFragment.FLUTTER_VIEW_ID); + return flutterViewInActivity != null ? flutterViewInActivity : flutterViewInFragment; + } + + private boolean isValidFlutterView(FlutterView flutterView) { + boolean hasChildren = flutterView.getChildCount() > 0; + boolean isSurfaceView = flutterView.getChildAt(0) instanceof SurfaceView; + return hasChildren && isSurfaceView; + } + + private Bitmap createBitmapFromSurface(SurfaceView surfaceView) { + int width = surfaceView.getWidth(); + int height = surfaceView.getHeight(); + + if (width <= 0 || height <= 0) { + return null; + } + Bitmap bitmap; + try { + if (((long) width * height * 4) < MemoryUtils.getFreeMemory(surfaceView.getContext())) { + // ARGB_8888 store each pixel in 4 bytes + bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + } else { + // RGB_565 store each pixel in 2 bytes + bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); + } + + } catch (IllegalArgumentException | OutOfMemoryError e) { + bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565); + } + + + return bitmap; + } +} diff --git a/android/src/main/java/com/instabug/flutter/util/privateViews/PrivateViewManager.java b/android/src/main/java/com/instabug/flutter/util/privateViews/PrivateViewManager.java new file mode 100644 index 000000000..3957b6390 --- /dev/null +++ b/android/src/main/java/com/instabug/flutter/util/privateViews/PrivateViewManager.java @@ -0,0 +1,134 @@ +package com.instabug.flutter.util.privateViews; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.os.Build; + +import androidx.annotation.NonNull; + +import com.instabug.flutter.generated.InstabugPrivateViewPigeon; +import com.instabug.flutter.util.ThreadManager; +import com.instabug.library.screenshot.ScreenshotCaptor; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicReference; + +public class PrivateViewManager { + private static final String THREAD_NAME = "IBG-Flutter-Screenshot"; + public static final String EXCEPTION_MESSAGE = "IBG-Flutter-Screenshot: error capturing screenshot"; + + private final ExecutorService screenshotExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable); + thread.setName(THREAD_NAME); + return thread; + }); + + private final InstabugPrivateViewPigeon.InstabugPrivateViewApi instabugPrivateViewApi; + private Activity activity; + final CaptureManager pixelCopyScreenshotCaptor; + final CaptureManager boundryScreenshotCaptor; + + public PrivateViewManager(@NonNull InstabugPrivateViewPigeon.InstabugPrivateViewApi instabugPrivateViewApi, CaptureManager pixelCopyCaptureManager, CaptureManager boundryCaptureManager) { + this.instabugPrivateViewApi = instabugPrivateViewApi; + this.pixelCopyScreenshotCaptor = pixelCopyCaptureManager; + this.boundryScreenshotCaptor = boundryCaptureManager; + + + } + + public void setActivity(Activity activity) { + this.activity = activity; + } + + + public void mask(ScreenshotCaptor.CapturingCallback capturingCallback) { + if (activity != null) { + CountDownLatch latch = new CountDownLatch(1); + AtomicReference> privateViews = new AtomicReference<>(); + final ScreenshotResultCallback boundryScreenshotResult = new ScreenshotResultCallback() { + + @Override + public void onScreenshotResult(ScreenshotResult screenshotResult) { + processScreenshot(screenshotResult, privateViews, latch, capturingCallback); + + } + + @Override + public void onError() { + capturingCallback.onCapturingFailure(new Exception(EXCEPTION_MESSAGE)); + } + }; + + try { + ThreadManager.runOnMainThread(new Runnable() { + @Override + public void run() { + instabugPrivateViewApi.getPrivateViews(result -> { + privateViews.set(result); + latch.countDown(); + }); + } + }); + + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + pixelCopyScreenshotCaptor.capture(activity, new ScreenshotResultCallback() { + @Override + public void onScreenshotResult(ScreenshotResult result) { + processScreenshot(result, privateViews, latch, capturingCallback); + } + + @Override + public void onError() { + boundryScreenshotCaptor.capture(activity, boundryScreenshotResult); + + } + }); + } else { + boundryScreenshotCaptor.capture(activity, boundryScreenshotResult); + } + + } catch (Exception e) { + capturingCallback.onCapturingFailure(e); + } + } else { + capturingCallback.onCapturingFailure(new Exception(EXCEPTION_MESSAGE)); + } + } + + + private void processScreenshot(ScreenshotResult result, AtomicReference> privateViews, CountDownLatch latch, ScreenshotCaptor.CapturingCallback capturingCallback) { + screenshotExecutor.execute(() -> { + try { + latch.await(); // Wait + Bitmap bitmap = result.getScreenshot(); + maskPrivateViews(result, privateViews.get()); + capturingCallback.onCapturingSuccess(bitmap); + } catch (InterruptedException e) { + capturingCallback.onCapturingFailure(e); + } + }); + } + + void maskPrivateViews(ScreenshotResult result, List privateViews) { + if (privateViews == null || privateViews.isEmpty()) return; + + Bitmap bitmap = result.getScreenshot(); + float pixelRatio = result.getPixelRatio(); + Canvas canvas = new Canvas(bitmap); + Paint paint = new Paint(); // Default color is black + + for (int i = 0; i < privateViews.size(); i += 4) { + float left = privateViews.get(i).floatValue() * pixelRatio; + float top = privateViews.get(i + 1).floatValue() * pixelRatio; + float right = privateViews.get(i + 2).floatValue() * pixelRatio; + float bottom = privateViews.get(i + 3).floatValue() * pixelRatio; + canvas.drawRect(left, top, right, bottom, paint); // Mask private view + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/instabug/flutter/util/privateViews/ScreenshotResult.java b/android/src/main/java/com/instabug/flutter/util/privateViews/ScreenshotResult.java new file mode 100644 index 000000000..d8473c8ad --- /dev/null +++ b/android/src/main/java/com/instabug/flutter/util/privateViews/ScreenshotResult.java @@ -0,0 +1,21 @@ +package com.instabug.flutter.util.privateViews; + +import android.graphics.Bitmap; + +public class ScreenshotResult { + private final float pixelRatio; + private final Bitmap screenshot; + + public ScreenshotResult(float pixelRatio, Bitmap screenshot) { + this.pixelRatio = pixelRatio; + this.screenshot = screenshot; + } + + public Bitmap getScreenshot() { + return screenshot; + } + + public float getPixelRatio() { + return pixelRatio; + } +} diff --git a/android/src/main/java/com/instabug/flutter/util/privateViews/ScreenshotResultCallback.java b/android/src/main/java/com/instabug/flutter/util/privateViews/ScreenshotResultCallback.java new file mode 100644 index 000000000..f10bdca23 --- /dev/null +++ b/android/src/main/java/com/instabug/flutter/util/privateViews/ScreenshotResultCallback.java @@ -0,0 +1,7 @@ +package com.instabug.flutter.util.privateViews; + + +public interface ScreenshotResultCallback { + void onScreenshotResult(ScreenshotResult screenshotResult); + void onError(); +} diff --git a/android/src/test/java/com/instabug/flutter/InstabugApiTest.java b/android/src/test/java/com/instabug/flutter/InstabugApiTest.java index 2abb8987e..653466ab3 100644 --- a/android/src/test/java/com/instabug/flutter/InstabugApiTest.java +++ b/android/src/test/java/com/instabug/flutter/InstabugApiTest.java @@ -26,6 +26,7 @@ import com.instabug.flutter.modules.InstabugApi; import com.instabug.flutter.util.GlobalMocks; import com.instabug.flutter.util.MockReflected; +import com.instabug.flutter.util.privateViews.PrivateViewManager; import com.instabug.library.Feature; import com.instabug.library.Instabug; import com.instabug.library.InstabugColorTheme; @@ -36,8 +37,10 @@ import com.instabug.library.ReproConfigurations; import com.instabug.library.ReproMode; import com.instabug.library.featuresflags.model.IBGFeatureFlag; +import com.instabug.library.internal.crossplatform.InternalCore; import com.instabug.library.invocation.InstabugInvocationEvent; import com.instabug.library.model.NetworkLog; +import com.instabug.library.screenshot.ScreenshotCaptor; import com.instabug.library.ui.onboarding.WelcomeMessage; import org.json.JSONObject; @@ -59,7 +62,6 @@ import java.util.concurrent.Callable; import io.flutter.plugin.common.BinaryMessenger; -import org.mockito.verification.VerificationMode; public class InstabugApiTest { private final Callable screenshotProvider = () -> mock(Bitmap.class); @@ -69,11 +71,12 @@ public class InstabugApiTest { private MockedStatic mBugReporting; private MockedConstruction mCustomTextPlaceHolder; private MockedStatic mHostApi; - + private InternalCore internalCore; @Before public void setUp() throws NoSuchMethodException { mCustomTextPlaceHolder = mockConstruction(InstabugCustomTextPlaceHolder.class); - api = spy(new InstabugApi(mContext, screenshotProvider)); + internalCore=spy(InternalCore.INSTANCE); + api = spy(new InstabugApi(mContext, mock(PrivateViewManager.class),internalCore)); mInstabug = mockStatic(Instabug.class); mBugReporting = mockStatic(BugReporting.class); mHostApi = mockStatic(InstabugPigeon.InstabugHostApi.class); @@ -93,7 +96,7 @@ public void cleanUp() { public void testInit() { BinaryMessenger messenger = mock(BinaryMessenger.class); - InstabugApi.init(messenger, mContext, screenshotProvider); + InstabugApi.init(messenger, mContext, mock(PrivateViewManager.class),internalCore); mHostApi.verify(() -> InstabugPigeon.InstabugHostApi.setup(eq(messenger), any(InstabugApi.class))); } @@ -132,10 +135,7 @@ public void testSdkInit() { verify(builder).setInvocationEvents(InstabugInvocationEvent.FLOATING_BUTTON); verify(builder).setSdkDebugLogsLevel(LogLevel.ERROR); verify(builder).build(); - - // Sets screenshot provider - mInstabug.verify(() -> Instabug.setScreenshotProvider(screenshotProvider)); - + verify(internalCore)._setScreenshotCaptor(any(ScreenshotCaptor.class)); // Sets current platform reflected.verify(() -> MockReflected.setCurrentPlatform(Platform.FLUTTER)); } diff --git a/android/src/test/java/com/instabug/flutter/util/privateViews/BoundryScreenshotCaptorTest.java b/android/src/test/java/com/instabug/flutter/util/privateViews/BoundryScreenshotCaptorTest.java new file mode 100644 index 000000000..e9a3d8b61 --- /dev/null +++ b/android/src/test/java/com/instabug/flutter/util/privateViews/BoundryScreenshotCaptorTest.java @@ -0,0 +1,75 @@ +package com.instabug.flutter.util.privateViews; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.os.Looper; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentMatcher; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import io.flutter.embedding.engine.renderer.FlutterRenderer; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {28}, manifest = Config.NONE) +public class BoundryScreenshotCaptorTest { + private Activity activityMock; + private Bitmap bitmap; + private CaptureManager captureManager; + + @Before + public void setUp() { + FlutterRenderer rendererMock = mock(FlutterRenderer.class); + activityMock = spy(Robolectric.buildActivity(Activity.class).setup().create().start().resume().get()); + bitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888); + when(rendererMock.getBitmap()).thenReturn(bitmap); + captureManager = new BoundryCaptureManager(rendererMock); + } + + @Test + public void testCaptureGivenEmptyActivity() { + ScreenshotResultCallback mockCallback = mock(ScreenshotResultCallback.class); + + captureManager.capture(null, mockCallback); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mockCallback).onError(); + verify(mockCallback, never()).onScreenshotResult(any(ScreenshotResult.class)); + + } + + @Test + public void testCapture() { + ScreenshotResultCallback mockCallback = mock(ScreenshotResultCallback.class); + captureManager.capture(activityMock, mockCallback); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mockCallback, never()).onError(); + verify(mockCallback).onScreenshotResult(argThat(new ArgumentMatcher() { + @Override + public boolean matches(ScreenshotResult argument) { + return (Math.abs(activityMock.getResources().getDisplayMetrics().density - argument.getPixelRatio()) < 0.01)&& + bitmap == argument.getScreenshot(); + + } + })); + } + + +} diff --git a/android/src/test/java/com/instabug/flutter/util/privateViews/PixelCopyScreenshotCaptorTest.java b/android/src/test/java/com/instabug/flutter/util/privateViews/PixelCopyScreenshotCaptorTest.java new file mode 100644 index 000000000..5745124c5 --- /dev/null +++ b/android/src/test/java/com/instabug/flutter/util/privateViews/PixelCopyScreenshotCaptorTest.java @@ -0,0 +1,90 @@ +package com.instabug.flutter.util.privateViews; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.os.Looper; +import android.view.SurfaceView; + +import com.instabug.library.util.memory.MemoryUtils; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.MockedStatic; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.android.FlutterView; +import io.flutter.embedding.engine.renderer.FlutterRenderer; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {28}, manifest = Config.NONE) +public class PixelCopyScreenshotCaptorTest { + private Activity activityMock; + private Bitmap bitmap; + private CaptureManager captureManager; + + @Before + public void setUp() { + FlutterRenderer rendererMock = mock(FlutterRenderer.class); + activityMock = spy(Robolectric.buildActivity(Activity.class).setup().create().start().resume().get()); + bitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888); + when(rendererMock.getBitmap()).thenReturn(bitmap); + captureManager = new PixelCopyCaptureManager(); + } + + @Test + public void testCaptureWithPixelCopyGivenEmptyView() { + + ScreenshotResultCallback mockScreenshotResultCallback = mock(ScreenshotResultCallback.class); + when(activityMock.findViewById(FlutterActivity.FLUTTER_VIEW_ID)).thenReturn(null); + captureManager.capture(activityMock,mockScreenshotResultCallback); + + verify(mockScreenshotResultCallback).onError(); + } + + @Test + public void testCaptureWithPixelCopy() { + try (MockedStatic mockedStatic = mockStatic(MemoryUtils.class)) { + mockedStatic.when(() -> MemoryUtils.getFreeMemory(any())).thenReturn(Long.MAX_VALUE); + + mockFlutterViewInPixelCopy(); + + ScreenshotResultCallback mockScreenshotResultCallback = mock(ScreenshotResultCallback.class); + + + captureManager.capture(activityMock, mockScreenshotResultCallback); + shadowOf(Looper.getMainLooper()).idle(); + + verify(mockScreenshotResultCallback, timeout(1000)).onScreenshotResult(any(ScreenshotResult.class)); // PixelCopy success + + } + } + + + private void mockFlutterViewInPixelCopy() { + + SurfaceView mockSurfaceView = mock(SurfaceView.class); + FlutterView flutterView = mock(FlutterView.class); + when(flutterView.getChildAt(0)).thenReturn(mockSurfaceView); + when(flutterView.getChildCount()).thenReturn(1); + + when(activityMock.findViewById(FlutterActivity.FLUTTER_VIEW_ID)).thenReturn(flutterView); + when(mockSurfaceView.getWidth()).thenReturn(100); + when(mockSurfaceView.getHeight()).thenReturn(100); + } +} diff --git a/android/src/test/java/com/instabug/flutter/util/privateViews/PrivateViewManagerTest.java b/android/src/test/java/com/instabug/flutter/util/privateViews/PrivateViewManagerTest.java new file mode 100644 index 000000000..c1620723f --- /dev/null +++ b/android/src/test/java/com/instabug/flutter/util/privateViews/PrivateViewManagerTest.java @@ -0,0 +1,142 @@ +package com.instabug.flutter.util.privateViews; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.app.Activity; +import android.graphics.Bitmap; +import android.os.Build; +import android.os.Looper; +import android.view.SurfaceView; + +import com.instabug.flutter.generated.InstabugPrivateViewPigeon; +import com.instabug.library.screenshot.ScreenshotCaptor; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Arrays; +import java.util.List; + +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.android.FlutterView; +import io.flutter.embedding.engine.renderer.FlutterRenderer; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = {28}, manifest = Config.NONE) +public class PrivateViewManagerTest { + + private PrivateViewManager privateViewManager; + private InstabugPrivateViewPigeon.InstabugPrivateViewApi instabugPrivateViewApiMock; + private Activity activityMock; + private Bitmap bitmap; + private CaptureManager pixelCopyScreenCaptor, boundryScreenCaptor; + + @Before + public void setUp() { + instabugPrivateViewApiMock = mock(InstabugPrivateViewPigeon.InstabugPrivateViewApi.class); + FlutterRenderer rendererMock = mock(FlutterRenderer.class); + activityMock = spy(Robolectric.buildActivity(Activity.class).setup().create().start().resume().get()); + bitmap = Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888); + when(rendererMock.getBitmap()).thenReturn(bitmap); + pixelCopyScreenCaptor = spy(new PixelCopyCaptureManager()); + boundryScreenCaptor = spy(new BoundryCaptureManager(rendererMock)); + privateViewManager = spy(new PrivateViewManager(instabugPrivateViewApiMock, pixelCopyScreenCaptor, boundryScreenCaptor)); + privateViewManager.setActivity(activityMock); + + } + + + @Test + public void testMaskGivenEmptyActivity() { + ScreenshotCaptor.CapturingCallback capturingCallbackMock = mock(ScreenshotCaptor.CapturingCallback.class); + privateViewManager.setActivity(null); + privateViewManager.mask(capturingCallbackMock); + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Throwable.class); + verify(capturingCallbackMock).onCapturingFailure(argumentCaptor.capture()); + assertEquals( PrivateViewManager.EXCEPTION_MESSAGE, argumentCaptor.getValue().getMessage()); + } + + @Test + public void testMask() throws InterruptedException { + ScreenshotCaptor.CapturingCallback capturingCallbackMock = mock(ScreenshotCaptor.CapturingCallback.class); + doAnswer(invocation -> { + InstabugPrivateViewPigeon.InstabugPrivateViewApi.Reply> callback = invocation.getArgument(0); // Get the callback + callback.reply(Arrays.asList(10.0, 20.0, 100.0, 200.0)); // Trigger the success callback + return null; + }).when(instabugPrivateViewApiMock).getPrivateViews(any(InstabugPrivateViewPigeon.InstabugPrivateViewApi.Reply.class)); // Mock the method call + + + // Trigger the mask operation + privateViewManager.mask(capturingCallbackMock); + // Mock that latch.await() has been called + shadowOf(Looper.getMainLooper()).idle(); + + // Simulate a successful bitmap capture + verify(capturingCallbackMock, timeout(1000)).onCapturingSuccess(bitmap); + } + + + private void mockFlutterViewInPixelCopy() { + SurfaceView mockSurfaceView = mock(SurfaceView.class); + FlutterView flutterView = mock(FlutterView.class); + when(flutterView.getChildAt(0)).thenReturn(mockSurfaceView); + when(flutterView.getChildCount()).thenReturn(1); + + when(activityMock.findViewById(FlutterActivity.FLUTTER_VIEW_ID)).thenReturn(flutterView); + when(mockSurfaceView.getWidth()).thenReturn(100); + when(mockSurfaceView.getHeight()).thenReturn(100); + } + + + @Test + public void testMaskPrivateViews() { + ScreenshotResult mockResult = mock(ScreenshotResult.class); + when(mockResult.getScreenshot()).thenReturn(Bitmap.createBitmap(200, 200, Bitmap.Config.ARGB_8888)); + when(mockResult.getPixelRatio()).thenReturn(2.0f); + + List privateViews = Arrays.asList(10.0, 20.0, 100.0, 200.0); + + privateViewManager.maskPrivateViews(mockResult, privateViews); + + assertNotNull(mockResult.getScreenshot()); + } + + @Test + @Config(sdk = {Build.VERSION_CODES.M}) + public void testMaskShouldGetScreenshotWhenAPIVersionLessThan28() { + ScreenshotCaptor.CapturingCallback capturingCallbackMock = mock(ScreenshotCaptor.CapturingCallback.class); + privateViewManager.mask(capturingCallbackMock); + shadowOf(Looper.getMainLooper()).idle(); + + verify(boundryScreenCaptor).capture(any(), any()); + + } + + @Test + public void testMaskShouldCallPixelCopyWhenAPIVersionMoreThan28() { + ScreenshotCaptor.CapturingCallback capturingCallbackMock = mock(ScreenshotCaptor.CapturingCallback.class); + mockFlutterViewInPixelCopy(); + privateViewManager.mask(capturingCallbackMock); + shadowOf(Looper.getMainLooper()).idle(); + verify(boundryScreenCaptor, never()).capture(any(), any()); + verify(pixelCopyScreenCaptor).capture(any(), any()); + + + } +} \ No newline at end of file diff --git a/example/assets/img.png b/example/assets/img.png new file mode 100644 index 000000000..fff04770f Binary files /dev/null and b/example/assets/img.png differ diff --git a/example/ios/InstabugTests/PrivateViewApiTests.m b/example/ios/InstabugTests/PrivateViewApiTests.m new file mode 100644 index 000000000..ee4de7a92 --- /dev/null +++ b/example/ios/InstabugTests/PrivateViewApiTests.m @@ -0,0 +1,206 @@ +#import +#import +#import +#import +#import "FlutterPluginRegistrar+FlutterEngine.h" + + +@interface MockFlutterPluginRegistrar : NSObject +@end + +@implementation MockFlutterPluginRegistrar + +@end + + +@interface PrivateViewApiTests : XCTestCase +@property (nonatomic, strong) PrivateViewApi *api; +@property (nonatomic, strong) id mockFlutterApi; +@property (nonatomic, strong) id mockRegistrar; +@property (nonatomic, strong) id mockFlutterViewController; +@property (nonatomic, strong) id mockEngine; + +@end + +@implementation PrivateViewApiTests + +#pragma mark - Setup / Teardown + +- (void)setUp { + [super setUp]; + + + self.mockFlutterApi = OCMClassMock([InstabugPrivateViewApi class]); + + + MockFlutterPluginRegistrar *mockRegistrar = [[MockFlutterPluginRegistrar alloc] init]; + + self.mockRegistrar = OCMPartialMock(mockRegistrar); + + self.mockEngine = OCMClassMock([FlutterEngine class]); + OCMStub([self.mockRegistrar flutterEngine]).andReturn(self.mockEngine); + + self.mockFlutterViewController = OCMClassMock([UIViewController class]); + + OCMStub([self.mockEngine viewController]).andReturn(_mockFlutterViewController); + + self.api = OCMPartialMock([[PrivateViewApi alloc] initWithFlutterApi:self.mockFlutterApi registrar: self.mockRegistrar]); +} + +- (void)tearDown { + [self.mockFlutterApi stopMocking]; + [self.mockRegistrar stopMocking]; + [self.mockFlutterViewController stopMocking]; + [self.mockEngine stopMocking]; + + self.api = nil; + + [super tearDown]; +} + +#pragma mark - Tests + +- (void)testMask_Success { + XCTestExpectation *expectation = [self expectationWithDescription:@"Mask method success"]; + + CGSize imageSize = CGSizeMake(100, 100); // 100x100 pixels + + // Step 2: Create the image using UIGraphicsImageRenderer + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:imageSize]; + + UIImage *screenshot = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) { + // Draw a red rectangle as an example + [[UIColor redColor] setFill]; + CGRect rect = CGRectMake(0, 0, imageSize.width, imageSize.height); + UIRectFill(rect); + }]; + + NSArray *rectangles = @[@10, @20, @30, @40]; + UIView *mockView = [[UIView alloc] initWithFrame:CGRectMake(10, 20, 30, 40)]; + + OCMStub([self.mockFlutterApi getPrivateViewsWithCompletion:([OCMArg invokeBlockWithArgs:rectangles, [NSNull null], nil])]); + + + + OCMStub([self.mockFlutterViewController view]).andReturn(mockView); + + + [self.api mask:screenshot completion:^(UIImage *result) { + XCTAssertNotNil(result, @"Masked image should be returned."); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)testMask_Error { + XCTestExpectation *expectation = [self expectationWithDescription:@"Mask method with error"]; + + UIImage *screenshot = [UIImage new]; + FlutterError *error = [FlutterError errorWithCode:@"ERROR" message:@"Test error" details:nil]; + + OCMStub([self.mockFlutterApi getPrivateViewsWithCompletion:([OCMArg invokeBlockWithArgs:[NSNull null], error, nil])]); + + [self.api mask:screenshot completion:^(UIImage *result) { + XCTAssertEqual(result, screenshot, @"Original screenshot should be returned on error."); + [expectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)testGetFlutterViewOrigin_ValidView { + UIView *mockView = [[UIView alloc] initWithFrame:CGRectMake(10, 20, 100, 100)]; + + OCMStub([self.mockFlutterViewController view]).andReturn(mockView); + + UIWindow* testWindow = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; + [testWindow addSubview:mockView]; + + CGPoint origin = [self.api getFlutterViewOrigin]; + + XCTAssertEqual(origin.x, 10); + XCTAssertEqual(origin.y, 20); +} + +- (void)testGetFlutterViewOrigin_NilView { + + OCMStub([self.mockFlutterViewController view]).andReturn(nil); +// + CGPoint origin = [self.api getFlutterViewOrigin]; + + XCTAssertEqual(origin.x, 0); + XCTAssertEqual(origin.y, 0); +} + +- (void)testDrawMaskedImage { + CGSize size = CGSizeMake(100, 100); + UIGraphicsBeginImageContext(size); + UIImage *screenshot = UIGraphicsGetImageFromCurrentImageContext(); + UIGraphicsEndImageContext(); + + NSArray *privateViews = @[ + [NSValue valueWithCGRect:CGRectMake(10, 10, 20, 20)], + [NSValue valueWithCGRect:CGRectMake(30, 30, 10, 10)] + ]; + + UIImage *result = [self.api drawMaskedImage:screenshot withPrivateViews:privateViews]; + + XCTAssertNotNil(result); + XCTAssertEqual(result.size.width, 100); + XCTAssertEqual(result.size.height, 100); +} + +- (void)testConvertToRectangles_ValidInput { + NSArray *rectangles = @[@10, @20, @30, @40]; + UIView *mockView = [[UIView alloc] initWithFrame:CGRectMake(5, 5, 100, 100)]; + OCMStub([self.mockFlutterViewController view]).andReturn(mockView); + + + NSArray *converted = [self.api convertToRectangles:rectangles]; + + XCTAssertEqual(converted.count, 1); + CGRect rect = [converted[0] CGRectValue]; + XCTAssertTrue(CGRectEqualToRect(rect, CGRectMake(10, 20, 21, 21))); +} + +- (void)testConcurrentMaskCalls { + XCTestExpectation *expectation = [self expectationWithDescription:@"Handle concurrent calls"]; + + CGSize imageSize = CGSizeMake(100, 100); // 100x100 pixels + + // Step 2: Create the image using UIGraphicsImageRenderer + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:imageSize]; + + UIImage *screenshot = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) { + // Draw a red rectangle as an example + [[UIColor redColor] setFill]; + CGRect rect = CGRectMake(0, 0, imageSize.width, imageSize.height); + UIRectFill(rect); + }]; + + NSArray *rectangles = @[@10, @20, @30, @40]; + + + OCMStub([self.mockFlutterApi getPrivateViewsWithCompletion:([OCMArg invokeBlockWithArgs:rectangles, [NSNull null], nil])]); + + + dispatch_group_t group = dispatch_group_create(); + + for (int i = 0; i < 5; i++) { + dispatch_group_enter(group); + + [self.api mask:screenshot completion:^(UIImage *result) { + XCTAssertNotNil(result, @"Each call should return a valid image."); + dispatch_group_leave(group); + }]; + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + + [self waitForExpectationsWithTimeout:2 handler:nil]; +} + +@end diff --git a/example/ios/Podfile b/example/ios/Podfile index cdffbc5db..22e035918 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -27,6 +27,8 @@ require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelpe flutter_ios_podfile_setup target 'Runner' do + pod 'Instabug', :podspec => 'https://ios-releases.instabug.com/custom/feature-flutter-private-views-base/13.4.2/Instabug.podspec' + use_frameworks! use_modular_headers! diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index b0ee46890..6bf8ca622 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -8,26 +8,28 @@ PODS: DEPENDENCIES: - Flutter (from `Flutter`) + - Instabug (from `https://ios-releases.instabug.com/custom/feature-flutter-private-views-base/13.4.2/Instabug.podspec`) - instabug_flutter (from `.symlinks/plugins/instabug_flutter/ios`) - OCMock (= 3.6) SPEC REPOS: trunk: - - Instabug - OCMock EXTERNAL SOURCES: Flutter: :path: Flutter + Instabug: + :podspec: https://ios-releases.instabug.com/custom/feature-flutter-private-views-base/13.4.2/Instabug.podspec instabug_flutter: :path: ".symlinks/plugins/instabug_flutter/ios" SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - Instabug: 7a71890217b97b1e32dbca96661845396b66da2f + Instabug: 7aacd5099c11ce96bc49dda40eba0963c06acccc instabug_flutter: a2df87e3d4d9e410785e0b1ffef4bc64d1f4b787 OCMock: 5ea90566be239f179ba766fd9fbae5885040b992 -PODFILE CHECKSUM: 8f7552fd115ace1988c3db54a69e4a123c448f84 +PODFILE CHECKSUM: f2e19aef9f983becf80950af8e2d9c1b8f57e7a2 COCOAPODS: 1.14.3 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 858ba01e5..d75211080 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -16,7 +16,8 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - 9D381ECFBB01BD0E978EBDF2 /* Pods_InstabugTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 71679BEC094CFF3474195C2E /* Pods_InstabugTests.framework */; }; + BEF638212CC82C7C004D29E9 /* PrivateViewApiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = BEF638202CC82C7C004D29E9 /* PrivateViewApiTests.m */; }; + BEF638292CCA5E2B004D29E9 /* Pods_InstabugTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 71679BEC094CFF3474195C2E /* Pods_InstabugTests.framework */; }; CC080E112937B7DB0041170A /* InstabugApiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC080E102937B7DB0041170A /* InstabugApiTests.m */; }; CC198C61293E1A21007077C8 /* SurveysApiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC198C60293E1A21007077C8 /* SurveysApiTests.m */; }; CC359DB92937720C0067A924 /* ApmApiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC359DB82937720C0067A924 /* ApmApiTests.m */; }; @@ -26,6 +27,7 @@ CC9925D7293DFB03001FD3EE /* InstabugLogApiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC9925D6293DFB03001FD3EE /* InstabugLogApiTests.m */; }; CC9925D9293DFD7F001FD3EE /* RepliesApiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC9925D8293DFD7F001FD3EE /* RepliesApiTests.m */; }; CCADBDD8293CFED300AE5EB8 /* BugReportingApiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CCADBDD7293CFED300AE5EB8 /* BugReportingApiTests.m */; }; + EDD1293B2F5742BC05EDD9F6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BEF6382E2CCA6D7D004D29E9 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -78,6 +80,9 @@ B03C8370EEFE061BDDDA1DA1 /* Pods-InstabugUITests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InstabugUITests.debug.xcconfig"; path = "Target Support Files/Pods-InstabugUITests/Pods-InstabugUITests.debug.xcconfig"; sourceTree = ""; }; BA5633844585BB93FE7BCCE7 /* Pods-InstabugTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InstabugTests.profile.xcconfig"; path = "Target Support Files/Pods-InstabugTests/Pods-InstabugTests.profile.xcconfig"; sourceTree = ""; }; BE26C80C2BD55575009FECCF /* IBGCrashReporting+CP.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "IBGCrashReporting+CP.h"; sourceTree = ""; }; + BEF638202CC82C7C004D29E9 /* PrivateViewApiTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PrivateViewApiTests.m; sourceTree = ""; }; + BEF6382C2CCA6176004D29E9 /* instabug_flutter.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = instabug_flutter.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + BEF6382E2CCA6D7D004D29E9 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BF9025BBD0A6FD7B193E903A /* Pods-InstabugTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InstabugTests.debug.xcconfig"; path = "Target Support Files/Pods-InstabugTests/Pods-InstabugTests.debug.xcconfig"; sourceTree = ""; }; C090017925D9A030006F3DAE /* InstabugTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = InstabugTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C090017D25D9A031006F3DAE /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -102,6 +107,7 @@ buildActionMask = 2147483647; files = ( 65C88E6E8EAE049E32FF2F52 /* Pods_Runner.framework in Frameworks */, + EDD1293B2F5742BC05EDD9F6 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -109,7 +115,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9D381ECFBB01BD0E978EBDF2 /* Pods_InstabugTests.framework in Frameworks */, + BEF638292CCA5E2B004D29E9 /* Pods_InstabugTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -135,6 +141,8 @@ 54C1C903B090526284242B67 /* Frameworks */ = { isa = PBXGroup; children = ( + BEF6382E2CCA6D7D004D29E9 /* Pods_Runner.framework */, + BEF6382C2CCA6176004D29E9 /* instabug_flutter.framework */, 853739F5879F6E4272829F47 /* Pods_Runner.framework */, 71679BEC094CFF3474195C2E /* Pods_InstabugTests.framework */, F5446C0D3B2623D9BCC7CCE3 /* Pods_InstabugUITests.framework */, @@ -194,6 +202,7 @@ C090017A25D9A031006F3DAE /* InstabugTests */ = { isa = PBXGroup; children = ( + BEF638202CC82C7C004D29E9 /* PrivateViewApiTests.m */, CC198C60293E1A21007077C8 /* SurveysApiTests.m */, CC78720A2938D1C5008CB2A5 /* Util */, CC080E102937B7DB0041170A /* InstabugApiTests.m */, @@ -457,6 +466,7 @@ CC080E112937B7DB0041170A /* InstabugApiTests.m in Sources */, CC198C61293E1A21007077C8 /* SurveysApiTests.m in Sources */, CCADBDD8293CFED300AE5EB8 /* BugReportingApiTests.m in Sources */, + BEF638212CC82C7C004D29E9 /* PrivateViewApiTests.m in Sources */, CC9925D9293DFD7F001FD3EE /* RepliesApiTests.m in Sources */, 206286ED2ABD0A1F00925509 /* SessionReplayApiTests.m in Sources */, CC9925D2293DEB0B001FD3EE /* CrashReportingApiTests.m in Sources */, @@ -802,6 +812,7 @@ "@loader_path/Frameworks", ); MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.instabug.InstabugTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "InstabugTests/InstabugTests-Bridging-Header.h"; @@ -835,6 +846,7 @@ "@loader_path/Frameworks", ); MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; PRODUCT_BUNDLE_IDENTIFIER = com.instabug.InstabugTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "InstabugTests/InstabugTests-Bridging-Header.h"; diff --git a/example/lib/main.dart b/example/lib/main.dart index cda7ff3ec..4a387d44f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -8,6 +8,7 @@ import 'package:instabug_flutter/instabug_flutter.dart'; import 'package:instabug_http_client/instabug_http_client.dart'; import 'package:instabug_flutter_example/src/app_routes.dart'; import 'package:instabug_flutter_example/src/widget/nested_view.dart'; +import 'package:video_player/video_player.dart'; import 'src/native/instabug_flutter_example_method_channel.dart'; import 'src/widget/instabug_button.dart'; @@ -40,6 +41,7 @@ part 'src/components/page.dart'; part 'src/components/traces_content.dart'; part 'src/components/flows_content.dart'; +part 'src/screens/private_view_page.dart'; void main() { runZonedGuarded( diff --git a/example/lib/src/screens/my_home_page.dart b/example/lib/src/screens/my_home_page.dart index 404d79cdd..30217da4f 100644 --- a/example/lib/src/screens/my_home_page.dart +++ b/example/lib/src/screens/my_home_page.dart @@ -151,6 +151,19 @@ class _MyHomePageState extends State { ); } + void _navigateToPrivateViews() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const InstabugCaptureScreenLoading( + screenName: ApmPage.screenName, + child: PrivateViewPage(), + ), + settings: const RouteSettings(name: ApmPage.screenName), + ), + ); + } + void _navigateToComplex() { Navigator.push( context, @@ -197,10 +210,12 @@ class _MyHomePageState extends State { style: buttonStyle, child: const Text('None'), ), - ElevatedButton( - onPressed: () => setInvocationEvent(InvocationEvent.shake), - style: buttonStyle, - child: const Text('Shake'), + InstabugPrivateView( + child: ElevatedButton( + onPressed: () => setInvocationEvent(InvocationEvent.shake), + style: buttonStyle, + child: const Text('Shake'), + ), ), ElevatedButton( onPressed: () => setInvocationEvent(InvocationEvent.screenshot), @@ -298,14 +313,22 @@ class _MyHomePageState extends State { onPressed: _navigateToCrashes, text: 'Crashes', ), - InstabugButton( - onPressed: _navigateToApm, - text: 'APM', + InstabugPrivateView( + child: InstabugButton( + onPressed: _navigateToApm, + text: 'APM', + ), ), InstabugButton( onPressed: _navigateToComplex, text: 'Complex', ), + InstabugPrivateView( + child: InstabugButton( + onPressed: _navigateToPrivateViews, + text: 'Private views', + ), + ), const SectionTitle('Sessions Replay'), InstabugButton( onPressed: getCurrentSessionReplaylink, @@ -347,9 +370,11 @@ class _MyHomePageState extends State { onPressed: () => removeFeatureFlag(), text: 'RemoveFeatureFlag', ), - InstabugButton( - onPressed: () => removeAllFeatureFlags(), - text: 'RemoveAllFeatureFlags', + InstabugPrivateView( + child: InstabugButton( + onPressed: () => removeAllFeatureFlags(), + text: 'RemoveAllFeatureFlags', + ), ), ], ); diff --git a/example/lib/src/screens/private_view_page.dart b/example/lib/src/screens/private_view_page.dart new file mode 100644 index 000000000..ec765c1e6 --- /dev/null +++ b/example/lib/src/screens/private_view_page.dart @@ -0,0 +1,128 @@ +part of '../../main.dart'; + +class PrivateViewPage extends StatefulWidget { + const PrivateViewPage({Key? key}) : super(key: key); + + @override + _PrivateViewPageState createState() => _PrivateViewPageState(); +} + +class _PrivateViewPageState extends State { + late VideoPlayerController _controller; + final _scaffoldKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _controller = VideoPlayerController.networkUrl( + Uri.parse( + 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'), + )..initialize().then((_) { + setState(() {}); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Page(scaffoldKey: _scaffoldKey, title: 'Private views', children: [ + SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 16), + InstabugPrivateView( + child: const Text( + 'Private TextView', + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + InstabugPrivateView( + child: ElevatedButton( + onPressed: () { + const snackBar = SnackBar( + content: Text('Hello, you clicked on a private button'), + ); + ScaffoldMessenger.of(context).showSnackBar(snackBar); + }, + child: const Text('I am a private button'), + ), + ), + const SizedBox(height: 16), + InstabugPrivateView( + child: Image.asset( + 'assets/img.png', + // Add this image to your assets folder + height: 100, + ), + ), + const SizedBox(height: 33), + InstabugPrivateView( + child: const TextField( + obscureText: true, + decoration: InputDecoration( + hintText: 'password', + labelText: 'Password', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(height: 16), + const TextField( + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: 'Email', + labelText: 'Email', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 24), + InstabugPrivateView( + child: Container( + height: 300, + child: _controller.value.isInitialized + ? AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ) + : const Center(child: CircularProgressIndicator()), + ), + ), + const SizedBox(height: 24), + const SizedBox(height: 24), + const SizedBox(height: 24), + SizedBox( + height: 200, + child: CustomScrollView( + slivers: [ + InstabugSliverPrivateView( + sliver: SliverToBoxAdapter( + child: Container( + color: Colors.red, + child: Text( + "Private Sliver Widget", + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + ), + ), + ) + ], + ), + ) + ], + ), + ), + ), + ]); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 31cb6f5dd..3424e4a47 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -41,14 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" - espresso: - dependency: "direct dev" + csslib: + dependency: transitive description: - name: espresso - sha256: "641bdfcaec98b2fe2f5c90d61a16cdf6879ddac4d7333a6467ef03d60933596b" + name: csslib + sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" url: "https://pub.dev" source: hosted - version: "0.2.0+5" + version: "1.0.0" fake_async: dependency: transitive description: @@ -88,11 +88,24 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter source: sdk version: "0.0.0" + html: + dependency: transitive + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" http: dependency: "direct main" description: @@ -128,18 +141,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -168,18 +181,18 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" path: dependency: transitive description: @@ -192,10 +205,18 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "2.1.8" process: dependency: transitive description: @@ -261,10 +282,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" typed_data: dependency: transitive description: @@ -281,14 +302,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "4a8c3492d734f7c39c2588a3206707a05ee80cef52e8c7f3b2078d430c84bc17" + url: "https://pub.dev" + source: hosted + version: "2.9.2" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "391e092ba4abe2f93b3e625bd6b6a6ec7d7414279462c1c0ee42b5ab8d0a0898" + url: "https://pub.dev" + source: hosted + version: "2.7.16" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: cd5ab8a8bc0eab65ab0cea40304097edc46da574c8c1ecdee96f28cd8ef3792f + url: "https://pub.dev" + source: hosted + version: "2.6.2" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "229d7642ccd9f3dc4aba169609dd6b5f3f443bb4cc15b82f7785fcada5af9bbb" + url: "https://pub.dev" + source: hosted + version: "6.2.3" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "881b375a934d8ebf868c7fb1423b2bfaa393a0a265fa3f733079a86536064a10" + url: "https://pub.dev" + source: hosted + version: "2.3.3" vm_service: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + url: "https://pub.dev" + source: hosted + version: "14.2.5" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "1.1.0" webdriver: dependency: transitive description: @@ -298,5 +367,5 @@ packages: source: hosted version: "3.0.3" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 7f3e9e622..c5a61ef85 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -27,9 +27,9 @@ dependencies: instabug_flutter: path: ../ instabug_http_client: ^2.4.0 + video_player: dev_dependencies: - espresso: 0.2.0+5 flutter_driver: sdk: flutter flutter_test: @@ -51,9 +51,8 @@ flutter: uses-material-design: true # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/img.png # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/assets-and-images/#resolution-aware. diff --git a/ios/Classes/InstabugFlutterPlugin.m b/ios/Classes/InstabugFlutterPlugin.m index 9b9182ae7..b1d7bd948 100644 --- a/ios/Classes/InstabugFlutterPlugin.m +++ b/ios/Classes/InstabugFlutterPlugin.m @@ -9,6 +9,7 @@ #import "RepliesApi.h" #import "SessionReplayApi.h" #import "SurveysApi.h" +#import "PrivateViewApi.h" @implementation InstabugFlutterPlugin @@ -17,11 +18,14 @@ + (void)registerWithRegistrar:(NSObject *)registrar { InitBugReportingApi([registrar messenger]); InitCrashReportingApi([registrar messenger]); InitFeatureRequestsApi([registrar messenger]); - InitInstabugApi([registrar messenger]); + PrivateViewApi* privateViewApi = InitPrivateViewApi([registrar messenger],registrar); + InitInstabugApi([registrar messenger],privateViewApi); InitInstabugLogApi([registrar messenger]); InitRepliesApi([registrar messenger]); InitSessionReplayApi([registrar messenger]); InitSurveysApi([registrar messenger]); + + } @end diff --git a/ios/Classes/Modules/InstabugApi.h b/ios/Classes/Modules/InstabugApi.h index 7030617c9..85fc07e0f 100644 --- a/ios/Classes/Modules/InstabugApi.h +++ b/ios/Classes/Modules/InstabugApi.h @@ -1,8 +1,10 @@ #import "InstabugPigeon.h" +#import "PrivateViewApi.h" -extern void InitInstabugApi(id messenger); +extern void InitInstabugApi(id _Nonnull messenger, PrivateViewApi * _Nonnull api); @interface InstabugApi : NSObject +@property (nonatomic, strong) PrivateViewApi* _Nonnull privateViewApi; - (UIImage *)getImageForAsset:(NSString *)assetName; - (UIFont *)getFontForAsset:(NSString *)assetName error:(FlutterError *_Nullable *_Nonnull)error; diff --git a/ios/Classes/Modules/InstabugApi.m b/ios/Classes/Modules/InstabugApi.m index 11ea09354..5bb9bcb55 100644 --- a/ios/Classes/Modules/InstabugApi.m +++ b/ios/Classes/Modules/InstabugApi.m @@ -5,11 +5,13 @@ #import "IBGNetworkLogger+CP.h" #import "InstabugApi.h" #import "ArgsRegistry.h" +#import "../Util/Instabug+CP.h" #define UIColorFromRGB(rgbValue) [UIColor colorWithRed:((float)((rgbValue & 0xFF0000) >> 16)) / 255.0 green:((float)((rgbValue & 0xFF00) >> 8)) / 255.0 blue:((float)(rgbValue & 0xFF)) / 255.0 alpha:((float)((rgbValue & 0xFF000000) >> 24)) / 255.0]; -extern void InitInstabugApi(id messenger) { +extern void InitInstabugApi(id _Nonnull messenger, PrivateViewApi * _Nonnull privateViewApi) { InstabugApi *api = [[InstabugApi alloc] init]; + api.privateViewApi = privateViewApi; InstabugHostApiSetup(messenger, api); } @@ -53,6 +55,14 @@ - (void)initToken:(NSString *)token invocationEvents:(NSArray *)invo [Instabug setSdkDebugLogsLevel:resolvedLogLevel]; [Instabug startWithToken:token invocationEvents:resolvedEvents]; + + [Instabug setScreenshotMaskingHandler:^(UIImage * _Nonnull screenshot, void (^ _Nonnull completion)(UIImage * _Nullable)) { + [self.privateViewApi mask:screenshot completion:^(UIImage * _Nonnull maskedImage) { + if (maskedImage != nil) { + completion(maskedImage); + } + }]; + }]; } - (void)showWithError:(FlutterError *_Nullable *_Nonnull)error { diff --git a/ios/Classes/Modules/PrivateViewApi.h b/ios/Classes/Modules/PrivateViewApi.h new file mode 100644 index 000000000..86d38c056 --- /dev/null +++ b/ios/Classes/Modules/PrivateViewApi.h @@ -0,0 +1,34 @@ +#import +#import "InstabugPrivateViewPigeon.h" +#import + + +@interface PrivateViewApi : NSObject + +@property (nonatomic, strong) InstabugPrivateViewApi *flutterApi; +@property (nonatomic, strong) NSObject * flutterEngineRegistrar; + +- (instancetype)initWithFlutterApi:(InstabugPrivateViewApi *)api + registrar:(NSObject *)registrar; + +- (void)mask:(UIImage *)screenshot + completion:(void (^)(UIImage *maskedImage))completion; +- (void)handlePrivateViewsResult:(NSArray *)rectangles + error:(FlutterError *)error + screenshot:(UIImage *)screenshot + completion:(void (^)(UIImage *))completion; +- (NSArray *)convertToRectangles:(NSArray *)rectangles; + +- (UIImage *)drawMaskedImage:(UIImage *)screenshot withPrivateViews:(NSArray *)privateViews; +- (CGPoint)getFlutterViewOrigin; + +- (void)logError:(FlutterError *)error; + +@end + +// Extern function to initialize PrivateViewApi +extern PrivateViewApi* InitPrivateViewApi( + id messenger, + NSObject *flutterEngineRegistrar +); + diff --git a/ios/Classes/Modules/PrivateViewApi.m b/ios/Classes/Modules/PrivateViewApi.m new file mode 100644 index 000000000..10c1d7c91 --- /dev/null +++ b/ios/Classes/Modules/PrivateViewApi.m @@ -0,0 +1,116 @@ +#import "PrivateViewApi.h" +#import "../Util/FlutterPluginRegistrar+FlutterEngine.h" + +extern PrivateViewApi* InitPrivateViewApi( + id messenger, + NSObject *flutterEngineRegistrar +) { + InstabugPrivateViewApi *flutterApi = [[InstabugPrivateViewApi alloc] initWithBinaryMessenger:messenger]; + return [[PrivateViewApi alloc] initWithFlutterApi:flutterApi registrar:flutterEngineRegistrar]; +} + +@implementation PrivateViewApi + +// Initializer with proper memory management +- (instancetype)initWithFlutterApi:(InstabugPrivateViewApi *)api + registrar:( NSObject *) registrar { + if ((self = [super init])) { + _flutterApi = api; + _flutterEngineRegistrar = registrar; + } + return self; +} + +- (void)mask:(UIImage *)screenshot + completion:(void (^)(UIImage *))completion { + + __weak typeof(self) weakSelf = self; + + [self.flutterApi getPrivateViewsWithCompletion:^(NSArray *rectangles, FlutterError *error) { + [weakSelf handlePrivateViewsResult:rectangles + error:error + screenshot:screenshot + completion:completion]; + }]; +} + +#pragma mark - Private Methods + +// Handle the result of fetching private views +- (void)handlePrivateViewsResult:(NSArray *)rectangles + error:(FlutterError *)error + screenshot:(UIImage *)screenshot + completion:(void (^)(UIImage *))completion { + if (error) { + [self logError:error]; + completion(screenshot); + return; + } + + NSArray *privateViews = [self convertToRectangles:rectangles]; + UIImage *maskedScreenshot = [self drawMaskedImage:screenshot withPrivateViews:privateViews]; + completion(maskedScreenshot); + +} + +// Convert the raw rectangles array into CGRect values +- (NSArray *)convertToRectangles:(NSArray *)rectangles { + + NSMutableArray *privateViews = [NSMutableArray arrayWithCapacity:rectangles.count / 4]; + CGPoint flutterOrigin = [self getFlutterViewOrigin]; + + for (NSUInteger i = 0; i < rectangles.count; i += 4) { + CGFloat left = rectangles[i].doubleValue; + CGFloat top = rectangles[i + 1].doubleValue; + CGFloat right = rectangles[i + 2].doubleValue; + CGFloat bottom = rectangles[i + 3].doubleValue; + + CGRect rect = CGRectMake(flutterOrigin.x + left, + flutterOrigin.y + top, + right - left + 1, + bottom - top + 1); + [privateViews addObject:[NSValue valueWithCGRect:rect]]; + } + return privateViews; +} + +// Draw the masked image by filling private views with black rectangles +- (UIImage *)drawMaskedImage:(UIImage *)screenshot withPrivateViews:(NSArray *)privateViews { + UIGraphicsBeginImageContextWithOptions(screenshot.size, NO, 0); + CGContextRef context = UIGraphicsGetCurrentContext(); + + @try { + [screenshot drawAtPoint:CGPointZero]; + CGContextSetFillColorWithColor(context, UIColor.blackColor.CGColor); + + for (NSValue *value in privateViews) { + CGContextFillRect(context, value.CGRectValue); + } + + return UIGraphicsGetImageFromCurrentImageContext(); + } @finally { + UIGraphicsEndImageContext(); + } +} + +// Retrieve the origin point of the Flutter view +- (CGPoint)getFlutterViewOrigin { + FlutterViewController *flutterVC = (FlutterViewController *)self.flutterEngineRegistrar.flutterEngine.viewController; + + UIView *flutterView = flutterVC.view; + if(!flutterView) + return CGPointZero; + UIWindow *window = flutterView.window; + CGRect globalFrame = [flutterView convertRect:flutterView.bounds toView:window]; + + return globalFrame.origin ; +} + + +// Log error details +- (void)logError:(FlutterError *)error { + NSLog(@"IBG-Flutter: Error getting private views: %@", error.message); +} + + +@end diff --git a/ios/Classes/Util/FlutterPluginRegistrar+FlutterEngine.h b/ios/Classes/Util/FlutterPluginRegistrar+FlutterEngine.h new file mode 100644 index 000000000..ae31cbcd4 --- /dev/null +++ b/ios/Classes/Util/FlutterPluginRegistrar+FlutterEngine.h @@ -0,0 +1,8 @@ +#import + +@interface NSObject (FlutterEngineAccess) + +// Method to access FlutterEngine +- (FlutterEngine *)flutterEngine; + +@end diff --git a/ios/Classes/Util/FlutterPluginRegistrar+FlutterEngine.m b/ios/Classes/Util/FlutterPluginRegistrar+FlutterEngine.m new file mode 100644 index 000000000..4e5109d3a --- /dev/null +++ b/ios/Classes/Util/FlutterPluginRegistrar+FlutterEngine.m @@ -0,0 +1,13 @@ + +#import "FlutterPluginRegistrar+FlutterEngine.h" + +@implementation NSObject (FlutterEngineAccess) + +- (FlutterEngine *)flutterEngine { + if ([self respondsToSelector:@selector(engine)]) { + return (FlutterEngine *)[self performSelector:@selector(engine)]; + } + return nil; +} + +@end diff --git a/ios/Classes/Util/Instabug+CP.h b/ios/Classes/Util/Instabug+CP.h new file mode 100644 index 000000000..79e988c29 --- /dev/null +++ b/ios/Classes/Util/Instabug+CP.h @@ -0,0 +1,8 @@ + +#import + +@interface Instabug (CP) + ++ (void)setScreenshotMaskingHandler:(nullable void (^)(UIImage *, void (^)(UIImage *)))maskingHandler; + +@end diff --git a/lib/instabug_flutter.dart b/lib/instabug_flutter.dart index 6dc949d1b..1ced7ebe6 100644 --- a/lib/instabug_flutter.dart +++ b/lib/instabug_flutter.dart @@ -17,6 +17,8 @@ export 'src/modules/session_replay.dart'; export 'src/modules/surveys.dart'; // Utils export 'src/utils/instabug_navigator_observer.dart'; +export 'src/utils/private_views/instabug_private_view.dart'; +export 'src/utils/private_views/instabug_sliver_private_view.dart'; export 'src/utils/screen_loading/instabug_capture_screen_loading.dart'; export 'src/utils/screen_loading/route_matcher.dart'; export 'src/utils/screen_name_masker.dart' show ScreenNameMaskingCallback; diff --git a/lib/src/modules/instabug.dart b/lib/src/modules/instabug.dart index bf457a4fb..cd2d28957 100644 --- a/lib/src/modules/instabug.dart +++ b/lib/src/modules/instabug.dart @@ -16,9 +16,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; import 'package:instabug_flutter/src/generated/instabug.api.g.dart'; +import 'package:instabug_flutter/src/generated/instabug_private_view.api.g.dart'; import 'package:instabug_flutter/src/utils/enum_converter.dart'; import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; import 'package:instabug_flutter/src/utils/instabug_logger.dart'; +import 'package:instabug_flutter/src/utils/private_views/private_views_manager.dart'; import 'package:instabug_flutter/src/utils/screen_name_masker.dart'; import 'package:meta/meta.dart'; @@ -152,6 +154,7 @@ class Instabug { BugReporting.$setup(); Replies.$setup(); Surveys.$setup(); + InstabugPrivateViewApi.setup(PrivateViewsManager.I); } /// @nodoc diff --git a/lib/src/utils/private_views/instabug_private_view.dart b/lib/src/utils/private_views/instabug_private_view.dart new file mode 100644 index 000000000..b9fc0dfeb --- /dev/null +++ b/lib/src/utils/private_views/instabug_private_view.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:instabug_flutter/src/utils/private_views/private_views_manager.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/visibility_detector.dart'; + +class InstabugPrivateView extends StatefulWidget { + final Widget child; + + // Making the constructor const prevents the VisibilityDetector from detecting changes in the view, + // ignore: prefer_const_constructors_in_immutables + InstabugPrivateView({required this.child}) : super(key: null); + + @override + State createState() => _InstabugPrivateViewState(); +} + +class _InstabugPrivateViewState extends State { + final GlobalKey _visibilityDetectorKey = GlobalKey(); + final GlobalKey _childKey = GlobalKey(); + + @override + void dispose() { + _removePrivateView(); + super.dispose(); + } + + void _addPrivateView() { + PrivateViewsManager.I.mask(_childKey); + } + + void _removePrivateView() { + PrivateViewsManager.I.unMask(_childKey); + } + + void _onVisibilityChanged(bool isVisible) { + if (isVisible) { + _addPrivateView(); + } else { + _removePrivateView(); + } + } + + @override + Widget build(BuildContext context) { + return VisibilityDetector( + key: _visibilityDetectorKey, + onVisibilityChanged: _onVisibilityChanged, + child: KeyedSubtree(key: _childKey, child: widget.child), + ); + } +} diff --git a/lib/src/utils/private_views/instabug_sliver_private_view.dart b/lib/src/utils/private_views/instabug_sliver_private_view.dart new file mode 100644 index 000000000..c0ad8358c --- /dev/null +++ b/lib/src/utils/private_views/instabug_sliver_private_view.dart @@ -0,0 +1,52 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:instabug_flutter/src/utils/private_views/private_views_manager.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/sliver_visibility_detector.dart'; + +class InstabugSliverPrivateView extends StatefulWidget { + final Widget sliver; + + // Making the constructor const prevents the VisibilityDetector from detecting changes in the view, + // ignore: prefer_const_constructors_in_immutables + InstabugSliverPrivateView({Key? key, required this.sliver}) : super(key: key); + + @override + State createState() => + _InstabugSliverPrivateViewState(); +} + +class _InstabugSliverPrivateViewState extends State { + final key = GlobalKey(); + final GlobalKey _childKey = GlobalKey(); + + @override + void dispose() { + _removePrivateView(); + super.dispose(); + } + + void _addPrivateView() { + PrivateViewsManager.I.mask(_childKey); + } + + void _removePrivateView() { + PrivateViewsManager.I.unMask(_childKey); + } + + void _onVisibilityChanged(bool isVisible) { + if (isVisible) { + _addPrivateView(); + } else { + _removePrivateView(); + } + } + + @override + Widget build(BuildContext context) { + return SliverVisibilityDetector( + key: key, + onVisibilityChanged: _onVisibilityChanged, + sliver: KeyedSubtree(key: _childKey, child: widget.sliver), + ); + } +} diff --git a/lib/src/utils/private_views/private_views_manager.dart b/lib/src/utils/private_views/private_views_manager.dart new file mode 100644 index 000000000..941a8c84e --- /dev/null +++ b/lib/src/utils/private_views/private_views_manager.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:instabug_flutter/src/generated/instabug_private_view.api.g.dart'; + +/// responsible for masking views +/// before they are sent to the native SDKs. +class PrivateViewsManager implements InstabugPrivateViewApi { + PrivateViewsManager._(); + + static PrivateViewsManager _instance = PrivateViewsManager._(); + + static PrivateViewsManager get instance => _instance; + + /// Shorthand for [instance] + static PrivateViewsManager get I => instance; + + final Set _keys = {}; + + @visibleForTesting + // ignore: use_setters_to_change_properties + static void setInstance(PrivateViewsManager instance) { + _instance = instance; + } + + Rect? getLayoutRectInfoFromKey(GlobalKey key) { + final renderObject = key.currentContext?.findRenderObject(); + + if (renderObject == null) { + return null; + } + + final globalOffset = _getRenderGlobalOffset(renderObject); + + if (renderObject is RenderProxyBox) { + if (renderObject.child == null) { + return null; + } + + return MatrixUtils.transformRect( + renderObject.child!.getTransformTo(renderObject), + Offset.zero & renderObject.child!.size, + ).shift(globalOffset); + } + + return renderObject.paintBounds.shift(globalOffset); + } + + // The is the same implementation used in RenderBox.localToGlobal (a subclass of RenderObject) + Offset _getRenderGlobalOffset(RenderObject renderObject) { + return MatrixUtils.transformPoint( + renderObject.getTransformTo(null), + Offset.zero, + ); + } + + void mask(GlobalKey key) { + _keys.add(key); + } + + void unMask(GlobalKey key) { + _keys.remove(key); + } + + @override + List getPrivateViews() { + final result = []; + + for (final view in _keys) { + final rect = getLayoutRectInfoFromKey(view); + + if (rect == null) continue; + + result.addAll([ + rect.left, + rect.top, + rect.right, + rect.bottom, + ]); + } + + return result; + } +} diff --git a/lib/src/utils/private_views/visibility_detector/base_render_visibility_detector.dart b/lib/src/utils/private_views/visibility_detector/base_render_visibility_detector.dart new file mode 100644 index 000000000..4dbe90c0b --- /dev/null +++ b/lib/src/utils/private_views/visibility_detector/base_render_visibility_detector.dart @@ -0,0 +1,268 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/visibillity_utils.dart'; + +typedef VisibilityChangedCallback = void Function(bool isVisible); + +mixin RenderVisibilityDetectorBase on RenderObject { + static int? get debugUpdateCount { + if (!kDebugMode) { + return null; + } + return _updates.length; + } + + static Duration updateInterval = const Duration(milliseconds: 500); + static final Map _updates = {}; + static final Map _lastVisibility = {}; + + static void forget(Key key) { + _updates.remove(key); + _lastVisibility.remove(key); + + if (_updates.isEmpty) { + _timer?.cancel(); + _timer = null; + } + } + + static Timer? _timer; + + static void _handleTimer() { + _timer = null; + // Ensure that work is done between frames so that calculations are + // performed from a consistent state. We use `scheduleTask` here instead + // of `addPostFrameCallback` or `scheduleFrameCallback` so that work will + // be done even if a new frame isn't scheduled and without unnecessarily + // scheduling a new frame. + SchedulerBinding.instance.scheduleTask( + _processCallbacks, + Priority.touch, + ); + } + + /// Executes visibility callbacks for all updated instances. + static void _processCallbacks() { + for (final callback in _updates.values) { + callback(); + } + + _updates.clear(); + } + + void _fireCallback(ContainerLayer? layer, Rect bounds) { + final oldInfo = _lastVisibility[key]; + final visible = _determineVisibility(layer, bounds); + + if (oldInfo == null) { + if (!visible) { + return; + } + } else if (visible == oldInfo) { + return; + } + + if (visible) { + _lastVisibility[key] = visible; + } else { + // Track only visible items so that the map does not grow unbounded. + _lastVisibility.remove(key); + } + + onVisibilityChanged?.call(visible); + } + + /// The key for the corresponding [VisibilityDetector] widget. + Key get key; + + VoidCallback? _compositionCallbackCanceller; + + VisibilityChangedCallback? _onVisibilityChanged; + + // ignore: use_setters_to_change_properties + void init({required VisibilityChangedCallback? visibilityChangedCallback}) { + _onVisibilityChanged = visibilityChangedCallback; + } + + VisibilityChangedCallback? get onVisibilityChanged => _onVisibilityChanged; + + set onVisibilityChanged(VisibilityChangedCallback? value) { + _compositionCallbackCanceller?.call(); + _compositionCallbackCanceller = null; + _onVisibilityChanged = value; + + if (value == null) { + forget(key); + } else { + markNeedsPaint(); + // If an update is happening and some ancestor no longer paints this RO, + // the markNeedsPaint above will never cause the composition callback to + // fire and we could miss a hide event. This schedule will get + // over-written by subsequent updates in paint, if paint is called. + _scheduleUpdate(); + } + } + + int _debugScheduleUpdateCount = 0; + + /// The number of times the schedule update callback has been invoked from + /// [Layer.addCompositionCallback]. + /// + /// This is used for testing, and always returns null outside of debug mode. + @visibleForTesting + int? get debugScheduleUpdateCount { + if (kDebugMode) { + return _debugScheduleUpdateCount; + } + return null; + } + + void _scheduleUpdate([ContainerLayer? layer]) { + if (kDebugMode) { + _debugScheduleUpdateCount += 1; + } + final isFirstUpdate = _updates.isEmpty; + _updates[key] = () { + if (bounds == null) { + return; + } + _fireCallback(layer, bounds!); + }; + + if (updateInterval == Duration.zero) { + if (isFirstUpdate) { + // We're about to render a frame, so a post-frame callback is guaranteed + // to fire and will give us the better immediacy than `scheduleTask`. + SchedulerBinding.instance.addPostFrameCallback((timeStamp) { + _processCallbacks(); + }); + } + } else if (_timer == null) { + // We use a normal [Timer] instead of a [RestartableTimer] so that changes + // to the update duration will be picked up automatically. + _timer = Timer(updateInterval, _handleTimer); + } else { + assert(_timer!.isActive); + } + } + + bool _determineVisibility(ContainerLayer? layer, Rect bounds) { + if (_disposed || layer == null || layer.attached == false || !attached) { + // layer is detached and thus invisible. + return false; + } + final transform = Matrix4.identity(); + + // Check if any ancestors decided to skip painting this RenderObject. + if (parent != null) { + // ignore: unnecessary_cast + var ancestor = parent! as RenderObject; + RenderObject child = this; + while (ancestor.parent != null) { + if (!ancestor.paintsChild(child)) { + return false; + } + child = ancestor; + // ignore: unnecessary_cast + ancestor = ancestor.parent! as RenderObject; + } + } + + // Create a list of Layers from layer to the root, excluding the root + // since that has the DPR transform and we want to work with logical pixels. + // Add one extra leaf layer so that we can apply the transform of `layer` + // to the matrix. + ContainerLayer? ancestor = layer; + final ancestors = [ContainerLayer()]; + while (ancestor != null && ancestor.parent != null) { + ancestors.add(ancestor); + ancestor = ancestor.parent; + } + + var clip = Rect.largest; + for (var index = ancestors.length - 1; index > 0; index -= 1) { + final parent = ancestors[index]; + final child = ancestors[index - 1]; + final parentClip = parent.describeClipBounds(); + if (parentClip != null) { + clip = clip.intersect(MatrixUtils.transformRect(transform, parentClip)); + } + parent.applyTransform(child, transform); + } + + // Apply whatever transform/clip was on the canvas when painting. + if (_lastPaintClipBounds != null) { + clip = clip.intersect( + MatrixUtils.transformRect( + transform, + _lastPaintClipBounds!, + ), + ); + } + if (_lastPaintTransform != null) { + transform.multiply(_lastPaintTransform!); + } + return isWidgetVisible( + MatrixUtils.transformRect(transform, bounds), + clip, + ); + } + + /// Used to get the bounds of the render object when it is time to update + /// clients about visibility. + /// + /// A null value means bounds are not available. + Rect? get bounds; + + Matrix4? _lastPaintTransform; + Rect? _lastPaintClipBounds; + + @override + void paint(PaintingContext context, Offset offset) { + if (onVisibilityChanged != null) { + _lastPaintClipBounds = context.canvas.getLocalClipBounds(); + _lastPaintTransform = + Matrix4.fromFloat64List(context.canvas.getTransform()) + ..translate(offset.dx, offset.dy); + + _compositionCallbackCanceller?.call(); + _compositionCallbackCanceller = + context.addCompositionCallback((Layer layer) { + assert(!debugDisposed!); + final container = layer is ContainerLayer ? layer : layer.parent; + _scheduleUpdate(container); + }); + } + super.paint(context, offset); + } + + bool _disposed = false; + + @override + void dispose() { + _compositionCallbackCanceller?.call(); + _compositionCallbackCanceller = null; + _disposed = true; + super.dispose(); + } +} + +class RenderVisibilityDetector extends RenderProxyBox + with RenderVisibilityDetectorBase { + RenderVisibilityDetector({ + RenderBox? child, + required this.key, + required VisibilityChangedCallback? onVisibilityChanged, + }) : super(child) { + _onVisibilityChanged = onVisibilityChanged; + } + + @override + final Key key; + + @override + Rect? get bounds => hasSize ? semanticBounds : null; +} diff --git a/lib/src/utils/private_views/visibility_detector/sliver_visibility_detector.dart b/lib/src/utils/private_views/visibility_detector/sliver_visibility_detector.dart new file mode 100644 index 000000000..4ea0be1bc --- /dev/null +++ b/lib/src/utils/private_views/visibility_detector/sliver_visibility_detector.dart @@ -0,0 +1,84 @@ +import 'dart:math' as math; + +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/base_render_visibility_detector.dart'; + +class RenderSliverVisibilityDetector extends RenderProxySliver + with RenderVisibilityDetectorBase { + RenderSliverVisibilityDetector({ + RenderSliver? sliver, + required this.key, + required VisibilityChangedCallback? onVisibilityChanged, + }) : super(sliver) { + init(visibilityChangedCallback: onVisibilityChanged); + } + + @override + final Key key; + + @override + Rect? get bounds { + if (geometry == null) { + return null; + } + + Size widgetSize; + Offset widgetOffset; + switch (applyGrowthDirectionToAxisDirection( + constraints.axisDirection, + constraints.growthDirection, + )) { + case AxisDirection.down: + widgetOffset = Offset(0, -constraints.scrollOffset); + widgetSize = Size(constraints.crossAxisExtent, geometry!.scrollExtent); + break; + case AxisDirection.up: + final startOffset = geometry!.paintExtent + + constraints.scrollOffset - + geometry!.scrollExtent; + widgetOffset = Offset(0, math.min(startOffset, 0)); + widgetSize = Size(constraints.crossAxisExtent, geometry!.scrollExtent); + break; + case AxisDirection.right: + widgetOffset = Offset(-constraints.scrollOffset, 0); + widgetSize = Size(geometry!.scrollExtent, constraints.crossAxisExtent); + break; + case AxisDirection.left: + final startOffset = geometry!.paintExtent + + constraints.scrollOffset - + geometry!.scrollExtent; + widgetOffset = Offset(math.min(startOffset, 0), 0); + widgetSize = Size(geometry!.scrollExtent, constraints.crossAxisExtent); + break; + } + return widgetOffset & widgetSize; + } +} + +class SliverVisibilityDetector extends SingleChildRenderObjectWidget { + const SliverVisibilityDetector({ + required Key key, + required Widget sliver, + required this.onVisibilityChanged, + }) : super(key: key, child: sliver); + + final VisibilityChangedCallback? onVisibilityChanged; + + @override + RenderSliverVisibilityDetector createRenderObject(BuildContext context) { + return RenderSliverVisibilityDetector( + key: key!, + onVisibilityChanged: onVisibilityChanged, + ); + } + + @override + void updateRenderObject( + BuildContext context, + RenderSliverVisibilityDetector renderObject, + ) { + assert(renderObject.key == key); + renderObject.onVisibilityChanged = onVisibilityChanged; + } +} diff --git a/lib/src/utils/private_views/visibility_detector/visibility_detector.dart b/lib/src/utils/private_views/visibility_detector/visibility_detector.dart new file mode 100644 index 000000000..3de6de3e8 --- /dev/null +++ b/lib/src/utils/private_views/visibility_detector/visibility_detector.dart @@ -0,0 +1,48 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/base_render_visibility_detector.dart'; + +class RenderVisibilityDetector extends RenderProxyBox + with RenderVisibilityDetectorBase { + RenderVisibilityDetector({ + RenderBox? child, + required this.key, + required VisibilityChangedCallback? onVisibilityChanged, + }) : super(child) { + init(visibilityChangedCallback: onVisibilityChanged); + } + + @override + final Key key; + + @override + Rect? get bounds => hasSize ? semanticBounds : null; +} + +class VisibilityDetector extends SingleChildRenderObjectWidget { + const VisibilityDetector({ + required Key key, + required Widget child, + required this.onVisibilityChanged, + }) : super(key: key, child: child); + + /// The callback to invoke when this widget's visibility changes. + final VisibilityChangedCallback? onVisibilityChanged; + + @override + RenderVisibilityDetector createRenderObject(BuildContext context) { + return RenderVisibilityDetector( + key: key!, + onVisibilityChanged: onVisibilityChanged, + ); + } + + @override + void updateRenderObject( + BuildContext context, + RenderVisibilityDetector renderObject, + ) { + assert(renderObject.key == key); + renderObject.onVisibilityChanged = onVisibilityChanged; + } +} diff --git a/lib/src/utils/private_views/visibility_detector/visibillity_utils.dart b/lib/src/utils/private_views/visibility_detector/visibillity_utils.dart new file mode 100644 index 000000000..855f625cf --- /dev/null +++ b/lib/src/utils/private_views/visibility_detector/visibillity_utils.dart @@ -0,0 +1,46 @@ +import 'dart:math'; + +import 'package:flutter/widgets.dart'; + +bool isWidgetVisible( + Rect widgetBounds, + Rect clipRect, +) { + final overlaps = widgetBounds.overlaps(clipRect); + // Compute the intersection in the widget's local coordinates. + final visibleBounds = overlaps + ? widgetBounds.intersect(clipRect).shift(-widgetBounds.topLeft) + : Rect.zero; + final visibleArea = _area(visibleBounds.size); + final maxVisibleArea = _area(widgetBounds.size); + + if (_floatNear(maxVisibleArea, 0)) { + return false; + } + + final visibleFraction = visibleArea / maxVisibleArea; + + if (_floatNear(visibleFraction, 0)) { + return false; + } else if (_floatNear(visibleFraction, 1)) { + return true; + } + + return true; +} + +/// Computes the area of a rectangle of the specified dimensions. +double _area(Size size) { + assert(size.width >= 0); + assert(size.height >= 0); + return size.width * size.height; +} + +/// Returns whether two floating-point values are approximately equal. +bool _floatNear(double f1, double f2) { + final absDiff = (f1 - f2).abs(); + return absDiff <= _kDefaultTolerance || + (absDiff / max(f1.abs(), f2.abs()) <= _kDefaultTolerance); +} + +const _kDefaultTolerance = 0.01; diff --git a/pigeons/instabug_private_view.api.dart b/pigeons/instabug_private_view.api.dart new file mode 100644 index 000000000..b8c5feeb6 --- /dev/null +++ b/pigeons/instabug_private_view.api.dart @@ -0,0 +1,6 @@ +import 'package:pigeon/pigeon.dart'; + +@FlutterApi() +abstract class InstabugPrivateViewApi { + List getPrivateViews(); +} diff --git a/test/utils/private_views/instabug_private_view_test.dart b/test/utils/private_views/instabug_private_view_test.dart new file mode 100644 index 000000000..c2c4e1a5f --- /dev/null +++ b/test/utils/private_views/instabug_private_view_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/instabug_flutter.dart'; +import 'package:instabug_flutter/src/utils/private_views/private_views_manager.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/base_render_visibility_detector.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'instabug_private_view_test.mocks.dart'; + +@GenerateMocks([PrivateViewsManager]) +void main() { + testWidgets('should mask the view when it is visible', (tester) async { + await tester.runAsync(() async { + final mock = MockPrivateViewsManager(); + RenderVisibilityDetectorBase.updateInterval = Duration.zero; + PrivateViewsManager.setInstance(mock); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: InstabugPrivateView( + child: const Text('Text invisible'), + ), + ), + ), + ); + + verify( + mock.mask(any), + ).called( + 1, + ); // one for initState and the other for visibility is shown is true + }); + }); + + testWidgets("should un-mask the view when it is invisible", (tester) async { + await tester.runAsync(() async { + final mock = MockPrivateViewsManager(); + RenderVisibilityDetectorBase.updateInterval = Duration.zero; + PrivateViewsManager.setInstance(mock); + var isVisible = true; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ListView( + children: [ + Visibility( + visible: isVisible, + maintainState: true, + child: InstabugPrivateView( + child: const SizedBox( + width: 40, + height: 40, + ), + ), + ), + ElevatedButton( + onPressed: () { + setState(() { + isVisible = false; // make the widget invisible + }); + }, + child: const Text('Make invisible'), + ), + ], + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Make invisible')); + await tester.pump(const Duration(seconds: 1)); + verify( + mock.unMask(any), + ).called( + 1, + ); + }); + }); +} diff --git a/test/utils/private_views/instabug_sliver_private_view_test.dart b/test/utils/private_views/instabug_sliver_private_view_test.dart new file mode 100644 index 000000000..494267bf1 --- /dev/null +++ b/test/utils/private_views/instabug_sliver_private_view_test.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/instabug_flutter.dart'; +import 'package:instabug_flutter/src/utils/private_views/instabug_sliver_private_view.dart'; +import 'package:instabug_flutter/src/utils/private_views/private_views_manager.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/base_render_visibility_detector.dart'; +import 'package:mockito/mockito.dart'; + +import 'instabug_private_view_test.mocks.dart'; + +void main() { + testWidgets('should mask sliver view when it is visible', (tester) async { + await tester.runAsync(() async { + final mock = MockPrivateViewsManager(); + RenderVisibilityDetectorBase.updateInterval = Duration.zero; + PrivateViewsManager.setInstance(mock); + + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + slivers: [ + InstabugSliverPrivateView( + sliver: const SliverToBoxAdapter( + child: SizedBox( + width: 20, + height: 40, + ), + ), + ), + ], + ), + ), + ); + + verify( + mock.mask(any), + ).called( + 1, + ); // one for initState and the other for visibility is shown is true + }); + }); + + testWidgets('should un-mask the sliver view when it is invisible', + (tester) async { + await tester.runAsync(() async { + final mock = MockPrivateViewsManager(); + RenderVisibilityDetectorBase.updateInterval = Duration.zero; + PrivateViewsManager.setInstance(mock); + var isVisible = true; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SafeArea( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: ElevatedButton( + onPressed: () { + setState(() { + isVisible = false; // make the widget invisible + }); + }, + child: const Text('Make invisible'), + ), + ), + SliverVisibility( + visible: isVisible, + maintainState: true, + sliver: InstabugSliverPrivateView( + sliver: const SliverToBoxAdapter( + child: SizedBox( + width: 40, + height: 40, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('Make invisible')); + await tester.pump(const Duration(milliseconds: 300)); + + verify( + mock.unMask(any), + ).called( + 1, + ); + }); + }); +} diff --git a/test/utils/private_views/private_views_manager_test.dart b/test/utils/private_views/private_views_manager_test.dart new file mode 100644 index 000000000..52078c0a9 --- /dev/null +++ b/test/utils/private_views/private_views_manager_test.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/instabug_flutter.dart'; +import 'package:instabug_flutter/src/utils/private_views/private_views_manager.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/base_render_visibility_detector.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + WidgetsFlutterBinding.ensureInitialized(); + testWidgets( + '[getPrivateViews] should return rect bounds data when there is a masked widget', + (tester) async { + await tester.runAsync(() async { + RenderVisibilityDetectorBase.updateInterval = Duration.zero; + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: InstabugPrivateView( + child: Text( + 'Text invisible', + key: key, + ), + ), + ), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + + expect(PrivateViewsManager.I.getPrivateViews().length, 4); + final rect = PrivateViewsManager.I.getLayoutRectInfoFromKey(key); + expect(PrivateViewsManager.I.getPrivateViews(), [ + rect?.left, + rect?.top, + rect?.right, + rect?.bottom, + ]); + }); + }); + testWidgets( + '[getPrivateViews] should return rect bounds data when there is a masked widget (Sliver)', + (tester) async { + await tester.runAsync(() async { + RenderVisibilityDetectorBase.updateInterval = Duration.zero; + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: CustomScrollView( + slivers: [ + InstabugSliverPrivateView( + sliver: SliverToBoxAdapter( + child: Text( + 'Text invisible', + key: key, + ), + ), + ), + ], + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + expect(PrivateViewsManager.I.getPrivateViews().length, 4); + final rect = PrivateViewsManager.I.getLayoutRectInfoFromKey(key); + expect(PrivateViewsManager.I.getPrivateViews(), [ + rect?.left, + rect?.top, + rect?.right, + rect?.bottom, + ]); + }); + }); + + testWidgets( + "[getPrivateViews] should return empty rect bounds data when there is no masked widget", + (tester) async { + await tester.runAsync(() async { + RenderVisibilityDetectorBase.updateInterval = Duration.zero; + var isVisible = true; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ListView( + shrinkWrap: true, + children: [ + Visibility( + visible: isVisible, + child: + InstabugPrivateView(child: const Text("masked text")), + ), + ElevatedButton( + onPressed: () { + setState(() { + isVisible = false; // make the widget invisible + }); + }, + child: const Text('Make invisible'), + ), + ], + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Make invisible')); + await tester.pump(const Duration(seconds: 2)); + expect(PrivateViewsManager.I.getPrivateViews().length, 0); + }); + }); + testWidgets( + "[getPrivateViews] should return empty rect bounds data when there is no masked widget (Sliver)", + (tester) async { + await tester.runAsync(() async { + RenderVisibilityDetectorBase.updateInterval = Duration.zero; + var isVisible = true; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: ElevatedButton( + onPressed: () { + setState(() { + isVisible = false; // make the widget invisible + }); + }, + child: const Text('Make invisible'), + ), + ), + SliverVisibility( + visible: isVisible, + maintainState: true, + sliver: InstabugSliverPrivateView( + sliver: const SliverToBoxAdapter( + child: SizedBox( + width: 40, + height: 40, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ), + ); + await tester.tap(find.text('Make invisible')); + await tester.pump(const Duration(seconds: 2)); + expect(PrivateViewsManager.I.getPrivateViews().length, 0); + }); + }); +} diff --git a/test/utils/private_views/visibility_detector/render_visibility_detector_test.dart b/test/utils/private_views/visibility_detector/render_visibility_detector_test.dart new file mode 100644 index 000000000..d52675fc7 --- /dev/null +++ b/test/utils/private_views/visibility_detector/render_visibility_detector_test.dart @@ -0,0 +1,110 @@ +import 'dart:ui'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/base_render_visibility_detector.dart'; + +void main() { + RenderVisibilityDetectorBase.updateInterval = Duration.zero; + + testWidgets('RenderVisibilityDetector unregisters its callback on paint', + (WidgetTester tester) async { + final detector = RenderVisibilityDetector( + key: const Key('test'), + onVisibilityChanged: (_) {}, + ); + + final layer = ContainerLayer(); + final context = PaintingContext(layer, Rect.largest); + expect(layer.subtreeHasCompositionCallbacks, false); + + renderBoxWidget(detector, context); + + context.stopRecordingIfNeeded(); // ignore: invalid_use_of_protected_member + + expect(layer.subtreeHasCompositionCallbacks, true); + + expect(detector.debugScheduleUpdateCount, 0); + layer.buildScene(SceneBuilder()).dispose(); + + expect(detector.debugScheduleUpdateCount, 1); + }); + + testWidgets('RenderVisibilityDetector unregisters its callback on dispose', + (WidgetTester tester) async { + final detector = RenderVisibilityDetector( + key: const Key('test'), + onVisibilityChanged: (_) {}, + ); + + final layer = ContainerLayer(); + final context = PaintingContext(layer, Rect.largest); + expect(layer.subtreeHasCompositionCallbacks, false); + + renderBoxWidget(detector, context); + expect(layer.subtreeHasCompositionCallbacks, true); + + detector.dispose(); + expect(layer.subtreeHasCompositionCallbacks, false); + + expect(detector.debugScheduleUpdateCount, 0); + context.stopRecordingIfNeeded(); // ignore: invalid_use_of_protected_member + layer.buildScene(SceneBuilder()).dispose(); + + expect(detector.debugScheduleUpdateCount, 0); + }); + + testWidgets( + 'RenderVisibilityDetector unregisters its callback when callback changes', + (WidgetTester tester) async { + final detector = RenderVisibilityDetector( + key: const Key('test'), + onVisibilityChanged: (_) {}, + ); + + final layer = ContainerLayer(); + final context = PaintingContext(layer, Rect.largest); + expect(layer.subtreeHasCompositionCallbacks, false); + + renderBoxWidget(detector, context); + expect(layer.subtreeHasCompositionCallbacks, true); + + detector.onVisibilityChanged = null; + + expect(layer.subtreeHasCompositionCallbacks, false); + + expect(detector.debugScheduleUpdateCount, 0); + context.stopRecordingIfNeeded(); // ignore: invalid_use_of_protected_member + layer.buildScene(SceneBuilder()).dispose(); + + expect(detector.debugScheduleUpdateCount, 0); + }); + + testWidgets( + 'RenderVisibilityDetector can schedule an update for a RO that is not laid out', + (WidgetTester tester) async { + final detector = RenderVisibilityDetector( + key: const Key('test'), + onVisibilityChanged: (_) { + fail('should not get called'); + }, + ); + + // Force an out of band update to get scheduled without laying out. + detector.onVisibilityChanged = (_) { + fail('This should also not get called'); + }; + + expect(detector.debugScheduleUpdateCount, 1); + + detector.dispose(); + }); +} + +void renderBoxWidget( + RenderVisibilityDetector detector, + PaintingContext context, +) { + detector.layout(BoxConstraints.tight(const Size(200, 200))); + detector.paint(context, Offset.zero); +} diff --git a/test/utils/private_views/visibility_detector/sliver_visibility_detector_test.dart b/test/utils/private_views/visibility_detector/sliver_visibility_detector_test.dart new file mode 100644 index 000000000..5cc0a001f --- /dev/null +++ b/test/utils/private_views/visibility_detector/sliver_visibility_detector_test.dart @@ -0,0 +1,132 @@ +import 'dart:ui'; + +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/base_render_visibility_detector.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/sliver_visibility_detector.dart'; + +void main() { + RenderVisibilityDetectorBase.updateInterval = Duration.zero; + + void renderSliverWidget( + RenderSliverVisibilityDetector detector, + ContainerLayer layer, + PaintingContext context, + ) { + expect(layer.subtreeHasCompositionCallbacks, false); + + detector.layout( + const SliverConstraints( + axisDirection: AxisDirection.down, + growthDirection: GrowthDirection.forward, + userScrollDirection: ScrollDirection.forward, + scrollOffset: 0, + precedingScrollExtent: 0, + overlap: 0, + remainingPaintExtent: 0, + crossAxisExtent: 0, + crossAxisDirection: AxisDirection.left, + viewportMainAxisExtent: 0, + remainingCacheExtent: 0, + cacheOrigin: 0, + ), + ); + + final owner = PipelineOwner(); + detector.attach(owner); + owner.flushCompositingBits(); + + detector.paint(context, Offset.zero); + } + + testWidgets( + 'RenderSliverVisibilityDetector unregisters its callback on paint', + (WidgetTester tester) async { + final detector = RenderSliverVisibilityDetector( + key: const Key('test'), + onVisibilityChanged: (_) {}, + sliver: RenderSliverToBoxAdapter(child: RenderLimitedBox()), + ); + final layer = ContainerLayer(); + final context = PaintingContext(layer, Rect.largest); + + renderSliverWidget(detector, layer, context); + expect(layer.subtreeHasCompositionCallbacks, true); + expect(detector.debugScheduleUpdateCount, 0); + context.stopRecordingIfNeeded(); // ignore: invalid_use_of_protected_member + layer.buildScene(SceneBuilder()).dispose(); + + expect(detector.debugScheduleUpdateCount, 1); + }); + + testWidgets( + 'RenderSliverVisibilityDetector unregisters its callback on dispose', + (WidgetTester tester) async { + final detector = RenderSliverVisibilityDetector( + key: const Key('test'), + sliver: RenderSliverToBoxAdapter(child: RenderLimitedBox()), + onVisibilityChanged: (_) {}, + ); + + final layer = ContainerLayer(); + final context = PaintingContext(layer, Rect.largest); + renderSliverWidget(detector, layer, context); + + expect(layer.subtreeHasCompositionCallbacks, true); + + detector.dispose(); + expect(layer.subtreeHasCompositionCallbacks, false); + + expect(detector.debugScheduleUpdateCount, 0); + context.stopRecordingIfNeeded(); // ignore: invalid_use_of_protected_member + layer.buildScene(SceneBuilder()).dispose(); + + expect(detector.debugScheduleUpdateCount, 0); + }); + + testWidgets( + 'RenderSliverVisibilityDetector unregisters its callback when callback changes', + (WidgetTester tester) async { + final detector = RenderSliverVisibilityDetector( + key: const Key('test'), + sliver: RenderSliverToBoxAdapter(child: RenderLimitedBox()), + onVisibilityChanged: (_) {}, + ); + + final layer = ContainerLayer(); + final context = PaintingContext(layer, Rect.largest); + renderSliverWidget(detector, layer, context); + + expect(layer.subtreeHasCompositionCallbacks, true); + + detector.onVisibilityChanged = null; + + expect(layer.subtreeHasCompositionCallbacks, false); + + expect(detector.debugScheduleUpdateCount, 0); + context.stopRecordingIfNeeded(); // ignore: invalid_use_of_protected_member + layer.buildScene(SceneBuilder()).dispose(); + + expect(detector.debugScheduleUpdateCount, 0); + }); + + testWidgets( + 'RenderSliverVisibilityDetector can schedule an update for a RO that is not laid out', + (WidgetTester tester) async { + final detector = RenderSliverVisibilityDetector( + key: const Key('test'), + onVisibilityChanged: (_) { + fail('should not get called'); + }, + ); + + // Force an out of band update to get scheduled without laying out. + detector.onVisibilityChanged = (_) { + fail('This should also not get called'); + }; + + expect(detector.debugScheduleUpdateCount, 1); + + detector.dispose(); + }); +} diff --git a/test/utils/private_views/visibility_detector/visibility_utils_test.dart b/test/utils/private_views/visibility_detector/visibility_utils_test.dart new file mode 100644 index 000000000..16e479c70 --- /dev/null +++ b/test/utils/private_views/visibility_detector/visibility_utils_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/src/utils/private_views/visibility_detector/visibillity_utils.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + WidgetsFlutterBinding.ensureInitialized(); + test( + '[isWidgetVisible] should return false when the widget bounds are outside the screen visible area', + () { + const widgetBounds = Rect.fromLTWH(15, 25, 10, 20); + const clipRect = Rect.fromLTWH(100, 200, 300, 400); + final isVisible = isWidgetVisible(widgetBounds, clipRect); + expect(isVisible, false); + }); + + test( + '[isWidgetVisible] should return true when part of widget bounds are inside the screen visible area', + () { + const widgetBounds = Rect.fromLTWH(115, 225, 10, 20); + const clipRect = Rect.fromLTWH(100, 200, 300, 400); + final isVisible = isWidgetVisible(widgetBounds, clipRect); + expect(isVisible, true); + }); + + test( + '[isWidgetVisible] should return true when widget bounds are inside the screen visible area', + () { + const widgetBounds = Rect.fromLTWH(100, 200, 300, 399); + const clipRect = Rect.fromLTWH(100, 200, 300, 400); + final isVisible = isWidgetVisible(widgetBounds, clipRect); + + expect(isVisible, true); + }); +}