diff --git a/CHANGELOG.md b/CHANGELOG.md index 039ad6ae1..ffb48d9ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,21 @@ # Changelog -## [15.0.1](https://github.com/Instabug/Instabug-Flutter/compare/v14.3.0...15.0.1) +## [Unreleased](https://github.com/Instabug/Instabug-Flutter/compare/v15.0.2...dev) + +### Added + +- Add support for Advanced UI customization with comprehensive theming capabilities ([#599](https://github.com/Instabug/Instabug-Flutter/pull/599)) + +- Add support for App variant. ([#585](https://github.com/Instabug/Instabug-Flutter/pull/585)) + +- Add screen rendering monitoring functionality within the APM product. ([#605](https://github.com/Instabug/Instabug-Flutter/pull/605)) + +### Changed + +- **BREAKING** Remove deprecated APIs ([#614](https://github.com/Instabug/Instabug-Flutter/pull/614)). See migration guide for more details. + + +## [15.0.2](https://github.com/Instabug/Instabug-Flutter/compare/v14.3.0...15.0.2) (Jul 7, 2025) ### Added @@ -12,7 +27,7 @@ - Bump Instabug iOS SDK to v15.1.1 ([#581](https://github.com/Instabug/Instabug-Flutter/pull/581)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/15.1.1). -- Bump Instabug Android SDK to v15.0.1 ([#581](https://github.com/Instabug/Instabug-Flutter/pull/581)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v15.0.1). +- Bump Instabug Android SDK to v15.0.2 ([#581](https://github.com/Instabug/Instabug-Flutter/pull/581)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v15.0.2). ## [14.3.1](https://github.com/Instabug/Instabug-Flutter/compare/v14.3.0...14.3.1) (May 20, 2025) diff --git a/android/build.gradle b/android/build.gradle index be7f0c4dc..e569ea981 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ group 'com.instabug.flutter' -version '15.0.1' +version '14.3.0' buildscript { repositories { @@ -17,11 +17,16 @@ rootProject.allprojects { google() mavenCentral() maven { - url 'https://oss.sonatype.org/content/repositories/snapshots' + url "https://mvn.instabug.com/nexus/repository/instabug-internal/" + credentials { + username "instabug" + password System.getenv("INSTABUG_REPOSITORY_PASSWORD") + } } } } + apply plugin: 'com.android.library' android { @@ -47,11 +52,10 @@ android { } dependencies { - api 'com.instabug.library:instabug:15.0.1' + api 'com.instabug.library:instabug:16.0.0.6893868-SNAPSHOT' testImplementation 'junit:junit:4.13.2' testImplementation "org.mockito:mockito-inline:3.12.1" testImplementation "io.mockk:mockk:1.13.13" - } // add upload_symbols task diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index ca8c529fc..5ca64aa4c 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,3 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.8-bin.zip diff --git a/android/src/main/java/com/instabug/flutter/InstabugFlutterPlugin.java b/android/src/main/java/com/instabug/flutter/InstabugFlutterPlugin.java index bb3b043fa..2488e8147 100644 --- a/android/src/main/java/com/instabug/flutter/InstabugFlutterPlugin.java +++ b/android/src/main/java/com/instabug/flutter/InstabugFlutterPlugin.java @@ -4,12 +4,18 @@ import android.app.Activity; import android.content.Context; import android.graphics.Bitmap; +import android.os.Build; import android.util.Log; +import android.view.Display; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleEventObserver; +import androidx.lifecycle.LifecycleOwner; +import com.instabug.flutter.generated.InstabugPigeon; import com.instabug.flutter.modules.ApmApi; import com.instabug.flutter.modules.BugReportingApi; import com.instabug.flutter.modules.CrashReportingApi; @@ -25,19 +31,23 @@ 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.plugins.lifecycle.HiddenLifecycleReference; import io.flutter.embedding.engine.renderer.FlutterRenderer; import io.flutter.plugin.common.BinaryMessenger; -public class InstabugFlutterPlugin implements FlutterPlugin, ActivityAware { +public class InstabugFlutterPlugin implements FlutterPlugin, ActivityAware, LifecycleEventObserver { private static final String TAG = InstabugFlutterPlugin.class.getName(); @SuppressLint("StaticFieldLeak") private static Activity activity; + private InstabugPigeon.InstabugFlutterApi instabugFlutterApi; + private Lifecycle lifecycle; @Override public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { register(binding.getApplicationContext(), binding.getBinaryMessenger(), (FlutterRenderer) binding.getTextureRegistry()); + instabugFlutterApi = new InstabugPigeon.InstabugFlutterApi(binding.getBinaryMessenger()); } @Override @@ -48,23 +58,60 @@ public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { @Override public void onAttachedToActivity(@NonNull ActivityPluginBinding binding) { activity = binding.getActivity(); + + // Register lifecycle observer if available + if (binding.getLifecycle() instanceof HiddenLifecycleReference) { + lifecycle = ((HiddenLifecycleReference) binding.getLifecycle()).getLifecycle(); + lifecycle.addObserver(this); + } } @Override public void onDetachedFromActivityForConfigChanges() { + if (lifecycle != null) { + lifecycle.removeObserver(this); + } activity = null; } @Override public void onReattachedToActivityForConfigChanges(@NonNull ActivityPluginBinding binding) { activity = binding.getActivity(); + + // Re-register lifecycle observer if available + if (binding.getLifecycle() instanceof HiddenLifecycleReference) { + lifecycle = ((HiddenLifecycleReference) binding.getLifecycle()).getLifecycle(); + lifecycle.addObserver(this); + } } @Override public void onDetachedFromActivity() { + if (lifecycle != null) { + lifecycle.removeObserver(this); + lifecycle = null; + } activity = null; } + @Override + public void onStateChanged(@NonNull LifecycleOwner source, @NonNull Lifecycle.Event event) { + if (event == Lifecycle.Event.ON_PAUSE) { + handleOnPause(); + } + } + + private void handleOnPause() { + if (instabugFlutterApi != null) { + instabugFlutterApi.dispose(new InstabugPigeon.InstabugFlutterApi.Reply() { + @Override + public void reply(Void reply) { + Log.d(TAG, "Screen render cleanup dispose called successfully"); + } + }); + } + } + private static void register(Context context, BinaryMessenger messenger, FlutterRenderer renderer) { final Callable screenshotProvider = new Callable() { @Override @@ -73,7 +120,14 @@ public Bitmap call() { } }; - ApmApi.init(messenger); + Callable refreshRateProvider = new Callable() { + @Override + public Float call() { + return getRefreshRate(); + } + }; + + ApmApi.init(messenger, refreshRateProvider); BugReportingApi.init(messenger); CrashReportingApi.init(messenger); FeatureRequestsApi.init(messenger); @@ -99,4 +153,20 @@ private static Bitmap takeScreenshot(FlutterRenderer renderer) { return null; } } + + private static float getRefreshRate() { + float refreshRate = 60f; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + final Display display = activity.getDisplay(); + if (display != null) { + refreshRate = display.getRefreshRate(); + } + } else { + refreshRate = activity.getWindowManager().getDefaultDisplay().getRefreshRate(); + } + + return refreshRate; + } + } diff --git a/android/src/main/java/com/instabug/flutter/modules/ApmApi.java b/android/src/main/java/com/instabug/flutter/modules/ApmApi.java index 607c569a4..23b11b3fe 100644 --- a/android/src/main/java/com/instabug/flutter/modules/ApmApi.java +++ b/android/src/main/java/com/instabug/flutter/modules/ApmApi.java @@ -9,38 +9,48 @@ import com.instabug.apm.InternalAPM; import com.instabug.apm.configuration.cp.APMFeature; import com.instabug.apm.configuration.cp.FeatureAvailabilityCallback; -import com.instabug.apm.model.ExecutionTrace; +import com.instabug.apm.configuration.cp.ToleranceValueCallback; import com.instabug.apm.networking.APMNetworkLogger; import com.instabug.apm.networkinterception.cp.APMCPNetworkLog; +import com.instabug.apm.screenrendering.models.cp.IBGFrameData; +import com.instabug.apm.screenrendering.models.cp.IBGScreenRenderingData; import com.instabug.flutter.generated.ApmPigeon; import com.instabug.flutter.util.Reflection; import com.instabug.flutter.util.ThreadManager; -import io.flutter.plugin.common.BinaryMessenger; - import org.json.JSONObject; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.Callable; + +import io.flutter.plugin.common.BinaryMessenger; public class ApmApi implements ApmPigeon.ApmHostApi { private final String TAG = ApmApi.class.getName(); - private final HashMap traces = new HashMap<>(); + private final Callable refreshRateCallback; - public static void init(BinaryMessenger messenger) { - final ApmApi api = new ApmApi(); + public ApmApi(Callable refreshRate) { + this.refreshRateCallback = refreshRate; + } + + public static void init(BinaryMessenger messenger, Callable refreshRateProvider) { + + final ApmApi api = new ApmApi(refreshRateProvider); ApmPigeon.ApmHostApi.setup(messenger, api); } - /** - * The function sets the enabled status of APM. - * - * @param isEnabled The `setEnabled` method in the code snippet is used to enable or disable a - * feature, and it takes a `Boolean` parameter named `isEnabled`. When this method is called with - * `true`, it enables the feature, and when called with `false`, it disables the feature. The method - * internally calls - */ + /** + * The function sets the enabled status of APM. + * + * @param isEnabled The `setEnabled` method in the code snippet is used to enable or disable a + * feature, and it takes a `Boolean` parameter named `isEnabled`. When this method is called with + * `true`, it enables the feature, and when called with `false`, it disables the feature. The method + * internally calls + */ @Override public void setEnabled(@NonNull Boolean isEnabled) { try { @@ -51,12 +61,12 @@ public void setEnabled(@NonNull Boolean isEnabled) { } /** - * Sets the cold app launch enabled status using the APM library. - * - * @param isEnabled The `isEnabled` parameter is a Boolean value that indicates whether cold app launch - * is enabled or not. When `isEnabled` is set to `true`, cold app launch is enabled, and when it is set - * to `false`, cold app launch is disabled. - */ + * Sets the cold app launch enabled status using the APM library. + * + * @param isEnabled The `isEnabled` parameter is a Boolean value that indicates whether cold app launch + * is enabled or not. When `isEnabled` is set to `true`, cold app launch is enabled, and when it is set + * to `false`, cold app launch is disabled. + */ @Override public void setColdAppLaunchEnabled(@NonNull Boolean isEnabled) { try { @@ -66,14 +76,14 @@ public void setColdAppLaunchEnabled(@NonNull Boolean isEnabled) { } } - /** - * The function sets the auto UI trace enabled status in an APM system, handling any exceptions that - * may occur. - * - * @param isEnabled The `isEnabled` parameter is a Boolean value that indicates whether the Auto UI - * trace feature should be enabled or disabled. When `isEnabled` is set to `true`, the Auto UI trace - * feature is enabled, and when it is set to `false`, the feature is disabled. - */ + /** + * The function sets the auto UI trace enabled status in an APM system, handling any exceptions that + * may occur. + * + * @param isEnabled The `isEnabled` parameter is a Boolean value that indicates whether the Auto UI + * trace feature should be enabled or disabled. When `isEnabled` is set to `true`, the Auto UI trace + * feature is enabled, and when it is set to `false`, the feature is disabled. + */ @Override public void setAutoUITraceEnabled(@NonNull Boolean isEnabled) { try { @@ -83,61 +93,6 @@ public void setAutoUITraceEnabled(@NonNull Boolean isEnabled) { } } - /** - * Starts an execution trace and handles the result - * using callbacks. - * - * @param id The `id` parameter is a non-null String that represents the identifier of the execution - * trace. - * @param name The `name` parameter in the `startExecutionTrace` method represents the name of the - * execution trace that will be started. It is used as a reference to identify the trace during - * execution monitoring. - * @param result The `result` parameter in the `startExecutionTrace` method is an instance of - * `ApmPigeon.Result`. This parameter is used to provide the result of the execution trace - * operation back to the caller. The `success` method of the `result` object is called with the - * - * @deprecated see {@link #startFlow} - */ - @Override - public void startExecutionTrace(@NonNull String id, @NonNull String name, ApmPigeon.Result result) { - ThreadManager.runOnBackground( - new Runnable() { - @Override - public void run() { - try { - ExecutionTrace trace = APM.startExecutionTrace(name); - if (trace != null) { - traces.put(id, trace); - - ThreadManager.runOnMainThread(new Runnable() { - @Override - public void run() { - result.success(id); - } - }); - } else { - ThreadManager.runOnMainThread(new Runnable() { - @Override - public void run() { - result.success(null); - } - }); - } - } catch (Exception e) { - e.printStackTrace(); - - ThreadManager.runOnMainThread(new Runnable() { - @Override - public void run() { - result.success(null); - } - }); - } - } - } - ); - } - /** * Starts an AppFlow with the specified name. *
@@ -158,7 +113,7 @@ public void startFlow(@NonNull String name) { } } - /** + /** * Sets custom attributes for AppFlow with a given name. *
* Setting an attribute value to null will remove its corresponding key if it already exists. @@ -187,7 +142,7 @@ public void setFlowAttribute(@NonNull String name, @NonNull String key, @Nullabl } } - /** + /** * Ends AppFlow with a given name. * * @param name AppFlow name to be ended. It can not be empty string or null @@ -201,39 +156,7 @@ public void endFlow(@NonNull String name) { } } - /** - * Adds a new attribute to trace - * - * @param id String id of the trace. - * @param key attribute key - * @param value attribute value. Null to remove attribute - * - * @deprecated see {@link #setFlowAttribute} - */ - @Override - public void setExecutionTraceAttribute(@NonNull String id, @NonNull String key, @NonNull String value) { - try { - traces.get(id).setAttribute(key, value); - } catch (Exception e) { - e.printStackTrace(); - } - } - /** - * Ends a trace - * - * @param id string id of the trace. - * - * @deprecated see {@link #endFlow} - */ - @Override - public void endExecutionTrace(@NonNull String id) { - try { - traces.get(id).end(); - } catch (Exception e) { - e.printStackTrace(); - } - } /** * Starts a UI trace. @@ -276,7 +199,7 @@ public void endAppLaunch() { /** * logs network-related information - * + * * @param data Map of network data object. */ @Override @@ -342,43 +265,40 @@ public void networkLogAndroid(@NonNull Map data) { } - if (data.containsKey("w3CCaughtHeader")) { - w3CCaughtHeader = (String) data.get("w3CCaughtHeader"); - - } + if (data.containsKey("w3CCaughtHeader")) { + w3CCaughtHeader = (String) data.get("w3CCaughtHeader"); + } - APMCPNetworkLog.W3CExternalTraceAttributes w3cExternalTraceAttributes = - null; - if (isW3cHeaderFound != null) { - w3cExternalTraceAttributes = new APMCPNetworkLog.W3CExternalTraceAttributes( - isW3cHeaderFound, partialId == null ? null : partialId.longValue(), - networkStartTimeInSeconds == null ? null : networkStartTimeInSeconds.longValue(), - w3CGeneratedHeader, w3CCaughtHeader - ); - } + APMCPNetworkLog.W3CExternalTraceAttributes w3cExternalTraceAttributes = null; + if (isW3cHeaderFound != null) { + w3cExternalTraceAttributes = new APMCPNetworkLog.W3CExternalTraceAttributes(isW3cHeaderFound, partialId == null ? null : partialId.longValue(), networkStartTimeInSeconds == null ? null : networkStartTimeInSeconds.longValue(), w3CGeneratedHeader, w3CCaughtHeader - Method method = Reflection.getMethod(Class.forName("com.instabug.apm.networking.APMNetworkLogger"), "log", long.class, long.class, String.class, String.class, long.class, String.class, String.class, String.class, String.class, String.class, long.class, int.class, String.class, String.class, String.class, String.class, APMCPNetworkLog.W3CExternalTraceAttributes.class); - if (method != null) { - method.invoke(apmNetworkLogger, requestStartTime, requestDuration, requestHeaders, requestBody, requestBodySize, requestMethod, requestUrl, requestContentType, responseHeaders, responseBody, responseBodySize, statusCode, responseContentType, errorMessage, gqlQueryName, serverErrorMessage, w3cExternalTraceAttributes); - } else { - Log.e(TAG, "APMNetworkLogger.log was not found by reflection"); - } + ); + } - } catch(Exception e){ - e.printStackTrace(); + Method method = Reflection.getMethod(Class.forName("com.instabug.apm.networking.APMNetworkLogger"), "log", long.class, long.class, String.class, String.class, long.class, String.class, String.class, String.class, String.class, String.class, long.class, int.class, String.class, String.class, String.class, String.class, APMCPNetworkLog.W3CExternalTraceAttributes.class); + if (method != null) { + method.invoke(apmNetworkLogger, requestStartTime, requestDuration, requestHeaders, requestBody, requestBodySize, requestMethod, requestUrl, requestContentType, responseHeaders, responseBody, responseBodySize, statusCode, responseContentType, errorMessage, gqlQueryName, serverErrorMessage, w3cExternalTraceAttributes); + } else { + Log.e(TAG, "APMNetworkLogger.log was not found by reflection"); } + + } catch (Exception e) { + e.printStackTrace(); } + } - /** - * This method is responsible for initiating a custom performance UI trace - * in the APM module. It takes three parameters: - * @param screenName: A string representing the name of the screen or UI element being traced. - * @param microTimeStamp: A number representing the timestamp in microseconds when the trace is started. - * @param traceId: A number representing the unique identifier for the trace. - */ + /** + * This method is responsible for initiating a custom performance UI trace + * in the APM module. It takes three parameters: + * + * @param screenName: A string representing the name of the screen or UI element being traced. + * @param microTimeStamp: A number representing the timestamp in microseconds when the trace is started. + * @param traceId: A number representing the unique identifier for the trace. + */ @Override public void startCpUiTrace(@NonNull String screenName, @NonNull Long microTimeStamp, @NonNull Long traceId) { try { @@ -389,16 +309,17 @@ public void startCpUiTrace(@NonNull String screenName, @NonNull Long microTimeSt } - /** - * This method is responsible for reporting the screen - * loading data from Dart side to Android side. It takes three parameters: - * @param startTimeStampMicro: A number representing the start timestamp in microseconds of the screen - * loading custom performance data. - * @param durationMicro: A number representing the duration in microseconds of the screen loading custom - * performance data. - * @param uiTraceId: A number representing the unique identifier for the UI trace associated with the - * screen loading. - */ + /** + * This method is responsible for reporting the screen + * loading data from Dart side to Android side. It takes three parameters: + * + * @param startTimeStampMicro: A number representing the start timestamp in microseconds of the screen + * loading custom performance data. + * @param durationMicro: A number representing the duration in microseconds of the screen loading custom + * performance data. + * @param uiTraceId: A number representing the unique identifier for the UI trace associated with the + * screen loading. + */ @Override public void reportScreenLoadingCP(@NonNull Long startTimeStampMicro, @NonNull Long durationMicro, @NonNull Long uiTraceId) { try { @@ -410,13 +331,14 @@ public void reportScreenLoadingCP(@NonNull Long startTimeStampMicro, @NonNull Lo /** - * This method is responsible for extend the end time if the screen loading custom - * trace. It takes two parameters: - * @param timeStampMicro: A number representing the timestamp in microseconds when the screen loading - * custom trace is ending. - * @param uiTraceId: A number representing the unique identifier for the UI trace associated with the - * screen loading. - */ + * This method is responsible for extend the end time if the screen loading custom + * trace. It takes two parameters: + * + * @param timeStampMicro: A number representing the timestamp in microseconds when the screen loading + * custom trace is ending. + * @param uiTraceId: A number representing the unique identifier for the UI trace associated with the + * screen loading. + */ @Override public void endScreenLoadingCP(@NonNull Long timeStampMicro, @NonNull Long uiTraceId) { try { @@ -428,13 +350,26 @@ public void endScreenLoadingCP(@NonNull Long timeStampMicro, @NonNull Long uiTra /** - * This method is used to check whether the end screen loading feature is enabled or not. + * This method is used to check whether the end screen loading feature is enabled or not. */ @Override public void isEndScreenLoadingEnabled(@NonNull ApmPigeon.Result result) { isScreenLoadingEnabled(result); } + @Override + public void isAutoUiTraceEnabled(@NonNull ApmPigeon.Result result) { + try { + InternalAPM._isFeatureEnabledCP(APMFeature.UI_TRACE, "InstabugCaptureScreenLoading", new FeatureAvailabilityCallback() { + @Override + public void invoke(boolean isFeatureAvailable) { + result.success(isFeatureAvailable); + } + }); + } catch (Exception e) { + e.printStackTrace(); + } + } @Override public void isEnabled(@NonNull ApmPigeon.Result result) { @@ -447,9 +382,9 @@ public void isEnabled(@NonNull ApmPigeon.Result result) { } } - /** - * checks whether the screen loading feature is enabled. - * */ + /** + * checks whether the screen loading feature is enabled. + */ @Override public void isScreenLoadingEnabled(@NonNull ApmPigeon.Result result) { try { @@ -475,4 +410,99 @@ public void setScreenLoadingEnabled(@NonNull Boolean isEnabled) { e.printStackTrace(); } } + + @Override + public void setScreenRenderEnabled(@NonNull Boolean isEnabled) { + try { + APM.setScreenRenderingEnabled(isEnabled); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void isScreenRenderEnabled(@NonNull ApmPigeon.Result result) { + try { + InternalAPM._isFeatureEnabledCP(APMFeature.SCREEN_RENDERING, "InstabugCaptureScreenRender", new FeatureAvailabilityCallback() { + @Override + public void invoke(boolean isEnabled) { + result.success(isEnabled); + } + }); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void getDeviceRefreshRateAndTolerance(@NonNull ApmPigeon.Result> result) { + try { + final double refreshRate = refreshRateCallback.call().doubleValue(); + InternalAPM._getToleranceValueForScreenRenderingCP(new ToleranceValueCallback() { + @Override + public void invoke(long tolerance) { + result.success(java.util.Arrays.asList(refreshRate, (double) tolerance)); + } + }); + + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void endScreenRenderForAutoUiTrace(@NonNull Map data) { + try { + final long traceId = ((Number) data.get("traceId")).longValue(); + final long slowFramesTotalDuration = ((Number) data.get("slowFramesTotalDuration")).longValue(); + final long frozenFramesTotalDuration = ((Number) data.get("frozenFramesTotalDuration")).longValue(); + final long endTime = ((Number) data.get("endTime")).longValue(); + + // Don't cast directly to ArrayList> because the inner lists may actually be ArrayList + // Instead, cast to List> and convert each value to long explicitly + List> rawFrames = (List>) data.get("frameData"); + ArrayList frames = new ArrayList<>(); + if (rawFrames != null) { + for (List frameValues : rawFrames) { + // Defensive: check size and nulls + if (frameValues != null && frameValues.size() >= 2) { + long frameStart = frameValues.get(0).longValue(); + long frameDuration = frameValues.get(1).longValue(); + frames.add(new IBGFrameData(frameStart, frameDuration)); + } + } + } + IBGScreenRenderingData screenRenderingData = new IBGScreenRenderingData(traceId, slowFramesTotalDuration, frozenFramesTotalDuration, frames); + InternalAPM._endAutoUiTraceWithScreenRendering(screenRenderingData, endTime); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void endScreenRenderForCustomUiTrace(@NonNull Map data) { + try { + final long traceId = ((Number) data.get("traceId")).longValue(); + final long slowFramesTotalDuration = ((Number) data.get("slowFramesTotalDuration")).longValue(); + final long frozenFramesTotalDuration = ((Number) data.get("frozenFramesTotalDuration")).longValue(); + + List> rawFrames = (List>) data.get("frameData"); + ArrayList frames = new ArrayList<>(); + if (rawFrames != null) { + for (List frameValues : rawFrames) { + // Defensive: check size and nulls + if (frameValues != null && frameValues.size() >= 2) { + long frameStart = frameValues.get(0).longValue(); + long frameDuration = frameValues.get(1).longValue(); + frames.add(new IBGFrameData(frameStart, frameDuration)); + } + } + } + IBGScreenRenderingData screenRenderingData = new IBGScreenRenderingData(traceId, slowFramesTotalDuration, frozenFramesTotalDuration, frames); + InternalAPM._endCustomUiTraceWithScreenRenderingCP(screenRenderingData); + } catch (Exception e) { + e.printStackTrace(); + } + } + } diff --git a/android/src/main/java/com/instabug/flutter/modules/BugReportingApi.java b/android/src/main/java/com/instabug/flutter/modules/BugReportingApi.java index f3236bb4e..c845de0c1 100644 --- a/android/src/main/java/com/instabug/flutter/modules/BugReportingApi.java +++ b/android/src/main/java/com/instabug/flutter/modules/BugReportingApi.java @@ -184,7 +184,7 @@ public void setCommentMinimumCharacterCount(@NonNull Long limit, @Nullable List< reportTypesArray[i] = ArgsRegistry.reportTypes.get(key); } } - BugReporting.setCommentMinimumCharacterCount(limit.intValue(), reportTypesArray); + BugReporting.setCommentMinimumCharacterCountForBugReportType(limit.intValue(), reportTypesArray); } @Override 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 edfde055a..0afbbd975 100644 --- a/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java +++ b/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java @@ -5,10 +5,13 @@ import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; +import android.graphics.Typeface; import android.util.Log; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; + import com.instabug.flutter.generated.InstabugPigeon; import com.instabug.flutter.util.ArgsRegistry; import com.instabug.flutter.util.Reflection; @@ -30,9 +33,11 @@ import com.instabug.library.invocation.InstabugInvocationEvent; import com.instabug.library.model.NetworkLog; 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; @@ -102,10 +107,12 @@ public Boolean isEnabled() { @NotNull @Override - public Boolean isBuilt() { return Instabug.isBuilt(); } + public Boolean isBuilt() { + return Instabug.isBuilt(); + } @Override - public void init(@NonNull String token, @NonNull List invocationEvents, @NonNull String debugLogsLevel) { + public void init(@NonNull String token, @NonNull List invocationEvents, @NonNull String debugLogsLevel, @Nullable String appVariant) { setCurrentPlatform(); InstabugInvocationEvent[] invocationEventsArray = new InstabugInvocationEvent[invocationEvents.size()]; @@ -116,11 +123,14 @@ public void init(@NonNull String token, @NonNull List invocationEvents, final Application application = (Application) context; final int parsedLogLevel = ArgsRegistry.sdkLogLevels.get(debugLogsLevel); - - new Instabug.Builder(application, token) + Instabug.Builder builder = new Instabug.Builder(application, token) .setInvocationEvents(invocationEventsArray) - .setSdkDebugLogsLevel(parsedLogLevel) - .build(); + .setSdkDebugLogsLevel(parsedLogLevel); + if (appVariant != null) { + builder.setAppVariant(appVariant); + } + + builder.build(); Instabug.setScreenshotProvider(screenshotProvider); } @@ -146,6 +156,17 @@ public void setUserData(@NonNull String data) { Instabug.setUserData(data); } + @Override + public void setAppVariant(@NonNull String appVariant) { + try { + Instabug.setAppVariant(appVariant); + + } catch (Exception e) { + e.printStackTrace(); + } + + } + @Override public void logUserEvent(@NonNull String name) { Instabug.logUserEvent(name); @@ -176,7 +197,6 @@ public void setWelcomeMessageMode(@NonNull String mode) { @Override public void setPrimaryColor(@NonNull Long color) { - Instabug.setPrimaryColor(color.intValue()); } @Override @@ -228,20 +248,7 @@ public void run() { ); } - @Override - public void addExperiments(@NonNull List experiments) { - Instabug.addExperiments(experiments); - } - - @Override - public void removeExperiments(@NonNull List experiments) { - Instabug.removeExperiments(experiments); - } - @Override - public void clearAllExperiments() { - Instabug.clearAllExperiments(); - } @Override public void addFeatureFlags(@NonNull Map featureFlags) { @@ -500,13 +507,177 @@ public void willRedirectToStore() { Instabug.willRedirectToStore(); } - + @Override public void setNetworkLogBodyEnabled(@NonNull Boolean isEnabled) { - try { - Instabug.setNetworkLogBodyEnabled(isEnabled); - } catch (Exception e) { - e.printStackTrace(); + try { + Instabug.setNetworkLogBodyEnabled(isEnabled); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void setTheme(@NonNull Map themeConfig) { + try { + Log.d(TAG, "setTheme called with config: " + themeConfig.toString()); + + com.instabug.library.model.IBGTheme.Builder builder = new com.instabug.library.model.IBGTheme.Builder(); + + if (themeConfig.containsKey("primaryColor")) { + builder.setPrimaryColor(getColor(themeConfig, "primaryColor")); + } + if (themeConfig.containsKey("secondaryTextColor")) { + builder.setSecondaryTextColor(getColor(themeConfig, "secondaryTextColor")); + } + if (themeConfig.containsKey("primaryTextColor")) { + builder.setPrimaryTextColor(getColor(themeConfig, "primaryTextColor")); + } + if (themeConfig.containsKey("titleTextColor")) { + builder.setTitleTextColor(getColor(themeConfig, "titleTextColor")); + } + if (themeConfig.containsKey("backgroundColor")) { + builder.setBackgroundColor(getColor(themeConfig, "backgroundColor")); + } + + if (themeConfig.containsKey("primaryTextStyle")) { + builder.setPrimaryTextStyle(getTextStyle(themeConfig, "primaryTextStyle")); + } + if (themeConfig.containsKey("secondaryTextStyle")) { + builder.setSecondaryTextStyle(getTextStyle(themeConfig, "secondaryTextStyle")); + } + if (themeConfig.containsKey("ctaTextStyle")) { + builder.setCtaTextStyle(getTextStyle(themeConfig, "ctaTextStyle")); + } + + setFontIfPresent(themeConfig, builder, "primaryFontPath", "primaryFontAsset", "primary"); + setFontIfPresent(themeConfig, builder, "secondaryFontPath", "secondaryFontAsset", "secondary"); + setFontIfPresent(themeConfig, builder, "ctaFontPath", "ctaFontAsset", "CTA"); + + com.instabug.library.model.IBGTheme theme = builder.build(); + Instabug.setTheme(theme); + Log.d(TAG, "Theme applied successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error in setTheme: " + e.getMessage()); + e.printStackTrace(); + } + } + + + + /** + * Retrieves a color value from the Map. + * + * @param map The Map object. + * @param key The key to look for. + * @return The parsed color as an integer, or black if missing or invalid. + */ + private int getColor(Map map, String key) { + try { + if (map != null && map.containsKey(key) && map.get(key) != null) { + String colorString = (String) map.get(key); + return android.graphics.Color.parseColor(colorString); + } + } catch (Exception e) { + e.printStackTrace(); + } + return android.graphics.Color.BLACK; + } + + /** + * Retrieves a text style from the Map. + * + * @param map The Map object. + * @param key The key to look for. + * @return The corresponding Typeface style, or Typeface.NORMAL if missing or invalid. + */ + private int getTextStyle(Map map, String key) { + try { + if (map != null && map.containsKey(key) && map.get(key) != null) { + String style = (String) map.get(key); + switch (style.toLowerCase()) { + case "bold": + return Typeface.BOLD; + case "italic": + return Typeface.ITALIC; + case "bold_italic": + return Typeface.BOLD_ITALIC; + case "normal": + default: + return Typeface.NORMAL; } + } + } catch (Exception e) { + e.printStackTrace(); + } + return Typeface.NORMAL; + } + + /** + * Sets a font on the theme builder if the font configuration is present in the theme config. + * + * @param themeConfig The theme configuration map + * @param builder The theme builder + * @param fileKey The key for font file path + * @param assetKey The key for font asset path + * @param fontType The type of font (for logging purposes) + */ + private void setFontIfPresent(Map themeConfig, com.instabug.library.model.IBGTheme.Builder builder, + String fileKey, String assetKey, String fontType) { + if (themeConfig.containsKey(fileKey) || themeConfig.containsKey(assetKey)) { + Typeface typeface = getTypeface(themeConfig, fileKey, assetKey); + if (typeface != null) { + switch (fontType) { + case "primary": + builder.setPrimaryTextFont(typeface); + break; + case "secondary": + builder.setSecondaryTextFont(typeface); + break; + case "CTA": + builder.setCtaTextFont(typeface); + break; + } + } + } } + + private Typeface getTypeface(Map map, String fileKey, String assetKey) { + String fontName = null; + + if (assetKey != null && map.containsKey(assetKey) && map.get(assetKey) != null) { + fontName = (String) map.get(assetKey); + } else if (fileKey != null && map.containsKey(fileKey) && map.get(fileKey) != null) { + fontName = (String) map.get(fileKey); + } + + if (fontName == null) { + return Typeface.DEFAULT; + } + + try { + String assetPath = "fonts/" + fontName; + return Typeface.createFromAsset(context.getAssets(), assetPath); + } catch (Exception e) { + try { + return Typeface.create(fontName, Typeface.NORMAL); + } catch (Exception e2) { + return Typeface.DEFAULT; + } + } + } + /** + * Enables or disables displaying in full-screen mode, hiding the status and navigation bars. + * @param isEnabled A boolean to enable/disable setFullscreen. + */ + @Override + public void setFullscreen(@NonNull final Boolean isEnabled) { + try { + Instabug.setFullscreen(isEnabled); + } catch (Exception e) { + e.printStackTrace(); + } + } + } diff --git a/android/src/test/java/com/instabug/flutter/ApmApiTest.java b/android/src/test/java/com/instabug/flutter/ApmApiTest.java index 725d3bd98..81b9d2584 100644 --- a/android/src/test/java/com/instabug/flutter/ApmApiTest.java +++ b/android/src/test/java/com/instabug/flutter/ApmApiTest.java @@ -5,49 +5,46 @@ import static org.junit.Assert.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; import com.instabug.apm.APM; import com.instabug.apm.InternalAPM; import com.instabug.apm.configuration.cp.APMFeature; import com.instabug.apm.configuration.cp.FeatureAvailabilityCallback; -import com.instabug.apm.model.ExecutionTrace; +import com.instabug.apm.configuration.cp.ToleranceValueCallback; import com.instabug.apm.networking.APMNetworkLogger; import com.instabug.flutter.generated.ApmPigeon; import com.instabug.flutter.modules.ApmApi; import com.instabug.flutter.util.GlobalMocks; import com.instabug.flutter.util.MockReflected; -import io.flutter.plugin.common.BinaryMessenger; - import org.json.JSONObject; import org.junit.After; -import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.MockedConstruction; import org.mockito.MockedStatic; +import java.lang.reflect.Array; +import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.concurrent.Callable; -import static com.instabug.flutter.util.GlobalMocks.reflected; -import static com.instabug.flutter.util.MockResult.makeResult; -import static org.junit.Assert.assertEquals; -import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; +import io.flutter.plugin.common.BinaryMessenger; public class ApmApiTest { private final BinaryMessenger mMessenger = mock(BinaryMessenger.class); - private final ApmApi api = new ApmApi(); + private final Callable refreshRateProvider = () -> mock(Float.class); + private final ApmApi api = new ApmApi(refreshRateProvider); private MockedStatic mAPM; private MockedStatic mInternalApmStatic; private MockedStatic mHostApi; @@ -68,22 +65,12 @@ public void cleanUp() { GlobalMocks.close(); } - private ExecutionTrace mockTrace(String id) { - String name = "trace-name"; - ExecutionTrace mTrace = mock(ExecutionTrace.class); - - mAPM.when(() -> APM.startExecutionTrace(name)).thenReturn(mTrace); - - api.startExecutionTrace(id, name, makeResult()); - - return mTrace; - } @Test public void testInit() { BinaryMessenger messenger = mock(BinaryMessenger.class); - ApmApi.init(messenger); + ApmApi.init(messenger, refreshRateProvider); mHostApi.verify(() -> ApmPigeon.ApmHostApi.setup(eq(messenger), any(ApmApi.class))); } @@ -115,53 +102,7 @@ public void testSetAutoUITraceEnabled() { mAPM.verify(() -> APM.setAutoUITraceEnabled(isEnabled)); } - @Test - public void testStartExecutionTraceWhenTraceNotNull() { - String expectedId = "trace-id"; - String name = "trace-name"; - ApmPigeon.Result result = makeResult((String actualId) -> assertEquals(expectedId, actualId)); - - mAPM.when(() -> APM.startExecutionTrace(name)).thenReturn(new ExecutionTrace(name)); - - api.startExecutionTrace(expectedId, name, result); - - mAPM.verify(() -> APM.startExecutionTrace(name)); - } - - @Test - public void testStartExecutionTraceWhenTraceIsNull() { - String id = "trace-id"; - String name = "trace-name"; - ApmPigeon.Result result = makeResult(Assert::assertNull); - - mAPM.when(() -> APM.startExecutionTrace(name)).thenReturn(null); - - api.startExecutionTrace(id, name, result); - - mAPM.verify(() -> APM.startExecutionTrace(name)); - } - - @Test - public void testSetExecutionTraceAttribute() { - String id = "trace-id"; - String key = "is_premium"; - String value = "true"; - ExecutionTrace mTrace = mockTrace(id); - api.setExecutionTraceAttribute(id, key, value); - - verify(mTrace).setAttribute(key, value); - } - - @Test - public void testEndExecutionTrace() { - String id = "trace-id"; - ExecutionTrace mTrace = mockTrace(id); - - api.endExecutionTrace(id); - - verify(mTrace).end(); - } @Test public void testStartFlow() { @@ -386,4 +327,188 @@ public void testSetScreenLoadingMonitoringEnabled() { mAPM.verify(() -> APM.setScreenLoadingEnabled(isEnabled)); } + + @Test + public void testIsAutoUiTraceEnabled() { + + boolean expected = true; + ApmPigeon.Result result = spy(makeResult((actual) -> assertEquals(expected, actual))); + + mInternalApmStatic.when(() -> InternalAPM._isFeatureEnabledCP(any(), any(), any())).thenAnswer( + invocation -> { + FeatureAvailabilityCallback callback = (FeatureAvailabilityCallback) invocation.getArguments()[2]; + callback.invoke(expected); + return null; + }); + + + api.isScreenRenderEnabled(result); + + mInternalApmStatic.verify(() -> InternalAPM._isFeatureEnabledCP(any(), any(), any())); + mInternalApmStatic.verifyNoMoreInteractions(); + + verify(result).success(expected); + } + + @Test + public void testIsScreenRenderEnabled() { + + boolean expected = true; + ApmPigeon.Result result = spy(makeResult((actual) -> assertEquals(expected, actual))); + + mInternalApmStatic.when(() -> InternalAPM._isFeatureEnabledCP(any(), any(), any())).thenAnswer( + invocation -> { + FeatureAvailabilityCallback callback = (FeatureAvailabilityCallback) invocation.getArguments()[2]; + callback.invoke(expected); + return null; + }); + + + api.isScreenRenderEnabled(result); + + mInternalApmStatic.verify(() -> InternalAPM._isFeatureEnabledCP(any(), any(), any())); + mInternalApmStatic.verifyNoMoreInteractions(); + + verify(result).success(expected); + } + + @Test + public void testSetScreenRenderEnabled() { + boolean isEnabled = false; + + api.setScreenRenderEnabled(isEnabled); + + mAPM.verify(() -> APM.setScreenRenderingEnabled(isEnabled)); + } + + + @Test + public void testDeviceRefreshRateWithException() throws Exception { + ApmPigeon.Result> result = spy(makeResult((actual) -> {})); + + // Mock the refresh rate provider to throw an exception + Callable mockRefreshRateProvider = () -> { + throw new RuntimeException("Test exception"); + }; + ApmApi testApi = new ApmApi(mockRefreshRateProvider); + + testApi.getDeviceRefreshRateAndTolerance(result); + + // Verify that the method doesn't crash when an exception occurs + // The exception is caught and printed, but the result is not called + verify(result, never()).success(any()); + } + + @Test + public void testGetDeviceRefreshRateAndTolerance() throws Exception { + // Arrange + double expectedRefreshRate = 60.0; + long expectedTolerance = 5L; + List expectedResult = Arrays.asList(expectedRefreshRate, (double) expectedTolerance); + ApmPigeon.Result> result = spy(makeResult((actual) -> assertEquals(expectedResult, actual))); + + // Mock the refresh rate provider + Callable mockRefreshRateProvider = () -> (float) expectedRefreshRate; + ApmApi testApi = new ApmApi(mockRefreshRateProvider); + + // Mock the tolerance callback + mInternalApmStatic.when(() -> InternalAPM._getToleranceValueForScreenRenderingCP(any(ToleranceValueCallback.class))).thenAnswer(invocation -> { + ToleranceValueCallback callback = invocation.getArgument(0); + callback.invoke(expectedTolerance); + return null; + }); + + // Act + testApi.getDeviceRefreshRateAndTolerance(result); + + // Assert + verify(result).success(expectedResult); + mInternalApmStatic.verify(() -> InternalAPM._getToleranceValueForScreenRenderingCP(any(ToleranceValueCallback.class))); + mInternalApmStatic.verifyNoMoreInteractions(); + } + + @Test + public void testEndScreenRenderForAutoUiTrace() { + Map data = new HashMap<>(); + data.put("traceId", 123L); + data.put("slowFramesTotalDuration", 1000L); + data.put("frozenFramesTotalDuration", 2000L); + data.put("endTime", 1234567890L); + data.put("frameData", null); + + api.endScreenRenderForAutoUiTrace(data); + + mInternalApmStatic.verify(() -> InternalAPM._endAutoUiTraceWithScreenRendering(any(), eq(1234567890L))); + mInternalApmStatic.verifyNoMoreInteractions(); + } + + @Test + public void testEndScreenRenderForAutoUiTraceWithFrameData() { + Map data = new HashMap<>(); + data.put("traceId", 123L); + data.put("slowFramesTotalDuration", 1000L); + data.put("frozenFramesTotalDuration", 2000L); + data.put("endTime", 1234567890L); + + // Create frame data with ArrayList> + java.util.ArrayList> frameData = new java.util.ArrayList<>(); + java.util.ArrayList frame1 = new java.util.ArrayList<>(); + frame1.add(100L); + frame1.add(200L); + frameData.add(frame1); + + java.util.ArrayList frame2 = new java.util.ArrayList<>(); + frame2.add(300L); + frame2.add(400L); + frameData.add(frame2); + + data.put("frameData", frameData); + + api.endScreenRenderForAutoUiTrace(data); + + mInternalApmStatic.verify(() -> InternalAPM._endAutoUiTraceWithScreenRendering(any(), eq(1234567890L))); + mInternalApmStatic.verifyNoMoreInteractions(); + } + + @Test + public void testEndScreenRenderForCustomUiTrace() { + Map data = new HashMap<>(); + data.put("traceId", 123L); + data.put("slowFramesTotalDuration", 1000L); + data.put("frozenFramesTotalDuration", 2000L); + data.put("endTime", 1234567890L); + data.put("frameData", null); + + api.endScreenRenderForCustomUiTrace(data); + + mInternalApmStatic.verify(() -> InternalAPM._endCustomUiTraceWithScreenRenderingCP(any())); + mInternalApmStatic.verifyNoMoreInteractions(); + } + + @Test + public void testEndScreenRenderForCustomUiTraceWithFrameData() { + Map data = new HashMap<>(); + data.put("traceId", 123L); + data.put("slowFramesTotalDuration", 1000L); + data.put("frozenFramesTotalDuration", 2000L); + + // Create frame data with ArrayList> + java.util.ArrayList> frameData = new java.util.ArrayList<>(); + java.util.ArrayList frame1 = new java.util.ArrayList<>(); + frame1.add(100L); + frame1.add(200L); + frameData.add(frame1); + + java.util.ArrayList frame2 = new java.util.ArrayList<>(); + frame2.add(300L); + frame2.add(400L); + frameData.add(frame2); + + data.put("frameData", frameData); + + api.endScreenRenderForCustomUiTrace(data); + + mInternalApmStatic.verify(() -> InternalAPM._endCustomUiTraceWithScreenRenderingCP(any())); + mInternalApmStatic.verifyNoMoreInteractions(); + } } diff --git a/android/src/test/java/com/instabug/flutter/BugReportingApiTest.java b/android/src/test/java/com/instabug/flutter/BugReportingApiTest.java index 6d22e26b8..50722762f 100644 --- a/android/src/test/java/com/instabug/flutter/BugReportingApiTest.java +++ b/android/src/test/java/com/instabug/flutter/BugReportingApiTest.java @@ -192,7 +192,7 @@ public void testSetCommentMinimumCharacterCount() { api.setCommentMinimumCharacterCount(limit, reportTypes); - mBugReporting.verify(() -> BugReporting.setCommentMinimumCharacterCount(limit.intValue(), BugReporting.ReportType.BUG, BugReporting.ReportType.QUESTION)); + mBugReporting.verify(() -> BugReporting.setCommentMinimumCharacterCountForBugReportType(limit.intValue(), BugReporting.ReportType.BUG, BugReporting.ReportType.QUESTION)); } @Test diff --git a/android/src/test/java/com/instabug/flutter/InstabugApiTest.java b/android/src/test/java/com/instabug/flutter/InstabugApiTest.java index 97b9cdf7b..8e9999e52 100644 --- a/android/src/test/java/com/instabug/flutter/InstabugApiTest.java +++ b/android/src/test/java/com/instabug/flutter/InstabugApiTest.java @@ -80,6 +80,8 @@ import org.mockito.verification.VerificationMode; import org.mockito.verification.VerificationMode; +import android.graphics.Typeface; + public class InstabugApiTest { private final Callable screenshotProvider = () -> mock(Bitmap.class); private final Application mContext = mock(Application.class); @@ -132,6 +134,8 @@ public void testSetCurrentPlatform() { @Test public void testSdkInit() { String token = "app-token"; + String appVariant = "app-variant"; + List invocationEvents = Collections.singletonList("InvocationEvent.floatingButton"); String logLevel = "LogLevel.error"; @@ -143,7 +147,7 @@ public void testSdkInit() { when(mock.setSdkDebugLogsLevel(anyInt())).thenReturn(mock); }); - api.init(token, invocationEvents, logLevel); + api.init(token, invocationEvents, logLevel,appVariant); Instabug.Builder builder = mInstabugBuilder.constructed().get(0); @@ -155,6 +159,8 @@ public void testSdkInit() { ); verify(builder).setInvocationEvents(InstabugInvocationEvent.FLOATING_BUTTON); verify(builder).setSdkDebugLogsLevel(LogLevel.ERROR); + verify(builder).setAppVariant(appVariant); + verify(builder).build(); // Sets screenshot provider @@ -276,13 +282,7 @@ public void testSetWelcomeMessageMode() { @Test public void testSetPrimaryColor() { - Long color = 0xFF0000L; - - api.setPrimaryColor(color); - - mInstabug.verify(() -> Instabug.setPrimaryColor(0xFF0000)); } - @Test public void testSetSessionProfilerEnabledGivenTrue() { Boolean isEnabled = true; @@ -346,30 +346,7 @@ public void testGetTags() { mInstabug.verify(Instabug::getTags); } - @Test - public void testAddExperiments() { - List experiments = Arrays.asList("premium", "star"); - - api.addExperiments(experiments); - - mInstabug.verify(() -> Instabug.addExperiments(experiments)); - } - - @Test - public void testRemoveExperiments() { - List experiments = Arrays.asList("premium", "star"); - api.removeExperiments(experiments); - - mInstabug.verify(() -> Instabug.removeExperiments(experiments)); - } - - @Test - public void testClearAllExperiments() { - api.clearAllExperiments(); - - mInstabug.verify(Instabug::clearAllExperiments); - } @Test public void testAddFeatureFlags() { @@ -652,10 +629,85 @@ public void testSetNetworkLogBodyEnabled() { mInstabug.verify(() -> Instabug.setNetworkLogBodyEnabled(true)); } + @Test + public void testSetAppVariant() { + String appVariant = "app-variant"; + api.setAppVariant(appVariant); + + mInstabug.verify(() -> Instabug.setAppVariant(appVariant)); + } + @Test public void testSetNetworkLogBodyDisabled() { api.setNetworkLogBodyEnabled(false); mInstabug.verify(() -> Instabug.setNetworkLogBodyEnabled(false)); } + + @Test + public void testSetThemeWithAllProperties() { + Map themeConfig = new HashMap<>(); + themeConfig.put("primaryColor", "#FF6B6B"); + themeConfig.put("backgroundColor", "#FFFFFF"); + themeConfig.put("titleTextColor", "#000000"); + themeConfig.put("primaryTextColor", "#333333"); + themeConfig.put("secondaryTextColor", "#666666"); + themeConfig.put("primaryTextStyle", "bold"); + themeConfig.put("secondaryTextStyle", "italic"); + themeConfig.put("ctaTextStyle", "bold_italic"); + themeConfig.put("primaryFontAsset", "assets/fonts/CustomFont-Regular.ttf"); + themeConfig.put("secondaryFontAsset", "assets/fonts/CustomFont-Bold.ttf"); + themeConfig.put("ctaFontAsset", "assets/fonts/CustomFont-Italic.ttf"); + + MockedConstruction mThemeBuilder = + mockConstruction(com.instabug.library.model.IBGTheme.Builder.class, (mock, context) -> { + when(mock.setPrimaryColor(anyInt())).thenReturn(mock); + when(mock.setBackgroundColor(anyInt())).thenReturn(mock); + when(mock.setTitleTextColor(anyInt())).thenReturn(mock); + when(mock.setPrimaryTextColor(anyInt())).thenReturn(mock); + when(mock.setSecondaryTextColor(anyInt())).thenReturn(mock); + when(mock.setPrimaryTextStyle(anyInt())).thenReturn(mock); + when(mock.setSecondaryTextStyle(anyInt())).thenReturn(mock); + when(mock.setCtaTextStyle(anyInt())).thenReturn(mock); + when(mock.setPrimaryTextFont(any(Typeface.class))).thenReturn(mock); + when(mock.setSecondaryTextFont(any(Typeface.class))).thenReturn(mock); + when(mock.setCtaTextFont(any(Typeface.class))).thenReturn(mock); + when(mock.build()).thenReturn(mock(com.instabug.library.model.IBGTheme.class)); + }); + + api.setTheme(themeConfig); + + com.instabug.library.model.IBGTheme.Builder builder = mThemeBuilder.constructed().get(0); + + verify(builder).setPrimaryColor(anyInt()); + verify(builder).setBackgroundColor(anyInt()); + verify(builder).setTitleTextColor(anyInt()); + verify(builder).setPrimaryTextColor(anyInt()); + verify(builder).setSecondaryTextColor(anyInt()); + verify(builder).setPrimaryTextStyle(Typeface.BOLD); + verify(builder).setSecondaryTextStyle(Typeface.ITALIC); + verify(builder).setCtaTextStyle(Typeface.BOLD_ITALIC); + + mInstabug.verify(() -> Instabug.setTheme(any(com.instabug.library.model.IBGTheme.class))); + } + + @Test + public void testSetFullscreen() { + boolean isEnabled = true; + + api.setFullscreen(isEnabled); + + mInstabug.verify(() -> Instabug.setFullscreen(isEnabled)); + } + + @Test + public void testSetFullscreenDisabled() { + boolean isEnabled = false; + + api.setFullscreen(isEnabled); + + mInstabug.verify(() -> Instabug.setFullscreen(isEnabled)); + } + + } diff --git a/example/android/app/src/main/kotlin/com/example/InstabugSample/InstabugExampleMethodCallHandler.kt b/example/android/app/src/main/kotlin/com/example/InstabugSample/InstabugExampleMethodCallHandler.kt index 17a7d35c6..f6fb0fc03 100644 --- a/example/android/app/src/main/kotlin/com/example/InstabugSample/InstabugExampleMethodCallHandler.kt +++ b/example/android/app/src/main/kotlin/com/example/InstabugSample/InstabugExampleMethodCallHandler.kt @@ -37,6 +37,12 @@ class InstabugExampleMethodCallHandler : MethodChannel.MethodCallHandler { sendOOM() result.success(null) } + SET_FULLSCREEN -> { + val isEnabled = call.arguments as? Map<*, *> + val enabled = isEnabled?.get("isEnabled") as? Boolean ?: false + setFullscreen(enabled) + result.success(null) + } else -> { Log.e(TAG, "onMethodCall for ${call.method} is not implemented") result.notImplemented() @@ -55,6 +61,7 @@ class InstabugExampleMethodCallHandler : MethodChannel.MethodCallHandler { const val SEND_NATIVE_FATAL_HANG = "sendNativeFatalHang" const val SEND_ANR = "sendAnr" const val SEND_OOM = "sendOom" + const val SET_FULLSCREEN = "setFullscreen" } private fun sendNativeNonFatal(exceptionObject: String?) { @@ -125,4 +132,25 @@ class InstabugExampleMethodCallHandler : MethodChannel.MethodCallHandler { return randomString.toString() } + private fun setFullscreen(enabled: Boolean) { + try { + + try { + val instabugClass = Class.forName("com.instabug.library.Instabug") + val setFullscreenMethod = instabugClass.getMethod("setFullscreen", Boolean::class.java) + setFullscreenMethod.invoke(null, enabled) + } catch (e: ClassNotFoundException) { + throw e + } catch (e: NoSuchMethodException) { + throw e + } catch (e: Exception) { + throw e + } + + } catch (e: Exception) { + e.printStackTrace() + + } + } + } diff --git a/example/ios/InstabugTests/ApmApiTests.m b/example/ios/InstabugTests/ApmApiTests.m index bdb710ac7..75572d958 100644 --- a/example/ios/InstabugTests/ApmApiTests.m +++ b/example/ios/InstabugTests/ApmApiTests.m @@ -19,16 +19,6 @@ - (void)setUp { self.api = [[ApmApi alloc] init]; } -- (IBGExecutionTrace *)mockTraceWithId:(NSString *)traceId { - NSString* name = @"trace-name"; - IBGExecutionTrace *mTrace = OCMClassMock([IBGExecutionTrace class]); - - OCMStub([self.mAPM startExecutionTraceWithName:name]).andReturn(mTrace); - - [self.api startExecutionTraceId:traceId name:name completion:^(NSString * _Nullable _, FlutterError * _Nullable __) {}]; - - return mTrace; -} - (void)testSetEnabled { NSNumber *isEnabled = @1; @@ -116,63 +106,6 @@ - (void)testSetAutoUITraceEnabled { OCMVerify([self.mAPM setAutoUITraceEnabled:YES]); } -- (void)testStartExecutionTraceWhenTraceNotNil { - NSString *expectedId = @"trace-id"; - NSString *name = @"trace-name"; - XCTestExpectation *expectation = [self expectationWithDescription:@"Call completion handler"]; - - IBGExecutionTrace *mTrace = OCMClassMock([IBGExecutionTrace class]); - OCMStub([self.mAPM startExecutionTraceWithName:name]).andReturn(mTrace); - - [self.api startExecutionTraceId:expectedId name:name completion:^(NSString *actualId, FlutterError *error) { - [expectation fulfill]; - XCTAssertEqual(actualId, expectedId); - XCTAssertNil(error); - }]; - - OCMVerify([self.mAPM startExecutionTraceWithName:name]); - [self waitForExpectations:@[expectation] timeout:5.0]; -} - -- (void)testStartExecutionTraceWhenTraceIsNil { - NSString *traceId = @"trace-id"; - NSString *name = @"trace-name"; - XCTestExpectation *expectation = [self expectationWithDescription:@"Call completion handler"]; - - OCMStub([self.mAPM startExecutionTraceWithName:name]).andReturn(nil); - - [self.api startExecutionTraceId:traceId name:name completion:^(NSString *actualId, FlutterError *error) { - [expectation fulfill]; - XCTAssertNil(actualId); - XCTAssertNil(error); - }]; - - OCMVerify([self.mAPM startExecutionTraceWithName:name]); - [self waitForExpectations:@[expectation] timeout:5.0]; -} - - -- (void)testSetExecutionTraceAttribute { - NSString *traceId = @"trace-id"; - NSString *key = @"is_premium"; - NSString *value = @"true"; - FlutterError *error; - id mTrace = [self mockTraceWithId:traceId]; - - [self.api setExecutionTraceAttributeId:traceId key:key value:value error:&error]; - - OCMVerify([mTrace setAttributeWithKey:key value:value]); -} - -- (void)testEndExecutionTrace { - NSString *traceId = @"trace-id"; - FlutterError *error; - IBGExecutionTrace *mTrace = [self mockTraceWithId:traceId]; - - [self.api endExecutionTraceId:traceId error:&error]; - - OCMVerify([mTrace end]); -} - (void) testStartFlow { NSString* appFlowName = @"app-flow-name"; @@ -267,4 +200,316 @@ - (void)testEndScreenLoading { } +- (void)testIsScreenRenderEnabled { + XCTestExpectation *expectation = [self expectationWithDescription:@"Call completion handler"]; + + BOOL isScreenRenderEnabled = YES; + OCMStub([self.mAPM isScreenRenderingOperational]).andReturn(isScreenRenderEnabled); + + [self.api isScreenRenderEnabledWithCompletion:^(NSNumber *isEnabledNumber, FlutterError *error) { + [expectation fulfill]; + + XCTAssertEqualObjects(isEnabledNumber, @(isScreenRenderEnabled)); + XCTAssertNil(error); + }]; + + [self waitForExpectations:@[expectation] timeout:5.0]; +} + +- (void)testIsScreenRenderEnabledWhenDisabled { + XCTestExpectation *expectation = [self expectationWithDescription:@"Call completion handler"]; + + BOOL isScreenRenderEnabled = NO; + OCMStub([self.mAPM isScreenRenderingOperational]).andReturn(isScreenRenderEnabled); + + [self.api isScreenRenderEnabledWithCompletion:^(NSNumber *isEnabledNumber, FlutterError *error) { + [expectation fulfill]; + + XCTAssertEqualObjects(isEnabledNumber, @(isScreenRenderEnabled)); + XCTAssertNil(error); + }]; + + [self waitForExpectations:@[expectation] timeout:5.0]; +} + +- (void)testSetScreenRenderEnabled { + NSNumber *isEnabled = @1; + FlutterError *error; + + [self.api setScreenRenderEnabledIsEnabled:isEnabled error:&error]; + + OCMVerify([self.mAPM setScreenRenderingEnabled:YES]); +} + +- (void)testSetScreenRenderDisabled { + NSNumber *isEnabled = @0; + FlutterError *error; + + [self.api setScreenRenderEnabledIsEnabled:isEnabled error:&error]; + + OCMVerify([self.mAPM setScreenRenderingEnabled:NO]); +} + +- (void)testGetDeviceRefreshRateAndTolerance { + XCTestExpectation *expectation = [self expectationWithDescription:@"Call completion handler"]; + + // Mock values + double expectedTolerance = 5.0; + double expectedRefreshRate = 60.0; + + // Mock the tolerance value + OCMStub([self.mAPM screenRenderingThreshold]).andReturn(expectedTolerance); + + // Mock UIScreen class methods + id mockUIScreen = OCMClassMock([UIScreen class]); + id mockMainScreen = OCMClassMock([UIScreen class]); + + // Stub the class method and instance property + OCMStub([mockUIScreen mainScreen]).andReturn(mockMainScreen); + OCMStub([mockMainScreen maximumFramesPerSecond]).andReturn(expectedRefreshRate); + + [self.api getDeviceRefreshRateAndToleranceWithCompletion:^(NSArray *result, FlutterError *error) { + [expectation fulfill]; + + XCTAssertNotNil(result); + XCTAssertEqual(result.count, 2); + XCTAssertEqualObjects(result[0], @(expectedRefreshRate)); + XCTAssertEqualObjects(result[1], @(expectedTolerance)); + XCTAssertNil(error); + }]; + + [self waitForExpectations:@[expectation] timeout:5.0]; + + [mockUIScreen stopMocking]; + [mockMainScreen stopMocking]; +} + + +- (void)testEndScreenRenderForAutoUiTrace { + FlutterError *error; + + // Create mock frame data + NSDictionary *frameData = @{ + @"frameData": @[ + @[@(1000.0), @(16.67)], // Frame 1: start time 1000.0µs, duration 16.67µs + @[@(1016.67), @(33.33)], // Frame 2: start time 1016.67µs, duration 33.33µs + @[@(1050.0), @(50.0)] // Frame 3: start time 1050.0µs, duration 50.0µs + ] + }; + + [self.api endScreenRenderForAutoUiTraceData:frameData error:&error]; + + // Verify that endAutoUITraceCPWithFrames was called + OCMVerify([self.mAPM endAutoUITraceCPWithFrames:[OCMArg checkWithBlock:^BOOL(NSArray *frames) { + // Verify that we have the correct number of frames + XCTAssertEqual(frames.count, 3); + + // Verify the first frame + IBGFrameInfo *firstFrame = frames[0]; + XCTAssertEqual(firstFrame.startTimestampInMicroseconds, 1000.0); + XCTAssertEqual(firstFrame.durationInMicroseconds, 16.67); + + // Verify the second frame + IBGFrameInfo *secondFrame = frames[1]; + XCTAssertEqual(secondFrame.startTimestampInMicroseconds, 1016.67); + XCTAssertEqual(secondFrame.durationInMicroseconds, 33.33); + + // Verify the third frame + IBGFrameInfo *thirdFrame = frames[2]; + XCTAssertEqual(thirdFrame.startTimestampInMicroseconds, 1050.0); + XCTAssertEqual(thirdFrame.durationInMicroseconds, 50.0); + + return YES; + }]]); +} + +- (void)testEndScreenRenderForCustomUiTrace { + FlutterError *error; + + // Create mock frame data + NSDictionary *frameData = @{ + @"frameData": @[ + @[@(2000.0), @(20.0)], // Frame 1: start time 2000.0µs, duration 20.0µs + @[@(2020.0), @(25.0)] // Frame 2: start time 2020.0µs, duration 25.0µs + ] + }; + + [self.api endScreenRenderForCustomUiTraceData:frameData error:&error]; + + // Verify that endCustomUITraceCPWithFrames was called + OCMVerify([self.mAPM endCustomUITraceCPWithFrames:[OCMArg checkWithBlock:^BOOL(NSArray *frames) { + // Verify that we have the correct number of frames + XCTAssertEqual(frames.count, 2); + + // Verify the first frame + IBGFrameInfo *firstFrame = frames[0]; + XCTAssertEqual(firstFrame.startTimestampInMicroseconds, 2000.0); + XCTAssertEqual(firstFrame.durationInMicroseconds, 20.0); + + // Verify the second frame + IBGFrameInfo *secondFrame = frames[1]; + XCTAssertEqual(secondFrame.startTimestampInMicroseconds, 2020.0); + XCTAssertEqual(secondFrame.durationInMicroseconds, 25.0); + + return YES; + }]]); +} + +- (void)testEndScreenRenderForAutoUiTraceWithEmptyFrameData { + FlutterError *error; + + // Create empty frame data + NSDictionary *frameData = @{ + @"frameData": @[] + }; + + [self.api endScreenRenderForAutoUiTraceData:frameData error:&error]; + + // Verify that endAutoUITraceCPWithFrames was called with empty array + OCMVerify([self.mAPM endAutoUITraceCPWithFrames:[OCMArg checkWithBlock:^BOOL(NSArray *frames) { + XCTAssertEqual(frames.count, 0); + return YES; + }]]); +} + +- (void)testEndScreenRenderForCustomUiTraceWithEmptyFrameData { + FlutterError *error; + + // Create empty frame data + NSDictionary *frameData = @{ + @"frameData": @[] + }; + + [self.api endScreenRenderForCustomUiTraceData:frameData error:&error]; + + // Verify that endCustomUITraceCPWithFrames was called with empty array + OCMVerify([self.mAPM endCustomUITraceCPWithFrames:[OCMArg checkWithBlock:^BOOL(NSArray *frames) { + XCTAssertEqual(frames.count, 0); + return YES; + }]]); +} + +- (void)testEndScreenRenderForAutoUiTraceWithMalformedFrameData { + FlutterError *error; + + // Create malformed frame data (missing values or extra values) + NSDictionary *frameData = @{ + @"frameData": @[ + @[@(1000.0)], // Frame with only one value (should be ignored) + @[@(1016.67), @(33.33)], // Valid frame + @[@(1050.0), @(50.0), @(100.0)] // Frame with extra values (should be ignored) + ] + }; + + [self.api endScreenRenderForAutoUiTraceData:frameData error:&error]; + + // Verify that endAutoUITraceCPWithFrames was called with only valid frames + OCMVerify([self.mAPM endAutoUITraceCPWithFrames:[OCMArg checkWithBlock:^BOOL(NSArray *frames) { + // Should only have 1 valid frame (first and third frames are ignored due to wrong count) + XCTAssertEqual(frames.count, 1); + + // Verify the valid frame + IBGFrameInfo *frame = frames[0]; + XCTAssertEqual(frame.startTimestampInMicroseconds, 1016.67); + XCTAssertEqual(frame.durationInMicroseconds, 33.33); + + return YES; + }]]); +} + +- (void)testEndScreenRenderForCustomUiTraceWithMalformedFrameData { + FlutterError *error; + + // Create malformed frame data (missing values) + NSDictionary *frameData = @{ + @"frameData": @[ + @[@(2000.0)], // Frame with only one value (should be ignored) + @[@(2020.0), @(25.0)] // Valid frame + ] + }; + + [self.api endScreenRenderForCustomUiTraceData:frameData error:&error]; + + // Verify that endCustomUITraceCPWithFrames was called with only valid frames + OCMVerify([self.mAPM endCustomUITraceCPWithFrames:[OCMArg checkWithBlock:^BOOL(NSArray *frames) { + // Should only have 1 valid frame + XCTAssertEqual(frames.count, 1); + + // Verify the valid frame + IBGFrameInfo *frame = frames[0]; + XCTAssertEqual(frame.startTimestampInMicroseconds, 2020.0); + XCTAssertEqual(frame.durationInMicroseconds, 25.0); + + return YES; + }]]); +} + +- (void)testEndScreenRenderForAutoUiTraceWithNilFrameData { + FlutterError *error; + + // Create frame data with nil frameData + NSDictionary *frameData = @{ + @"frameData": [NSNull null] + }; + + [self.api endScreenRenderForAutoUiTraceData:frameData error:&error]; + + // Verify that endAutoUITraceCPWithFrames was called with empty array + OCMVerify([self.mAPM endAutoUITraceCPWithFrames:[OCMArg checkWithBlock:^BOOL(NSArray *frames) { + XCTAssertEqual(frames.count, 0); + return YES; + }]]); +} + +- (void)testEndScreenRenderForCustomUiTraceWithNilFrameData { + FlutterError *error; + + // Create frame data with nil frameData + NSDictionary *frameData = @{ + @"frameData": [NSNull null] + }; + + [self.api endScreenRenderForCustomUiTraceData:frameData error:&error]; + + // Verify that endCustomUITraceCPWithFrames was called with empty array + OCMVerify([self.mAPM endCustomUITraceCPWithFrames:[OCMArg checkWithBlock:^BOOL(NSArray *frames) { + XCTAssertEqual(frames.count, 0); + return YES; + }]]); +} + +- (void)testEndScreenRenderForAutoUiTraceWithMissingFrameDataKey { + FlutterError *error; + + // Create frame data without frameData key + NSDictionary *frameData = @{ + @"otherKey": @"someValue" + }; + + [self.api endScreenRenderForAutoUiTraceData:frameData error:&error]; + + // Verify that endAutoUITraceCPWithFrames was called with empty array + OCMVerify([self.mAPM endAutoUITraceCPWithFrames:[OCMArg checkWithBlock:^BOOL(NSArray *frames) { + XCTAssertEqual(frames.count, 0); + return YES; + }]]); +} + +- (void)testEndScreenRenderForCustomUiTraceWithMissingFrameDataKey { + FlutterError *error; + + // Create frame data without frameData key + NSDictionary *frameData = @{ + @"otherKey": @"someValue" + }; + + [self.api endScreenRenderForCustomUiTraceData:frameData error:&error]; + + // Verify that endCustomUITraceCPWithFrames was called with empty array + OCMVerify([self.mAPM endCustomUITraceCPWithFrames:[OCMArg checkWithBlock:^BOOL(NSArray *frames) { + XCTAssertEqual(frames.count, 0); + return YES; + }]]); +} + @end diff --git a/example/ios/InstabugTests/BugReportingApiTests.m b/example/ios/InstabugTests/BugReportingApiTests.m index e01df21d2..5b6954d59 100644 --- a/example/ios/InstabugTests/BugReportingApiTests.m +++ b/example/ios/InstabugTests/BugReportingApiTests.m @@ -162,7 +162,7 @@ - (void)testSetCommentMinimumCharacterCountGivenReportTypes { [self.api setCommentMinimumCharacterCountLimit:limit reportTypes:reportTypes error:&error]; - OCMVerify([self.mBugReporting setCommentMinimumCharacterCountForReportTypes:IBGBugReportingReportTypeBug | IBGBugReportingReportTypeQuestion withLimit:limit.intValue]); + OCMVerify([self.mBugReporting setCommentMinimumCharacterCount:limit.intValue forBugReportType:IBGBugReportingReportTypeBug | IBGBugReportingReportTypeQuestion]); } - (void)testSetCommentMinimumCharacterCountGivenNoReportTypes { @@ -172,7 +172,7 @@ - (void)testSetCommentMinimumCharacterCountGivenNoReportTypes { [self.api setCommentMinimumCharacterCountLimit:limit reportTypes:reportTypes error:&error]; - OCMVerify([self.mBugReporting setCommentMinimumCharacterCountForReportTypes:IBGBugReportingReportTypeBug | IBGBugReportingReportTypeFeedback | IBGBugReportingReportTypeQuestion withLimit:limit.intValue]); + OCMVerify([self.mBugReporting setCommentMinimumCharacterCount:limit.intValue forBugReportType:IBGBugReportingReportTypeBug | IBGBugReportingReportTypeFeedback | IBGBugReportingReportTypeQuestion]); } - (void)testAddUserConsentWithKey { NSString *key = @"testKey"; diff --git a/example/ios/InstabugTests/InstabugApiTests.m b/example/ios/InstabugTests/InstabugApiTests.m index 9f2c04373..3e1abf332 100644 --- a/example/ios/InstabugTests/InstabugApiTests.m +++ b/example/ios/InstabugTests/InstabugApiTests.m @@ -38,15 +38,22 @@ - (void)testSetEnabled { - (void)testInit { NSString *token = @"app-token"; + NSString *appVariant = @"app-variant"; + NSArray *invocationEvents = @[@"InvocationEvent.floatingButton", @"InvocationEvent.screenshot"]; NSString *logLevel = @"LogLevel.error"; FlutterError *error; - [self.api initToken:token invocationEvents:invocationEvents debugLogsLevel:logLevel error:&error]; + [self.api initToken:token invocationEvents:invocationEvents debugLogsLevel:logLevel appVariant:appVariant error:&error]; OCMVerify([self.mInstabug setCurrentPlatform:IBGPlatformFlutter]); + OCMVerify([self.mInstabug setSdkDebugLogsLevel:IBGSDKDebugLogsLevelError]); + OCMVerify([self.mInstabug startWithToken:token invocationEvents:(IBGInvocationEventFloatingButton | IBGInvocationEventScreenshot)]); + + XCTAssertEqual(Instabug.appVariant, appVariant); + } - (void)testShow { @@ -200,31 +207,6 @@ - (void)testGetTags { [self waitForExpectations:@[expectation] timeout:5.0]; } -- (void)testAddExperiments { - NSArray *experiments = @[@"premium", @"star"]; - FlutterError *error; - - [self.api addExperimentsExperiments:experiments error:&error]; - - OCMVerify([self.mInstabug addExperiments:experiments]); -} - -- (void)testRemoveExperiments { - NSArray *experiments = @[@"premium", @"star"]; - FlutterError *error; - - [self.api removeExperimentsExperiments:experiments error:&error]; - - OCMVerify([self.mInstabug removeExperiments:experiments]); -} - -- (void)testClearAllExperiments { - FlutterError *error; - - [self.api clearAllExperimentsWithError:&error]; - - OCMVerify([self.mInstabug clearAllExperiments]); -} - (void)testAddFeatureFlags { NSDictionary *featureFlagsMap = @{ @"key13" : @"value1", @"key2" : @"value2"}; @@ -611,4 +593,42 @@ - (void)testisW3CFeatureFlagsEnabled { } +- (void)testSetThemeWithAllProperties { + NSDictionary *themeConfig = @{ + @"primaryColor": @"#FF6B6B", + @"backgroundColor": @"#FFFFFF", + @"titleTextColor": @"#000000", + @"primaryTextColor": @"#333333", + @"secondaryTextColor": @"#666666", + @"callToActionTextColor": @"#FF6B6B", + @"primaryFontPath": @"assets/fonts/CustomFont-Regular.ttf", + @"secondaryFontPath": @"assets/fonts/CustomFont-Bold.ttf", + @"ctaFontPath": @"assets/fonts/CustomFont-Italic.ttf" + }; + + id mockTheme = OCMClassMock([IBGTheme class]); + OCMStub([mockTheme primaryColor]).andReturn([UIColor redColor]); + OCMStub([mockTheme backgroundColor]).andReturn([UIColor whiteColor]); + OCMStub([mockTheme titleTextColor]).andReturn([UIColor blackColor]); + OCMStub([mockTheme primaryTextColor]).andReturn([UIColor darkGrayColor]); + OCMStub([mockTheme secondaryTextColor]).andReturn([UIColor grayColor]); + OCMStub([mockTheme callToActionTextColor]).andReturn([UIColor redColor]); + + FlutterError *error; + + [self.api setThemeThemeConfig:themeConfig error:&error]; + + OCMVerify([self.mInstabug setTheme:[OCMArg isNotNil]]); +} + +- (void)testSetFullscreen { + NSNumber *isEnabled = @1; + FlutterError *error; + + [self.api setFullscreenIsEnabled:isEnabled error:&error]; + + // Since this is an empty implementation, we just verify the method can be called without error + XCTAssertNil(error); +} + @end diff --git a/example/ios/Podfile b/example/ios/Podfile index 00756a1dd..410f49b97 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -30,7 +30,7 @@ target 'Runner' do use_frameworks! use_modular_headers! - + pod 'Instabug', :podspec => 'https://ios-releases.instabug.com/custom/faeture-screen_rendering-release/15.1.32/Instabug.podspec' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 619d1436c..14c2a7b68 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,33 +1,35 @@ PODS: - Flutter (1.0.0) - - Instabug (15.1.1) - - instabug_flutter (15.0.1): + - Instabug (15.1.32) + - instabug_flutter (14.3.0): - Flutter - - Instabug (= 15.1.1) + - Instabug (= 15.1.32) - OCMock (3.6) DEPENDENCIES: - Flutter (from `Flutter`) + - Instabug (from `https://ios-releases.instabug.com/custom/faeture-screen_rendering-release/15.1.32/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/faeture-screen_rendering-release/15.1.32/Instabug.podspec instabug_flutter: :path: ".symlinks/plugins/instabug_flutter/ios" SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - Instabug: 3e7af445c14d7823fcdecba223f09b5f7c0c6ce1 - instabug_flutter: e4e366434313bab3a2db123c1501ca6247dc950b + Instabug: ee379b2694fa1dd3951526e5a34782bac886102e + instabug_flutter: 33230b1cc57be3b343b4d30f6dfdd03f9bf43599 OCMock: 5ea90566be239f179ba766fd9fbae5885040b992 -PODFILE CHECKSUM: 4d0aaaf6a444f68024f992999ff2c2ee26baa6ec +PODFILE CHECKSUM: 41b206566c390a4111f60619beb4e420eba98359 -COCOAPODS: 1.16.2 +COCOAPODS: 1.15.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index d32f983fc..548562ea4 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -389,7 +389,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; A19F33D3063ED99B3A6581AC /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; @@ -559,7 +559,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 14.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -697,7 +697,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 14.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -729,7 +729,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.6; + IPHONEOS_DEPLOYMENT_TARGET = 14.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 0b15932d1..3264af370 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/example/lib/main.dart b/example/lib/main.dart index 91b0a67e7..5946f75c1 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,58 +1,53 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:developer'; import 'dart:io'; -import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; -import 'package:instabug_flutter_example/src/components/apm_switch.dart'; -import 'package:instabug_http_client/instabug_http_client.dart'; +import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_manager.dart'; import 'package:instabug_flutter_example/src/app_routes.dart'; +import 'package:instabug_flutter_example/src/utils/show_messages.dart'; import 'package:instabug_flutter_example/src/widget/nested_view.dart'; +import 'package:instabug_http_client/instabug_http_client.dart'; import 'src/native/instabug_flutter_example_method_channel.dart'; import 'src/widget/instabug_button.dart'; import 'src/widget/instabug_clipboard_input.dart'; import 'src/widget/instabug_text_field.dart'; -import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_manager.dart'; - import 'src/widget/section_title.dart'; -part 'src/screens/crashes_page.dart'; - -part 'src/screens/complex_page.dart'; - -part 'src/screens/apm_page.dart'; - -part 'src/screens/screen_capture_premature_extension_page.dart'; - -part 'src/screens/screen_loading_page.dart'; - -part 'src/screens/my_home_page.dart'; - +part 'src/components/animated_box.dart'; +part 'src/components/apm_switch.dart'; part 'src/components/fatal_crashes_content.dart'; - -part 'src/components/non_fatal_crashes_content.dart'; - +part 'src/components/flows_content.dart'; part 'src/components/network_content.dart'; - +part 'src/components/non_fatal_crashes_content.dart'; part 'src/components/page.dart'; - -part 'src/components/traces_content.dart'; - -part 'src/components/flows_content.dart'; +part 'src/components/screen_render.dart'; +part 'src/components/screen_render_switch.dart'; +part 'src/components/ui_traces_content.dart'; +part 'src/screens/apm_page.dart'; +part 'src/screens/complex_page.dart'; +part 'src/screens/crashes_page.dart'; +part 'src/screens/my_home_page.dart'; +part 'src/screens/screen_capture_premature_extension_page.dart'; +part 'src/screens/screen_loading_page.dart'; +part 'src/screens/screen_render_page.dart'; void main() { runZonedGuarded( - () { + () async { WidgetsFlutterBinding.ensureInitialized(); - Instabug.init( + await Instabug.init( token: 'ed6f659591566da19b67857e1b9d40ab', invocationEvents: [InvocationEvent.floatingButton], debugLogsLevel: LogLevel.verbose, + appVariant: 'variant 1', ); - + APM.setScreenRenderingEnabled(true); + // APM.setAutoUITraceEnabled(false); FlutterError.onError = (FlutterErrorDetails details) { Zone.current.handleUncaughtError(details.exception, details.stack!); }; diff --git a/example/lib/src/components/animated_box.dart b/example/lib/src/components/animated_box.dart new file mode 100644 index 000000000..fc0a8e362 --- /dev/null +++ b/example/lib/src/components/animated_box.dart @@ -0,0 +1,80 @@ +part of '../../main.dart'; + +class AnimatedBox extends StatefulWidget { + const AnimatedBox({Key? key}) : super(key: key); + + @override + _AnimatedBoxState createState() => _AnimatedBoxState(); +} + +class _AnimatedBoxState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(minutes: 1, seconds: 40), + vsync: this, + ); + _animation = Tween(begin: 0, end: 100).animate(_controller) + ..addListener(() { + setState(() { + // The state that has changed here is the animation value + }); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _startAnimation() { + _controller.forward(); + } + + void _stopAnimation() { + _controller.stop(); + } + + void _resetAnimation() { + _controller.reset(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + RotationTransition( + turns: _animation, + child: const FlutterLogo(size: 100), + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => _startAnimation(), + child: const Text('Start'), + ), + const SizedBox(width: 20), + ElevatedButton( + onPressed: () => _stopAnimation(), + child: const Text('Stop'), + ), + const SizedBox(width: 20), + ElevatedButton( + onPressed: () => _resetAnimation(), + child: const Text('reset'), + ), + ], + ), + ], + ); + } +} diff --git a/example/lib/src/components/apm_switch.dart b/example/lib/src/components/apm_switch.dart index df8dd6123..fca7d4bb6 100644 --- a/example/lib/src/components/apm_switch.dart +++ b/example/lib/src/components/apm_switch.dart @@ -1,6 +1,4 @@ -import 'package:flutter/material.dart'; -import 'package:instabug_flutter/instabug_flutter.dart'; -import 'package:instabug_flutter_example/src/utils/show_messages.dart'; +part of '../../main.dart'; class APMSwitch extends StatefulWidget { const APMSwitch({Key? key}) : super(key: key); @@ -14,15 +12,19 @@ class _APMSwitchState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - SwitchListTile.adaptive( - title: const Text('APM Enabled'), - value: isEnabled, - onChanged: (value) => onAPMChanged(context, value), - ), - ], - ); + return FutureBuilder( + future: APM.isEnabled(), + builder: (context, snapshot) { + if (snapshot.hasData) { + isEnabled = snapshot.data ?? false; + return SwitchListTile.adaptive( + title: const Text('APM Enabled'), + value: isEnabled, + onChanged: (value) => onAPMChanged(context, value), + ); + } + return const SizedBox.shrink(); + }); } void onAPMChanged(BuildContext context, bool value) { diff --git a/example/lib/src/components/screen_render.dart b/example/lib/src/components/screen_render.dart new file mode 100644 index 000000000..2ab70282f --- /dev/null +++ b/example/lib/src/components/screen_render.dart @@ -0,0 +1,31 @@ +part of '../../main.dart'; + +class ScreenRender extends StatelessWidget { + const ScreenRender({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const SectionTitle('Screen Render'), + InstabugButton( + text: 'Screen Render', + onPressed: () => _navigateToScreenRender(context), + ), + ], + ); + } + + _navigateToScreenRender(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const InstabugCaptureScreenLoading( + screenName: ScreenRenderPage.screenName, + child: ScreenRenderPage(), + ), + settings: const RouteSettings(name: ScreenRenderPage.screenName), + ), + ); + } +} diff --git a/example/lib/src/components/screen_render_switch.dart b/example/lib/src/components/screen_render_switch.dart new file mode 100644 index 000000000..53238d807 --- /dev/null +++ b/example/lib/src/components/screen_render_switch.dart @@ -0,0 +1,35 @@ +part of '../../main.dart'; + +class ScreenRenderSwitch extends StatefulWidget { + const ScreenRenderSwitch({Key? key}) : super(key: key); + + @override + State createState() => _ScreenRenderSwitchState(); +} + +class _ScreenRenderSwitchState extends State { + bool isEnabled = false; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: APM.isScreenRenderEnabled(), + builder: (context, snapshot) { + if (snapshot.hasData) { + isEnabled = snapshot.data ?? false; + return SwitchListTile.adaptive( + title: const Text('Screen Render Enabled'), + value: isEnabled, + onChanged: (value) => onScreenRenderChanged(context, value), + ); + } + return const SizedBox.shrink(); + }); + } + + void onScreenRenderChanged(BuildContext context, bool value) { + APM.setScreenRenderingEnabled(value); + showSnackBar(context, "Screen Render is ${value ? "enabled" : "disabled"}"); + setState(() => isEnabled = value); + } +} diff --git a/example/lib/src/components/traces_content.dart b/example/lib/src/components/traces_content.dart deleted file mode 100644 index 888460d43..000000000 --- a/example/lib/src/components/traces_content.dart +++ /dev/null @@ -1,157 +0,0 @@ -part of '../../main.dart'; - -class TracesContent extends StatefulWidget { - const TracesContent({Key? key}) : super(key: key); - - @override - State createState() => _TracesContentState(); -} - -class _TracesContentState extends State { - final traceNameController = TextEditingController(); - final traceKeyAttributeController = TextEditingController(); - final traceValueAttributeController = TextEditingController(); - - bool? didTraceEnd; - - Trace? trace; - - @override - Widget build(BuildContext context) { - final textTheme = Theme.of(context).textTheme; - return Column( - children: [ - InstabugTextField( - label: 'Trace name', - labelStyle: textTheme.labelMedium, - controller: traceNameController, - ), - SizedBox.fromSize( - size: const Size.fromHeight(10.0), - ), - Row( - children: [ - Flexible( - flex: 5, - child: InstabugButton.smallFontSize( - text: 'Start Trace', - onPressed: () => _startTrace(traceNameController.text), - margin: const EdgeInsetsDirectional.only( - start: 20.0, - end: 10.0, - ), - ), - ), - Flexible( - flex: 5, - child: InstabugButton.smallFontSize( - text: 'Start Trace With Delay', - onPressed: () => _startTrace( - traceNameController.text, - delayInMilliseconds: 5000, - ), - margin: const EdgeInsetsDirectional.only( - start: 10.0, - end: 20.0, - ), - ), - ), - ], - ), - Row( - children: [ - Flexible( - flex: 5, - child: InstabugTextField( - label: 'Trace Key Attribute', - controller: traceKeyAttributeController, - labelStyle: textTheme.labelMedium, - margin: const EdgeInsetsDirectional.only( - end: 10.0, - start: 20.0, - ), - ), - ), - Flexible( - flex: 5, - child: InstabugTextField( - label: 'Trace Value Attribute', - labelStyle: textTheme.labelMedium, - controller: traceValueAttributeController, - margin: const EdgeInsetsDirectional.only( - start: 10.0, - end: 20.0, - ), - ), - ), - ], - ), - SizedBox.fromSize( - size: const Size.fromHeight(10.0), - ), - InstabugButton( - text: 'Set Trace Attribute', - onPressed: () => _setTraceAttribute( - trace, - traceKeyAttribute: traceKeyAttributeController.text, - traceValueAttribute: traceValueAttributeController.text, - ), - ), - InstabugButton( - text: 'End Trace', - onPressed: () => _endTrace(), - ), - ], - ); - } - - void _startTrace( - String traceName, { - int delayInMilliseconds = 0, - }) { - if (traceName.trim().isNotEmpty) { - log('_startTrace — traceName: $traceName, delay in Milliseconds: $delayInMilliseconds'); - log('traceName: $traceName'); - Future.delayed( - Duration(milliseconds: delayInMilliseconds), - () => APM - .startExecutionTrace(traceName) - .then((value) => trace = value)); - } else { - log('startTrace - Please enter a trace name'); - } - } - - void _endTrace() { - if (didTraceEnd == true) { - log('_endTrace — Please, start a new trace before setting attributes.'); - } - if (trace == null) { - log('_endTrace — Please, start a trace before ending it.'); - } - log('_endTrace — ending Trace.'); - trace?.end(); - didTraceEnd = true; - } - - void _setTraceAttribute( - Trace? trace, { - required String traceKeyAttribute, - required String traceValueAttribute, - }) { - if (trace == null) { - log('_setTraceAttribute — Please, start a trace before setting attributes.'); - } - if (didTraceEnd == true) { - log('_setTraceAttribute — Please, start a new trace before setting attributes.'); - } - if (traceKeyAttribute.trim().isEmpty) { - log('_setTraceAttribute — Please, fill the trace key attribute input before settings attributes.'); - } - if (traceValueAttribute.trim().isEmpty) { - log('_setTraceAttribute — Please, fill the trace value attribute input before settings attributes.'); - } - log('_setTraceAttribute — setting attributes -> key: $traceKeyAttribute, value: $traceValueAttribute.'); - trace?.setAttribute(traceKeyAttribute, traceValueAttribute); - } -} diff --git a/example/lib/src/components/ui_traces_content.dart b/example/lib/src/components/ui_traces_content.dart new file mode 100644 index 000000000..b05144408 --- /dev/null +++ b/example/lib/src/components/ui_traces_content.dart @@ -0,0 +1,74 @@ +part of '../../main.dart'; + +class UITracesContent extends StatefulWidget { + const UITracesContent({Key? key}) : super(key: key); + + @override + State createState() => _UITracesContentState(); +} + +class _UITracesContentState extends State { + final traceNameController = TextEditingController(); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Column( + children: [ + InstabugTextField( + label: 'UI Trace name', + labelStyle: textTheme.labelMedium, + controller: traceNameController, + ), + SizedBox.fromSize( + size: const Size.fromHeight(10.0), + ), + Row( + children: [ + Flexible( + flex: 5, + child: InstabugButton.smallFontSize( + text: 'Start UI Trace', + onPressed: () => _startTrace(traceNameController.text), + margin: const EdgeInsetsDirectional.only( + start: 20.0, + end: 10.0, + ), + ), + ), + Flexible( + flex: 5, + child: InstabugButton.smallFontSize( + text: 'End UI Trace', + onPressed: () => _endTrace(), + margin: const EdgeInsetsDirectional.only( + start: 10.0, + end: 20.0, + ), + ), + ), + ], + ), + ], + ); + } + + void _startTrace( + String traceName, { + int delayInMilliseconds = 0, + }) { + if (traceName.trim().isNotEmpty) { + log('_startTrace — traceName: $traceName, delay in Milliseconds: $delayInMilliseconds'); + log('traceName: $traceName'); + Future.delayed(Duration(milliseconds: delayInMilliseconds), + () => APM.startUITrace(traceName)); + } else { + log('startUITrace - Please enter a trace name'); + } + } + + void _endTrace() { + log('endUITrace - '); + APM.endUITrace(); + } +} diff --git a/example/lib/src/native/instabug_flutter_example_method_channel.dart b/example/lib/src/native/instabug_flutter_example_method_channel.dart index 9507cc403..118097dc3 100644 --- a/example/lib/src/native/instabug_flutter_example_method_channel.dart +++ b/example/lib/src/native/instabug_flutter_example_method_channel.dart @@ -54,6 +54,22 @@ class InstabugFlutterExampleMethodChannel { log("Failed to send out of memory: '${e.message}'.", name: _tag); } } + + static Future setFullscreen(bool isEnabled) async { + if (!Platform.isAndroid) { + return; + } + + try { + await _channel.invokeMethod(Constants.setFullscreenMethodName, { + 'isEnabled': isEnabled, + }); + } on PlatformException catch (e) { + log("Failed to set fullscreen: '${e.message}'.", name: _tag); + } catch (e) { + log("Unexpected error setting fullscreen: '$e'.", name: _tag); + } + } } class Constants { @@ -65,4 +81,5 @@ class Constants { static const sendNativeFatalHangMethodName = "sendNativeFatalHang"; static const sendAnrMethodName = "sendAnr"; static const sendOomMethodName = "sendOom"; + static const setFullscreenMethodName = "setFullscreen"; } diff --git a/example/lib/src/screens/apm_page.dart b/example/lib/src/screens/apm_page.dart index 798e906fa..1c0c3ce05 100644 --- a/example/lib/src/screens/apm_page.dart +++ b/example/lib/src/screens/apm_page.dart @@ -36,10 +36,10 @@ class _ApmPageState extends State { ), const SectionTitle('Network'), const NetworkContent(), - const SectionTitle('Traces'), - const TracesContent(), const SectionTitle('Flows'), const FlowsContent(), + const SectionTitle('Custom UI Traces'), + const UITracesContent(), const SectionTitle('Screen Loading'), SizedBox.fromSize( size: const Size.fromHeight(12), @@ -51,6 +51,7 @@ class _ApmPageState extends State { SizedBox.fromSize( size: const Size.fromHeight(12), ), + const ScreenRender(), ], ); } diff --git a/example/lib/src/screens/my_home_page.dart b/example/lib/src/screens/my_home_page.dart index 404d79cdd..5f7d50a88 100644 --- a/example/lib/src/screens/my_home_page.dart +++ b/example/lib/src/screens/my_home_page.dart @@ -114,10 +114,10 @@ class _MyHomePageState extends State { BugReporting.setInvocationEvents([invocationEvent]); } - void changePrimaryColor() { - String text = 'FF' + primaryColorController.text.replaceAll('#', ''); - Color color = Color(int.parse(text, radix: 16)); - Instabug.setPrimaryColor(color); + void changePrimaryColor() async { + String text = primaryColorController.text.replaceAll('#', ''); + await Instabug.setTheme(ThemeConfig(primaryColor: '#$text')); + await Future.delayed(const Duration(milliseconds: 500)); } void setColorTheme(ColorTheme colorTheme) { diff --git a/example/lib/src/screens/screen_render_page.dart b/example/lib/src/screens/screen_render_page.dart new file mode 100644 index 000000000..6c5ee315c --- /dev/null +++ b/example/lib/src/screens/screen_render_page.dart @@ -0,0 +1,60 @@ +part of '../../main.dart'; + +class ScreenRenderPage extends StatefulWidget { + const ScreenRenderPage({Key? key}) : super(key: key); + static const String screenName = "/screenRenderPageRoute"; + + @override + State createState() => _ScreenRenderPageState(); +} + +class _ScreenRenderPageState extends State { + @override + Widget build(BuildContext context) { + return Page(title: 'Screen Render', children: [ + const ScreenRenderSwitch(), + SizedBox.fromSize(size: const Size.fromHeight(16.0)), + const AnimatedBox(), + SizedBox.fromSize( + size: const Size.fromHeight(50), + ), + SizedBox.fromSize(size: const Size.fromHeight(16.0)), + InstabugButton( + text: 'Trigger Slow Frame', + onPressed: () => _simulateHeavyComputation(200), + ), + InstabugButton( + text: 'Trigger Frozen Frame', + onPressed: () => _simulateHeavyComputation(1000), + ), + InstabugButton( + text: 'Monitored Complex Page', + onPressed: () => _navigateToComplexPage(context), + ), + ]); + } + + void _navigateToComplexPage(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ComplexPage.monitored(), + settings: const RouteSettings( + name: ComplexPage.screenName, + ), + ), + ); + } + + // Simulates a computationally expensive task + _simulateHeavyComputation(int delayInMilliseconds) { + setState(() { + final startTime = DateTime.now(); + final pauseTime = delayInMilliseconds; + // Block the UI thread for ~delayInMilliseconds + while (DateTime.now().difference(startTime).inMilliseconds <= pauseTime) { + // Busy waiting + } + }); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index b6bf3b61e..ed56bbe53 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,62 +1,76 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.6" async: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "2.12.0" + version: "2.8.2" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "2.1.2" + version: "2.1.0" characters: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" + charcode: + dependency: transitive + description: + name: charcode + url: "https://pub.dartlang.org" source: hosted - version: "1.4.0" + version: "1.3.1" clock: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "1.1.2" + version: "1.1.0" collection: dependency: transitive description: name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" + url: "https://pub.dartlang.org" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + url: "https://pub.dartlang.org" source: hosted - version: "1.19.1" + version: "3.0.1" fake_async: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "1.3.2" + version: "1.2.0" file: dependency: transitive description: name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "7.0.1" + version: "6.1.2" flutter: dependency: "direct main" description: flutter @@ -71,8 +85,7 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: b543301ad291598523947dc534aaddc5aaad597b709d2426d3a0e0d44c5cb493 - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted version: "1.0.4" flutter_test: @@ -89,16 +102,14 @@ packages: dependency: "direct main" description: name: http - sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "0.13.6" + version: "0.13.5" http_parser: dependency: transitive description: name: http_parser - sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted version: "4.0.2" instabug_flutter: @@ -107,188 +118,144 @@ packages: path: ".." relative: true source: path - version: "15.0.1" + version: "15.0.2" instabug_http_client: dependency: "direct main" description: name: instabug_http_client - sha256: "7d52803c0dd639f6dddbe07333418eb251ae02f3f9f4d30402517533ca692784" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "2.4.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec - url: "https://pub.dev" - source: hosted - version: "10.0.8" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 - url: "https://pub.dev" - source: hosted - version: "3.0.9" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.dev" - source: hosted - version: "3.0.1" + version: "2.6.0" lints: dependency: transitive description: name: lints - sha256: a2c3d198cb5ea2e179926622d433331d8b58374ab8f29cdda6e863bd62fd369c - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted version: "1.0.1" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "0.12.17" + version: "0.12.11" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "0.11.1" + version: "0.1.3" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "1.16.0" + version: "1.7.0" path: dependency: transitive description: name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "1.9.1" + version: "1.8.0" platform: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "3.1.6" + version: "3.1.0" process: dependency: transitive description: name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "5.0.3" + version: "4.2.4" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.0" + version: "0.0.99" source_span: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "1.10.1" + version: "1.8.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "1.12.1" + version: "1.10.0" stream_channel: dependency: transitive description: name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "2.1.4" + version: "2.1.0" string_scanner: dependency: transitive description: name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "1.4.1" + version: "1.1.0" sync_http: dependency: transitive description: name: sync_http - sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "0.3.1" + version: "0.3.0" term_glyph: dependency: transitive description: name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "1.2.2" + version: "1.2.0" test_api: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "0.7.4" + version: "0.4.8" typed_data: dependency: transitive description: name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "1.4.0" + version: "1.3.0" vector_math: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "2.1.4" + version: "2.1.1" vm_service: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "14.3.1" + version: "7.5.0" webdriver: dependency: transitive description: name: webdriver - sha256: "3d773670966f02a646319410766d3b5e1037efb7f07cc68f844d5e06cd4d61c8" - url: "https://pub.dev" + url: "https://pub.dartlang.org" source: hosted - version: "3.0.4" + version: "3.0.0" sdks: - dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=2.14.0 <3.0.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index fe72aaa2d..dfd49f2aa 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -51,6 +51,7 @@ flutter: # To add assets to your application, add an assets section, like this: # assets: + # - assets/fonts/ # - images/a_dot_burr.jpeg # - images/a_dot_ham.jpeg @@ -66,6 +67,9 @@ flutter: # list giving the asset and other descriptors for the font. For # example: # fonts: + # - family: ManufacturingConsent + # fonts: + # - asset: assets/fonts/ManufacturingConsent-Regular.ttf # - family: Schyler # fonts: # - asset: fonts/Schyler-Regular.ttf diff --git a/ios/Classes/Modules/ApmApi.m b/ios/Classes/Modules/ApmApi.m index c6295ce67..ac16b84da 100644 --- a/ios/Classes/Modules/ApmApi.m +++ b/ios/Classes/Modules/ApmApi.m @@ -70,44 +70,6 @@ - (void)setAutoUITraceEnabledIsEnabled:(NSNumber *)isEnabled error:(FlutterError IBGAPM.autoUITraceEnabled = [isEnabled boolValue]; } -// This method is responsible for starting an execution trace -// with a given `id` and `name`. -// -// Deprecated - see [startFlowName, setFlowAttributeName & endFlowName]. -- (void)startExecutionTraceId:(NSString *)id name:(NSString *)name completion:(void(^)(NSString *_Nullable, FlutterError *_Nullable))completion { - IBGExecutionTrace *trace = [IBGAPM startExecutionTraceWithName:name]; - - if (trace != nil) { - [traces setObject:trace forKey:id]; - return completion(id, nil); - } else { - return completion(nil, nil); - } -} - -// This method is responsible for setting an attribute for a specific -// execution trace identified by the provided `id`. -// -// Deprecated - see [startFlowName, setFlowAttributeName & endFlowName]. -- (void)setExecutionTraceAttributeId:(NSString *)id key:(NSString *)key value:(NSString *)value error:(FlutterError *_Nullable *_Nonnull)error { - IBGExecutionTrace *trace = [traces objectForKey:id]; - - if (trace != nil) { - [trace setAttributeWithKey:key value:value]; - } -} - -// This method `endExecutionTraceId` is responsible for ending an execution trace identified by the -// provided `id`. -// -// Deprecated - see [startFlowName, setFlowAttributeName & endFlowName]. -- (void)endExecutionTraceId:(NSString *)id error:(FlutterError *_Nullable *_Nonnull)error { - IBGExecutionTrace *trace = [traces objectForKey:id]; - - if (trace != nil) { - [trace end]; - } -} // This method is responsible for starting a flow with the given `name`. This functionality is used to // track and monitor the performance of specific flows within the application. @@ -130,13 +92,13 @@ - (void)endFlowName:(nonnull NSString *)name error:(FlutterError * _Nullable __a [IBGAPM endFlowWithName:name]; } -// This method is responsible for starting a UI trace with the given `name`. +// This method is responsible for starting a UI trace with the given `name`. // Which initiates the tracking of user interface interactions for monitoring the performance of the application. - (void)startUITraceName:(NSString *)name error:(FlutterError *_Nullable *_Nonnull)error { [IBGAPM startUITraceWithName:name]; } -// The method is responsible for ending the currently active UI trace. +// The method is responsible for ending the currently active UI trace. // Which signifies the completion of tracking user interface interactions. - (void)endUITraceWithError:(FlutterError *_Nullable *_Nonnull)error { [IBGAPM endUITrace]; @@ -197,5 +159,73 @@ - (void)isEndScreenLoadingEnabledWithCompletion:(nonnull void (^)(NSNumber * _Nu completion(isEnabledNumber, nil); } +- (void)isScreenRenderEnabledWithCompletion:(void (^)(NSNumber * _Nullable, FlutterError * _Nullable))completion{ + BOOL isScreenRenderEnabled = IBGAPM.isScreenRenderingOperational; + NSNumber *isEnabledNumber = @(isScreenRenderEnabled); + completion(isEnabledNumber, nil); +} + +- (void)setScreenRenderEnabledIsEnabled:(nonnull NSNumber *)isEnabled error:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + [IBGAPM setScreenRenderingEnabled:[isEnabled boolValue]]; + +} + + +- (void)endScreenRenderForAutoUiTraceData:(nonnull NSDictionary *)data error:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + NSArray *> *rawFrames = data[@"frameData"]; + NSMutableArray *frameInfos = [[NSMutableArray alloc] init]; + + if (rawFrames && [rawFrames isKindOfClass:[NSArray class]]) { + for (NSArray *frameValues in rawFrames) { + if ([frameValues count] == 2) { + IBGFrameInfo *frameInfo = [[IBGFrameInfo alloc] init]; + frameInfo.startTimestampInMicroseconds = [frameValues[0] doubleValue]; + frameInfo.durationInMicroseconds = [frameValues[1] doubleValue]; + [frameInfos addObject:frameInfo]; + } + } + } + [IBGAPM endAutoUITraceCPWithFrames:frameInfos]; +} + + +- (void)endScreenRenderForCustomUiTraceData:(nonnull NSDictionary *)data error:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + NSArray *> *rawFrames = data[@"frameData"]; + NSMutableArray *frameInfos = [[NSMutableArray alloc] init]; + + if (rawFrames && [rawFrames isKindOfClass:[NSArray class]]) { + for (NSArray *frameValues in rawFrames) { + if ([frameValues count] == 2) { + IBGFrameInfo *frameInfo = [[IBGFrameInfo alloc] init]; + frameInfo.startTimestampInMicroseconds = [frameValues[0] doubleValue]; + frameInfo.durationInMicroseconds = [frameValues[1] doubleValue]; + [frameInfos addObject:frameInfo]; + } + } + } + + [IBGAPM endCustomUITraceCPWithFrames:frameInfos]; +} + +- (void)getDeviceRefreshRateAndToleranceWithCompletion:(nonnull void (^)(NSArray * _Nullable, FlutterError * _Nullable))completion { + double tolerance = IBGAPM.screenRenderingThreshold; + if (@available(iOS 10.3, *)) { + double refreshRate = [UIScreen mainScreen].maximumFramesPerSecond; + completion(@[@(refreshRate), @(tolerance)] ,nil); + } else { + // Fallback for very old iOS versions. + completion(@[@(60.0), @(tolerance)] , nil); + } +} + +- (void)isAutoUiTraceEnabledWithCompletion:(nonnull void (^)(NSNumber * _Nullable, FlutterError * _Nullable))completion { + BOOL isAutoUiTraceIsEnabled = IBGAPM.autoUITraceEnabled && IBGAPM.enabled; + NSNumber *isEnabledNumber = @(isAutoUiTraceIsEnabled); + completion(isEnabledNumber, nil); +} + + + + @end diff --git a/ios/Classes/Modules/BugReportingApi.m b/ios/Classes/Modules/BugReportingApi.m index bb97810b8..7a92a9563 100644 --- a/ios/Classes/Modules/BugReportingApi.m +++ b/ios/Classes/Modules/BugReportingApi.m @@ -151,8 +151,7 @@ - (void)setDisclaimerTextText:(NSString *)text error:(FlutterError *_Nullable *_ } - (void)setCommentMinimumCharacterCountLimit:(NSNumber *)limit reportTypes:(nullable NSArray *)reportTypes error:(FlutterError *_Nullable *_Nonnull)error { - IBGBugReportingReportType resolvedTypes = 0; - + IBGBugReportingType resolvedTypes = 0; if (![reportTypes count]) { resolvedTypes = (ArgsRegistry.reportTypes[@"ReportType.bug"]).integerValue | (ArgsRegistry.reportTypes[@"ReportType.feedback"]).integerValue | (ArgsRegistry.reportTypes[@"ReportType.question"]).integerValue; } @@ -162,7 +161,7 @@ - (void)setCommentMinimumCharacterCountLimit:(NSNumber *)limit reportTypes:(null } } - [IBGBugReporting setCommentMinimumCharacterCountForReportTypes:resolvedTypes withLimit:limit.intValue]; + [IBGBugReporting setCommentMinimumCharacterCount:[limit integerValue] forBugReportType:resolvedTypes]; } - (void)addUserConsentsKey:(NSString *)key diff --git a/ios/Classes/Modules/InstabugApi.m b/ios/Classes/Modules/InstabugApi.m index 3bdc465f0..813173c9d 100644 --- a/ios/Classes/Modules/InstabugApi.m +++ b/ios/Classes/Modules/InstabugApi.m @@ -14,7 +14,9 @@ extern void InitInstabugApi(id messenger) { InstabugHostApiSetup(messenger, api); } -@implementation InstabugApi +@implementation InstabugApi { + NSMutableSet *_registeredFonts; +} - (void)setEnabledIsEnabled:(NSNumber *)isEnabled error:(FlutterError *_Nullable *_Nonnull)error { Instabug.enabled = [isEnabled boolValue]; @@ -29,7 +31,13 @@ - (nullable NSNumber *)isEnabledWithError:(FlutterError * _Nullable __autoreleas return @(Instabug.enabled); } -- (void)initToken:(NSString *)token invocationEvents:(NSArray *)invocationEvents debugLogsLevel:(NSString *)debugLogsLevel error:(FlutterError *_Nullable *_Nonnull)error { +- (void)initToken:(nonnull NSString *)token invocationEvents:(nonnull NSArray *)invocationEvents debugLogsLevel:(nonnull NSString *)debugLogsLevel appVariant:(nullable NSString *)appVariant error:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + + if(appVariant != nil){ + Instabug.appVariant = appVariant; + } + + SEL setPrivateApiSEL = NSSelectorFromString(@"setCurrentPlatform:"); if ([[Instabug class] respondsToSelector:setPrivateApiSEL]) { NSInteger *platformID = IBGPlatformFlutter; @@ -46,6 +54,7 @@ - (void)initToken:(NSString *)token invocationEvents:(NSArray *)invo IBGInvocationEvent resolvedEvents = 0; + for (NSString *event in invocationEvents) { resolvedEvents |= (ArgsRegistry.invocationEvents[event]).integerValue; } @@ -97,7 +106,7 @@ - (void)setWelcomeMessageModeMode:(NSString *)mode error:(FlutterError *_Nullabl } - (void)setPrimaryColorColor:(NSNumber *)color error:(FlutterError *_Nullable *_Nonnull)error { - Instabug.tintColor = UIColorFromRGB([color longValue]); +// Instabug.tintColor = UIColorFromRGB([color longValue]); } - (void)setSessionProfilerEnabledEnabled:(NSNumber *)enabled error:(FlutterError *_Nullable *_Nonnull)error { @@ -127,17 +136,7 @@ - (void)getTagsWithCompletion:(nonnull void (^)(NSArray * _Nullable, completion([Instabug getTags], nil); } -- (void)addExperimentsExperiments:(NSArray *)experiments error:(FlutterError *_Nullable *_Nonnull)error { - [Instabug addExperiments:experiments]; -} - -- (void)removeExperimentsExperiments:(NSArray *)experiments error:(FlutterError *_Nullable *_Nonnull)error { - [Instabug removeExperiments:experiments]; -} -- (void)clearAllExperimentsWithError:(FlutterError *_Nullable *_Nonnull)error { - [Instabug clearAllExperiments]; -} - (void)setUserAttributeValue:(NSString *)value key:(NSString *)key error:(FlutterError *_Nullable *_Nonnull)error { [Instabug setUserAttribute:value withKey:key]; @@ -396,4 +395,182 @@ - (void)setNetworkLogBodyEnabledIsEnabled:(NSNumber *)isEnabled IBGNetworkLogger.logBodyEnabled = [isEnabled boolValue]; } + +- (void)setAppVariantAppVariant:(nonnull NSString *)appVariant error:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + + Instabug.appVariant = appVariant; + +} + + +- (void)setThemeThemeConfig:(NSDictionary *)themeConfig error:(FlutterError *_Nullable *_Nonnull)error { + IBGTheme *theme = [[IBGTheme alloc] init]; + + NSDictionary *colorMapping = @{ + @"primaryColor": ^(UIColor *color) { theme.primaryColor = color; }, + @"backgroundColor": ^(UIColor *color) { theme.backgroundColor = color; }, + @"titleTextColor": ^(UIColor *color) { theme.titleTextColor = color; }, + @"subtitleTextColor": ^(UIColor *color) { theme.subtitleTextColor = color; }, + @"primaryTextColor": ^(UIColor *color) { theme.primaryTextColor = color; }, + @"secondaryTextColor": ^(UIColor *color) { theme.secondaryTextColor = color; }, + @"callToActionTextColor": ^(UIColor *color) { theme.callToActionTextColor = color; }, + @"headerBackgroundColor": ^(UIColor *color) { theme.headerBackgroundColor = color; }, + @"footerBackgroundColor": ^(UIColor *color) { theme.footerBackgroundColor = color; }, + @"rowBackgroundColor": ^(UIColor *color) { theme.rowBackgroundColor = color; }, + @"selectedRowBackgroundColor": ^(UIColor *color) { theme.selectedRowBackgroundColor = color; }, + @"rowSeparatorColor": ^(UIColor *color) { theme.rowSeparatorColor = color; } + }; + + for (NSString *key in colorMapping) { + if (themeConfig[key]) { + NSString *colorString = themeConfig[key]; + UIColor *color = [self colorFromHexString:colorString]; + if (color) { + void (^setter)(UIColor *) = colorMapping[key]; + setter(color); + } + } + } + + [self setFontIfPresent:themeConfig[@"primaryFontPath"] ?: themeConfig[@"primaryFontAsset"] forTheme:theme type:@"primary"]; + [self setFontIfPresent:themeConfig[@"secondaryFontPath"] ?: themeConfig[@"secondaryFontAsset"] forTheme:theme type:@"secondary"]; + [self setFontIfPresent:themeConfig[@"ctaFontPath"] ?: themeConfig[@"ctaFontAsset"] forTheme:theme type:@"cta"]; + + Instabug.theme = theme; +} + +- (void)setFontIfPresent:(NSString *)fontPath forTheme:(IBGTheme *)theme type:(NSString *)type { + if (!fontPath || fontPath.length == 0 || !theme || !type) return; + + if (!_registeredFonts) { + _registeredFonts = [NSMutableSet set]; + } + + // Check if font is already registered + if ([_registeredFonts containsObject:fontPath]) { + UIFont *font = [UIFont fontWithName:fontPath size:UIFont.systemFontSize]; + if (font) { + [self setFont:font forTheme:theme type:type]; + } + return; + } + + // Try to load font from system fonts first + UIFont *font = [UIFont fontWithName:fontPath size:UIFont.systemFontSize]; + if (font) { + [_registeredFonts addObject:fontPath]; + [self setFont:font forTheme:theme type:type]; + return; + } + + // Try to load font from bundle + font = [self loadFontFromPath:fontPath]; + if (font) { + [_registeredFonts addObject:fontPath]; + [self setFont:font forTheme:theme type:type]; + } +} + +- (UIFont *)loadFontFromPath:(NSString *)fontPath { + NSString *fontFileName = [fontPath stringByDeletingPathExtension]; + NSArray *fontExtensions = @[@"ttf", @"otf", @"woff", @"woff2"]; + + // Find font file in bundle + NSString *fontFilePath = nil; + for (NSString *extension in fontExtensions) { + fontFilePath = [[NSBundle mainBundle] pathForResource:fontFileName ofType:extension]; + if (fontFilePath) break; + } + + if (!fontFilePath) { + return nil; + } + + // Load font data + NSData *fontData = [NSData dataWithContentsOfFile:fontFilePath]; + if (!fontData) { + return nil; + } + + // Create data provider + CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)fontData); + if (!provider) { + return nil; + } + + // Create CG font + CGFontRef cgFont = CGFontCreateWithDataProvider(provider); + CGDataProviderRelease(provider); + + if (!cgFont) { + return nil; + } + + // Register font + CFErrorRef error = NULL; + BOOL registered = CTFontManagerRegisterGraphicsFont(cgFont, &error); + + if (!registered) { + if (error) { + CFStringRef description = CFErrorCopyDescription(error); + CFRelease(description); + CFRelease(error); + } + CGFontRelease(cgFont); + return nil; + } + + // Get PostScript name and create UIFont + NSString *postScriptName = (__bridge_transfer NSString *)CGFontCopyPostScriptName(cgFont); + CGFontRelease(cgFont); + + if (!postScriptName) { + return nil; + } + + return [UIFont fontWithName:postScriptName size:UIFont.systemFontSize]; +} + +- (void)setFont:(UIFont *)font forTheme:(IBGTheme *)theme type:(NSString *)type { + if (!font || !theme || !type) return; + + if ([type isEqualToString:@"primary"]) { + theme.primaryTextFont = font; + } else if ([type isEqualToString:@"secondary"]) { + theme.secondaryTextFont = font; + } else if ([type isEqualToString:@"cta"]) { + theme.callToActionTextFont = font; + } +} + +- (UIColor *)colorFromHexString:(NSString *)hexString { + NSString *cleanString = [hexString stringByReplacingOccurrencesOfString:@"#" withString:@""]; + + if (cleanString.length == 6) { + unsigned int rgbValue = 0; + NSScanner *scanner = [NSScanner scannerWithString:cleanString]; + [scanner scanHexInt:&rgbValue]; + + return [UIColor colorWithRed:((rgbValue & 0xFF0000) >> 16) / 255.0 + green:((rgbValue & 0xFF00) >> 8) / 255.0 + blue:(rgbValue & 0xFF) / 255.0 + alpha:1.0]; + } else if (cleanString.length == 8) { + unsigned int rgbaValue = 0; + NSScanner *scanner = [NSScanner scannerWithString:cleanString]; + [scanner scanHexInt:&rgbaValue]; + + return [UIColor colorWithRed:((rgbaValue & 0xFF000000) >> 24) / 255.0 + green:((rgbaValue & 0xFF0000) >> 16) / 255.0 + blue:((rgbaValue & 0xFF00) >> 8) / 255.0 + alpha:(rgbaValue & 0xFF) / 255.0]; + } + + return [UIColor blackColor]; +} + +- (void)setFullscreenIsEnabled:(NSNumber *)isEnabled error:(FlutterError *_Nullable *_Nonnull)error { + // Empty implementation as requested +} + @end diff --git a/ios/Classes/Util/IBGAPM+PrivateAPIs.h b/ios/Classes/Util/IBGAPM+PrivateAPIs.h index 22207d45b..bc8a1e90a 100644 --- a/ios/Classes/Util/IBGAPM+PrivateAPIs.h +++ b/ios/Classes/Util/IBGAPM+PrivateAPIs.h @@ -8,6 +8,7 @@ #import #import "IBGTimeIntervalUnits.h" +#import @interface IBGAPM (PrivateAPIs) @@ -15,6 +16,8 @@ /// `endScreenLoadingEnabled` will be only true if APM, screenLoadingFeature.enabled and autoUITracesUserPreference are true @property (class, atomic, assign) BOOL endScreenLoadingEnabled; ++ (void)setScreenRenderingEnabled:(BOOL)enabled; + + (void)startUITraceCPWithName:(NSString *)name startTimestampMUS:(IBGMicroSecondsTimeInterval)startTimestampMUS; + (void)reportScreenLoadingCPWithStartTimestampMUS:(IBGMicroSecondsTimeInterval)startTimestampMUS @@ -22,4 +25,12 @@ + (void)endScreenLoadingCPWithEndTimestampMUS:(IBGMicroSecondsTimeInterval)endTimestampMUS; ++ (BOOL)isScreenRenderingOperational; + ++ (void)endAutoUITraceCPWithFrames:(nullable NSArray *)frames; + ++ (void)endCustomUITraceCPWithFrames:(nullable NSArray *)frames; + ++ (double)screenRenderingThreshold; + @end diff --git a/ios/instabug_flutter.podspec b/ios/instabug_flutter.podspec index 2d898f9c6..2cf55fcdd 100644 --- a/ios/instabug_flutter.podspec +++ b/ios/instabug_flutter.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'instabug_flutter' - s.version = '15.0.1' + s.version = '14.3.0' s.summary = 'Flutter plugin for integrating the Instabug SDK.' s.author = 'Instabug' s.homepage = 'https://www.instabug.com/platforms/flutter' @@ -17,6 +17,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'OTHER_LDFLAGS' => '-framework "Flutter" -framework "InstabugSDK"'} s.dependency 'Flutter' - s.dependency 'Instabug', '15.1.1' + s.dependency 'Instabug', '15.1.32' end diff --git a/lib/instabug_flutter.dart b/lib/instabug_flutter.dart index e38545897..345aab2ef 100644 --- a/lib/instabug_flutter.dart +++ b/lib/instabug_flutter.dart @@ -3,9 +3,8 @@ export 'src/models/crash_data.dart'; export 'src/models/exception_data.dart'; export 'src/models/feature_flag.dart'; export 'src/models/network_data.dart'; -export 'src/models/trace.dart'; +export 'src/models/theme_config.dart'; export 'src/models/w3c_header.dart'; - // Modules export 'src/modules/apm.dart'; export 'src/modules/bug_reporting.dart'; @@ -20,5 +19,5 @@ export 'src/modules/surveys.dart'; // Utils export 'src/utils/instabug_navigator_observer.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; +export 'src/utils/ui_trace/route_matcher.dart'; diff --git a/lib/src/models/instabug_frame_data.dart b/lib/src/models/instabug_frame_data.dart new file mode 100644 index 000000000..d4d31c709 --- /dev/null +++ b/lib/src/models/instabug_frame_data.dart @@ -0,0 +1,20 @@ +class InstabugFrameData { + int startTimeTimestamp; + int duration; + + InstabugFrameData(this.startTimeTimestamp, this.duration); + + @override + String toString() => "start time: $startTimeTimestamp, duration: $duration"; + + @override + // ignore: hash_and_equals + bool operator ==(covariant InstabugFrameData other) { + if (identical(this, other)) return true; + return startTimeTimestamp == other.startTimeTimestamp && + duration == other.duration; + } + + /// Serializes the object to a List for efficient channel transfer. + List toList() => [startTimeTimestamp, duration]; +} diff --git a/lib/src/models/instabug_screen_render_data.dart b/lib/src/models/instabug_screen_render_data.dart new file mode 100644 index 000000000..ad9e5fe8a --- /dev/null +++ b/lib/src/models/instabug_screen_render_data.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import 'package:instabug_flutter/src/models/instabug_frame_data.dart'; +import 'package:instabug_flutter/src/utils/ibg_date_time.dart'; + +class InstabugScreenRenderData { + int traceId; + int slowFramesTotalDurationMicro; + int frozenFramesTotalDurationMicro; + int? endTimeMicro; + List frameData; + + InstabugScreenRenderData({ + this.slowFramesTotalDurationMicro = 0, + this.frozenFramesTotalDurationMicro = 0, + this.endTimeMicro, + required this.frameData, + this.traceId = -1, + }); + + bool get isEmpty => frameData.isEmpty; + + bool get isNotEmpty => frameData.isNotEmpty; + + bool get isActive => traceId != -1; + + void clear() { + traceId = -1; + frozenFramesTotalDurationMicro = 0; + slowFramesTotalDurationMicro = 0; + frameData.clear(); + } + + void saveEndTime() => + endTimeMicro = IBGDateTime.I.now().microsecondsSinceEpoch; + + @override + String toString() => '\nTraceId: $traceId\n' + 'SlowFramesTotalDuration: $slowFramesTotalDurationMicro\n' + 'FrozenFramesTotalDuration: $frozenFramesTotalDurationMicro\n' + 'EndTime: $endTimeMicro\n' + 'FrameData: [${frameData.map((element) => '$element')}]'; + + @override + // ignore: hash_and_equals + bool operator ==(covariant InstabugScreenRenderData other) { + if (identical(this, other)) return true; + return traceId == other.traceId && + slowFramesTotalDurationMicro == other.slowFramesTotalDurationMicro && + frozenFramesTotalDurationMicro == + other.frozenFramesTotalDurationMicro && + listEquals(frameData, other.frameData); + } + + /// Serializes the object to a Map for efficient channel transfer. + Map toMap() { + return { + 'traceId': traceId, + 'slowFramesTotalDuration': slowFramesTotalDurationMicro, + 'frozenFramesTotalDuration': frozenFramesTotalDurationMicro, + 'endTime': endTimeMicro, + // Convert List to List> + 'frameData': frameData.map((frame) => frame.toList()).toList(), + }; + } +} diff --git a/lib/src/models/theme_config.dart b/lib/src/models/theme_config.dart new file mode 100644 index 000000000..ea3a6754e --- /dev/null +++ b/lib/src/models/theme_config.dart @@ -0,0 +1,121 @@ +class ThemeConfig { + /// Primary color for UI elements indicating interactivity or call to action. + final String? primaryColor; + + /// Background color for the main UI. + final String? backgroundColor; + + /// Color for title text elements. + final String? titleTextColor; + + /// Color for subtitle text elements. + final String? subtitleTextColor; + + /// Color for primary text elements. + final String? primaryTextColor; + + /// Color for secondary text elements. + final String? secondaryTextColor; + + /// Color for call-to-action text elements. + final String? callToActionTextColor; + + /// Background color for header elements. + final String? headerBackgroundColor; + + /// Background color for footer elements. + final String? footerBackgroundColor; + + /// Background color for row elements. + final String? rowBackgroundColor; + + /// Background color for selected row elements. + final String? selectedRowBackgroundColor; + + /// Color for row separator lines. + final String? rowSeparatorColor; + + /// Text style for primary text (Android only). + final String? primaryTextStyle; + + /// Text style for secondary text (Android only). + final String? secondaryTextStyle; + + /// Text style for title text (Android only). + final String? titleTextStyle; + + /// Text style for call-to-action text (Android only). + final String? ctaTextStyle; + + /// Path to primary font file. + final String? primaryFontPath; + + /// Asset path to primary font file. + final String? primaryFontAsset; + + /// Path to secondary font file. + final String? secondaryFontPath; + + /// Asset path to secondary font file. + final String? secondaryFontAsset; + + /// Path to call-to-action font file. + final String? ctaFontPath; + + /// Asset path to call-to-action font file. + final String? ctaFontAsset; + + const ThemeConfig({ + this.primaryColor, + this.backgroundColor, + this.titleTextColor, + this.subtitleTextColor, + this.primaryTextColor, + this.secondaryTextColor, + this.callToActionTextColor, + this.headerBackgroundColor, + this.footerBackgroundColor, + this.rowBackgroundColor, + this.selectedRowBackgroundColor, + this.rowSeparatorColor, + this.primaryTextStyle, + this.secondaryTextStyle, + this.titleTextStyle, + this.ctaTextStyle, + this.primaryFontPath, + this.primaryFontAsset, + this.secondaryFontPath, + this.secondaryFontAsset, + this.ctaFontPath, + this.ctaFontAsset, + }); + + Map toMap() { + return Map.fromEntries( + [ + MapEntry('primaryColor', primaryColor), + MapEntry('backgroundColor', backgroundColor), + MapEntry('titleTextColor', titleTextColor), + MapEntry('subtitleTextColor', subtitleTextColor), + MapEntry('primaryTextColor', primaryTextColor), + MapEntry('secondaryTextColor', secondaryTextColor), + MapEntry('callToActionTextColor', callToActionTextColor), + MapEntry('headerBackgroundColor', headerBackgroundColor), + MapEntry('footerBackgroundColor', footerBackgroundColor), + MapEntry('rowBackgroundColor', rowBackgroundColor), + MapEntry('selectedRowBackgroundColor', selectedRowBackgroundColor), + MapEntry('rowSeparatorColor', rowSeparatorColor), + MapEntry('primaryTextStyle', primaryTextStyle), + MapEntry('secondaryTextStyle', secondaryTextStyle), + MapEntry('titleTextStyle', titleTextStyle), + MapEntry('ctaTextStyle', ctaTextStyle), + MapEntry('primaryFontPath', primaryFontPath), + MapEntry('primaryFontAsset', primaryFontAsset), + MapEntry('secondaryFontPath', secondaryFontPath), + MapEntry('secondaryFontAsset', secondaryFontAsset), + MapEntry('ctaFontPath', ctaFontPath), + MapEntry('ctaFontAsset', ctaFontAsset), + ].where((entry) => entry.value != null), + ); + } +} diff --git a/lib/src/models/trace.dart b/lib/src/models/trace.dart deleted file mode 100644 index bf267640b..000000000 --- a/lib/src/models/trace.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:instabug_flutter/src/modules/apm.dart'; - -class Trace { - Trace({ - required this.id, - required this.name, - }); - - final String id; - final String name; - final Map attributes = {}; - - /// Sets attribute of execution trace. - /// [String] id of the trace. - /// [String] key of attribute. - /// [String] value of attribute. - /// - /// Please migrate to the App Flows APIs: [APM.startFlow], [APM.setFlowAttribute], and [APM.endFlow]. - @Deprecated( - 'Please migrate to the App Flows APIs: APM.startAppFlow, APM.endFlow, and APM.setFlowAttribute. This feature was deprecated in v13.0.0', - ) - void setAttribute(String key, String value) { - APM.setExecutionTraceAttribute(id, key, value); - attributes[key] = value; - } - - /// Ends Execution Trace - /// - /// Please migrate to the App Flows APIs: [APM.startFlow], [APM.setFlowAttribute], and [APM.endFlow]. - @Deprecated( - 'Please migrate to the App Flows APIs: APM.startAppFlow, APM.endFlow, and APM.setFlowAttribute. This feature was deprecated in v13.0.0', - ) - void end() { - APM.endExecutionTrace(id); - } - - Map toJson() { - return { - 'id': id, - 'name': name, - 'attributes': attributes, - }; - } -} diff --git a/lib/src/modules/apm.dart b/lib/src/modules/apm.dart index a9f6e0a7c..3d28a0379 100644 --- a/lib/src/modules/apm.dart +++ b/lib/src/modules/apm.dart @@ -4,12 +4,13 @@ import 'dart:async'; import 'package:flutter/widgets.dart' show WidgetBuilder; import 'package:instabug_flutter/src/generated/apm.api.g.dart'; +import 'package:instabug_flutter/src/models/instabug_screen_render_data.dart'; import 'package:instabug_flutter/src/models/network_data.dart'; -import 'package:instabug_flutter/src/models/trace.dart'; import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; -import 'package:instabug_flutter/src/utils/ibg_date_time.dart'; import 'package:instabug_flutter/src/utils/instabug_logger.dart'; import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_manager.dart'; +import 'package:instabug_flutter/src/utils/screen_rendering/instabug_screen_render_manager.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/flags_config.dart'; import 'package:meta/meta.dart'; class APM { @@ -72,58 +73,6 @@ class APM { return _host.setColdAppLaunchEnabled(isEnabled); } - /// Starts an execution trace. - /// [String] name of the trace. - /// - /// Please migrate to the App Flows APIs: [startFlow], [setFlowAttribute], and [endFlow]. - @Deprecated( - 'Please migrate to the App Flows APIs: APM.startAppFlow, APM.endFlow, and APM.setFlowAttribute. This feature was deprecated in v13.0.0', - ) - static Future startExecutionTrace(String name) async { - final id = IBGDateTime.instance.now(); - final traceId = await _host.startExecutionTrace(id.toString(), name); - - if (traceId == null) { - return Future.error( - "Execution trace $name wasn't created. Please make sure to enable APM first by following " - 'the instructions at this link: https://docs.instabug.com/reference#enable-or-disable-apm', - ); - } - - return Trace( - id: traceId, - name: name, - ); - } - - /// Sets attribute of an execution trace. - /// [String] id of the trace. - /// [String] key of attribute. - /// [String] value of attribute. - /// - /// Please migrate to the App Flows APIs: [startFlow], [setFlowAttribute], and [endFlow]. - @Deprecated( - 'Please migrate to the App Flows APIs: APM.startAppFlow, APM.endFlow, and APM.setFlowAttribute. This feature was deprecated in v13.0.0', - ) - static Future setExecutionTraceAttribute( - String id, - String key, - String value, - ) async { - return _host.setExecutionTraceAttribute(id, key, value); - } - - /// Ends an execution trace. - /// [String] id of the trace. - /// - /// Please migrate to the App Flows APIs: [startFlow], [setFlowAttribute], and [endFlow]. - @Deprecated( - 'Please migrate to the App Flows APIs: APM.startAppFlow, APM.endFlow, and APM.setFlowAttribute. This feature was deprecated in v13.0.0', - ) - static Future endExecutionTrace(String id) async { - return _host.endExecutionTrace(id); - } - /// Starts an AppFlow with the given [name]. /// /// The [name] must not be an empty string. It should be unique and not exceed 150 characters, @@ -189,7 +138,25 @@ class APM { /// Returns: /// The method is returning a `Future`. static Future startUITrace(String name) async { - return _host.startUITrace(name); + final isScreenRenderingEnabled = + await FlagsConfig.screenRendering.isEnabled(); + await InstabugScreenRenderManager.I + .checkForScreenRenderInitialization(isScreenRenderingEnabled); + + // Ends the active custom ui trace before starting new one. + if (isScreenRenderingEnabled) { + InstabugScreenRenderManager.I + .endScreenRenderCollector(UiTraceType.custom); + } + return _host.startUITrace(name).then( + (_) { + // Start screen render collector for custom ui trace if enabled. + if (isScreenRenderingEnabled) { + InstabugScreenRenderManager.I + .startScreenRenderCollectorForTraceId(0, UiTraceType.custom); + } + }, + ); } /// The [endUITrace] function ends a UI trace. @@ -197,6 +164,19 @@ class APM { /// Returns: /// The method is returning a `Future`. static Future endUITrace() async { + // End screen render collector for custom ui trace if enabled. + if (InstabugScreenRenderManager.I.screenRenderEnabled) { + InstabugScreenRenderManager.I + .endScreenRenderCollector(UiTraceType.custom); + + final isAutoUiTraceEnabled = await FlagsConfig.uiTrace.isEnabled(); + + // dispose the InstabugScreenRenderManager if AutoUiTrace is disabled. + if (!isAutoUiTraceEnabled) InstabugScreenRenderManager.I.dispose(); + + return; + } + return _host.endUITrace(); } @@ -346,4 +326,84 @@ class APM { }) { return ScreenLoadingManager.wrapRoutes(routes, exclude: exclude); } + + /// Returns a Future indicating whether the auto + /// ui trace is enabled. + /// + /// Returns: + /// A Future is being returned. + @internal + static Future isAutoUiTraceEnabled() async { + return _host.isAutoUiTraceEnabled(); + } + + /// Returns a Future indicating whether the screen + /// render is enabled. + /// + /// Returns: + /// A Future is being returned. + @internal + static Future isScreenRenderEnabled() async { + return _host.isScreenRenderEnabled(); + } + + /// Retrieve the device refresh rate from native side . + /// + /// Returns: + /// A Future that represent the refresh rate. + /// Retrieve the device refresh rate and tolerance value from native side. + /// + /// Returns: + /// A Future> where the first element is the refresh rate and the second is the tolerance value. + @internal + static Future> getDeviceRefreshRateAndTolerance() { + return _host.getDeviceRefreshRateAndTolerance(); + } + + /// Sets the screen Render state based on the provided boolean value. + /// + /// Args: + /// isEnabled (bool): The [isEnabled] parameter is a boolean value that determines whether screen + /// Render is enabled or disabled. If [isEnabled] is `true`, screen render will be enabled; if + /// [isEnabled] is `false`, screen render will be disabled. + /// + /// Returns: + /// A Future is being returned. + static Future setScreenRenderingEnabled(bool isEnabled) async { + return _host.setScreenRenderEnabled(isEnabled); + } + + /// Ends screen rendering for + /// automatic UI tracing using data provided in `InstabugScreenRenderData` object. + /// + /// Args: + /// data (InstabugScreenRenderData): The `data` parameter in the `endScreenRenderForAutoUiTrace` + /// function is of type `InstabugScreenRenderData`. It contains information related to screen + /// rendering. + /// + /// Returns: + /// A `Future` is being returned. + @internal + static Future endScreenRenderForAutoUiTrace( + InstabugScreenRenderData data, + ) { + return _host.endScreenRenderForAutoUiTrace(data.toMap()); + } + + /// Ends the screen render for a custom + /// UI trace using data provided in `InstabugScreenRenderData`. + /// + /// Args: + /// data (InstabugScreenRenderData): The `data` parameter in the `endScreenRenderForCustomUiTrace` + /// function is of type `InstabugScreenRenderData`, which contains information related to the + /// rendering of a screen in the Instabug custom UI. + /// + /// Returns: + /// A `Future` is being returned. + @internal + static Future endScreenRenderForCustomUiTrace( + InstabugScreenRenderData data, + ) { + return _host.endScreenRenderForCustomUiTrace(data.toMap()); + } } diff --git a/lib/src/modules/instabug.dart b/lib/src/modules/instabug.dart index 6bba8ed1f..7dc40873a 100644 --- a/lib/src/modules/instabug.dart +++ b/lib/src/modules/instabug.dart @@ -11,6 +11,7 @@ import 'dart:typed_data'; import 'dart:ui'; import 'package:flutter/material.dart'; + // to maintain supported versions prior to Flutter 3.3 // ignore: unused_import import 'package:flutter/services.dart'; @@ -21,6 +22,7 @@ import 'package:instabug_flutter/src/utils/feature_flags_manager.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/screen_name_masker.dart'; +import 'package:instabug_flutter/src/utils/screen_rendering/instabug_widget_binding_observer.dart'; import 'package:meta/meta.dart'; enum InvocationEvent { @@ -135,6 +137,23 @@ enum CustomTextPlaceHolderKey { enum ReproStepsMode { enabled, disabled, enabledWithNoScreenshots } +/// Disposal manager for handling Android lifecycle events +class _InstabugDisposalManager implements InstabugFlutterApi { + _InstabugDisposalManager._(); + + static final _InstabugDisposalManager _instance = + _InstabugDisposalManager._(); + + static _InstabugDisposalManager get instance => _instance; + + @override + void dispose() { + // Call the InstabugWidgetsBindingObserver dispose method when Android onPause is triggered + // to overcome calling onActivityDestroy() from android side before sending the data to it. + InstabugWidgetsBindingObserver.dispose(); + } +} + class Instabug { static var _host = InstabugHostApi(); @@ -153,6 +172,8 @@ class Instabug { BugReporting.$setup(); Replies.$setup(); Surveys.$setup(); + // Set up InstabugFlutterApi for Android onDestroy disposal + InstabugFlutterApi.setup(_InstabugDisposalManager.instance); } /// @nodoc @@ -179,10 +200,12 @@ class Instabug { /// The [token] that identifies the app, you can find it on your dashboard. /// The [invocationEvents] are the events that invoke the SDK's UI. /// The [debugLogsLevel] used to debug Instabug's SDK. + /// The [appVariant] used to set current App variant name. static Future init({ required String token, required List invocationEvents, LogLevel debugLogsLevel = LogLevel.error, + String? appVariant, }) async { $setup(); InstabugLogger.I.logLevel = debugLogsLevel; @@ -190,6 +213,7 @@ class Instabug { token, invocationEvents.mapToString(), debugLogsLevel.toString(), + appVariant, ); return FeatureFlagsManager().registerW3CFlagsListener(); } @@ -258,31 +282,6 @@ class Instabug { return tags?.cast(); } - /// Adds experiments to the next report. - @Deprecated( - 'Please migrate to the new feature flags APIs: Instabug.addFeatureFlags.', - ) - static Future addExperiments(List experiments) async { - return _host.addExperiments(experiments); - } - - /// Removes certain experiments from the next report. - @Deprecated( - 'Please migrate to the new feature flags APIs: Instabug.removeFeatureFlags.', - ) - static Future removeExperiments(List experiments) async { - return _host.removeExperiments(experiments); - } - - /// Clears all experiments from the next report. - - @Deprecated( - 'Please migrate to the new feature flags APIs: Instabug.clearAllFeatureFlags.', - ) - static Future clearAllExperiments() async { - return _host.clearAllExperiments(); - } - /// Adds feature flags to the next report. static Future addFeatureFlags(List featureFlags) async { final map = {}; @@ -358,8 +357,13 @@ class Instabug { /// Sets the primary color of the SDK's UI. /// Sets the color of UI elements indicating interactivity or call to action. /// [color] primaryColor A color to set the UI elements of the SDK to. + /// + /// Note: This API is deprecated. Please use `Instabug.setTheme` instead. + @Deprecated( + 'This API is deprecated. Please use Instabug.setTheme instead.', + ) static Future setPrimaryColor(Color color) async { - return _host.setPrimaryColor(color.value); + await setTheme(ThemeConfig(primaryColor: color.toString())); } /// Adds specific user data that you need to be added to the reports @@ -482,4 +486,51 @@ class Instabug { static Future willRedirectToStore() async { return _host.willRedirectToStore(); } + + /// This property sets the `appVariant` string to be included in all network requests. + /// It should be set before calling [init] method. + /// [appVariant] used to set current App variant name + static Future setAppVariant(String appVariant) async { + return _host.setAppVariant(appVariant); + } + + /// Sets a custom theme for Instabug UI elements. + /// + /// @param theme - Configuration object containing theme properties + /// + /// Example: + /// ```dart + /// + /// Instabug.setTheme(ThemeConfig( + /// primaryColor: '#FF6B6B', + /// secondaryTextColor: '#666666', + /// primaryTextColor: '#333333', + /// titleTextColor: '#000000', + /// backgroundColor: '#FFFFFF', + /// primaryTextStyle: 'bold', + /// secondaryTextStyle: 'normal', + /// titleTextStyle: 'bold', + /// ctaTextStyle: 'bold', + /// primaryFontPath: '/data/user/0/com.yourapp/files/fonts/YourFont.ttf', + /// secondaryFontPath: '/data/user/0/com.yourapp/files/fonts/YourFont.ttf', + /// ctaFontPath: '/data/user/0/com.yourapp/files/fonts/YourFont.ttf', + /// primaryFontAsset: 'fonts/YourFont.ttf', + /// secondaryFontAsset: 'fonts/YourFont.ttf' + /// )); + /// ``` + static Future setTheme(ThemeConfig themeConfig) async { + return _host.setTheme(themeConfig.toMap()); + } + + /// Sets the fullscreen mode for Instabug UI. + /// + /// [isFullscreen] - Whether to enable fullscreen mode or not. + /// + /// Example: + /// ```dart + /// Instabug.setFullscreen(true); + /// ``` + static Future setFullscreen(bool isEnabled) async { + return _host.setFullscreen(isEnabled); + } } diff --git a/lib/src/utils/instabug_navigator_observer.dart b/lib/src/utils/instabug_navigator_observer.dart index d9d6b02db..d10c59e6c 100644 --- a/lib/src/utils/instabug_navigator_observer.dart +++ b/lib/src/utils/instabug_navigator_observer.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; import 'package:instabug_flutter/src/models/instabug_route.dart'; @@ -6,6 +8,8 @@ import 'package:instabug_flutter/src/utils/instabug_logger.dart'; import 'package:instabug_flutter/src/utils/repro_steps_constants.dart'; import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_manager.dart'; import 'package:instabug_flutter/src/utils/screen_name_masker.dart'; +import 'package:instabug_flutter/src/utils/screen_rendering/instabug_screen_render_manager.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/flags_config.dart'; class InstabugNavigatorObserver extends NavigatorObserver { final List _steps = []; @@ -23,8 +27,11 @@ class InstabugNavigatorObserver extends NavigatorObserver { name: maskedScreenName, ); - // Starts a the new UI trace which is exclusive to screen loading - ScreenLoadingManager.I.startUiTrace(maskedScreenName, screenName); + InstabugScreenRenderManager.I.endScreenRenderCollector(); + ScreenLoadingManager.I + .startUiTrace(maskedScreenName, screenName) + .then(_startScreenRenderCollector); + // If there is a step that hasn't been pushed yet if (_steps.isNotEmpty) { // Report the last step and remove it from the list @@ -58,4 +65,16 @@ class InstabugNavigatorObserver extends NavigatorObserver { void didPush(Route route, Route? previousRoute) { screenChanged(route); } + + FutureOr _startScreenRenderCollector(int? uiTraceId) async { + if (uiTraceId == null) return; + final isScreenRenderEnabled = await FlagsConfig.screenRendering.isEnabled(); + + await InstabugScreenRenderManager.I + .checkForScreenRenderInitialization(isScreenRenderEnabled); + if (isScreenRenderEnabled) { + InstabugScreenRenderManager.I + .startScreenRenderCollectorForTraceId(uiTraceId); + } + } } diff --git a/lib/src/utils/screen_loading/screen_loading_manager.dart b/lib/src/utils/screen_loading/screen_loading_manager.dart index b01b77627..9a2747622 100644 --- a/lib/src/utils/screen_loading/screen_loading_manager.dart +++ b/lib/src/utils/screen_loading/screen_loading_manager.dart @@ -4,9 +4,9 @@ import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; import 'package:instabug_flutter/src/utils/ibg_date_time.dart'; import 'package:instabug_flutter/src/utils/instabug_logger.dart'; import 'package:instabug_flutter/src/utils/instabug_montonic_clock.dart'; -import 'package:instabug_flutter/src/utils/screen_loading/flags_config.dart'; import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_trace.dart'; -import 'package:instabug_flutter/src/utils/screen_loading/ui_trace.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/flags_config.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/ui_trace.dart'; import 'package:meta/meta.dart'; /// Manages screen loading traces and UI traces for performance monitoring. @@ -139,7 +139,7 @@ class ScreenLoadingManager { /// [matchingScreenName] as the screen name used for matching the UI trace /// with a Screen Loading trace. @internal - Future startUiTrace( + Future startUiTrace( String screenName, [ String? matchingScreenName, ]) async { @@ -150,21 +150,19 @@ class ScreenLoadingManager { final isSDKBuilt = await _checkInstabugSDKBuilt("APM.InstabugCaptureScreenLoading"); - if (!isSDKBuilt) return; - // TODO: On Android, FlagsConfig.apm.isEnabled isn't implemented correctly - // so we skip the isApmEnabled check on Android and only check on iOS. - // This is a temporary fix until we implement the isEnabled check correctly. - // We need to fix this in the future. - final isApmEnabled = await FlagsConfig.apm.isEnabled(); - if (!isApmEnabled && IBGBuildInfo.I.isIOS) { + if (!isSDKBuilt) return null; + + final isAutoUiTraceEnabled = await FlagsConfig.uiTrace.isEnabled(); + + if (!isAutoUiTraceEnabled) { InstabugLogger.I.e( 'APM is disabled, skipping starting the UI trace for screen: $screenName.\n' 'Please refer to the documentation for how to enable APM on your app: ' 'https://docs.instabug.com/docs/react-native-apm-disabling-enabling', tag: APM.tag, ); - return; + return null; } final sanitizedScreenName = sanitizeScreenName(screenName); @@ -181,8 +179,10 @@ class ScreenLoadingManager { matchingScreenName: sanitizedMatchingScreenName, traceId: uiTraceId, ); + return uiTraceId; } catch (error, stackTrace) { _logExceptionErrorAndStackTrace(error, stackTrace); + return null; } } diff --git a/lib/src/utils/screen_rendering/instabug_screen_render_manager.dart b/lib/src/utils/screen_rendering/instabug_screen_render_manager.dart new file mode 100644 index 000000000..4beb720af --- /dev/null +++ b/lib/src/utils/screen_rendering/instabug_screen_render_manager.dart @@ -0,0 +1,448 @@ +import 'dart:async'; +import 'dart:ui' show TimingsCallback, FrameTiming, FramePhase; + +import 'package:flutter/widgets.dart'; +import 'package:instabug_flutter/instabug_flutter.dart' show CrashReporting; +import 'package:instabug_flutter/src/models/instabug_frame_data.dart'; +import 'package:instabug_flutter/src/models/instabug_screen_render_data.dart'; +import 'package:instabug_flutter/src/modules/apm.dart'; +import 'package:instabug_flutter/src/utils/instabug_logger.dart'; +import 'package:instabug_flutter/src/utils/screen_rendering/instabug_widget_binding_observer.dart'; +import 'package:meta/meta.dart'; + +@internal +enum UiTraceType { + auto, + custom, +} + +@internal +class InstabugScreenRenderManager { + WidgetsBinding? _widgetsBinding; + late int _buildTimeMs; + late int _rasterTimeMs; + late int _totalTimeMs; + late TimingsCallback _timingsCallback; + late InstabugScreenRenderData _screenRenderForAutoUiTrace; + late InstabugScreenRenderData _screenRenderForCustomUiTrace; + int _slowFramesTotalDurationMicro = 0; + int _frozenFramesTotalDurationMicro = 0; + int? _epochOffset; + bool _isTimingsListenerAttached = false; + bool screenRenderEnabled = false; + bool _isWidgetBindingObserverAdded = false; + + final List _delayedFrames = []; + + /// Default refresh rate for 60 FPS displays in milliseconds (16.67ms) + double _slowFrameThresholdMs = 16.67; + + /// Default frozen frame threshold in milliseconds (700ms) + final _frozenFrameThresholdMs = 700; + + InstabugScreenRenderManager._(); + + static InstabugScreenRenderManager _instance = + InstabugScreenRenderManager._(); + + /// Returns the singleton instance of [InstabugScreenRenderManager]. + static InstabugScreenRenderManager get instance => _instance; + + /// Shorthand for [instance] + static InstabugScreenRenderManager get I => instance; + + /// Logging tag for debugging purposes. + static const tag = "ScreenRenderManager"; + + /// setup function for [InstabugScreenRenderManager] + @internal + Future init(WidgetsBinding? widgetBinding) async { + try { + // passing WidgetsBinding? (nullable) for flutter versions prior than 3.x + if (!screenRenderEnabled && widgetBinding != null) { + _widgetsBinding = widgetBinding; + _addWidgetBindingObserver(); + await _initStaticValues(); + _initFrameTimings(); + screenRenderEnabled = true; + } + } catch (error, stackTrace) { + _logExceptionErrorAndStackTrace(error, stackTrace); + } + } + + /// Analyze frame data to detect slow or frozen frames efficiently. + @visibleForTesting + void analyzeFrameTiming(FrameTiming frameTiming) { + _buildTimeMs = frameTiming.buildDuration.inMilliseconds; + _rasterTimeMs = frameTiming.rasterDuration.inMilliseconds; + _totalTimeMs = frameTiming.totalSpan.inMilliseconds; + + if (_isTotalTimeLarge) { + final micros = frameTiming.totalSpan.inMicroseconds; + _frozenFramesTotalDurationMicro += micros; + _onDelayedFrameDetected( + _getMicrosecondsSinceEpoch( + frameTiming.timestampInMicroseconds(FramePhase.vsyncStart), + ), + micros, + ); + return; + } + + if (_isUiSlow) { + final micros = frameTiming.buildDuration.inMicroseconds; + _slowFramesTotalDurationMicro += micros; + _onDelayedFrameDetected( + _getMicrosecondsSinceEpoch( + frameTiming.timestampInMicroseconds(FramePhase.buildStart), + ), + micros, + ); + return; + } + + if (_isRasterSlow) { + final micros = frameTiming.rasterDuration.inMicroseconds; + _slowFramesTotalDurationMicro += micros; + _onDelayedFrameDetected( + _getMicrosecondsSinceEpoch( + frameTiming.timestampInMicroseconds(FramePhase.rasterStart), + ), + micros, + ); + } + } + + /// Start collecting screen render data for the running [UITrace]. + /// It ends the running collector when starting a new one of the same type [UiTraceType]. + @internal + void startScreenRenderCollectorForTraceId( + int traceId, [ + UiTraceType type = UiTraceType.auto, + ]) { + // Return if frameTimingListener not attached + if (_frameCollectorIsNotActive) return; + + if (type == UiTraceType.custom) { + _screenRenderForCustomUiTrace.traceId = traceId; + } + + if (type == UiTraceType.auto) { + _screenRenderForAutoUiTrace.traceId = traceId; + } + } + + @internal + void endScreenRenderCollector([ + UiTraceType type = UiTraceType.auto, + ]) { + // Return if frameTimingListener not attached + if (_frameCollectorIsNotActive) return; + + //Save the memory cached data to be sent to native side + if (_delayedFrames.isNotEmpty) { + _saveCollectedData(); + _resetCachedFrameData(); + } + + //Sync the captured screen render data of the Custom UI trace if the collector was active + if (type == UiTraceType.custom && _screenRenderForCustomUiTrace.isActive) { + _reportScreenRenderForCustomUiTrace(_screenRenderForCustomUiTrace); + _screenRenderForCustomUiTrace.clear(); + } + + //Sync the captured screen render data of the Auto UI trace if the collector was active + if (type == UiTraceType.auto && _screenRenderForAutoUiTrace.isActive) { + _reportScreenRenderForAutoUiTrace(_screenRenderForAutoUiTrace); + _screenRenderForAutoUiTrace.clear(); + } + } + + /// Stop screen render collector and sync the captured data. + @internal + void stopScreenRenderCollector() { + // Return if frameTimingListener not attached + if (_frameCollectorIsNotActive) return; + + if (_delayedFrames.isNotEmpty) { + _saveCollectedData(); + _resetCachedFrameData(); + } + + // Sync Screen Render data for custom ui trace if exists + if (_screenRenderForCustomUiTrace.isActive) { + _reportScreenRenderForCustomUiTrace(_screenRenderForCustomUiTrace); + _screenRenderForCustomUiTrace.clear(); + } + + // Sync Screen Render data for auto ui trace if exists + if (_screenRenderForAutoUiTrace.isActive) { + _reportScreenRenderForAutoUiTrace(_screenRenderForAutoUiTrace); + _screenRenderForAutoUiTrace.clear(); + } + } + + @internal + Future checkForScreenRenderInitialization( + bool isScreenRenderEnabledFromBackend, + ) async { + if (isScreenRenderEnabledFromBackend) { + if (!screenRenderEnabled) { + await init(WidgetsBinding.instance); + } + } else { + if (screenRenderEnabled) { + dispose(); + } + } + } + + /// Dispose InstabugScreenRenderManager by removing timings callback and cashed data. + @internal + void dispose() { + try { + _resetCachedFrameData(); + _removeFrameTimings(); + _removeWidgetBindingObserver(); + _widgetsBinding = null; + screenRenderEnabled = false; + _epochOffset = null; + } catch (error, stackTrace) { + _logExceptionErrorAndStackTrace(error, stackTrace); + } + } + + /// --------------------------- private methods --------------------- + + bool get _isUiSlow => + _buildTimeMs > _slowFrameThresholdMs && + _buildTimeMs < _frozenFrameThresholdMs; + + bool get _isRasterSlow => + _rasterTimeMs > _slowFrameThresholdMs && + _rasterTimeMs < _frozenFrameThresholdMs; + + bool get _isTotalTimeLarge => _totalTimeMs >= _frozenFrameThresholdMs; + + bool get _frameCollectorIsNotActive => + !screenRenderEnabled || !_isTimingsListenerAttached; + + /// Calculate the target time for the frame to be drawn in milliseconds based on the device refresh rate. + double _targetMsPerFrame(double displayRefreshRate) => + 1 / displayRefreshRate * 1000; + + /// Get device refresh rate from native side. + Future> get _getDeviceRefreshRateAndToleranceFromNative => + APM.getDeviceRefreshRateAndTolerance(); + + /// add new [WidgetsBindingObserver] to track app lifecycle. + void _addWidgetBindingObserver() { + if (_widgetsBinding == null) { + return; + } + if (!_isWidgetBindingObserverAdded) { + _widgetsBinding!.addObserver(InstabugWidgetsBindingObserver.instance); + _isWidgetBindingObserverAdded = true; + } + } + + /// remove [WidgetsBindingObserver] from [WidgetsBinding] + void _removeWidgetBindingObserver() { + if (_widgetsBinding == null) { + return; + } + if (_isWidgetBindingObserverAdded) { + _widgetsBinding!.removeObserver(InstabugWidgetsBindingObserver.instance); + _isWidgetBindingObserverAdded = false; + } + } + + /// Initialize the static variables + Future _initStaticValues() async { + _timingsCallback = (timings) { + // Establish the offset on the first available timing. + _epochOffset ??= _getEpochOffset(timings.first); + + for (final frameTiming in timings) { + analyzeFrameTiming(frameTiming); + } + }; + _slowFrameThresholdMs = await _getSlowFrameThresholdMs; + _screenRenderForAutoUiTrace = InstabugScreenRenderData(frameData: []); + _screenRenderForCustomUiTrace = InstabugScreenRenderData(frameData: []); + } + + Future get _getSlowFrameThresholdMs async { + final deviceRefreshRateAndTolerance = + await _getDeviceRefreshRateAndToleranceFromNative; + final deviceRefreshRate = deviceRefreshRateAndTolerance[0] ?? + 60; // default to 60 FPS if not available + final toleranceMs = (deviceRefreshRateAndTolerance[1] ?? 10) / + 1000; // convert the tolerance from microseconds to milliseconds + final targetMsPerFrame = _targetMsPerFrame(deviceRefreshRate); + return double.parse( + (targetMsPerFrame + toleranceMs).toStringAsFixed( + 2, + ), + ); // round the result to the nearest 2 precision digits + } + + int _getEpochOffset(FrameTiming firstPatchedFrameTiming) { + return DateTime.now().microsecondsSinceEpoch - + firstPatchedFrameTiming.timestampInMicroseconds(FramePhase.vsyncStart); + } + + /// Add a frame observer by calling [WidgetsBinding.instance.addTimingsCallback] + void _initFrameTimings() { + if (_isTimingsListenerAttached) { + return; // A timings callback is already attached + } + _widgetsBinding?.addTimingsCallback(_timingsCallback); + _isTimingsListenerAttached = true; + } + + /// Remove the running frame observer by calling [_widgetsBinding.removeTimingsCallback] + void _removeFrameTimings() { + if (!_isTimingsListenerAttached) return; // No timings callback attached. + _widgetsBinding?.removeTimingsCallback(_timingsCallback); + _isTimingsListenerAttached = false; + } + + /// Reset the memory cashed data + void _resetCachedFrameData() { + _slowFramesTotalDurationMicro = 0; + _frozenFramesTotalDurationMicro = 0; + _delayedFrames.clear(); + } + + /// Save Slow/Frozen Frames data + void _onDelayedFrameDetected(int startTime, int durationInMicroseconds) { + _delayedFrames.add( + InstabugFrameData( + startTime, + durationInMicroseconds, + ), + ); + } + + /// Ends custom ui trace with the screen render data that has been collected for it. + /// params: + /// [InstabugScreenRenderData] screenRenderData. + Future _reportScreenRenderForCustomUiTrace( + InstabugScreenRenderData screenRenderData, + ) async { + try { + screenRenderData.saveEndTime(); + await APM.endScreenRenderForCustomUiTrace(screenRenderData); + return true; + } catch (error, stackTrace) { + _logExceptionErrorAndStackTrace(error, stackTrace); + return false; + } + } + + /// Ends auto ui trace with the screen render data that has been collected for it. + /// params: + /// [InstabugScreenRenderData] screenRenderData. + Future _reportScreenRenderForAutoUiTrace( + InstabugScreenRenderData screenRenderData, + ) async { + try { + // Save the end time for the running ui trace, it's only needed in Android SDK. + screenRenderData.saveEndTime(); + await APM.endScreenRenderForAutoUiTrace(screenRenderData); + + return true; + } catch (error, stackTrace) { + _logExceptionErrorAndStackTrace(error, stackTrace); + return false; + } + } + + /// Add the memory cashed data to the objects that will be synced asynchronously to the native side. + void _saveCollectedData() { + if (_screenRenderForAutoUiTrace.isActive) { + _updateAutoUiData(); + } + if (_screenRenderForCustomUiTrace.isActive) { + _updateCustomUiData(); + } + } + + /// Updates the custom UI trace screen render data with the currently collected + /// frame information and durations. + /// + /// This method accumulates the total duration of slow and frozen frames (in microseconds) + /// for the custom UI trace, and appends the list of delayed frames collected so far + /// to the trace's frame data. This prepares the custom UI trace data to be reported + /// or synced with the native side. + void _updateCustomUiData() { + _screenRenderForCustomUiTrace.slowFramesTotalDurationMicro += + _slowFramesTotalDurationMicro; + _screenRenderForCustomUiTrace.frozenFramesTotalDurationMicro += + _frozenFramesTotalDurationMicro; + _screenRenderForCustomUiTrace.frameData.addAll(_delayedFrames); + } + + /// Updates the auto UI trace screen render data with the currently collected + /// frame information and durations. + /// + /// This method accumulates the total duration of slow and frozen frames (in microseconds) + /// for the auto UI trace, and appends the list of delayed frames collected so far + /// to the trace's frame data. This prepares the auto UI trace data to be reported + /// or synced with the native side. + void _updateAutoUiData() { + _screenRenderForAutoUiTrace.slowFramesTotalDurationMicro += + _slowFramesTotalDurationMicro; + _screenRenderForAutoUiTrace.frozenFramesTotalDurationMicro += + _frozenFramesTotalDurationMicro; + _screenRenderForAutoUiTrace.frameData.addAll(_delayedFrames); + } + + /// @nodoc + void _logExceptionErrorAndStackTrace(Object error, StackTrace stackTrace) { + //Log the crash details to the user. + InstabugLogger.I.e( + '[Error]:$error \n' + '[StackTrace]: $stackTrace', + tag: tag, + ); + + //Report nonfatal for the crash details. + CrashReporting.reportHandledCrash( + error, + stackTrace, + ); + } + + /// @nodoc + int _getMicrosecondsSinceEpoch(int timeInMicroseconds) => + timeInMicroseconds + (_epochOffset ?? 0); + + /// --------------------------- testing helper methods --------------------- + + @visibleForTesting + InstabugScreenRenderManager.init(); + + @visibleForTesting + // ignore: use_setters_to_change_properties + static void setInstance(InstabugScreenRenderManager instance) { + _instance = instance; + } + + @visibleForTesting + InstabugScreenRenderData get screenRenderForAutoUiTrace => + _screenRenderForAutoUiTrace; + + @visibleForTesting + InstabugScreenRenderData get screenRenderForCustomUiTrace => + _screenRenderForCustomUiTrace; + + @visibleForTesting + void setFrameData(InstabugScreenRenderData data) { + _delayedFrames.addAll(data.frameData); + _frozenFramesTotalDurationMicro = data.frozenFramesTotalDurationMicro; + _slowFramesTotalDurationMicro = data.slowFramesTotalDurationMicro; + } +} diff --git a/lib/src/utils/screen_rendering/instabug_widget_binding_observer.dart b/lib/src/utils/screen_rendering/instabug_widget_binding_observer.dart new file mode 100644 index 000000000..a085cf011 --- /dev/null +++ b/lib/src/utils/screen_rendering/instabug_widget_binding_observer.dart @@ -0,0 +1,109 @@ +import 'package:flutter/widgets.dart'; +import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; +import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_manager.dart'; +import 'package:instabug_flutter/src/utils/screen_name_masker.dart'; +import 'package:instabug_flutter/src/utils/screen_rendering/instabug_screen_render_manager.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/flags_config.dart'; +import 'package:meta/meta.dart'; + +class InstabugWidgetsBindingObserver extends WidgetsBindingObserver { + InstabugWidgetsBindingObserver._(); + + static final InstabugWidgetsBindingObserver _instance = + InstabugWidgetsBindingObserver._(); + + /// Returns the singleton instance of [InstabugWidgetsBindingObserver]. + static InstabugWidgetsBindingObserver get instance => _instance; + + /// Shorthand for [instance] + static InstabugWidgetsBindingObserver get I => instance; + + /// Logging tag for debugging purposes. + static const tag = "InstabugWidgetsBindingObserver"; + + /// Disposes all screen render resources. + static void dispose() { + //Save the screen rendering data for the active traces Auto|Custom. + InstabugScreenRenderManager.I.stopScreenRenderCollector(); + + // The dispose method is safe to call multiple times due to state tracking + InstabugScreenRenderManager.I.dispose(); + } + + void _handleResumedState() { + final lastUiTrace = ScreenLoadingManager.I.currentUiTrace; + + if (lastUiTrace == null) return; + + final maskedScreenName = ScreenNameMasker.I.mask(lastUiTrace.screenName); + + ScreenLoadingManager.I + .startUiTrace(maskedScreenName, lastUiTrace.screenName) + .then((uiTraceId) async { + if (uiTraceId == null) return; + + final isScreenRenderEnabled = + await FlagsConfig.screenRendering.isEnabled(); + + if (!isScreenRenderEnabled) return; + + await InstabugScreenRenderManager.I + .checkForScreenRenderInitialization(isScreenRenderEnabled); + + //End any active ScreenRenderCollector before starting a new one (Safe garde condition). + InstabugScreenRenderManager.I.endScreenRenderCollector(); + + //Start new ScreenRenderCollector. + InstabugScreenRenderManager.I + .startScreenRenderCollectorForTraceId(uiTraceId); + }); + } + + void _handlePausedState() { + // Only handles iOS platform because in android we use pigeon @FlutterApi(). + // To overcome the onActivityDestroy() before sending the data to the android side. + if (InstabugScreenRenderManager.I.screenRenderEnabled && + IBGBuildInfo.I.isIOS) { + InstabugScreenRenderManager.I.stopScreenRenderCollector(); + } + } + + Future _handleDetachedState() async { + // Only handles iOS platform because in android we use pigeon @FlutterApi(). + // To overcome the onActivityDestroy() before sending the data to the android side. + if (InstabugScreenRenderManager.I.screenRenderEnabled && + IBGBuildInfo.I.isIOS) { + dispose(); + } + } + + void _handleDefaultState() { + // Added for lint warnings + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + _handleResumedState(); + break; + case AppLifecycleState.paused: + _handlePausedState(); + break; + case AppLifecycleState.detached: + _handleDetachedState(); + break; + default: + _handleDefaultState(); + } + } +} + +@internal +void checkForWidgetBinding() { + try { + WidgetsBinding.instance; + } catch (_) { + WidgetsFlutterBinding.ensureInitialized(); + } +} diff --git a/lib/src/utils/screen_loading/flags_config.dart b/lib/src/utils/ui_trace/flags_config.dart similarity index 74% rename from lib/src/utils/screen_loading/flags_config.dart rename to lib/src/utils/ui_trace/flags_config.dart index f18eb1ccb..bc447b70b 100644 --- a/lib/src/utils/screen_loading/flags_config.dart +++ b/lib/src/utils/ui_trace/flags_config.dart @@ -5,6 +5,7 @@ enum FlagsConfig { uiTrace, screenLoading, endScreenLoading, + screenRendering, } extension FeatureExtensions on FlagsConfig { @@ -12,10 +13,14 @@ extension FeatureExtensions on FlagsConfig { switch (this) { case FlagsConfig.apm: return APM.isEnabled(); + case FlagsConfig.uiTrace: + return APM.isAutoUiTraceEnabled(); case FlagsConfig.screenLoading: return APM.isScreenLoadingEnabled(); case FlagsConfig.endScreenLoading: return APM.isEndScreenLoadingEnabled(); + case FlagsConfig.screenRendering: + return APM.isScreenRenderEnabled(); default: return false; } diff --git a/lib/src/utils/screen_loading/route_matcher.dart b/lib/src/utils/ui_trace/route_matcher.dart similarity index 100% rename from lib/src/utils/screen_loading/route_matcher.dart rename to lib/src/utils/ui_trace/route_matcher.dart diff --git a/lib/src/utils/screen_loading/ui_trace.dart b/lib/src/utils/ui_trace/ui_trace.dart similarity index 94% rename from lib/src/utils/screen_loading/ui_trace.dart rename to lib/src/utils/ui_trace/ui_trace.dart index 17ef41046..34c88cbc0 100644 --- a/lib/src/utils/screen_loading/ui_trace.dart +++ b/lib/src/utils/ui_trace/ui_trace.dart @@ -1,4 +1,4 @@ -import 'package:instabug_flutter/src/utils/screen_loading/route_matcher.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/route_matcher.dart'; class UiTrace { final String screenName; diff --git a/pigeons/apm.api.dart b/pigeons/apm.api.dart index 84fe9eb8e..5d7bc1ea7 100644 --- a/pigeons/apm.api.dart +++ b/pigeons/apm.api.dart @@ -11,18 +11,9 @@ abstract class ApmHostApi { void setColdAppLaunchEnabled(bool isEnabled); void setAutoUITraceEnabled(bool isEnabled); - @async - String? startExecutionTrace(String id, String name); - void startFlow(String name); void setFlowAttribute(String name, String key, String? value); void endFlow(String name); - void setExecutionTraceAttribute( - String id, - String key, - String value, - ); - void endExecutionTrace(String id); void startUITrace(String name); void endUITrace(); void endAppLaunch(); @@ -40,4 +31,19 @@ abstract class ApmHostApi { @async bool isEndScreenLoadingEnabled(); + + @async + bool isAutoUiTraceEnabled(); + + @async + bool isScreenRenderEnabled(); + + @async + List getDeviceRefreshRateAndTolerance(); + + void setScreenRenderEnabled(bool isEnabled); + + void endScreenRenderForAutoUiTrace(Map data); + + void endScreenRenderForCustomUiTrace(Map data); } diff --git a/pigeons/instabug.api.dart b/pigeons/instabug.api.dart index 275306987..eede56de7 100644 --- a/pigeons/instabug.api.dart +++ b/pigeons/instabug.api.dart @@ -9,42 +9,67 @@ abstract class FeatureFlagsFlutterApi { ); } +@FlutterApi() +abstract class InstabugFlutterApi { + void dispose(); +} + @HostApi() abstract class InstabugHostApi { void setEnabled(bool isEnabled); + bool isEnabled(); + bool isBuilt(); - void init(String token, List invocationEvents, String debugLogsLevel); + + void init( + String token, + List invocationEvents, + String debugLogsLevel, + String? appVariant, + ); void show(); + void showWelcomeMessageWithMode(String mode); void identifyUser(String email, String? name, String? userId); + void setUserData(String data); + + void setAppVariant(String appVariant); + void logUserEvent(String name); + void logOut(); void setLocale(String locale); + void setColorTheme(String theme); + void setWelcomeMessageMode(String mode); + void setPrimaryColor(int color); + void setSessionProfilerEnabled(bool enabled); + void setValueForStringWithKey(String value, String key); void appendTags(List tags); + void resetTags(); @async List? getTags(); - void addExperiments(List experiments); - void removeExperiments(List experiments); - void clearAllExperiments(); void addFeatureFlags(Map featureFlagsMap); + void removeFeatureFlags(List featureFlags); + void removeAllFeatureFlags(); void setUserAttribute(String value, String key); + void removeUserAttribute(String key); @async @@ -58,13 +83,17 @@ abstract class InstabugHostApi { String? crashMode, String? sessionReplayMode, ); + void reportScreenChange(String screenName); void setCustomBrandingImage(String light, String dark); + void setFont(String font); void addFileAttachmentWithURL(String filePath, String fileName); + void addFileAttachmentWithData(Uint8List data, String fileName); + void clearFileAttachments(); void networkLog(Map data); @@ -76,4 +105,7 @@ abstract class InstabugHostApi { void willRedirectToStore(); void setNetworkLogBodyEnabled(bool isEnabled); + + void setTheme(Map themeConfig); + void setFullscreen(bool isEnabled); } diff --git a/pubspec.yaml b/pubspec.yaml index b6957c5b8..86884fb71 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: instabug_flutter -version: 15.0.1 +version: 15.0.2 description: >- Instabug empowers mobile teams to monitor, prioritize, and debug performance and stability issues throughout the app development lifecycle. diff --git a/scripts/pigeon.sh b/scripts/pigeon.sh old mode 100644 new mode 100755 diff --git a/test/apm_test.dart b/test/apm_test.dart index c801926f3..7a9e78230 100644 --- a/test/apm_test.dart +++ b/test/apm_test.dart @@ -4,6 +4,7 @@ import 'package:instabug_flutter/instabug_flutter.dart'; import 'package:instabug_flutter/src/generated/apm.api.g.dart'; import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; import 'package:instabug_flutter/src/utils/ibg_date_time.dart'; +import 'package:instabug_flutter/src/utils/screen_rendering/instabug_screen_render_manager.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -13,6 +14,7 @@ import 'apm_test.mocks.dart'; ApmHostApi, IBGDateTime, IBGBuildInfo, + InstabugScreenRenderManager, ]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -21,6 +23,7 @@ void main() { final mHost = MockApmHostApi(); final mDateTime = MockIBGDateTime(); final mBuildInfo = MockIBGBuildInfo(); + final mScreenRenderManager = MockInstabugScreenRenderManager(); setUpAll(() { APM.$setHostApi(mHost); @@ -86,46 +89,10 @@ void main() { ).called(1); }); - test('[startExecutionTrace] should call host method', () async { - final id = DateTime.now(); - const name = "trace"; - - when(mDateTime.now()).thenAnswer((_) => id); - when(mHost.startExecutionTrace(id.toString(), name)) - .thenAnswer((_) async => id.toString()); - - // ignore: deprecated_member_use_from_same_package - final trace = await APM.startExecutionTrace(name); - - expect(trace.id, id.toString()); - - verify( - mHost.startExecutionTrace(id.toString(), name), - ).called(1); - }); - - test('[setExecutionTraceAttribute] should call host method', () async { - final id = DateTime.now().toString(); - const key = "attr-key"; - const attribute = "Trace Attribute"; - - // ignore: deprecated_member_use_from_same_package - await APM.setExecutionTraceAttribute(id, key, attribute); - - verify( - mHost.setExecutionTraceAttribute(id, key, attribute), - ).called(1); - }); - - test('[endExecutionTrace] should call host method', () async { - final id = DateTime.now().toString(); - - // ignore: deprecated_member_use_from_same_package - await APM.endExecutionTrace(id); - - verify( - mHost.endExecutionTrace(id), - ).called(1); + test("[isAutoUiTraceEnabled] should call host method", () async { + when(mHost.isAutoUiTraceEnabled()).thenAnswer((_) async => true); + await APM.isAutoUiTraceEnabled(); + verify(mHost.isAutoUiTraceEnabled()); }); test('[startFlow] should call host method', () async { @@ -165,6 +132,9 @@ void main() { test('[startUITrace] should call host method', () async { const name = 'UI-trace'; + //disable the feature flag for screen render feature in order to skip its checking. + when(mHost.isScreenRenderEnabled()).thenAnswer((_) async => false); + await APM.startUITrace(name); verify( @@ -214,7 +184,6 @@ void main() { verify( mHost.startCpUiTrace(screenName, microTimeStamp, traceId), ).called(1); - verifyNoMoreInteractions(mHost); }); test('[reportScreenLoading] should call host method', () async { @@ -235,7 +204,6 @@ void main() { uiTraceId, ), ).called(1); - verifyNoMoreInteractions(mHost); }); test('[endScreenLoading] should call host method', () async { @@ -247,7 +215,6 @@ void main() { verify( mHost.endScreenLoadingCP(timeStampMicro, uiTraceId), ).called(1); - verifyNoMoreInteractions(mHost); }); test('[isSEndScreenLoadingEnabled] should call host method', () async { @@ -258,4 +225,117 @@ void main() { mHost.isEndScreenLoadingEnabled(), ).called(1); }); + + group("ScreenRender", () { + setUp(() { + InstabugScreenRenderManager.setInstance(mScreenRenderManager); + }); + tearDown(() { + reset(mScreenRenderManager); + reset(mHost); + }); + test("[isScreenRenderEnabled] should call host method", () async { + when(mHost.isScreenRenderEnabled()).thenAnswer((_) async => true); + await APM.isScreenRenderEnabled(); + verify(mHost.isScreenRenderEnabled()); + }); + + test("[getDeviceRefreshRateAndTolerance] should call host method", + () async { + when(mHost.getDeviceRefreshRateAndTolerance()).thenAnswer( + (_) async => [60.0, 10.0], + ); + await APM.getDeviceRefreshRateAndTolerance(); + verify(mHost.getDeviceRefreshRateAndTolerance()).called(1); + }); + + test("[setScreenRenderingEnabled] should call host method", () async { + const isEnabled = false; + when(mScreenRenderManager.screenRenderEnabled).thenReturn(false); + await APM.setScreenRenderingEnabled(isEnabled); + verify(mHost.setScreenRenderEnabled(isEnabled)).called(1); + }); + + test("[setScreenRenderEnabled] should call host method when enabled", + () async { + const isEnabled = true; + await APM.setScreenRenderingEnabled(isEnabled); + verify(mHost.setScreenRenderEnabled(isEnabled)).called(1); + }); + + test("[setScreenRenderEnabled] should call host method when disabled", + () async { + const isEnabled = false; + await APM.setScreenRenderingEnabled(isEnabled); + verify(mHost.setScreenRenderEnabled(isEnabled)).called(1); + }); + + test( + "[startUITrace] should start screen render collector with right params, if screen render feature is enabled", + () async { + when(mHost.isScreenRenderEnabled()).thenAnswer((_) async => true); + + const traceName = "traceNameTest"; + await APM.startUITrace(traceName); + + verify(mHost.startUITrace(traceName)).called(1); + verify(mHost.isScreenRenderEnabled()).called(1); + verify( + mScreenRenderManager.startScreenRenderCollectorForTraceId( + 0, + UiTraceType.custom, + ), + ).called(1); + }); + + test( + "[startUITrace] should not start screen render collector, if screen render feature is disabled", + () async { + when(mHost.isScreenRenderEnabled()).thenAnswer((_) async => false); + + const traceName = "traceNameTest"; + await APM.startUITrace(traceName); + + verify(mHost.startUITrace(traceName)).called(1); + verify(mHost.isScreenRenderEnabled()).called(1); + verifyNever( + mScreenRenderManager.startScreenRenderCollectorForTraceId( + any, + any, + ), + ); + }); + + test( + "[endUITrace] should stop screen render collector with, if screen render feature is enabled", + () async { + when(mHost.isScreenRenderEnabled()).thenAnswer((_) async => true); + when(mHost.isAutoUiTraceEnabled()).thenAnswer((_) async => true); + when(mScreenRenderManager.screenRenderEnabled).thenReturn(true); + await APM.endUITrace(); + + verify( + mScreenRenderManager.endScreenRenderCollector(UiTraceType.custom), + ).called(1); + verifyNever(mHost.endUITrace()); + }); + + test( + "[endUITrace] should acts as normal and do nothing related to screen render, if screen render feature is disabled", + () async { + when(mHost.isScreenRenderEnabled()).thenAnswer((_) async => false); + when(mScreenRenderManager.screenRenderEnabled).thenReturn(false); + const traceName = "traceNameTest"; + await APM.startUITrace(traceName); + await APM.endUITrace(); + + verify(mHost.startUITrace(traceName)).called(1); + verify( + mHost.endUITrace(), + ).called(1); + verifyNever( + mScreenRenderManager.endScreenRenderCollector(), + ); + }); + }); } diff --git a/test/instabug_test.dart b/test/instabug_test.dart index e2fd7d298..5e5041a3c 100644 --- a/test/instabug_test.dart +++ b/test/instabug_test.dart @@ -1,8 +1,10 @@ +// ignore_for_file: deprecated_member_use import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; +import 'package:instabug_flutter/src/generated/apm.api.g.dart'; import 'package:instabug_flutter/src/generated/instabug.api.g.dart'; import 'package:instabug_flutter/src/utils/enum_converter.dart'; import 'package:instabug_flutter/src/utils/feature_flags_manager.dart'; @@ -17,6 +19,7 @@ import 'instabug_test.mocks.dart'; InstabugHostApi, IBGBuildInfo, ScreenNameMasker, + ApmHostApi, ]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -25,12 +28,14 @@ void main() { final mHost = MockInstabugHostApi(); final mBuildInfo = MockIBGBuildInfo(); final mScreenNameMasker = MockScreenNameMasker(); + final mApmHost = MockApmHostApi(); setUpAll(() { Instabug.$setHostApi(mHost); FeatureFlagsManager().$setHostApi(mHost); IBGBuildInfo.setInstance(mBuildInfo); ScreenNameMasker.setInstance(mScreenNameMasker); + APM.$setHostApi(mApmHost); }); test('[setEnabled] should call host method', () async { @@ -78,13 +83,17 @@ void main() { "isW3cCaughtHeaderEnabled": true, }), ); + + //disable the feature flag for screen render feature in order to skip its checking. + when(mApmHost.isScreenRenderEnabled()).thenAnswer((_) async => false); + await Instabug.init( token: token, invocationEvents: events, ); verify( - mHost.init(token, events.mapToString(), LogLevel.error.toString()), + mHost.init(token, events.mapToString(), LogLevel.error.toString(), null), ).called(1); }); @@ -197,16 +206,6 @@ void main() { ).called(1); }); - test('[setPrimaryColor] should call host method', () async { - const color = Color(0x00000000); - - await Instabug.setPrimaryColor(color); - - verify( - mHost.setPrimaryColor(color.value), - ).called(1); - }); - test('[setSessionProfilerEnabled] should call host method', () async { const enabled = true; @@ -258,37 +257,6 @@ void main() { ).called(1); }); - test('[addExperiments] should call host method', () async { - const experiments = ["exp-1", "exp-2"]; - - // ignore: deprecated_member_use_from_same_package - await Instabug.addExperiments(experiments); - - verify( - mHost.addExperiments(experiments), - ).called(1); - }); - - test('[removeExperiments] should call host method', () async { - const experiments = ["exp-1", "exp-2"]; - - // ignore: deprecated_member_use_from_same_package - await Instabug.removeExperiments(experiments); - - verify( - mHost.removeExperiments(experiments), - ).called(1); - }); - - test('[clearAllExperiments] should call host method', () async { - // ignore: deprecated_member_use_from_same_package - await Instabug.clearAllExperiments(); - - verify( - mHost.clearAllExperiments(), - ).called(1); - }); - test('[addFeatureFlags] should call host method', () async { await Instabug.addFeatureFlags([ FeatureFlag(name: 'name1', variant: 'variant1'), @@ -474,4 +442,52 @@ void main() { mHost.willRedirectToStore(), ).called(1); }); + + test('[setFullscreen] should call host method', () async { + const isEnabled = true; + + await Instabug.setFullscreen(isEnabled); + + verify( + mHost.setFullscreen(isEnabled), + ).called(1); + }); + + test('[setFullscreen] should call host method with false', () async { + const isEnabled = false; + + await Instabug.setFullscreen(isEnabled); + + verify( + mHost.setFullscreen(isEnabled), + ).called(1); + }); + + test('[setTheme] should call host method with theme config', () async { + const themeConfig = ThemeConfig(primaryColor: '#FF0000'); + + await Instabug.setTheme(themeConfig); + + verify( + mHost.setTheme(themeConfig.toMap()), + ).called(1); + }); + + group('Disposal Manager', () { + test( + 'InstabugFlutterApi dispose should call widget binding observer dispose', + () { + // Test that the FlutterApi setup and dispose functionality works + // This verifies that when Android calls dispose(), it properly cleans up resources + expect( + () { + // Get the InstabugFlutterApi setup that was configured in Instabug.$setup() + // The actual dispose call will be tested in integration tests + // Here we just verify the setup doesn't crash + Instabug.$setup(); + }, + returnsNormally, + ); + }); + }); } diff --git a/test/route_matcher_test.dart b/test/route_matcher_test.dart index 977c61d88..5d8b234c4 100644 --- a/test/route_matcher_test.dart +++ b/test/route_matcher_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:instabug_flutter/src/utils/screen_loading/route_matcher.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/route_matcher.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); diff --git a/test/trace_test.dart b/test/trace_test.dart deleted file mode 100644 index 2415420be..000000000 --- a/test/trace_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:instabug_flutter/instabug_flutter.dart'; -import 'package:instabug_flutter/src/generated/apm.api.g.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'trace_test.mocks.dart'; - -@GenerateMocks([ - ApmHostApi, -]) -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - WidgetsFlutterBinding.ensureInitialized(); - - final mHost = MockApmHostApi(); - final trace = Trace( - id: "trace", - name: "Execution Trace", - ); - - setUpAll(() { - APM.$setHostApi(mHost); - }); - - test('[end] should call host method', () async { - // ignore: deprecated_member_use_from_same_package - trace.end(); - - verify( - mHost.endExecutionTrace(trace.id), - ).called(1); - }); - - test('[setAttribute] should call host method', () async { - const key = "attr-key"; - const attribute = "Trace Attribute"; - // ignore: deprecated_member_use_from_same_package - trace.setAttribute(key, attribute); - - verify( - mHost.setExecutionTraceAttribute(trace.id, key, attribute), - ).called(1); - }); -} diff --git a/test/utils/instabug_navigator_observer_test.dart b/test/utils/instabug_navigator_observer_test.dart index ebf541137..76ae1840e 100644 --- a/test/utils/instabug_navigator_observer_test.dart +++ b/test/utils/instabug_navigator_observer_test.dart @@ -5,6 +5,7 @@ import 'package:instabug_flutter/instabug_flutter.dart'; import 'package:instabug_flutter/src/generated/instabug.api.g.dart'; import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_manager.dart'; import 'package:instabug_flutter/src/utils/screen_name_masker.dart'; +import 'package:instabug_flutter/src/utils/screen_rendering/instabug_screen_render_manager.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; @@ -13,13 +14,14 @@ import 'instabug_navigator_observer_test.mocks.dart'; @GenerateMocks([ InstabugHostApi, ScreenLoadingManager, + InstabugScreenRenderManager, ]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); - WidgetsFlutterBinding.ensureInitialized(); final mHost = MockInstabugHostApi(); final mScreenLoadingManager = MockScreenLoadingManager(); + final mScreenRenderManager = MockInstabugScreenRenderManager(); late InstabugNavigatorObserver observer; const screen = '/screen'; @@ -38,10 +40,11 @@ void main() { previousRoute = createRoute(previousScreen); ScreenNameMasker.I.setMaskingCallback(null); + when(mScreenRenderManager.screenRenderEnabled).thenReturn(false); }); test('should report screen change when a route is pushed', () { - fakeAsync((async) { + fakeAsync((async) async { observer.didPush(route, previousRoute); async.elapse(const Duration(milliseconds: 1000)); @@ -60,6 +63,9 @@ void main() { 'should report screen change when a route is popped and previous is known', () { fakeAsync((async) { + when(mScreenLoadingManager.startUiTrace(previousScreen, previousScreen)) + .thenAnswer((realInvocation) async => null); + observer.didPop(route, previousRoute); async.elapse(const Duration(milliseconds: 1000)); @@ -97,6 +103,9 @@ void main() { final route = createRoute(''); const fallback = 'N/A'; + when(mScreenLoadingManager.startUiTrace(fallback, fallback)) + .thenAnswer((realInvocation) async => null); + observer.didPush(route, previousRoute); async.elapse(const Duration(milliseconds: 1000)); @@ -114,6 +123,9 @@ void main() { test('should mask screen name when masking callback is set', () { const maskedScreen = 'maskedScreen'; + when(mScreenLoadingManager.startUiTrace(maskedScreen, screen)) + .thenAnswer((realInvocation) async => null); + ScreenNameMasker.I.setMaskingCallback((_) => maskedScreen); fakeAsync((async) { @@ -130,6 +142,62 @@ void main() { ).called(1); }); }); + + test('should start new screen render collector when a route is pushed', () { + fakeAsync((async) async { + const traceID = 123; + + when(mScreenLoadingManager.startUiTrace(screen, screen)) + .thenAnswer((_) async => traceID); + when(mScreenRenderManager.screenRenderEnabled).thenReturn(true); + + observer.didPush(route, previousRoute); + + async.elapse(const Duration(milliseconds: 1000)); + + verify( + mScreenRenderManager.startScreenRenderCollectorForTraceId(traceID), + ).called(1); + }); + }); + + test( + 'should not start new screen render collector when a route is pushed and [traceID] is null', + () { + fakeAsync((async) async { + when(mScreenLoadingManager.startUiTrace(screen, screen)) + .thenAnswer((_) async => null); + + when(mScreenRenderManager.screenRenderEnabled).thenReturn(true); + + observer.didPush(route, previousRoute); + + async.elapse(const Duration(milliseconds: 1000)); + + verifyNever( + mScreenRenderManager.startScreenRenderCollectorForTraceId(any), + ); + }); + }); + + test( + 'should not start new screen render collector when a route is pushed and [mScreenRenderManager.screenRenderEnabled] is false', + () { + fakeAsync((async) async { + when(mScreenLoadingManager.startUiTrace(screen, screen)) + .thenAnswer((_) async => 123); + + when(mScreenRenderManager.screenRenderEnabled).thenReturn(false); + + observer.didPush(route, previousRoute); + + async.elapse(const Duration(milliseconds: 1000)); + + verifyNever( + mScreenRenderManager.startScreenRenderCollectorForTraceId(any), + ); + }); + }); } Route createRoute(String? name) { diff --git a/test/utils/screen_loading/screen_loading_manager_test.dart b/test/utils/screen_loading/screen_loading_manager_test.dart index c008b8bcc..1d8598948 100644 --- a/test/utils/screen_loading/screen_loading_manager_test.dart +++ b/test/utils/screen_loading/screen_loading_manager_test.dart @@ -7,12 +7,13 @@ import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; import 'package:instabug_flutter/src/utils/ibg_date_time.dart'; import 'package:instabug_flutter/src/utils/instabug_logger.dart'; import 'package:instabug_flutter/src/utils/instabug_montonic_clock.dart'; -import 'package:instabug_flutter/src/utils/screen_loading/flags_config.dart'; import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_manager.dart'; import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_trace.dart'; -import 'package:instabug_flutter/src/utils/screen_loading/ui_trace.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/flags_config.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/ui_trace.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; + import 'screen_loading_manager_test.mocks.dart'; class ScreenLoadingManagerNoResets extends ScreenLoadingManager { @@ -167,30 +168,10 @@ void main() { when(mDateTime.now()).thenReturn(time); }); - test('[startUiTrace] with SDK not build should Log error', () async { - mScreenLoadingManager.currentUiTrace = uiTrace; - when(mInstabugHost.isBuilt()).thenAnswer((_) async => false); - - await ScreenLoadingManager.I.startUiTrace(screenName); - - final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; - expect(actualUiTrace, null); - - verify( - mInstabugLogger.e( - 'Instabug API {APM.InstabugCaptureScreenLoading} was called before the SDK is built. To build it, first by following the instructions at this link:\n' - 'https://docs.instabug.com/reference#showing-and-manipulating-the-invocation', - tag: APM.tag, - ), - ).called(1); - verifyNever(mApmHost.startCpUiTrace(any, any, any)); - }); - test('[startUiTrace] with APM disabled on iOS Platform should Log error', () async { mScreenLoadingManager.currentUiTrace = uiTrace; - when(FlagsConfig.apm.isEnabled()).thenAnswer((_) async => false); - when(IBGBuildInfo.I.isIOS).thenReturn(true); + when(FlagsConfig.uiTrace.isEnabled()).thenAnswer((_) async => false); await ScreenLoadingManager.I.startUiTrace(screenName); @@ -210,8 +191,7 @@ void main() { test( '[startUiTrace] with APM enabled on android Platform should call `APM.startCpUiTrace and set UiTrace', () async { - when(FlagsConfig.apm.isEnabled()).thenAnswer((_) async => true); - when(IBGBuildInfo.I.isIOS).thenReturn(false); + when(FlagsConfig.uiTrace.isEnabled()).thenAnswer((_) async => true); await ScreenLoadingManager.I.startUiTrace(screenName); @@ -235,8 +215,7 @@ void main() { test( '[startUiTrace] with APM enabled should create a UI trace with the matching screen name', () async { - when(FlagsConfig.apm.isEnabled()).thenAnswer((_) async => true); - when(IBGBuildInfo.I.isIOS).thenReturn(false); + when(FlagsConfig.uiTrace.isEnabled()).thenAnswer((_) async => true); when( RouteMatcher.I.match( routePath: anyNamed('routePath'), diff --git a/test/utils/screen_loading/ui_trace_test.dart b/test/utils/screen_loading/ui_trace_test.dart index 11ed57c66..68a0f06cf 100644 --- a/test/utils/screen_loading/ui_trace_test.dart +++ b/test/utils/screen_loading/ui_trace_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; -import 'package:instabug_flutter/src/utils/screen_loading/ui_trace.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/ui_trace.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; diff --git a/test/utils/screen_render/instabug_screen_render_manager_test.dart b/test/utils/screen_render/instabug_screen_render_manager_test.dart new file mode 100644 index 000000000..2282b8b80 --- /dev/null +++ b/test/utils/screen_render/instabug_screen_render_manager_test.dart @@ -0,0 +1,595 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/instabug_flutter.dart'; +import 'package:instabug_flutter/src/models/instabug_frame_data.dart'; +import 'package:instabug_flutter/src/models/instabug_screen_render_data.dart'; +import 'package:instabug_flutter/src/utils/instabug_logger.dart'; +import 'package:instabug_flutter/src/utils/screen_rendering/instabug_screen_render_manager.dart'; +import 'package:mockito/mockito.dart'; + +import 'instabug_screen_render_manager_test_mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late InstabugScreenRenderManager manager; + late MockApmHostApi mApmHost; + late MockWidgetsBinding mWidgetBinding; + + setUp(() async { + mApmHost = MockApmHostApi(); + mWidgetBinding = MockWidgetsBinding(); + manager = InstabugScreenRenderManager.init(); // test-only constructor + APM.$setHostApi(mApmHost); + when(mApmHost.getDeviceRefreshRateAndTolerance()) + .thenAnswer((_) async => [60, 0]); + manager.init(mWidgetBinding); + }); + + group('InstabugScreenRenderManager.init()', () { + test('should initialize timings callback and add observer', () async { + expect(manager, isA()); + + verify(mWidgetBinding.addObserver(any)).called(1); + + verify(mWidgetBinding.addTimingsCallback(any)).called(1); + }); + + test('calling init more that one time should do nothing', () async { + manager.init(mWidgetBinding); + manager.init( + mWidgetBinding, + ); // second call should be ignored + + verify(mWidgetBinding.addObserver(any)).called(1); + + verify(mWidgetBinding.addTimingsCallback(any)).called(1); + }); + }); + + group('startScreenRenderCollectorForTraceId()', () { + test('should not attach timing listener if it is attached', () async { + manager.startScreenRenderCollectorForTraceId(1); + manager.startScreenRenderCollectorForTraceId(2); + manager.startScreenRenderCollectorForTraceId(3); + + verify(mWidgetBinding.addTimingsCallback(any)).called( + 1, + ); // the one form initForTesting() + }); + + test('should attach timing listener if it is not attached', () async { + manager.stopScreenRenderCollector(); // this should detach listener safely + + manager.startScreenRenderCollectorForTraceId(1); + + verify(mWidgetBinding.addTimingsCallback(any)).called( + 1, + ); + }); + + test('should update the data for same trace type', () { + const firstTraceId = 123; + const secondTraceId = 456; + + expect(manager.screenRenderForAutoUiTrace.isActive, false); + + manager.startScreenRenderCollectorForTraceId( + firstTraceId, + ); + expect(manager.screenRenderForAutoUiTrace.isActive, true); + expect(manager.screenRenderForAutoUiTrace.traceId, firstTraceId); + + manager.startScreenRenderCollectorForTraceId( + secondTraceId, + ); + expect(manager.screenRenderForAutoUiTrace.isActive, true); + expect(manager.screenRenderForAutoUiTrace.traceId, secondTraceId); + }); + + test('should not update the data for same trace type', () { + const firstTraceId = 123; + const secondTraceId = 456; + + expect(manager.screenRenderForAutoUiTrace.isActive, false); + expect(manager.screenRenderForCustomUiTrace.isActive, false); + + manager.startScreenRenderCollectorForTraceId( + firstTraceId, + ); + expect(manager.screenRenderForAutoUiTrace.isActive, true); + expect(manager.screenRenderForAutoUiTrace.traceId, firstTraceId); + + manager.startScreenRenderCollectorForTraceId( + secondTraceId, + UiTraceType.custom, + ); + expect(manager.screenRenderForAutoUiTrace.traceId, firstTraceId); + expect(manager.screenRenderForCustomUiTrace.traceId, secondTraceId); + }); + }); + + group('stopScreenRenderCollector()', () { + test('should not save data if no UI trace is started', () { + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [ + InstabugFrameData(10000, 200), + InstabugFrameData(20000, 1000), + ], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 200, + ); + + manager.setFrameData(frameTestData); + + manager.stopScreenRenderCollector(); + + expect(manager.screenRenderForAutoUiTrace.isActive, false); + expect(manager.screenRenderForAutoUiTrace == frameTestData, false); + + expect(manager.screenRenderForCustomUiTrace.isActive, false); + expect(manager.screenRenderForCustomUiTrace == frameTestData, false); + }); + + test( + 'for auto UITrace should report data to native using endScreenRenderForAutoUiTrace', + () { + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 1000, + endTimeMicro: 30000, + ); + + manager.startScreenRenderCollectorForTraceId( + frameTestData.traceId, + ); + + manager.startScreenRenderCollectorForTraceId( + frameTestData.traceId + 1, + UiTraceType.custom, + ); + + manager.setFrameData(frameTestData); + + manager.stopScreenRenderCollector(); + + verify( + mApmHost.endScreenRenderForAutoUiTrace(any), + ); // the content has been verified in the above assertion. + + expect(manager.screenRenderForAutoUiTrace.isActive, false); + + expect(manager.screenRenderForCustomUiTrace.isActive, false); + + expect(manager.screenRenderForAutoUiTrace.isEmpty, true); + }); + + test( + 'for custom UITrace should report data to native using endScreenRenderForCustomUiTrace', + () { + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [ + InstabugFrameData(10000, 400), + InstabugFrameData(10000, 600), + InstabugFrameData(20000, 1000), + ], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 1000, + endTimeMicro: 30000, + ); + + manager.startScreenRenderCollectorForTraceId( + frameTestData.traceId, + UiTraceType.custom, + ); + + manager.setFrameData(frameTestData); + + manager.stopScreenRenderCollector(); + + expect(manager.screenRenderForCustomUiTrace.isActive, false); + + expect(manager.screenRenderForAutoUiTrace.isActive, false); + + expect(manager.screenRenderForCustomUiTrace.isEmpty, true); + + verify( + mApmHost.endScreenRenderForCustomUiTrace(any), + ); // the content has been verified in the above assertion. + }); + + test('should not remove timing callback listener', () { + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [ + InstabugFrameData(10000, 200), + InstabugFrameData(20000, 1000), + ], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 200, + ); + + manager.setFrameData(frameTestData); + manager.stopScreenRenderCollector(); + + verifyNever(mWidgetBinding.removeTimingsCallback(any)); + }); + + test('should report data to native side with the correct type', () async { + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [ + InstabugFrameData(10000, 200), + InstabugFrameData(20000, 1000), + ], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 200, + ); + + manager.startScreenRenderCollectorForTraceId(0, UiTraceType.custom); + manager.setFrameData(frameTestData); + manager.stopScreenRenderCollector(); + verify(mApmHost.endScreenRenderForCustomUiTrace(any)).called(1); + verifyNever(mApmHost.endScreenRenderForAutoUiTrace(any)); + }); + }); + + group('endScreenRenderCollector()', () { + setUp(() { + manager.screenRenderForAutoUiTrace.clear(); + manager.screenRenderForCustomUiTrace.clear(); + }); + + test('should not save data if no custom UI trace is started', () { + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [ + InstabugFrameData(10000, 200), + InstabugFrameData(20000, 1000), + ], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 200, + ); + + manager.setFrameData(frameTestData); + + manager.endScreenRenderCollector(); + + expect(manager.screenRenderForCustomUiTrace.isActive, false); + expect(manager.screenRenderForCustomUiTrace == frameTestData, false); + }); + + test( + 'should save data to screenRenderForCustomUiTrace if custom UI trace is started', + () { + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [ + InstabugFrameData(10000, 200), + InstabugFrameData(20000, 1000), + ], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 200, + ); + + manager.startScreenRenderCollectorForTraceId( + frameTestData.traceId, + UiTraceType.custom, + ); + + manager.setFrameData(frameTestData); + + manager.endScreenRenderCollector(); + }); + + test('should not remove timing callback listener', () { + manager.endScreenRenderCollector(); + + verifyNever(mWidgetBinding.removeTimingsCallback(any)); + }); + + test('should report data to native side', () async { + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [ + InstabugFrameData(10000, 200), + InstabugFrameData(20000, 1000), + ], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 200, + ); + + manager.startScreenRenderCollectorForTraceId(0, UiTraceType.custom); + manager.setFrameData(frameTestData); + manager.endScreenRenderCollector(UiTraceType.custom); + verify(mApmHost.endScreenRenderForCustomUiTrace(any)).called(1); + }); + }); + + group('analyzeFrameTiming()', () { + late MockFrameTiming mockFrameTiming; + + setUp(() { + mockFrameTiming = MockFrameTiming(); + when(mockFrameTiming.buildDuration) + .thenReturn(const Duration(milliseconds: 1)); + when(mockFrameTiming.rasterDuration) + .thenReturn(const Duration(milliseconds: 1)); + when(mockFrameTiming.totalSpan) + .thenReturn(const Duration(milliseconds: 2)); + when(mockFrameTiming.timestampInMicroseconds(any)).thenReturn(1000); + }); + + test('should detect slow frame on ui thread and record duration', () { + const buildDuration = 20; + when(mockFrameTiming.buildDuration) + .thenReturn(const Duration(milliseconds: buildDuration)); + + manager.startScreenRenderCollectorForTraceId(1); // start new collector + manager.analyzeFrameTiming(mockFrameTiming); // mock frame timing + manager.stopScreenRenderCollector(); // should save data + + expect( + manager.screenRenderForAutoUiTrace.frameData.isEmpty, + true, + ); // reset cached data after sync + }); + + test('should detect slow frame on raster thread and record duration', () { + const rasterDuration = 20; + when(mockFrameTiming.rasterDuration) + .thenReturn(const Duration(milliseconds: rasterDuration)); + + manager.startScreenRenderCollectorForTraceId(1); // start new collector + manager.analyzeFrameTiming(mockFrameTiming); // mock frame timing + manager.stopScreenRenderCollector(); // should save data + + expect( + manager.screenRenderForAutoUiTrace.frameData.isEmpty, + true, + ); // reset cached data after sync + }); + + test( + 'should detect frozen frame when durations are greater than or equal 700 ms', + () { + const totalTime = 700; + when(mockFrameTiming.totalSpan) + .thenReturn(const Duration(milliseconds: totalTime)); + manager.startScreenRenderCollectorForTraceId(1); // start new collector + manager.analyzeFrameTiming(mockFrameTiming); // mock frame timing + manager.stopScreenRenderCollector(); // should save data + + expect( + manager.screenRenderForAutoUiTrace.frameData.isEmpty, + true, + ); // reset cached data after sync + }); + + test('should detect no slow or frozen frame under thresholds', () { + when(mockFrameTiming.buildDuration) + .thenReturn(const Duration(milliseconds: 5)); + when(mockFrameTiming.rasterDuration) + .thenReturn(const Duration(milliseconds: 5)); + when(mockFrameTiming.totalSpan) + .thenReturn(const Duration(milliseconds: 10)); + manager.analyzeFrameTiming(mockFrameTiming); + expect(manager.screenRenderForAutoUiTrace.frameData.isEmpty, true); + expect( + manager.screenRenderForAutoUiTrace.frozenFramesTotalDurationMicro, + 0, + ); // * 1000 to convert from milliseconds to microseconds + expect( + manager.screenRenderForAutoUiTrace.slowFramesTotalDurationMicro, + 0, + ); + }); + }); + + group('InstabugScreenRenderManager.endScreenRenderCollector', () { + test('should save and reset cached data if delayed frames exist', () { + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [ + InstabugFrameData(10000, 200), + InstabugFrameData(20000, 1000), + ], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 200, + ); + manager.startScreenRenderCollectorForTraceId(1); + manager.setFrameData(frameTestData); + manager.endScreenRenderCollector(); + verify(mApmHost.endScreenRenderForAutoUiTrace(any)).called(1); + expect(manager.screenRenderForAutoUiTrace.isEmpty, true); + expect(manager.screenRenderForAutoUiTrace.isActive, false); + }); + + test('should report and clear custom trace if type is custom and active', + () { + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [ + InstabugFrameData(10000, 200), + InstabugFrameData(20000, 1000), + ], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 200, + ); + manager.startScreenRenderCollectorForTraceId(1, UiTraceType.custom); + manager.setFrameData(frameTestData); + manager.endScreenRenderCollector(UiTraceType.custom); + verify(mApmHost.endScreenRenderForCustomUiTrace(any)).called(1); + expect(manager.screenRenderForCustomUiTrace.isEmpty, true); + expect(manager.screenRenderForCustomUiTrace.isActive, false); + }); + + test('should return early if not enabled or timings not attached', () { + manager.screenRenderEnabled = false; + manager.endScreenRenderCollector(); + verifyNever(mApmHost.endScreenRenderForAutoUiTrace(any)); + verifyNever(mApmHost.endScreenRenderForCustomUiTrace(any)); + }); + }); + + group('InstabugScreenRenderManager() error handling', () { + late InstabugScreenRenderManager realManager; + late MockInstabugLogger mInstabugLogger; + late MockApmHostApi mApmHostForErrorTest; + late MockWidgetsBinding mWidgetBindingForErrorTest; + late MockCrashReportingHostApi mCrashReportingHost; + + setUp(() { + realManager = InstabugScreenRenderManager.init(); // Use real instance + mInstabugLogger = MockInstabugLogger(); + mApmHostForErrorTest = MockApmHostApi(); + mWidgetBindingForErrorTest = MockWidgetsBinding(); + mCrashReportingHost = MockCrashReportingHostApi(); + + InstabugScreenRenderManager.setInstance(realManager); + InstabugLogger.setInstance(mInstabugLogger); + APM.$setHostApi(mApmHostForErrorTest); + + // Mock CrashReporting host to prevent platform channel calls + CrashReporting.$setHostApi(mCrashReportingHost); + }); + + test('should log error and stack trace when init() encounters an exception', + () async { + const error = 'Test error in getDeviceRefreshRateAndTolerance'; + final exception = Exception(error); + + when(mApmHostForErrorTest.getDeviceRefreshRateAndTolerance()) + .thenThrow(exception); + + await realManager.init(mWidgetBindingForErrorTest); + + final capturedLog = verify( + mInstabugLogger.e( + captureAny, + tag: InstabugScreenRenderManager.tag, + ), + ).captured.single as String; + + expect(capturedLog, contains('[Error]:$exception')); + expect(capturedLog, contains('[StackTrace]:')); + + // Verify that non-fatal crash reporting was called + verify( + mCrashReportingHost.sendNonFatalError( + any, // jsonCrash + any, // userAttributes + any, // fingerprint + any, // nonFatalExceptionLevel + ), + ).called(1); + }); + + test( + 'should log error and stack trace when _reportScreenRenderForAutoUiTrace() encounters an exception', + () async { + const error = 'Test error in endScreenRenderForAutoUiTrace'; + final exception = Exception(error); + + // First initialize the manager properly + when(mApmHostForErrorTest.getDeviceRefreshRateAndTolerance()) + .thenAnswer((_) async => [60.0, 10000.0]); + + await realManager.init(mWidgetBindingForErrorTest); + + final frameTestData = InstabugScreenRenderData( + traceId: 123, + frameData: [ + InstabugFrameData(10000, 200), + InstabugFrameData(20000, 1000), + ], + frozenFramesTotalDurationMicro: 1000, + slowFramesTotalDurationMicro: 200, + ); + + when(mApmHostForErrorTest.endScreenRenderForAutoUiTrace(any)) + .thenThrow(exception); + + // Start the collector and add frame data + realManager.startScreenRenderCollectorForTraceId(123); + realManager.setFrameData(frameTestData); + // End the collector which should trigger the error + realManager.endScreenRenderCollector(); + + final capturedLog = verify( + mInstabugLogger.e( + captureAny, + tag: InstabugScreenRenderManager.tag, + ), + ).captured.single as String; + + expect(capturedLog, contains('[Error]:$exception')); + expect(capturedLog, contains('[StackTrace]:')); + + // Verify that non-fatal crash reporting was called + verify( + mCrashReportingHost.sendNonFatalError( + any, // jsonCrash + any, // userAttributes + any, // fingerprint + any, // nonFatalExceptionLevel + ), + ).called(1); + }); + + test( + 'should log error and stack trace when _reportScreenRenderForCustomUiTrace() encounters an exception', + () async { + const error = 'Test error in endScreenRenderForCustomUiTrace'; + final exception = Exception(error); + + // First initialize the manager properly + when(mApmHostForErrorTest.getDeviceRefreshRateAndTolerance()) + .thenAnswer((_) async => [60.0, 10000.0]); + + await realManager.init(mWidgetBindingForErrorTest); + + final frameTestData = InstabugScreenRenderData( + traceId: 456, + frameData: [ + InstabugFrameData(15000, 300), + InstabugFrameData(25000, 1200), + ], + frozenFramesTotalDurationMicro: 1200, + slowFramesTotalDurationMicro: 300, + ); + + when(mApmHostForErrorTest.endScreenRenderForCustomUiTrace(any)) + .thenThrow(exception); + + // Start the collector and add frame data + realManager.startScreenRenderCollectorForTraceId(456, UiTraceType.custom); + realManager.setFrameData(frameTestData); + // End the collector which should trigger the error + realManager.endScreenRenderCollector(UiTraceType.custom); + + final capturedLog = verify( + mInstabugLogger.e( + captureAny, + tag: InstabugScreenRenderManager.tag, + ), + ).captured.single as String; + + expect(capturedLog, contains('[Error]:$exception')); + expect(capturedLog, contains('[StackTrace]:')); + + // Verify that non-fatal crash reporting was called + verify( + mCrashReportingHost.sendNonFatalError( + any, // jsonCrash + any, // userAttributes + any, // fingerprint + any, // nonFatalExceptionLevel + ), + ).called(1); + }); + }); +} diff --git a/test/utils/screen_render/instabug_screen_render_manager_test_mocks.dart b/test/utils/screen_render/instabug_screen_render_manager_test_mocks.dart new file mode 100644 index 000000000..3928266e0 --- /dev/null +++ b/test/utils/screen_render/instabug_screen_render_manager_test_mocks.dart @@ -0,0 +1,818 @@ +// Mocks generated by Mockito 5.2.0 from annotations +// in instabug_flutter/example/ios/.symlinks/plugins/instabug_flutter/test/utils/screen_render/instabug_screen_render_manager_test.dart. +// Do not manually edit this file. + +import 'dart:async' as _i9; +import 'dart:developer' as _i13; +import 'dart:ui' as _i4; + +import 'package:flutter/foundation.dart' as _i3; +import 'package:flutter/gestures.dart' as _i6; +import 'package:flutter/rendering.dart' as _i7; +import 'package:flutter/scheduler.dart' as _i11; +import 'package:flutter/services.dart' as _i5; +import 'package:flutter/src/widgets/binding.dart' as _i10; +import 'package:flutter/src/widgets/focus_manager.dart' as _i2; +import 'package:flutter/src/widgets/framework.dart' as _i12; +import 'package:instabug_flutter/instabug_flutter.dart' as _i16; +import 'package:instabug_flutter/src/generated/apm.api.g.dart' as _i8; +import 'package:instabug_flutter/src/generated/crash_reporting.api.g.dart' + as _i14; +import 'package:instabug_flutter/src/utils/instabug_logger.dart' as _i15; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types + +class _FakeFocusManager_0 extends _i1.Fake implements _i2.FocusManager { + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); +} + +class _FakeSingletonFlutterWindow_1 extends _i1.Fake + implements _i4.SingletonFlutterWindow {} + +class _FakePlatformDispatcher_2 extends _i1.Fake + implements _i4.PlatformDispatcher {} + +class _FakeHardwareKeyboard_3 extends _i1.Fake + implements _i5.HardwareKeyboard {} + +class _FakeKeyEventManager_4 extends _i1.Fake implements _i5.KeyEventManager {} + +class _FakeBinaryMessenger_5 extends _i1.Fake implements _i5.BinaryMessenger {} + +class _FakeChannelBuffers_6 extends _i1.Fake implements _i4.ChannelBuffers {} + +class _FakeRestorationManager_7 extends _i1.Fake + implements _i5.RestorationManager {} + +class _FakeDuration_8 extends _i1.Fake implements Duration {} + +class _FakePointerRouter_9 extends _i1.Fake implements _i6.PointerRouter {} + +class _FakeGestureArenaManager_10 extends _i1.Fake + implements _i6.GestureArenaManager {} + +class _FakePointerSignalResolver_11 extends _i1.Fake + implements _i6.PointerSignalResolver {} + +class _FakeMouseTracker_12 extends _i1.Fake implements _i7.MouseTracker {} + +class _FakePipelineOwner_13 extends _i1.Fake implements _i7.PipelineOwner {} + +class _FakeRenderView_14 extends _i1.Fake implements _i7.RenderView { + @override + String toString({_i3.DiagnosticLevel? minLevel = _i3.DiagnosticLevel.info}) => + super.toString(); +} + +class _FakeAccessibilityFeatures_15 extends _i1.Fake + implements _i4.AccessibilityFeatures {} + +class _FakeViewConfiguration_16 extends _i1.Fake + implements _i7.ViewConfiguration {} + +class _FakeSemanticsUpdateBuilder_17 extends _i1.Fake + implements _i4.SemanticsUpdateBuilder {} + +/// A class which mocks [ApmHostApi]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockApmHostApi extends _i1.Mock implements _i8.ApmHostApi { + MockApmHostApi() { + _i1.throwOnMissingStub(this); + } + + @override + _i9.Future setEnabled(bool? arg_isEnabled) => + (super.noSuchMethod(Invocation.method(#setEnabled, [arg_isEnabled]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future isEnabled() => + (super.noSuchMethod(Invocation.method(#isEnabled, []), + returnValue: Future.value(false)) as _i9.Future); + @override + _i9.Future setScreenLoadingEnabled(bool? arg_isEnabled) => + (super.noSuchMethod( + Invocation.method(#setScreenLoadingEnabled, [arg_isEnabled]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future isScreenLoadingEnabled() => + (super.noSuchMethod(Invocation.method(#isScreenLoadingEnabled, []), + returnValue: Future.value(false)) as _i9.Future); + @override + _i9.Future setColdAppLaunchEnabled(bool? arg_isEnabled) => + (super.noSuchMethod( + Invocation.method(#setColdAppLaunchEnabled, [arg_isEnabled]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future setAutoUITraceEnabled(bool? arg_isEnabled) => (super + .noSuchMethod(Invocation.method(#setAutoUITraceEnabled, [arg_isEnabled]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future startFlow(String? arg_name) => + (super.noSuchMethod(Invocation.method(#startFlow, [arg_name]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future setFlowAttribute( + String? arg_name, String? arg_key, String? arg_value) => + (super.noSuchMethod( + Invocation.method(#setFlowAttribute, [arg_name, arg_key, arg_value]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future endFlow(String? arg_name) => + (super.noSuchMethod(Invocation.method(#endFlow, [arg_name]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future startUITrace(String? arg_name) => + (super.noSuchMethod(Invocation.method(#startUITrace, [arg_name]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future endUITrace() => + (super.noSuchMethod(Invocation.method(#endUITrace, []), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future endAppLaunch() => + (super.noSuchMethod(Invocation.method(#endAppLaunch, []), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future networkLogAndroid(Map? arg_data) => + (super.noSuchMethod(Invocation.method(#networkLogAndroid, [arg_data]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future startCpUiTrace( + String? arg_screenName, int? arg_microTimeStamp, int? arg_traceId) => + (super.noSuchMethod( + Invocation.method(#startCpUiTrace, + [arg_screenName, arg_microTimeStamp, arg_traceId]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future reportScreenLoadingCP(int? arg_startTimeStampMicro, + int? arg_durationMicro, int? arg_uiTraceId) => + (super.noSuchMethod( + Invocation.method(#reportScreenLoadingCP, + [arg_startTimeStampMicro, arg_durationMicro, arg_uiTraceId]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future endScreenLoadingCP( + int? arg_timeStampMicro, int? arg_uiTraceId) => + (super.noSuchMethod( + Invocation.method( + #endScreenLoadingCP, [arg_timeStampMicro, arg_uiTraceId]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future isEndScreenLoadingEnabled() => + (super.noSuchMethod(Invocation.method(#isEndScreenLoadingEnabled, []), + returnValue: Future.value(false)) as _i9.Future); + @override + _i9.Future isAutoUiTraceEnabled() => + (super.noSuchMethod(Invocation.method(#isAutoUiTraceEnabled, []), + returnValue: Future.value(false)) as _i9.Future); + @override + _i9.Future isScreenRenderEnabled() => + (super.noSuchMethod(Invocation.method(#isScreenRenderEnabled, []), + returnValue: Future.value(false)) as _i9.Future); + @override + _i9.Future> getDeviceRefreshRateAndTolerance() => + (super.noSuchMethod( + Invocation.method(#getDeviceRefreshRateAndTolerance, []), + returnValue: Future>.value([])) + as _i9.Future>); + @override + _i9.Future setScreenRenderEnabled(bool? arg_isEnabled) => (super + .noSuchMethod(Invocation.method(#setScreenRenderEnabled, [arg_isEnabled]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future endScreenRenderForAutoUiTrace( + Map? arg_data) => + (super.noSuchMethod( + Invocation.method(#endScreenRenderForAutoUiTrace, [arg_data]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future endScreenRenderForCustomUiTrace( + Map? arg_data) => + (super.noSuchMethod( + Invocation.method(#endScreenRenderForCustomUiTrace, [arg_data]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); +} + +/// A class which mocks [WidgetsBinding]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockWidgetsBinding extends _i1.Mock implements _i10.WidgetsBinding { + MockWidgetsBinding() { + _i1.throwOnMissingStub(this); + } + + @override + bool get debugBuildingDirtyElements => + (super.noSuchMethod(Invocation.getter(#debugBuildingDirtyElements), + returnValue: false) as bool); + @override + set debugBuildingDirtyElements(bool? _debugBuildingDirtyElements) => + super.noSuchMethod( + Invocation.setter( + #debugBuildingDirtyElements, _debugBuildingDirtyElements), + returnValueForMissingStub: null); + @override + _i2.FocusManager get focusManager => + (super.noSuchMethod(Invocation.getter(#focusManager), + returnValue: _FakeFocusManager_0()) as _i2.FocusManager); + @override + bool get firstFrameRasterized => + (super.noSuchMethod(Invocation.getter(#firstFrameRasterized), + returnValue: false) as bool); + @override + _i9.Future get waitUntilFirstFrameRasterized => + (super.noSuchMethod(Invocation.getter(#waitUntilFirstFrameRasterized), + returnValue: Future.value()) as _i9.Future); + @override + bool get debugDidSendFirstFrameEvent => + (super.noSuchMethod(Invocation.getter(#debugDidSendFirstFrameEvent), + returnValue: false) as bool); + @override + bool get framesEnabled => + (super.noSuchMethod(Invocation.getter(#framesEnabled), returnValue: false) + as bool); + @override + bool get isRootWidgetAttached => + (super.noSuchMethod(Invocation.getter(#isRootWidgetAttached), + returnValue: false) as bool); + @override + _i4.SingletonFlutterWindow get window => + (super.noSuchMethod(Invocation.getter(#window), + returnValue: _FakeSingletonFlutterWindow_1()) + as _i4.SingletonFlutterWindow); + @override + _i4.PlatformDispatcher get platformDispatcher => + (super.noSuchMethod(Invocation.getter(#platformDispatcher), + returnValue: _FakePlatformDispatcher_2()) as _i4.PlatformDispatcher); + @override + bool get locked => + (super.noSuchMethod(Invocation.getter(#locked), returnValue: false) + as bool); + @override + _i5.HardwareKeyboard get keyboard => + (super.noSuchMethod(Invocation.getter(#keyboard), + returnValue: _FakeHardwareKeyboard_3()) as _i5.HardwareKeyboard); + @override + _i5.KeyEventManager get keyEventManager => + (super.noSuchMethod(Invocation.getter(#keyEventManager), + returnValue: _FakeKeyEventManager_4()) as _i5.KeyEventManager); + @override + _i5.BinaryMessenger get defaultBinaryMessenger => + (super.noSuchMethod(Invocation.getter(#defaultBinaryMessenger), + returnValue: _FakeBinaryMessenger_5()) as _i5.BinaryMessenger); + @override + _i4.ChannelBuffers get channelBuffers => + (super.noSuchMethod(Invocation.getter(#channelBuffers), + returnValue: _FakeChannelBuffers_6()) as _i4.ChannelBuffers); + @override + _i5.RestorationManager get restorationManager => + (super.noSuchMethod(Invocation.getter(#restorationManager), + returnValue: _FakeRestorationManager_7()) as _i5.RestorationManager); + @override + _i11.SchedulingStrategy get schedulingStrategy => + (super.noSuchMethod(Invocation.getter(#schedulingStrategy), + returnValue: ({int? priority, _i11.SchedulerBinding? scheduler}) => + false) as _i11.SchedulingStrategy); + @override + set schedulingStrategy(_i11.SchedulingStrategy? _schedulingStrategy) => super + .noSuchMethod(Invocation.setter(#schedulingStrategy, _schedulingStrategy), + returnValueForMissingStub: null); + @override + int get transientCallbackCount => + (super.noSuchMethod(Invocation.getter(#transientCallbackCount), + returnValue: 0) as int); + @override + _i9.Future get endOfFrame => + (super.noSuchMethod(Invocation.getter(#endOfFrame), + returnValue: Future.value()) as _i9.Future); + @override + bool get hasScheduledFrame => + (super.noSuchMethod(Invocation.getter(#hasScheduledFrame), + returnValue: false) as bool); + @override + _i11.SchedulerPhase get schedulerPhase => + (super.noSuchMethod(Invocation.getter(#schedulerPhase), + returnValue: _i11.SchedulerPhase.idle) as _i11.SchedulerPhase); + @override + Duration get currentFrameTimeStamp => + (super.noSuchMethod(Invocation.getter(#currentFrameTimeStamp), + returnValue: _FakeDuration_8()) as Duration); + @override + Duration get currentSystemFrameTimeStamp => + (super.noSuchMethod(Invocation.getter(#currentSystemFrameTimeStamp), + returnValue: _FakeDuration_8()) as Duration); + @override + _i6.PointerRouter get pointerRouter => + (super.noSuchMethod(Invocation.getter(#pointerRouter), + returnValue: _FakePointerRouter_9()) as _i6.PointerRouter); + @override + _i6.GestureArenaManager get gestureArena => (super.noSuchMethod( + Invocation.getter(#gestureArena), + returnValue: _FakeGestureArenaManager_10()) as _i6.GestureArenaManager); + @override + _i6.PointerSignalResolver get pointerSignalResolver => + (super.noSuchMethod(Invocation.getter(#pointerSignalResolver), + returnValue: _FakePointerSignalResolver_11()) + as _i6.PointerSignalResolver); + @override + bool get resamplingEnabled => + (super.noSuchMethod(Invocation.getter(#resamplingEnabled), + returnValue: false) as bool); + @override + set resamplingEnabled(bool? _resamplingEnabled) => super.noSuchMethod( + Invocation.setter(#resamplingEnabled, _resamplingEnabled), + returnValueForMissingStub: null); + @override + Duration get samplingOffset => + (super.noSuchMethod(Invocation.getter(#samplingOffset), + returnValue: _FakeDuration_8()) as Duration); + @override + set samplingOffset(Duration? _samplingOffset) => + super.noSuchMethod(Invocation.setter(#samplingOffset, _samplingOffset), + returnValueForMissingStub: null); + @override + _i7.MouseTracker get mouseTracker => + (super.noSuchMethod(Invocation.getter(#mouseTracker), + returnValue: _FakeMouseTracker_12()) as _i7.MouseTracker); + @override + _i7.PipelineOwner get pipelineOwner => + (super.noSuchMethod(Invocation.getter(#pipelineOwner), + returnValue: _FakePipelineOwner_13()) as _i7.PipelineOwner); + @override + _i7.RenderView get renderView => + (super.noSuchMethod(Invocation.getter(#renderView), + returnValue: _FakeRenderView_14()) as _i7.RenderView); + @override + set renderView(_i7.RenderView? value) => + super.noSuchMethod(Invocation.setter(#renderView, value), + returnValueForMissingStub: null); + @override + bool get sendFramesToEngine => + (super.noSuchMethod(Invocation.getter(#sendFramesToEngine), + returnValue: false) as bool); + @override + _i4.AccessibilityFeatures get accessibilityFeatures => + (super.noSuchMethod(Invocation.getter(#accessibilityFeatures), + returnValue: _FakeAccessibilityFeatures_15()) + as _i4.AccessibilityFeatures); + @override + bool get disableAnimations => + (super.noSuchMethod(Invocation.getter(#disableAnimations), + returnValue: false) as bool); + @override + void initInstances() => + super.noSuchMethod(Invocation.method(#initInstances, []), + returnValueForMissingStub: null); + @override + void initServiceExtensions() => + super.noSuchMethod(Invocation.method(#initServiceExtensions, []), + returnValueForMissingStub: null); + @override + void addObserver(_i10.WidgetsBindingObserver? observer) => + super.noSuchMethod(Invocation.method(#addObserver, [observer]), + returnValueForMissingStub: null); + @override + bool removeObserver(_i10.WidgetsBindingObserver? observer) => + (super.noSuchMethod(Invocation.method(#removeObserver, [observer]), + returnValue: false) as bool); + @override + void handleMetricsChanged() => + super.noSuchMethod(Invocation.method(#handleMetricsChanged, []), + returnValueForMissingStub: null); + @override + void handleTextScaleFactorChanged() => + super.noSuchMethod(Invocation.method(#handleTextScaleFactorChanged, []), + returnValueForMissingStub: null); + @override + void handlePlatformBrightnessChanged() => super.noSuchMethod( + Invocation.method(#handlePlatformBrightnessChanged, []), + returnValueForMissingStub: null); + @override + void handleAccessibilityFeaturesChanged() => super.noSuchMethod( + Invocation.method(#handleAccessibilityFeaturesChanged, []), + returnValueForMissingStub: null); + @override + void handleLocaleChanged() => + super.noSuchMethod(Invocation.method(#handleLocaleChanged, []), + returnValueForMissingStub: null); + @override + void dispatchLocalesChanged(List<_i4.Locale>? locales) => + super.noSuchMethod(Invocation.method(#dispatchLocalesChanged, [locales]), + returnValueForMissingStub: null); + @override + void dispatchAccessibilityFeaturesChanged() => super.noSuchMethod( + Invocation.method(#dispatchAccessibilityFeaturesChanged, []), + returnValueForMissingStub: null); + @override + _i9.Future handlePopRoute() => + (super.noSuchMethod(Invocation.method(#handlePopRoute, []), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future handlePushRoute(String? route) => + (super.noSuchMethod(Invocation.method(#handlePushRoute, [route]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + void handleAppLifecycleStateChanged(_i4.AppLifecycleState? state) => super + .noSuchMethod(Invocation.method(#handleAppLifecycleStateChanged, [state]), + returnValueForMissingStub: null); + @override + void handleMemoryPressure() => + super.noSuchMethod(Invocation.method(#handleMemoryPressure, []), + returnValueForMissingStub: null); + @override + void drawFrame() => super.noSuchMethod(Invocation.method(#drawFrame, []), + returnValueForMissingStub: null); + @override + void scheduleAttachRootWidget(_i12.Widget? rootWidget) => super.noSuchMethod( + Invocation.method(#scheduleAttachRootWidget, [rootWidget]), + returnValueForMissingStub: null); + @override + void attachRootWidget(_i12.Widget? rootWidget) => + super.noSuchMethod(Invocation.method(#attachRootWidget, [rootWidget]), + returnValueForMissingStub: null); + @override + _i9.Future performReassemble() => + (super.noSuchMethod(Invocation.method(#performReassemble, []), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i4.Locale? computePlatformResolvedLocale( + List<_i4.Locale>? supportedLocales) => + (super.noSuchMethod(Invocation.method( + #computePlatformResolvedLocale, [supportedLocales])) as _i4.Locale?); + @override + _i9.Future lockEvents(_i9.Future Function()? callback) => + (super.noSuchMethod(Invocation.method(#lockEvents, [callback]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + void unlocked() => super.noSuchMethod(Invocation.method(#unlocked, []), + returnValueForMissingStub: null); + @override + _i9.Future reassembleApplication() => + (super.noSuchMethod(Invocation.method(#reassembleApplication, []), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + void registerSignalServiceExtension( + {String? name, _i3.AsyncCallback? callback}) => + super.noSuchMethod( + Invocation.method(#registerSignalServiceExtension, [], + {#name: name, #callback: callback}), + returnValueForMissingStub: null); + @override + void registerBoolServiceExtension( + {String? name, + _i3.AsyncValueGetter? getter, + _i3.AsyncValueSetter? setter}) => + super.noSuchMethod( + Invocation.method(#registerBoolServiceExtension, [], + {#name: name, #getter: getter, #setter: setter}), + returnValueForMissingStub: null); + @override + void registerNumericServiceExtension( + {String? name, + _i3.AsyncValueGetter? getter, + _i3.AsyncValueSetter? setter}) => + super.noSuchMethod( + Invocation.method(#registerNumericServiceExtension, [], + {#name: name, #getter: getter, #setter: setter}), + returnValueForMissingStub: null); + @override + void postEvent(String? eventKind, Map? eventData) => + super.noSuchMethod(Invocation.method(#postEvent, [eventKind, eventData]), + returnValueForMissingStub: null); + @override + void registerStringServiceExtension( + {String? name, + _i3.AsyncValueGetter? getter, + _i3.AsyncValueSetter? setter}) => + super.noSuchMethod( + Invocation.method(#registerStringServiceExtension, [], + {#name: name, #getter: getter, #setter: setter}), + returnValueForMissingStub: null); + @override + void registerServiceExtension( + {String? name, _i3.ServiceExtensionCallback? callback}) => + super.noSuchMethod( + Invocation.method(#registerServiceExtension, [], + {#name: name, #callback: callback}), + returnValueForMissingStub: null); + @override + _i5.BinaryMessenger createBinaryMessenger() => + (super.noSuchMethod(Invocation.method(#createBinaryMessenger, []), + returnValue: _FakeBinaryMessenger_5()) as _i5.BinaryMessenger); + @override + _i9.Future handleSystemMessage(Object? systemMessage) => (super + .noSuchMethod(Invocation.method(#handleSystemMessage, [systemMessage]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + void initLicenses() => + super.noSuchMethod(Invocation.method(#initLicenses, []), + returnValueForMissingStub: null); + @override + void evict(String? asset) => + super.noSuchMethod(Invocation.method(#evict, [asset]), + returnValueForMissingStub: null); + @override + void readInitialLifecycleStateFromNativeWindow() => super.noSuchMethod( + Invocation.method(#readInitialLifecycleStateFromNativeWindow, []), + returnValueForMissingStub: null); + @override + _i5.RestorationManager createRestorationManager() => + (super.noSuchMethod(Invocation.method(#createRestorationManager, []), + returnValue: _FakeRestorationManager_7()) as _i5.RestorationManager); + @override + void setSystemUiChangeCallback(_i5.SystemUiChangeCallback? callback) => super + .noSuchMethod(Invocation.method(#setSystemUiChangeCallback, [callback]), + returnValueForMissingStub: null); + @override + void addTimingsCallback(_i4.TimingsCallback? callback) => + super.noSuchMethod(Invocation.method(#addTimingsCallback, [callback]), + returnValueForMissingStub: null); + @override + void removeTimingsCallback(_i4.TimingsCallback? callback) => + super.noSuchMethod(Invocation.method(#removeTimingsCallback, [callback]), + returnValueForMissingStub: null); + @override + _i9.Future scheduleTask( + _i11.TaskCallback? task, _i11.Priority? priority, + {String? debugLabel, _i13.Flow? flow}) => + (super.noSuchMethod( + Invocation.method(#scheduleTask, [task, priority], + {#debugLabel: debugLabel, #flow: flow}), + returnValue: Future.value(null)) as _i9.Future); + @override + bool handleEventLoopCallback() => + (super.noSuchMethod(Invocation.method(#handleEventLoopCallback, []), + returnValue: false) as bool); + @override + int scheduleFrameCallback(_i11.FrameCallback? callback, + {bool? rescheduling = false}) => + (super.noSuchMethod( + Invocation.method(#scheduleFrameCallback, [callback], + {#rescheduling: rescheduling}), + returnValue: 0) as int); + @override + void cancelFrameCallbackWithId(int? id) => + super.noSuchMethod(Invocation.method(#cancelFrameCallbackWithId, [id]), + returnValueForMissingStub: null); + @override + bool debugAssertNoTransientCallbacks(String? reason) => (super.noSuchMethod( + Invocation.method(#debugAssertNoTransientCallbacks, [reason]), + returnValue: false) as bool); + @override + void addPersistentFrameCallback(_i11.FrameCallback? callback) => super + .noSuchMethod(Invocation.method(#addPersistentFrameCallback, [callback]), + returnValueForMissingStub: null); + @override + void addPostFrameCallback(_i11.FrameCallback? callback) => + super.noSuchMethod(Invocation.method(#addPostFrameCallback, [callback]), + returnValueForMissingStub: null); + @override + void ensureFrameCallbacksRegistered() => + super.noSuchMethod(Invocation.method(#ensureFrameCallbacksRegistered, []), + returnValueForMissingStub: null); + @override + void ensureVisualUpdate() => + super.noSuchMethod(Invocation.method(#ensureVisualUpdate, []), + returnValueForMissingStub: null); + @override + void scheduleFrame() => + super.noSuchMethod(Invocation.method(#scheduleFrame, []), + returnValueForMissingStub: null); + @override + void scheduleForcedFrame() => + super.noSuchMethod(Invocation.method(#scheduleForcedFrame, []), + returnValueForMissingStub: null); + @override + void scheduleWarmUpFrame() => + super.noSuchMethod(Invocation.method(#scheduleWarmUpFrame, []), + returnValueForMissingStub: null); + @override + void resetEpoch() => super.noSuchMethod(Invocation.method(#resetEpoch, []), + returnValueForMissingStub: null); + @override + void handleBeginFrame(Duration? rawTimeStamp) => + super.noSuchMethod(Invocation.method(#handleBeginFrame, [rawTimeStamp]), + returnValueForMissingStub: null); + @override + void handleDrawFrame() => + super.noSuchMethod(Invocation.method(#handleDrawFrame, []), + returnValueForMissingStub: null); + @override + void cancelPointer(int? pointer) => + super.noSuchMethod(Invocation.method(#cancelPointer, [pointer]), + returnValueForMissingStub: null); + @override + void handlePointerEvent(_i6.PointerEvent? event) => + super.noSuchMethod(Invocation.method(#handlePointerEvent, [event]), + returnValueForMissingStub: null); + @override + void hitTest(_i6.HitTestResult? result, _i4.Offset? position) => + super.noSuchMethod(Invocation.method(#hitTest, [result, position]), + returnValueForMissingStub: null); + @override + void dispatchEvent( + _i6.PointerEvent? event, _i6.HitTestResult? hitTestResult) => + super.noSuchMethod( + Invocation.method(#dispatchEvent, [event, hitTestResult]), + returnValueForMissingStub: null); + @override + void handleEvent(_i6.PointerEvent? event, _i6.HitTestEntry? entry) => + super.noSuchMethod(Invocation.method(#handleEvent, [event, entry]), + returnValueForMissingStub: null); + @override + void resetGestureBinding() => + super.noSuchMethod(Invocation.method(#resetGestureBinding, []), + returnValueForMissingStub: null); + @override + void initRenderView() => + super.noSuchMethod(Invocation.method(#initRenderView, []), + returnValueForMissingStub: null); + @override + _i7.ViewConfiguration createViewConfiguration() => + (super.noSuchMethod(Invocation.method(#createViewConfiguration, []), + returnValue: _FakeViewConfiguration_16()) as _i7.ViewConfiguration); + @override + void initMouseTracker([_i7.MouseTracker? tracker]) => + super.noSuchMethod(Invocation.method(#initMouseTracker, [tracker]), + returnValueForMissingStub: null); + @override + void setSemanticsEnabled(bool? enabled) => + super.noSuchMethod(Invocation.method(#setSemanticsEnabled, [enabled]), + returnValueForMissingStub: null); + @override + void deferFirstFrame() => + super.noSuchMethod(Invocation.method(#deferFirstFrame, []), + returnValueForMissingStub: null); + @override + void allowFirstFrame() => + super.noSuchMethod(Invocation.method(#allowFirstFrame, []), + returnValueForMissingStub: null); + @override + void resetFirstFrameSent() => + super.noSuchMethod(Invocation.method(#resetFirstFrameSent, []), + returnValueForMissingStub: null); + @override + _i4.SemanticsUpdateBuilder createSemanticsUpdateBuilder() => + (super.noSuchMethod(Invocation.method(#createSemanticsUpdateBuilder, []), + returnValue: _FakeSemanticsUpdateBuilder_17()) + as _i4.SemanticsUpdateBuilder); +} + +/// A class which mocks [FrameTiming]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFrameTiming extends _i1.Mock implements _i4.FrameTiming { + MockFrameTiming() { + _i1.throwOnMissingStub(this); + } + + @override + Duration get buildDuration => + (super.noSuchMethod(Invocation.getter(#buildDuration), + returnValue: _FakeDuration_8()) as Duration); + @override + Duration get rasterDuration => + (super.noSuchMethod(Invocation.getter(#rasterDuration), + returnValue: _FakeDuration_8()) as Duration); + @override + Duration get vsyncOverhead => + (super.noSuchMethod(Invocation.getter(#vsyncOverhead), + returnValue: _FakeDuration_8()) as Duration); + @override + Duration get totalSpan => (super.noSuchMethod(Invocation.getter(#totalSpan), + returnValue: _FakeDuration_8()) as Duration); + @override + int get layerCacheCount => + (super.noSuchMethod(Invocation.getter(#layerCacheCount), returnValue: 0) + as int); + @override + int get layerCacheBytes => + (super.noSuchMethod(Invocation.getter(#layerCacheBytes), returnValue: 0) + as int); + @override + double get layerCacheMegabytes => + (super.noSuchMethod(Invocation.getter(#layerCacheMegabytes), + returnValue: 0.0) as double); + @override + int get pictureCacheCount => + (super.noSuchMethod(Invocation.getter(#pictureCacheCount), returnValue: 0) + as int); + @override + int get pictureCacheBytes => + (super.noSuchMethod(Invocation.getter(#pictureCacheBytes), returnValue: 0) + as int); + @override + double get pictureCacheMegabytes => + (super.noSuchMethod(Invocation.getter(#pictureCacheMegabytes), + returnValue: 0.0) as double); + @override + int get frameNumber => + (super.noSuchMethod(Invocation.getter(#frameNumber), returnValue: 0) + as int); + @override + int timestampInMicroseconds(_i4.FramePhase? phase) => + (super.noSuchMethod(Invocation.method(#timestampInMicroseconds, [phase]), + returnValue: 0) as int); +} + +/// A class which mocks [CrashReportingHostApi]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockCrashReportingHostApi extends _i1.Mock + implements _i14.CrashReportingHostApi { + MockCrashReportingHostApi() { + _i1.throwOnMissingStub(this); + } + + @override + _i9.Future setEnabled(bool? arg_isEnabled) => + (super.noSuchMethod(Invocation.method(#setEnabled, [arg_isEnabled]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future send(String? arg_jsonCrash, bool? arg_isHandled) => (super + .noSuchMethod(Invocation.method(#send, [arg_jsonCrash, arg_isHandled]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); + @override + _i9.Future sendNonFatalError( + String? arg_jsonCrash, + Map? arg_userAttributes, + String? arg_fingerprint, + String? arg_nonFatalExceptionLevel) => + (super.noSuchMethod( + Invocation.method(#sendNonFatalError, [ + arg_jsonCrash, + arg_userAttributes, + arg_fingerprint, + arg_nonFatalExceptionLevel + ]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i9.Future); +} + +/// A class which mocks [InstabugLogger]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockInstabugLogger extends _i1.Mock implements _i15.InstabugLogger { + MockInstabugLogger() { + _i1.throwOnMissingStub(this); + } + + @override + set logLevel(_i16.LogLevel? level) => + super.noSuchMethod(Invocation.setter(#logLevel, level), + returnValueForMissingStub: null); + @override + void log(String? message, {_i16.LogLevel? level, String? tag = r''}) => + super.noSuchMethod( + Invocation.method(#log, [message], {#level: level, #tag: tag}), + returnValueForMissingStub: null); + @override + void e(String? message, {String? tag = r''}) => + super.noSuchMethod(Invocation.method(#e, [message], {#tag: tag}), + returnValueForMissingStub: null); + @override + void d(String? message, {String? tag = r''}) => + super.noSuchMethod(Invocation.method(#d, [message], {#tag: tag}), + returnValueForMissingStub: null); + @override + void v(String? message, {String? tag = r''}) => + super.noSuchMethod(Invocation.method(#v, [message], {#tag: tag}), + returnValueForMissingStub: null); +} diff --git a/test/utils/screen_render/instabug_widget_binding_observer_test.dart b/test/utils/screen_render/instabug_widget_binding_observer_test.dart new file mode 100644 index 000000000..e11852ac2 --- /dev/null +++ b/test/utils/screen_render/instabug_widget_binding_observer_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/instabug_flutter.dart' show APM; +import 'package:instabug_flutter/src/generated/apm.api.g.dart'; +import 'package:instabug_flutter/src/utils/ibg_build_info.dart'; +import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_manager.dart'; +import 'package:instabug_flutter/src/utils/screen_name_masker.dart'; +import 'package:instabug_flutter/src/utils/screen_rendering/instabug_screen_render_manager.dart'; +import 'package:instabug_flutter/src/utils/screen_rendering/instabug_widget_binding_observer.dart'; +import 'package:instabug_flutter/src/utils/ui_trace/ui_trace.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'instabug_widget_binding_observer_test.mocks.dart'; + +@GenerateMocks([ + InstabugScreenRenderManager, + ScreenLoadingManager, + ScreenNameMasker, + UiTrace, + ApmHostApi, + IBGBuildInfo, +]) +void main() { + late MockInstabugScreenRenderManager mockRenderManager; + late MockScreenLoadingManager mockLoadingManager; + late MockScreenNameMasker mockNameMasker; + late MockUiTrace mockUiTrace; + + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + mockRenderManager = MockInstabugScreenRenderManager(); + mockLoadingManager = MockScreenLoadingManager(); + mockNameMasker = MockScreenNameMasker(); + mockUiTrace = MockUiTrace(); + + // Inject singleton mocks + InstabugScreenRenderManager.setInstance(mockRenderManager); + ScreenLoadingManager.setInstance(mockLoadingManager); + ScreenNameMasker.setInstance(mockNameMasker); + }); + + group('InstabugWidgetsBindingObserver', () { + final mApmHost = MockApmHostApi(); + final mIBGBuildInfo = MockIBGBuildInfo(); + setUp(() { + APM.$setHostApi(mApmHost); + IBGBuildInfo.setInstance(mIBGBuildInfo); + }); + + test('returns the singleton instance', () { + final instance = InstabugWidgetsBindingObserver.instance; + final shorthand = InstabugWidgetsBindingObserver.I; + expect(instance, isA()); + expect(shorthand, same(instance)); + }); + + test('handles AppLifecycleState.resumed and starts UiTrace', () async { + when(mockLoadingManager.currentUiTrace).thenReturn(mockUiTrace); + when(mockUiTrace.screenName).thenReturn("HomeScreen"); + when(mockNameMasker.mask("HomeScreen")).thenReturn("MaskedHome"); + when(mockLoadingManager.startUiTrace("MaskedHome", "HomeScreen")) + .thenAnswer((_) async => 123); + when(mockRenderManager.screenRenderEnabled).thenReturn(true); + + when(mApmHost.isScreenRenderEnabled()).thenAnswer((_) async => true); + + InstabugWidgetsBindingObserver.I + .didChangeAppLifecycleState(AppLifecycleState.resumed); + + // wait for async call to complete + await untilCalled( + mockRenderManager.startScreenRenderCollectorForTraceId(123), + ); + + verify(mockRenderManager.startScreenRenderCollectorForTraceId(123)) + .called(1); + }); + + test( + 'handles AppLifecycleState.paused and stops render collector for iOS platform', + () { + when(mockRenderManager.screenRenderEnabled).thenReturn(true); + when(mIBGBuildInfo.isIOS).thenReturn(true); + + InstabugWidgetsBindingObserver.I + .didChangeAppLifecycleState(AppLifecycleState.paused); + + verify(mockRenderManager.stopScreenRenderCollector()).called(1); + }); + + test('handles AppLifecycleState.inactive with no action', () { + // Just ensure it doesn't crash + expect( + () { + InstabugWidgetsBindingObserver.I + .didChangeAppLifecycleState(AppLifecycleState.inactive); + }, + returnsNormally, + ); + }); + + test('_handleResumedState does nothing if no currentUiTrace', () { + when(mockLoadingManager.currentUiTrace).thenReturn(null); + + InstabugWidgetsBindingObserver.I + .didChangeAppLifecycleState(AppLifecycleState.resumed); + + verifyNever(mockRenderManager.startScreenRenderCollectorForTraceId(any)); + }); + + test('checkForWidgetBinding ensures initialization', () { + expect(() => checkForWidgetBinding(), returnsNormally); + }); + }); +}