diff --git a/CHANGELOG.md b/CHANGELOG.md index cebed1910..a59302235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,131 +1,187 @@ # Changelog -## [13.0.0](https://github.com/Instabug/Instabug-React-Native/compare/v12.7.0...dev) (April 29, 2024) +## [Unreleased](https://github.com/Instabug/Instabug-Flutter/compare/v13.0.0...dev) + +### Changed + +- Bump Instabug iOS SDK to v13.1.0 ([#1227](https://github.com/Instabug/Instabug-Flutter/pull/1227)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/13.1.0). +- Bump Instabug Android SDK to v13.1.1 ([#474](https://github.com/Instabug/Instabug-Flutter/pull/474)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v13.1.1). + + +## [13.0.0](https://github.com/Instabug/Instabug-Flutter/compare/v12.7.0...dev) (April 29, 2024) ### Added - Adds custom app rating api ([#453](https://github.com/Instabug/Instabug-Flutter/pull/453)) -- Add `SessionReplay.getSessionReplayLink` API which retrieves the current session's replay link ([#445](https://github.com/Instabug/Instabug-Flutter/pull/445)). -- Add support for App Flows APIs `APM.startFlow`, `APM.endFlow` and `APM.setFlowAttribute` ([#446](https://github.com/Instabug/Instabug-Flutter/pull/446)). +- Add `SessionReplay.getSessionReplayLink` API which retrieves the current session's replay + link ([#445](https://github.com/Instabug/Instabug-Flutter/pull/445)). +- Add support for App Flows APIs `APM.startFlow`, `APM.endFlow` + and `APM.setFlowAttribute` ([#446](https://github.com/Instabug/Instabug-Flutter/pull/446)). ### Deprecated -- Deprecate execution traces APIs `APM.startExecutionTrace`, `APM.setExecutionTraceAttribute`, `APM.endExecutionTrace`, `Trace.setAttribute` and `Trace.end` in favor of the new app flow APIs ([#446](https://github.com/Instabug/Instabug-Flutter/pull/446)). +- Deprecate execution traces + APIs `APM.startExecutionTrace`, `APM.setExecutionTraceAttribute`, `APM.endExecutionTrace`, `Trace.setAttribute` + and `Trace.end` in favor of the new app flow + APIs ([#446](https://github.com/Instabug/Instabug-Flutter/pull/446)). ### Changed -- Bump Instabug Android SDK to v13.0.0 ([#455](https://github.com/Instabug/Instabug-Flutter/pull/455)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v13.0.0). -- Bump Instabug iOS SDK to v13.0.0 ([#446](https://github.com/Instabug/Instabug-Flutter/pull/446)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/13.0.0). + +- Bump Instabug Android SDK to + v13.0.0 ([#455](https://github.com/Instabug/Instabug-Flutter/pull/455)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v13.0.0). +- Bump Instabug iOS SDK to + v13.0.0 ([#446](https://github.com/Instabug/Instabug-Flutter/pull/446)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/13.0.0). ## [12.7.0](https://github.com/Instabug/Instabug-Flutter/compare/v12.5.0...v12.7.0) (February 15, 2024) ### Added -- Support user identification using ID ([#435](https://github.com/Instabug/Instabug-Flutter/pull/435)). +- Support user identification using + ID ([#435](https://github.com/Instabug/Instabug-Flutter/pull/435)). ### Changed -- Bump Instabug iOS SDK to v12.7.0 ([#440](https://github.com/Instabug/Instabug-Flutter/pull/440)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/12.7.0). -- Bump Instabug Android SDK to v12.7.1 ([#439](https://github.com/Instabug/Instabug-Flutter/pull/439)). See release notes for [v12.7.1](https://github.com/Instabug/Instabug-Android/releases/tag/v12.7.1). +- Bump Instabug iOS SDK to + v12.7.0 ([#440](https://github.com/Instabug/Instabug-Flutter/pull/440)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/12.7.0). +- Bump Instabug Android SDK to + v12.7.1 ([#439](https://github.com/Instabug/Instabug-Flutter/pull/439)). See release notes + for [v12.7.1](https://github.com/Instabug/Instabug-Android/releases/tag/v12.7.1). ## [12.5.0](https://github.com/Instabug/Instabug-Flutter/compare/v12.4.0...v12.5.0) (January 08 , 2024) ### Changed -- Bump Instabug iOS SDK to v12.5.0 ([#425](https://github.com/Instabug/Instabug-Flutter/pull/425)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/12.5.0). -- Bump Instabug Android SDK to v12.5.1 ([#426](https://github.com/Instabug/Instabug-Flutter/pull/426)). See release notes for [v12.5.0](https://github.com/Instabug/Instabug-Android/releases/tag/v12.5.0) and [v12.5.1](https://github.com/Instabug/Instabug-Android/releases/tag/v12.5.1). +- Bump Instabug iOS SDK to + v12.5.0 ([#425](https://github.com/Instabug/Instabug-Flutter/pull/425)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/12.5.0). +- Bump Instabug Android SDK to + v12.5.1 ([#426](https://github.com/Instabug/Instabug-Flutter/pull/426)). See release notes + for [v12.5.0](https://github.com/Instabug/Instabug-Android/releases/tag/v12.5.0) + and [v12.5.1](https://github.com/Instabug/Instabug-Android/releases/tag/v12.5.1). ## [12.4.0](https://github.com/Instabug/Instabug-Flutter/compare/v12.2.0...v12.4.0) (December 13, 2023) ### Changed -- Bump Instabug iOS SDK to v12.4.0 ([#419](https://github.com/Instabug/Instabug-Flutter/pull/419)). See release notes for [v12.3.0](https://github.com/instabug/instabug-ios/releases/tag/12.3.0) and [v12.4.0](https://github.com/instabug/instabug-ios/releases/tag/12.4.0). -- Bump Instabug Android SDK to v12.4.1 ([#420](https://github.com/Instabug/Instabug-Flutter/pull/420)). See release notes for [v12.3.0](https://github.com/Instabug/android/releases/tag/v12.3.0), [v12.3.1](https://github.com/Instabug/android/releases/tag/v12.3.1), [v12.4.0](https://github.com/Instabug/android/releases/tag/v12.4.0) and [v12.4.1](https://github.com/Instabug/android/releases/tag/v12.4.1). +- Bump Instabug iOS SDK to v12.4.0 ([#419](https://github.com/Instabug/Instabug-Flutter/pull/419)). + See release notes for [v12.3.0](https://github.com/instabug/instabug-ios/releases/tag/12.3.0) + and [v12.4.0](https://github.com/instabug/instabug-ios/releases/tag/12.4.0). +- Bump Instabug Android SDK to + v12.4.1 ([#420](https://github.com/Instabug/Instabug-Flutter/pull/420)). See release notes + for [v12.3.0](https://github.com/Instabug/android/releases/tag/v12.3.0), [v12.3.1](https://github.com/Instabug/android/releases/tag/v12.3.1), [v12.4.0](https://github.com/Instabug/android/releases/tag/v12.4.0) + and [v12.4.1](https://github.com/Instabug/android/releases/tag/v12.4.1). ## [12.2.0](https://github.com/Instabug/Instabug-Flutter/compare/12.1.0...12.2.0) (November 16, 2023) ### Changed -- Bump Instabug iOS SDK to v12.2.0 ([#406](https://github.com/Instabug/Instabug-Flutter/pull/406)). [See release notes](https://github.com/instabug/instabug-ios/releases/tag/12.2.0). -- Bump Instabug Android SDK to v12.2.0 ([#405](https://github.com/Instabug/Instabug-Flutter/pull/405)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v12.2.0). +- Bump Instabug iOS SDK to + v12.2.0 ([#406](https://github.com/Instabug/Instabug-Flutter/pull/406)). [See release notes](https://github.com/instabug/instabug-ios/releases/tag/12.2.0). +- Bump Instabug Android SDK to + v12.2.0 ([#405](https://github.com/Instabug/Instabug-Flutter/pull/405)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v12.2.0). ### Fixed -- Re-enable screenshot capturing for Crash Reporting and Session Replay by removing redundant mapping ([#407](https://github.com/Instabug/Instabug-Flutter/pull/407)). +- Re-enable screenshot capturing for Crash Reporting and Session Replay by removing redundant + mapping ([#407](https://github.com/Instabug/Instabug-Flutter/pull/407)). ## [12.1.0](https://github.com/Instabug/Instabug-Flutter/compare/v11.14.0...v12.1.0) (September 28, 2023) ### Added -- Add support for Session Replay, which includes capturing session details, visual reproduction of sessions as well as support for user steps, network and Instabug logs. ([#395](https://github.com/Instabug/Instabug-Flutter/pull/395)). +- Add support for Session Replay, which includes capturing session details, visual reproduction of + sessions as well as support for user steps, network and Instabug + logs. ([#395](https://github.com/Instabug/Instabug-Flutter/pull/395)). ### Changed -- **BREAKING** Remove deprecated APIs ([#385](https://github.com/Instabug/Instabug-Flutter/pull/385)). See migration guide for more details. -- Bump Instabug iOS SDK to v12.1.0 ([#396](https://github.com/Instabug/Instabug-Flutter/pull/396)). See release notes for [v12.0.0](https://github.com/instabug/instabug-ios/releases/tag/12.0.0) and [v12.1.0](https://github.com/instabug/instabug-ios/releases/tag/12.1.0). -- Bump Instabug Android SDK to v12.1.0 ([#397](https://github.com/Instabug/Instabug-Flutter/pull/397)). See release notes for [v12.0.0](https://github.com/Instabug/Instabug-Android/releases/tag/v12.0.0), [v12.0.1](https://github.com/Instabug/Instabug-Android/releases/tag/v12.0.1) and [v12.1.0](https://github.com/Instabug/Instabug-Android/releases/tag/v12.1.0). +- **BREAKING** Remove deprecated + APIs ([#385](https://github.com/Instabug/Instabug-Flutter/pull/385)). See migration guide for more + details. +- Bump Instabug iOS SDK to v12.1.0 ([#396](https://github.com/Instabug/Instabug-Flutter/pull/396)). + See release notes for [v12.0.0](https://github.com/instabug/instabug-ios/releases/tag/12.0.0) + and [v12.1.0](https://github.com/instabug/instabug-ios/releases/tag/12.1.0). +- Bump Instabug Android SDK to + v12.1.0 ([#397](https://github.com/Instabug/Instabug-Flutter/pull/397)). See release notes + for [v12.0.0](https://github.com/Instabug/Instabug-Android/releases/tag/v12.0.0), [v12.0.1](https://github.com/Instabug/Instabug-Android/releases/tag/v12.0.1) + and [v12.1.0](https://github.com/Instabug/Instabug-Android/releases/tag/v12.1.0). ## [11.14.0](https://github.com/Instabug/Instabug-Flutter/compare/v11.13.0...v11.14.0) (September 13, 2023) ### Added -- Add network logs obfuscation support using the new `NetworkLogger.obfuscateLog` API ([#380](https://github.com/Instabug/Instabug-Flutter/pull/380)). -- Add network logs omission support using the new `NetworkLogger.omitLog` API ([#382](https://github.com/Instabug/Instabug-Flutter/pull/382)). -- Add the new repro steps configuration API `Instabug.setReproStepsConfig` ([#388](https://github.com/Instabug/Instabug-Flutter/pull/388)). +- Add network logs obfuscation support using the new `NetworkLogger.obfuscateLog` + API ([#380](https://github.com/Instabug/Instabug-Flutter/pull/380)). +- Add network logs omission support using the new `NetworkLogger.omitLog` + API ([#382](https://github.com/Instabug/Instabug-Flutter/pull/382)). +- Add the new repro steps configuration + API `Instabug.setReproStepsConfig` ([#388](https://github.com/Instabug/Instabug-Flutter/pull/388)). ### Changed -- Bump Instabug Android SDK to v11.14.0 ([#384](https://github.com/Instabug/Instabug-Flutter/pull/384)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v11.14.0). -- Bump Instabug iOS SDK to v11.14.0 ([#383](https://github.com/Instabug/Instabug-Flutter/pull/383)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/11.14.0). +- Bump Instabug Android SDK to + v11.14.0 ([#384](https://github.com/Instabug/Instabug-Flutter/pull/384)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v11.14.0). +- Bump Instabug iOS SDK to + v11.14.0 ([#383](https://github.com/Instabug/Instabug-Flutter/pull/383)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/11.14.0). ### Deprecated -- Deprecate `Instabug.setReproStepsMode` in favor of the new `Instabug.setReproStepsConfig` ([#388](https://github.com/Instabug/Instabug-Flutter/pull/388)). +- Deprecate `Instabug.setReproStepsMode` in favor of the + new `Instabug.setReproStepsConfig` ([#388](https://github.com/Instabug/Instabug-Flutter/pull/388)). ## [11.13.0](https://github.com/Instabug/Instabug-Flutter/compare/v11.12.0...v11.13.0) (July 10, 2023) ### Changed -- Bump Instabug iOS SDK to v11.13.3 ([#373](https://github.com/Instabug/Instabug-Flutter/pull/373)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/v11.13.0). -- Bump Instabug Android SDK to v11.13.0 ([#372](https://github.com/Instabug/Instabug-Flutter/pull/372)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v11.13.0). +- Bump Instabug iOS SDK to + v11.13.3 ([#373](https://github.com/Instabug/Instabug-Flutter/pull/373)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/v11.13.0). +- Bump Instabug Android SDK to + v11.13.0 ([#372](https://github.com/Instabug/Instabug-Flutter/pull/372)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v11.13.0). ### Fixed -- Fix an issue that caused APIs that return a value or invoke a callback break on Android in some versions of Flutter ([#370](https://github.com/Instabug/Instabug-Flutter/pull/370), [#369](https://github.com/Instabug/Instabug-Flutter/pull/369)). +- Fix an issue that caused APIs that return a value or invoke a callback break on Android in some + versions of + Flutter ([#370](https://github.com/Instabug/Instabug-Flutter/pull/370), [#369](https://github.com/Instabug/Instabug-Flutter/pull/369)). Below is a list of all the affected APIs: - - `APM.startExecutionTrace` - - `BugReporting.setOnInvokeCallback` - - `BugReporting.setOnDismissCallback` - - `Instabug.getTags` - - `Instabug.getUserAttributeForKey` - - `Instabug.getUserAttributes` - - `Replies.getUnreadRepliesCount` - - `Replies.hasChats` - - `Replies.setOnNewReplyReceivedCallback` - - `Surveys.hasRespondToSurvey` - - `Surveys.setOnShowCallback` - - `Surveys.setOnDismissCallback` + - `APM.startExecutionTrace` + - `BugReporting.setOnInvokeCallback` + - `BugReporting.setOnDismissCallback` + - `Instabug.getTags` + - `Instabug.getUserAttributeForKey` + - `Instabug.getUserAttributes` + - `Replies.getUnreadRepliesCount` + - `Replies.hasChats` + - `Replies.setOnNewReplyReceivedCallback` + - `Surveys.hasRespondToSurvey` + - `Surveys.setOnShowCallback` + - `Surveys.setOnDismissCallback` ## [11.12.0](https://github.com/Instabug/Instabug-Flutter/compare/v11.10.1...v11.12.0) (May 30, 2023) ### Changed -- Bump Instabug Android SDK to v11.12.0 ([#366](https://github.com/Instabug/Instabug-Flutter/pull/366)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v11.12.0). -- Bump Instabug iOS SDK to v11.12.0 ([#365](https://github.com/Instabug/Instabug-Flutter/pull/365)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/11.12.0). +- Bump Instabug Android SDK to + v11.12.0 ([#366](https://github.com/Instabug/Instabug-Flutter/pull/366)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v11.12.0). +- Bump Instabug iOS SDK to + v11.12.0 ([#365](https://github.com/Instabug/Instabug-Flutter/pull/365)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/11.12.0). ## [11.10.1](https://github.com/Instabug/Instabug-Flutter/compare/v11.10.0...v11.10.1) (April 17, 2023) ### Changed -- Bump Instabug iOS SDK to v11.10.1 ([#358](https://github.com/Instabug/Instabug-Flutter/pull/358)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/11.10.1). +- Bump Instabug iOS SDK to + v11.10.1 ([#358](https://github.com/Instabug/Instabug-Flutter/pull/358)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/11.10.1). ## [11.10.0](https://github.com/Instabug/Instabug-Flutter/compare/v11.9.0...v11.10.0) (April 12, 2023) ### Changed -- Bump Instabug Android SDK to v11.11.0 ([#352](https://github.com/Instabug/Instabug-Flutter/pull/352)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v11.11.0). -- Bump Instabug iOS SDK to v11.10.0 ([#353](https://github.com/Instabug/Instabug-Flutter/pull/353)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/11.10.0). +- Bump Instabug Android SDK to + v11.11.0 ([#352](https://github.com/Instabug/Instabug-Flutter/pull/352)). [See release notes](https://github.com/Instabug/Instabug-Android/releases/tag/v11.11.0). +- Bump Instabug iOS SDK to + v11.10.0 ([#353](https://github.com/Instabug/Instabug-Flutter/pull/353)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/11.10.0). ## 11.9.0 (2023-02-21) @@ -141,7 +197,8 @@ ``` - Adds `hungarian` and `finnish` locales support. - Deprecates `Instabug.start` in favour of `Instabug.init`. -- Deprecates `Instabug.setDebugEnabled`, `Instabug.setSdkDebugLogsLevel`, and `APM.setLogLevel` in favour of `debugLogsLevel` parameter of `Instabug.init`. +- Deprecates `Instabug.setDebugEnabled`, `Instabug.setSdkDebugLogsLevel`, and `APM.setLogLevel` in + favour of `debugLogsLevel` parameter of `Instabug.init`. - Deprecates the `IBGSDKDebugLogsLevel` enum in favour of the `LogLevel` enum. - Deprecates both `warning` and `info` values from the `LogLevel` enum. - Fixes `norwegian` and `slovak` locales on iOS. @@ -152,7 +209,8 @@ - Bumps Instabug Android SDK to v11.7.0 - Bumps Instabug iOS SDK to v11.6.0 -- Adds new string keys: okButtonText, audio, image, screenRecording, messagesNotificationAndOthers, insufficientContentTitle, insufficientContentMessage +- Adds new string keys: okButtonText, audio, image, screenRecording, messagesNotificationAndOthers, + insufficientContentTitle, insufficientContentMessage - Fixes APM network logging on Android - Fixes a NullPointerException when overriding a string key that doesn't exist on Android - Removes redundant native logs @@ -168,14 +226,17 @@ - Removes "Media Projection" dialog while taking screenshots on Android - Fixes APM network logging on Android - Fixes main thread violation on Android -- Fixes an issue with request and response headers parameters type causing network requests not getting logged on iOS -- Improves performance by using pigeon for internal communication between Flutter and the host platform -- Deprecates Instabug.enableAndroid and Instabug.disableAndroid APIs in favour of the new API Instabug.setEnabled, which works on both platforms +- Fixes an issue with request and response headers parameters type causing network requests not + getting logged on iOS +- Improves performance by using pigeon for internal communication between Flutter and the host + platform +- Deprecates Instabug.enableAndroid and Instabug.disableAndroid APIs in favour of the new API + Instabug.setEnabled, which works on both platforms - Deprecates callbacks in favor of return values in the following APIs: - 1. Replies.getUnreadRepliesCount - 2. Replies.hasChats - 3. Surveys.hasRespondedToSurvey - 4. Surveys.getAvailableSurveys + 1. Replies.getUnreadRepliesCount + 2. Replies.hasChats + 3. Surveys.hasRespondedToSurvey + 4. Surveys.getAvailableSurveys ## 11.3.0 (2022-09-30) @@ -191,19 +252,24 @@ - Bumps Instabug Android SDK to v11.4.1 - Bumps Instabug iOS SDK to v11.2.0 - Fixes an issue with BugReporting.setInvocationEvents on iOS that always sets the event to none -- Fixes an issue with network logging on iOS which caused the initial network requests logs to be skipped +- Fixes an issue with network logging on iOS which caused the initial network requests logs to be + skipped - Renames Android package from com.instabug.instabugFlutter to com.instabug.flutter ## v11.0.0 (2022-07-20) - Bumps Instabug native SDKs to v11 -- Adds the ability to initialize the Android SDK from Dart. Check the migration guide referenced in our docs -- Changes the package importing style for a more conventional use. Check the migration guide referenced in our docs -- Moves InstabugCustomHttpClient used for network logging into a separate repo. Check the migration guide referenced in our docs +- Adds the ability to initialize the Android SDK from Dart. Check the migration guide referenced in + our docs +- Changes the package importing style for a more conventional use. Check the migration guide + referenced in our docs +- Moves InstabugCustomHttpClient used for network logging into a separate repo. Check the migration + guide referenced in our docs - Flutter 3 compatibility - Bumps Gradle to 6.8 & Android Gradle plugin to 4.1 - Adds BugReporting.setFloatingButtonEdge API -- Removes the string keys bugReportHeader and feedbackReportHeader. Check the migration guide referenced in our docs +- Removes the string keys bugReportHeader and feedbackReportHeader. Check the migration guide + referenced in our docs - Removes the deprecated APIs. Check the migration guide referenced in our docs - Fixes an issue with Android screenshots being black on release mode on SDK v10.13.0 @@ -257,7 +323,8 @@ ## v9.1.9 (2021-05-11) -- Adds support for overriding the replies notification string values through `repliesNotificationTeamName`, `repliesNotificationReplyButton`, `repliesNotificationDismissButton` +- Adds support for overriding the replies notification string values + through `repliesNotificationTeamName`, `repliesNotificationReplyButton`, `repliesNotificationDismissButton` - Removes the use of `android:requestLegacyExternalStorage` attribute on Android ## v9.1.8 (2021-02-17) @@ -274,8 +341,10 @@ ## v9.1.6 (2020-07-13) - Added CrashReporting -- Added setShakingThresholdForiPhone, setShakingThresholdForiPad and setShakingThresholdForAndroid APIs -- Added Proguard rules to protect Flutter bridge class and method names from getting obfuscated when the minifyEnabled flag is set to true. +- Added setShakingThresholdForiPhone, setShakingThresholdForiPad and setShakingThresholdForAndroid + APIs +- Added Proguard rules to protect Flutter bridge class and method names from getting obfuscated when + the minifyEnabled flag is set to true. ## v9.1.0 (2020-03-19) @@ -291,7 +360,9 @@ ## Version 9.0.1 (2019-12-12) -- Added enum `CustomTextPlaceHolderKey.reportQuestion` which maps to `InstabugCustomTextPlaceHolder.Key.REPORT_QUESTION` on Android and `kIBGAskAQuestionStringName` on iOS +- Added enum `CustomTextPlaceHolderKey.reportQuestion` which maps + to `InstabugCustomTextPlaceHolder.Key.REPORT_QUESTION` on Android and `kIBGAskAQuestionStringName` + on iOS ## Version 9.0.0 (2019-12-09) @@ -316,7 +387,8 @@ ## Version 1.0.0 (2019-07-29) -**⚠️ Package on pub has been renamed to `instabug_flutter` the old package `instabug` is deprecated** +**⚠️ Package on pub has been renamed to `instabug_flutter` the old package `instabug` is deprecated +** ## Version 1.0.0-beta.5 (2019-07-22) diff --git a/android/build.gradle b/android/build.gradle index 9afbfed05..b208baf42 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,56 +1,59 @@ group 'com.instabug.flutter' version '13.0.0' - buildscript { repositories { google() mavenCentral() + maven { + credentials { + username "instabug" + password System.getenv()["INSTABUG_REPOSITORY_PASSWORD"] + } + url "https://mvn.instabug.com/nexus/repository/instabug-internal/" + } } - dependencies { classpath 'com.android.tools.build:gradle:4.1.0' } } - rootProject.allprojects { repositories { google() mavenCentral() + maven { + credentials { + username "instabug" + password System.getenv()["INSTABUG_REPOSITORY_PASSWORD"] + } + url "https://mvn.instabug.com/nexus/repository/instabug-internal/" + } } } - apply plugin: 'com.android.library' - android { compileSdkVersion 28 - compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } - defaultConfig { minSdkVersion 16 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles 'proguard-rules.txt' } - lintOptions { disable 'InvalidPackage' } } - dependencies { - api 'com.instabug.library:instabug:13.0.0' - + api 'com.instabug.library:instabug:13.0.1.5819402-SNAPSHOT' testImplementation 'junit:junit:4.13.2' testImplementation "org.mockito:mockito-inline:3.12.1" } - // add upload_symbols task apply from: './upload_symbols.gradle' tasks.whenTaskAdded { task -> if (task.name == 'assembleRelease') { task.finalizedBy upload_symbols_task } -} +} \ No newline at end of file diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 393eba80f..8c678b4e2 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,10 @@ + + + + + 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 c0862acac..ea14dc9e4 100644 --- a/android/src/main/java/com/instabug/flutter/modules/ApmApi.java +++ b/android/src/main/java/com/instabug/flutter/modules/ApmApi.java @@ -1,15 +1,23 @@ package com.instabug.flutter.modules; import android.util.Log; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; + 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.networking.APMNetworkLogger; 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.jetbrains.annotations.NotNull; import org.json.JSONObject; import java.lang.reflect.Method; @@ -213,4 +221,71 @@ public void networkLogAndroid(@NonNull Map data) { e.printStackTrace(); } } + + + @Override + public void startCpUiTrace(@NonNull String screenName, @NonNull Long microTimeStamp, @NonNull Long traceId) { + try { + InternalAPM._startUiTraceCP(screenName, microTimeStamp, traceId); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void reportScreenLoadingCP(@NonNull Long startTimeStampMicro, @NonNull Long durationMicro, @NonNull Long uiTraceId) { + try { + InternalAPM._reportScreenLoadingCP(startTimeStampMicro, durationMicro, uiTraceId); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void endScreenLoadingCP(@NonNull Long timeStampMicro, @NonNull Long uiTraceId) { + try { + InternalAPM._endScreenLoadingCP(timeStampMicro, uiTraceId); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void isEndScreenLoadingEnabled(@NonNull ApmPigeon.Result result) { + isScreenLoadingEnabled(result); + } + + @Override + public void isEnabled(@NonNull ApmPigeon.Result result) { + try { + // TODO: replace true with an actual implementation of APM.isEnabled once implemented + // in the Android SDK. + result.success(true); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void isScreenLoadingEnabled(@NonNull ApmPigeon.Result result) { + try { + InternalAPM._isFeatureEnabledCP(APMFeature.SCREEN_LOADING, "InstabugCaptureScreenLoading", new FeatureAvailabilityCallback() { + @Override + public void invoke(boolean isFeatureAvailable) { + result.success(isFeatureAvailable); + } + }); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void setScreenLoadingEnabled(@NonNull Boolean isEnabled) { + try { + APM.setScreenLoadingEnabled(isEnabled); + } catch (Exception e) { + e.printStackTrace(); + } + } } 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 5cfc178e8..12c0c72d6 100644 --- a/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java +++ b/android/src/main/java/com/instabug/flutter/modules/InstabugApi.java @@ -6,27 +6,22 @@ import android.graphics.BitmapFactory; import android.net.Uri; import android.util.Log; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; - -import com.instabug.flutter.util.ArgsRegistry; import com.instabug.flutter.generated.InstabugPigeon; +import com.instabug.flutter.util.ArgsRegistry; import com.instabug.flutter.util.Reflection; import com.instabug.flutter.util.ThreadManager; -import com.instabug.library.Feature; -import com.instabug.library.Instabug; -import com.instabug.library.InstabugColorTheme; -import com.instabug.library.InstabugCustomTextPlaceHolder; -import com.instabug.library.IssueType; -import com.instabug.library.Platform; -import com.instabug.library.ReproConfigurations; +import com.instabug.library.*; import com.instabug.library.internal.module.InstabugLocale; 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; import java.io.File; @@ -39,10 +34,6 @@ import java.util.Map; import java.util.concurrent.Callable; -import io.flutter.FlutterInjector; -import io.flutter.plugin.common.BinaryMessenger; -import io.flutter.embedding.engine.loader.FlutterLoader; - public class InstabugApi implements InstabugPigeon.InstabugHostApi { private final String TAG = InstabugApi.class.getName(); private final Context context; @@ -85,6 +76,16 @@ public void setEnabled(@NonNull Boolean isEnabled) { } } + @NotNull + @Override + public Boolean isEnabled() { + return Instabug.isEnabled(); + } + + @NotNull + @Override + public Boolean isBuilt() { return Instabug.isBuilt(); } + @Override public void init(@NonNull String token, @NonNull List invocationEvents, @NonNull String debugLogsLevel) { setCurrentPlatform(); diff --git a/android/src/main/java/com/instabug/flutter/modules/SessionReplayApi.java b/android/src/main/java/com/instabug/flutter/modules/SessionReplayApi.java index 2170f2c98..29ff95596 100644 --- a/android/src/main/java/com/instabug/flutter/modules/SessionReplayApi.java +++ b/android/src/main/java/com/instabug/flutter/modules/SessionReplayApi.java @@ -1,12 +1,8 @@ package com.instabug.flutter.modules; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - import com.instabug.flutter.generated.SessionReplayPigeon; -import com.instabug.library.OnSessionReplayLinkReady; import com.instabug.library.sessionreplay.SessionReplay; - import io.flutter.plugin.common.BinaryMessenger; public class SessionReplayApi implements SessionReplayPigeon.SessionReplayHostApi { diff --git a/android/src/test/java/com/instabug/flutter/ApmApiTest.java b/android/src/test/java/com/instabug/flutter/ApmApiTest.java index aefb3b62d..677da604b 100644 --- a/android/src/test/java/com/instabug/flutter/ApmApiTest.java +++ b/android/src/test/java/com/instabug/flutter/ApmApiTest.java @@ -1,18 +1,9 @@ package com.instabug.flutter; -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.any; -import static org.mockito.ArgumentMatchers.anyInt; -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.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.networking.APMNetworkLogger; import com.instabug.flutter.generated.ApmPigeon; @@ -20,6 +11,8 @@ 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; @@ -31,23 +24,32 @@ import java.util.HashMap; import java.util.Map; -import io.flutter.plugin.common.BinaryMessenger; +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.*; public class ApmApiTest { + + private final BinaryMessenger mMessenger = mock(BinaryMessenger.class); private final ApmApi api = new ApmApi(); private MockedStatic mAPM; + private MockedStatic mInternalApmStatic; private MockedStatic mHostApi; @Before public void setUp() throws NoSuchMethodException { mAPM = mockStatic(APM.class); + mInternalApmStatic = mockStatic(InternalAPM.class); mHostApi = mockStatic(ApmPigeon.ApmHostApi.class); GlobalMocks.setUp(); } @After public void cleanUp() { + mInternalApmStatic.close(); mAPM.close(); mHostApi.close(); GlobalMocks.close(); @@ -266,4 +268,108 @@ public void testNetworkLogAndroid() { mAPMNetworkLogger.close(); mJSONObject.close(); } + + @Test + public void testStartUiTraceCP() { + String screenName = "screen-name"; + long microTimeStamp = System.currentTimeMillis() / 1000; + long traceId = System.currentTimeMillis(); + + + api.startCpUiTrace(screenName, microTimeStamp, traceId); + + mInternalApmStatic.verify(() -> InternalAPM._startUiTraceCP(screenName, microTimeStamp, traceId)); + mInternalApmStatic.verifyNoMoreInteractions(); + } + + @Test + public void testReportScreenLoadingCP() { + long startTimeStampMicro = System.currentTimeMillis() / 1000; + long durationMicro = System.currentTimeMillis() / 1000; + long uiTraceId = System.currentTimeMillis(); + + api.reportScreenLoadingCP(startTimeStampMicro, durationMicro, uiTraceId); + + mInternalApmStatic.verify(() -> InternalAPM._reportScreenLoadingCP(startTimeStampMicro, durationMicro, uiTraceId)); + mInternalApmStatic.verifyNoMoreInteractions(); + } + + @Test + public void testEndScreenLoading() { + long timeStampMicro = System.currentTimeMillis() / 1000; + long uiTraceId = System.currentTimeMillis(); + + api.endScreenLoadingCP(timeStampMicro, uiTraceId); + + mInternalApmStatic.verify(() -> InternalAPM._endScreenLoadingCP(timeStampMicro, uiTraceId)); + mInternalApmStatic.verifyNoMoreInteractions(); + } + + @Test + public void testIsEnabled() { + boolean expected = true; + ApmPigeon.Result result = spy(makeResult((actual) -> assertEquals(expected, actual))); + mInternalApmStatic.when(() -> InternalAPM._isFeatureEnabledCP(eq(APMFeature.SCREEN_LOADING), any(), any(FeatureAvailabilityCallback.class))).thenAnswer(invocation -> { + FeatureAvailabilityCallback callback = invocation.getArgument(1); + callback.invoke(expected); + return null; + }); + + api.isEnabled(result); + + verify(result).success(expected); + } + + @Test + public void testIsScreenLoadingEnabled() { + 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.isScreenLoadingEnabled(result); + + mInternalApmStatic.verify(() -> InternalAPM._isFeatureEnabledCP(any(), any(), any())); + mInternalApmStatic.verifyNoMoreInteractions(); + + verify(result).success(expected); + } + + @Test + public void testIsEndScreenLoadingEnabled() { + 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.isEndScreenLoadingEnabled(result); + + mInternalApmStatic.verify(() -> InternalAPM._isFeatureEnabledCP(any(), any(), any())); + mInternalApmStatic.verifyNoMoreInteractions(); + + verify(result).success(expected); + + } + + + @Test + public void testSetScreenLoadingMonitoringEnabled() { + boolean isEnabled = false; + + api.setScreenLoadingEnabled(isEnabled); + + mAPM.verify(() -> APM.setScreenLoadingEnabled(isEnabled)); + } } diff --git a/android/src/test/java/com/instabug/flutter/InstabugApiTest.java b/android/src/test/java/com/instabug/flutter/InstabugApiTest.java index b542259b6..deb8d7b47 100644 --- a/android/src/test/java/com/instabug/flutter/InstabugApiTest.java +++ b/android/src/test/java/com/instabug/flutter/InstabugApiTest.java @@ -57,6 +57,7 @@ import java.util.concurrent.Callable; import io.flutter.plugin.common.BinaryMessenger; +import org.mockito.verification.VerificationMode; public class InstabugApiTest { private final Callable screenshotProvider = () -> mock(Bitmap.class); @@ -155,6 +156,20 @@ public void testSetEnabledGivenFalse() { mInstabug.verify(Instabug::disable); } + @Test + public void testIsEnabled() { + api.isEnabled(); + + mInstabug.verify(Instabug::isEnabled); + } + + @Test + public void testIsBuilt() { + api.isBuilt(); + + mInstabug.verify(Instabug::isBuilt); + } + @Test public void testShow() { api.show(); diff --git a/android/src/test/java/com/instabug/flutter/SessionReplayApiTest.java b/android/src/test/java/com/instabug/flutter/SessionReplayApiTest.java index 223871844..838202379 100644 --- a/android/src/test/java/com/instabug/flutter/SessionReplayApiTest.java +++ b/android/src/test/java/com/instabug/flutter/SessionReplayApiTest.java @@ -1,24 +1,19 @@ package com.instabug.flutter; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.timeout; -import static org.mockito.Mockito.verify; - import com.instabug.flutter.generated.SessionReplayPigeon; import com.instabug.flutter.modules.SessionReplayApi; import com.instabug.flutter.util.GlobalMocks; -import com.instabug.library.OnSessionReplayLinkReady; import com.instabug.library.sessionreplay.SessionReplay; - +import io.flutter.plugin.common.BinaryMessenger; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.MockedStatic; -import io.flutter.plugin.common.BinaryMessenger; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; public class SessionReplayApiTest { @@ -84,28 +79,28 @@ public void testSetUserStepsEnabled() { mSessionReplay.verify(() -> SessionReplay.setUserStepsEnabled(true)); } - @Test - public void testGetSessionReplayLink() { - SessionReplayPigeon.Result result = mock(SessionReplayPigeon.Result.class); - String link="instabug link"; - - mSessionReplay.when(() -> SessionReplay.getSessionReplayLink(any())).thenAnswer( - invocation -> { - OnSessionReplayLinkReady callback = (OnSessionReplayLinkReady) invocation.getArguments()[0]; - callback.onSessionReplayLinkReady(link); - return callback; - }); - api.getSessionReplayLink(result); - - - mSessionReplay.verify(() -> SessionReplay.getSessionReplayLink(any())); - mSessionReplay.verifyNoMoreInteractions(); - - - verify(result, timeout(1000)).success(link); - - - } +// @Test +// public void testGetSessionReplayLink() { +// SessionReplayPigeon.Result result = mock(SessionReplayPigeon.Result.class); +// String link="instabug link"; +// +// mSessionReplay.when(() -> SessionReplay.getSessionReplayLink(any())).thenAnswer( +// invocation -> { +// OnSessionReplayLinkReady callback = (OnSessionReplayLinkReady) invocation.getArguments()[0]; +// callback.onSessionReplayLinkReady(link); +// return callback; +// }); +// api.getSessionReplayLink(result); +// +// +// mSessionReplay.verify(() -> SessionReplay.getSessionReplayLink(any())); +// mSessionReplay.verifyNoMoreInteractions(); +// +// +// verify(result, timeout(1000)).success(link); +// +// +// } } diff --git a/android/src/test/java/com/instabug/flutter/util/GlobalMocks.java b/android/src/test/java/com/instabug/flutter/util/GlobalMocks.java index 8a37d3ba1..2e8f6995c 100644 --- a/android/src/test/java/com/instabug/flutter/util/GlobalMocks.java +++ b/android/src/test/java/com/instabug/flutter/util/GlobalMocks.java @@ -1,13 +1,8 @@ package com.instabug.flutter.util; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; - import android.graphics.Bitmap; import android.net.Uri; import android.util.Log; - import org.json.JSONObject; import org.mockito.MockedStatic; import org.mockito.invocation.InvocationOnMock; @@ -15,6 +10,10 @@ import java.lang.reflect.Method; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; + public class GlobalMocks { public static MockedStatic threadManager; public static MockedStatic log; @@ -77,6 +76,18 @@ public static void setUp() throws NoSuchMethodException { uri = mockStatic(Uri.class); uri.when(() -> Uri.fromFile(any())).thenReturn(mock(Uri.class)); + + Method mStartUiTraceCP = MockReflected.class.getDeclaredMethod("startUiTraceCP", String.class, Long.class, Long.class); + mStartUiTraceCP.setAccessible(true); + reflection.when(() -> Reflection.getMethod(Class.forName("com.instabug.apm.APM"), "startUiTraceCP", String.class, Long.class, Long.class)).thenReturn(mStartUiTraceCP); + + Method mReportScreenLoadingCP = MockReflected.class.getDeclaredMethod("reportScreenLoadingCP", Long.class, Long.class, Long.class); + mReportScreenLoadingCP.setAccessible(true); + reflection.when(() -> Reflection.getMethod(Class.forName("com.instabug.apm.APM"), "reportScreenLoadingCP", Long.class, Long.class, Long.class)).thenReturn(mReportScreenLoadingCP); + + Method mEndScreenLoadingCP = MockReflected.class.getDeclaredMethod("endScreenLoadingCP", Long.class, Long.class); + mEndScreenLoadingCP.setAccessible(true); + reflection.when(() -> Reflection.getMethod(Class.forName("com.instabug.apm.APM"), "endScreenLoadingCP", Long.class, Long.class)).thenReturn(mEndScreenLoadingCP); } public static void close() { diff --git a/android/src/test/java/com/instabug/flutter/util/MockReflected.java b/android/src/test/java/com/instabug/flutter/util/MockReflected.java index cb81c4f1c..d9de3a187 100644 --- a/android/src/test/java/com/instabug/flutter/util/MockReflected.java +++ b/android/src/test/java/com/instabug/flutter/util/MockReflected.java @@ -1,9 +1,7 @@ package com.instabug.flutter.util; import android.graphics.Bitmap; - import androidx.annotation.Nullable; - import org.json.JSONObject; /** @@ -36,4 +34,10 @@ public static void apmNetworkLog(long requestStartTime, long requestDuration, St * CrashReporting.reportException */ public static void crashReportException(JSONObject exception, boolean isHandled) {} + + public static void startUiTraceCP(String screenName, Long microTimeStamp, Long traceId) {} + + public static void reportScreenLoadingCP(Long startTimeStampMicro, Long durationMicro, Long uiTraceId) {} + + public static void endScreenLoadingCP(Long timeStampMicro, Long uiTraceId) {} } diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 000000000..0d2902135 --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index bf23504ff..2416f09d0 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -16,6 +16,7 @@ android:label="Instabug - Flutter" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" + android:networkSecurityConfig="@xml/network_security_config" android:usesCleartextTraffic="true"> { + Log.d(TAG, "Sending native non-fatal crash from Android") + val exceptionObject = call.arguments as? String + sendNativeNonFatal(exceptionObject) + result.success(null) + } + SEND_NATIVE_FATAL_CRASH -> { + Log.d(TAG, "Sending native fatal crash from Android") + sendNativeFatalCrash() + result.success(null) + } + SEND_NATIVE_FATAL_HANG -> { + Log.d(TAG, "Sending native fatal hang for 3000 ms") + sendANR() + result.success(null) + } + SEND_ANR -> { + Log.d(TAG, "Sending android not responding 'ANR' hanging for 20000 ms") + sendFatalHang() + result.success(null) + } + SEND_OOM -> { + Log.d(TAG, "sending out of memory") + sendOOM() + result.success(null) + } + else -> { + Log.e(TAG, "onMethodCall for ${call.method} is not implemented") + result.notImplemented() + } + } + } + + companion object { + const val TAG = "IBGEMethodCallHandler"; + + const val METHOD_CHANNEL_NAME = "instabug_flutter_example" + + // Method Names + const val SEND_NATIVE_NON_FATAL_CRASH = "sendNativeNonFatalCrash" + const val SEND_NATIVE_FATAL_CRASH = "sendNativeFatalCrash" + const val SEND_NATIVE_FATAL_HANG = "sendNativeFatalHang" + const val SEND_ANR = "sendAnr" + const val SEND_OOM = "sendOom" + } + + private fun sendNativeNonFatal(exceptionObject: String?) { + val exception: IBGNonFatalException = IBGNonFatalException.Builder(IllegalStateException("Test exception")) + .build() + CrashReporting.report(exception) + } + + private fun sendNativeFatalCrash() { + throw IllegalStateException("Unhandled IllegalStateException from Instabug Test App") + } + + private fun sendANR() { + try { + Thread.sleep(20000) + } catch (e: InterruptedException) { + throw RuntimeException(e) + } + } + + private fun sendFatalHang() { + try { + Thread.sleep(3000) + } catch (e: InterruptedException) { + throw RuntimeException(e) + } + } + + private fun sendOOM() { + oomCrash() + } + + private fun oomCrash() { + Thread { + val stringList: MutableList = ArrayList() + for (i in 0 until 1000000) { + stringList.add(getRandomString(10000)) + } + }.start() + } + + private fun getRandomString(length: Int): String { + val charset: MutableList = ArrayList() + var ch = 'a' + while (ch <= 'z') { + charset.add(ch) + ch++ + } + ch = 'A' + while (ch <= 'Z') { + charset.add(ch) + ch++ + } + ch = '0' + while (ch <= '9') { + charset.add(ch) + ch++ + } + val randomString = StringBuilder() + val random = java.util.Random() + for (i in 0 until length) { + val randomChar = charset[random.nextInt(charset.size)] + randomString.append(randomChar) + } + return randomString.toString() + } + +} diff --git a/example/android/app/src/main/kotlin/com/example/InstabugSample/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/InstabugSample/MainActivity.kt index de0236769..b6d6f7352 100644 --- a/example/android/app/src/main/kotlin/com/example/InstabugSample/MainActivity.kt +++ b/example/android/app/src/main/kotlin/com/example/InstabugSample/MainActivity.kt @@ -1,6 +1,15 @@ package com.instabug.flutter.example +import com.example.InstabugSample.InstabugExampleMethodCallHandler +import com.example.InstabugSample.InstabugExampleMethodCallHandler.Companion.METHOD_CHANNEL_NAME import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity: FlutterActivity() { +class +MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL_NAME).setMethodCallHandler(InstabugExampleMethodCallHandler()) + } } diff --git a/example/android/app/src/main/res/xml/network_security_config.xml b/example/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..9db612408 --- /dev/null +++ b/example/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,12 @@ + + + + localhost + + + + + + + + diff --git a/example/ios/InstabugTests/ApmApiTests.m b/example/ios/InstabugTests/ApmApiTests.m index 09e8bad46..073937c04 100644 --- a/example/ios/InstabugTests/ApmApiTests.m +++ b/example/ios/InstabugTests/ApmApiTests.m @@ -3,6 +3,7 @@ #import "ApmApi.h" #import "Instabug/IBGAPM.h" #import "Instabug/Instabug.h" +#import "IBGAPM+PrivateAPIs.h" @interface ApmApiTests : XCTestCase @@ -38,6 +39,65 @@ - (void)testSetEnabled { OCMVerify([self.mAPM setEnabled:YES]); } +- (void)testIsEnabled { + XCTestExpectation *expectation = [self expectationWithDescription:@"Call completion handler"]; + + BOOL isEnabled = YES; + OCMStub([self.mAPM enabled]).andReturn(isEnabled); + [self.api isEnabledWithCompletion:^(NSNumber *isEnabledNumber, FlutterError *error) { + [expectation fulfill]; + + XCTAssertEqualObjects(isEnabledNumber, @(isEnabled)); + XCTAssertNil(error); + }]; + + [self waitForExpectations:@[expectation] timeout:5.0]; +} + +- (void)testSetScreenLoadingEnabled { + + NSNumber *isEnabled = @1; + FlutterError *error; + + [self.api setScreenLoadingEnabledIsEnabled:isEnabled error:&error]; + + OCMVerify([self.mAPM setScreenLoadingEnabled:YES]); +} + +- (void)testIsScreenLoadingEnabled { + XCTestExpectation *expectation = [self expectationWithDescription:@"Call completion handler"]; + + BOOL isScreenLoadingMonitoringEnabled = YES; + OCMStub([self.mAPM screenLoadingEnabled]).andReturn(isScreenLoadingMonitoringEnabled); + + [self.api isScreenLoadingEnabledWithCompletion:^(NSNumber *isEnabledNumber, FlutterError *error) { + [expectation fulfill]; + + XCTAssertEqualObjects(isEnabledNumber, @(isScreenLoadingMonitoringEnabled)); + + XCTAssertNil(error); + }]; + + [self waitForExpectations:@[expectation] timeout:5.0]; +} + +- (void)testIsEndScreenLoadingEnabled { + XCTestExpectation *expectation = [self expectationWithDescription:@"Call completion handler"]; + + BOOL isEndScreenLoadingEnabled = YES; + OCMStub([self.mAPM endScreenLoadingEnabled]).andReturn(isEndScreenLoadingEnabled); + + [self.api isEndScreenLoadingEnabledWithCompletion:^(NSNumber *isEnabledNumber, FlutterError *error) { + [expectation fulfill]; + + XCTAssertEqualObjects(isEnabledNumber, @(isEndScreenLoadingEnabled)); + + XCTAssertNil(error); + }]; + + [self waitForExpectations:@[expectation] timeout:5.0]; +} + - (void)testSetColdAppLaunchEnabled { NSNumber *isEnabled = @1; FlutterError *error; @@ -168,4 +228,43 @@ - (void)testEndAppLaunch { OCMVerify([self.mAPM endAppLaunch]); } +- (void)testStartCpUiTrace { + NSString *screenName = @"testScreen"; + NSNumber *microTimeStamp = @(123456789); + NSNumber *traceId = @(987654321); + + NSTimeInterval microTimeStampMUS = [microTimeStamp doubleValue]; + FlutterError *error; + + [self.api startCpUiTraceScreenName:screenName microTimeStamp:microTimeStamp traceId:traceId error:&error]; + + OCMVerify([self.mAPM startUITraceCPWithName:screenName startTimestampMUS:microTimeStampMUS]); +} + +- (void)testReportScreenLoading { + NSNumber *startTimeStampMicro = @(123456789); + NSNumber *durationMicro = @(987654321); + NSNumber *uiTraceId = @(135792468); + FlutterError *error; + + NSTimeInterval startTimeStampMicroMUS = [startTimeStampMicro doubleValue]; + NSTimeInterval durationMUS = [durationMicro doubleValue]; + + [self.api reportScreenLoadingCPStartTimeStampMicro:startTimeStampMicro durationMicro:durationMicro uiTraceId:uiTraceId error:&error]; + + OCMVerify([self.mAPM reportScreenLoadingCPWithStartTimestampMUS:startTimeStampMicroMUS durationMUS:durationMUS]); +} + +- (void)testEndScreenLoading { + NSNumber *timeStampMicro = @(123456789); + NSNumber *uiTraceId = @(987654321); + FlutterError *error; + + NSTimeInterval endScreenLoadingCPWithEndTimestampMUS = [timeStampMicro doubleValue]; + [self.api endScreenLoadingCPTimeStampMicro:timeStampMicro uiTraceId:uiTraceId error:&error]; + + OCMVerify([self.mAPM endScreenLoadingCPWithEndTimestampMUS:endScreenLoadingCPWithEndTimestampMUS]); +} + + @end diff --git a/example/ios/InstabugTests/Util/Apm+Test.h b/example/ios/InstabugTests/Util/Apm+Test.h new file mode 100644 index 000000000..c3bddc4bf --- /dev/null +++ b/example/ios/InstabugTests/Util/Apm+Test.h @@ -0,0 +1,11 @@ +// This header file defines Instabug methods that are called using selectors for test verification. + +#import +#import + +@interface IBGAPM (Test) ++ (void)startUITraceCPWithName:(NSString *)name startTimestampMUS:(NSTimeInterval)startTimestampMUS; ++ (void)reportScreenLoadingCPWithStartTimestampMUS:(NSTimeInterval)startTimestampMUS + durationMUS:(NSTimeInterval)durationMUS; ++ (void)endScreenLoadingCPWithEndTimestampMUS:(NSTimeInterval)endTimestampMUS; +@end diff --git a/example/ios/Podfile b/example/ios/Podfile index 3a216ba41..22cd9439a 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -30,13 +30,14 @@ flutter_ios_podfile_setup target 'Runner' do use_frameworks! use_modular_headers! + pod 'Instabug', :podspec => 'https://ios-releases.instabug.com/custom/feature-flutter-screenloading/13.1.0/Instabug.podspec' flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) end target 'InstabugTests' do pod 'OCMock', '3.6' - + use_frameworks! use_modular_headers! @@ -53,5 +54,8 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) + target.build_configurations.each do |config| + config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"] = "11.0" + end end end diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 4a9cac97e..e2900ee1e 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,33 +1,35 @@ PODS: - Flutter (1.0.0) - - Instabug (13.0.0) + - Instabug (13.1.0) - instabug_flutter (13.0.0): - Flutter - - Instabug (= 13.0.0) + - Instabug (= 13.1.0) - OCMock (3.6) DEPENDENCIES: - Flutter (from `Flutter`) + - Instabug (from `https://ios-releases.instabug.com/custom/feature-flutter-screenloading/13.0.0/Instabug.podspec`) - instabug_flutter (from `.symlinks/plugins/instabug_flutter/ios`) - OCMock (= 3.6) SPEC REPOS: trunk: - - Instabug - OCMock EXTERNAL SOURCES: Flutter: :path: Flutter + Instabug: + :podspec: https://ios-releases.instabug.com/custom/feature-flutter-screenloading/13.0.0/Instabug.podspec instabug_flutter: :path: ".symlinks/plugins/instabug_flutter/ios" SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - Instabug: fa52de4a6cac26cde0a60ec5e0540f2461a06fe2 - instabug_flutter: b80c4b8748d1da660a8f0cc0b2e5f4375898761c + Instabug: 3d55eff7ea55adf22df404908a2b954b8b585c29 + instabug_flutter: 8b86ee14635a4b0ebfb4f760a108c7b0606c47e4 OCMock: 5ea90566be239f179ba766fd9fbae5885040b992 -PODFILE CHECKSUM: 637e800c0a0982493b68adb612d2dd60c15c8e5c +PODFILE CHECKSUM: 03fc227efec8d8485f83d3825510bdf640d8a087 -COCOAPODS: 1.13.0 +COCOAPODS: 1.15.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 5b2b044d0..75a9072f8 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,11 +3,12 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2001D1442B8F501000885261 /* InstabugExampleMethodCallHandler.m in Sources */ = {isa = PBXBuildFile; fileRef = 2001D1432B8F501000885261 /* InstabugExampleMethodCallHandler.m */; }; 206286ED2ABD0A1F00925509 /* SessionReplayApiTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 206286EC2ABD0A1F00925509 /* SessionReplayApiTests.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3EA1F5233E85A5C4F9EF3957 /* Pods_InstabugUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5446C0D3B2623D9BCC7CCE3 /* Pods_InstabugUITests.framework */; }; @@ -67,7 +68,10 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2001D1432B8F501000885261 /* InstabugExampleMethodCallHandler.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = InstabugExampleMethodCallHandler.m; sourceTree = ""; }; + 2001D1452B8F504C00885261 /* InstabugExampleMethodCallHandler.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = InstabugExampleMethodCallHandler.h; sourceTree = ""; }; 206286EC2ABD0A1F00925509 /* SessionReplayApiTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SessionReplayApiTests.m; sourceTree = ""; }; + 20CE6BF92BC6DCA400105F88 /* Apm+Test.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Apm+Test.h"; sourceTree = ""; }; 243EF14638ECA64074771B11 /* Pods-InstabugTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InstabugTests.release.xcconfig"; path = "Target Support Files/Pods-InstabugTests/Pods-InstabugTests.release.xcconfig"; sourceTree = ""; }; 354EA318B622513FE3FD25E4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; @@ -216,6 +220,8 @@ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 2001D1432B8F501000885261 /* InstabugExampleMethodCallHandler.m */, + 2001D1452B8F504C00885261 /* InstabugExampleMethodCallHandler.h */, ); path = Runner; sourceTree = ""; @@ -255,6 +261,7 @@ CC78720A2938D1C5008CB2A5 /* Util */ = { isa = PBXGroup; children = ( + 20CE6BF92BC6DCA400105F88 /* Apm+Test.h */, CC78720E293CA8EE008CB2A5 /* Instabug+Test.h */, CC787211293CAB28008CB2A5 /* IBGNetworkLogger+Test.h */, CC198C62293E2392007077C8 /* IBGSurvey+Test.h */, @@ -564,6 +571,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 2001D1442B8F501000885261 /* InstabugExampleMethodCallHandler.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 70693e4a8..11f416a26 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -3,11 +3,17 @@ import Flutter @UIApplicationMain @objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + let controller = window?.rootViewController as! FlutterViewController + let channel = FlutterMethodChannel(name: kInstabugChannelName, binaryMessenger: controller.binaryMessenger) + let methodCallHandler = InstabugExampleMethodCallHandler() + channel.setMethodCallHandler { methodCall, result in + methodCallHandler.handle(methodCall, result: result) + } + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } } diff --git a/example/ios/Runner/InstabugExampleMethodCallHandler.h b/example/ios/Runner/InstabugExampleMethodCallHandler.h new file mode 100644 index 000000000..4ea8e8e63 --- /dev/null +++ b/example/ios/Runner/InstabugExampleMethodCallHandler.h @@ -0,0 +1,13 @@ +#import + +extern NSString * const kInstabugChannelName; + +@interface InstabugExampleMethodCallHandler : NSObject + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result; +- (void)sendNativeNonFatal:(NSString *)exceptionObject; +- (void)sendNativeFatalCrash; +- (void)sendFatalHang; +- (void)sendOOM; + +@end diff --git a/example/ios/Runner/InstabugExampleMethodCallHandler.m b/example/ios/Runner/InstabugExampleMethodCallHandler.m new file mode 100644 index 000000000..6b1331587 --- /dev/null +++ b/example/ios/Runner/InstabugExampleMethodCallHandler.m @@ -0,0 +1,121 @@ +#import +#import "InstabugExampleMethodCallHandler.h" +#import +#import +#import + +// MARK: - Private Interface +@interface InstabugExampleMethodCallHandler() +@property (nonatomic, strong) NSMutableArray *oomBelly; +@property (nonatomic, strong) dispatch_queue_t serialQueue; +@end + + + +// MARK: - Constants + +extern NSString * const kSendNativeNonFatalCrashMethod; +extern NSString * const kSendNativeFatalCrashMethod; +extern NSString * const kSendNativeFatalHangMethod; +extern NSString * const kSendOOMMethod; + +extern NSString * const kInstabugChannelName; + +// MARK: - MethodCallHandler Implementation + +@implementation InstabugExampleMethodCallHandler + +NSString * const kSendNativeNonFatalCrashMethod = @"sendNativeNonFatalCrash"; +NSString * const kSendNativeFatalCrashMethod = @"sendNativeFatalCrash"; +NSString * const kSendNativeFatalHangMethod = @"sendNativeFatalHang"; +NSString * const kSendOOMMethod = @"sendOom"; + +NSString * const kInstabugChannelName = @"instabug_flutter_example"; + +// MARK: - Initializer + +- (instancetype)init { + self = [super init]; + if (self) { + self.serialQueue = dispatch_queue_create("QUEUE>SERIAL", DISPATCH_QUEUE_SERIAL); + } + return self; +} + + +// MARK: - Flutter Plugin Methods + +- (dispatch_queue_t)methodQueue { + return dispatch_get_main_queue(); +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (void)oomCrash { + dispatch_async(self.serialQueue, ^{ + self.oomBelly = [NSMutableArray array]; + [UIApplication.sharedApplication beginBackgroundTaskWithName:@"OOM Crash" expirationHandler:nil]; + while (true) { + unsigned long dinnerLength = 1024 * 1024 * 10; + char *dinner = malloc(sizeof(char) * dinnerLength); + for (int i=0; i < dinnerLength; i++) + { + //write to each byte ensure that the memory pages are actually allocated + dinner[i] = '0'; + } + NSData *plate = [NSData dataWithBytesNoCopy:dinner length:dinnerLength freeWhenDone:YES]; + [self.oomBelly addObject:plate]; + } + }); +} + +- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result { + if ([kSendNativeNonFatalCrashMethod isEqualToString:call.method]) { + NSLog(@"Sending native non-fatal crash from iOS"); + [self sendNativeNonFatal:call.arguments]; + result(nil); + } else if ([kSendNativeFatalCrashMethod isEqualToString:call.method]) { + NSLog(@"Sending native fatal crash from iOS"); + [self sendNativeFatalCrash]; + result(nil); + } else if ([kSendNativeFatalHangMethod isEqualToString:call.method]) { + NSLog(@"Sending native fatal hang for 3000 ms from iOS"); + [self sendFatalHang]; + result(nil); + } else if ([kSendOOMMethod isEqualToString:call.method]) { + NSLog(@"Sending out of memory from iOS"); + [self sendOOM]; + result(nil); + } else { + result(FlutterMethodNotImplemented); + } +} + +// MARK: - Helper Methods + +- (void)sendNativeNonFatal:(NSString *)exceptionObject { + IBGNonFatalException *nonFatalException = [IBGCrashReporting exception:[NSException exceptionWithName:@"native Handled NS Exception" reason:@"Test iOS Handled Crash" userInfo:@{@"Key": @"Value"}]]; + + [nonFatalException report]; +} + +- (void)sendNativeFatalCrash { + NSException *exception = [NSException exceptionWithName:@"native Unhandled NS Exception" reason:@"Test iOS Unhandled Crash" userInfo:nil]; + @throw exception; +} + +- (void)sendFatalHang { + [NSThread sleepForTimeInterval:3.0f]; +} + +- (void)sendOOM { + [self oomCrash]; +} + +- (void)sendNativeNonFatal { +} + +@end diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h index 308a2a560..acd89f7a3 100644 --- a/example/ios/Runner/Runner-Bridging-Header.h +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -1 +1,2 @@ #import "GeneratedPluginRegistrant.h" +#import "InstabugExampleMethodCallHandler.h"; diff --git a/example/lib/main.dart b/example/lib/main.dart index d5e90e1db..7749ed02d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,7 +1,45 @@ import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; +import 'dart:convert'; +import 'package:http/http.dart' as http; import 'package:flutter/material.dart'; import 'package:instabug_flutter/instabug_flutter.dart'; +import 'package:instabug_flutter_example/src/app_routes.dart'; +import 'package:instabug_flutter_example/src/widget/nested_view.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/fatal_crashes_content.dart'; + +part 'src/components/non_fatal_crashes_content.dart'; + +part 'src/components/network_content.dart'; + +part 'src/components/page.dart'; + +part 'src/components/traces_content.dart'; + +part 'src/components/flows_content.dart'; void main() { runZonedGuarded( @@ -11,19 +49,22 @@ void main() { Instabug.init( token: 'ed6f659591566da19b67857e1b9d40ab', invocationEvents: [InvocationEvent.floatingButton], + debugLogsLevel: LogLevel.verbose, ); FlutterError.onError = (FlutterErrorDetails details) { Zone.current.handleUncaughtError(details.exception, details.stack!); }; - runApp(MyApp()); + runApp(const MyApp()); }, CrashReporting.reportCrash, ); } class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + @override Widget build(BuildContext context) { return MaterialApp( @@ -31,364 +72,11 @@ class MyApp extends StatelessWidget { navigatorObservers: [ InstabugNavigatorObserver(), ], + routes: APM.wrapRoutes(appRoutes, exclude: [CrashesPage.screenName]), theme: ThemeData( primarySwatch: Colors.blue, visualDensity: VisualDensity.adaptivePlatformDensity, ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class InstabugButton extends StatelessWidget { - String text; - void Function()? onPressed; - - InstabugButton({required this.text, this.onPressed}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - margin: const EdgeInsets.only(left: 20.0, right: 20.0), - child: ElevatedButton( - onPressed: onPressed, - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all(Colors.lightBlue), - foregroundColor: MaterialStateProperty.all(Colors.white), - ), - child: Text(text), - ), - ); - } -} - -class InstabugTextField extends StatelessWidget { - String label; - TextEditingController controller; - - InstabugTextField({required this.label, required this.controller}); - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - margin: const EdgeInsets.only(left: 20.0, right: 20.0), - child: TextField( - controller: controller, - decoration: InputDecoration( - labelText: label, - ), - ), - ); - } -} - -class SectionTitle extends StatelessWidget { - String text; - - SectionTitle(this.text); - - @override - Widget build(BuildContext context) { - return Container( - alignment: Alignment.centerLeft, - margin: const EdgeInsets.only(top: 20.0, left: 20.0), - child: Text( - text, - textAlign: TextAlign.left, - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({Key? key, required this.title}) : super(key: key); - - final String title; - - @override - _MyHomePageState createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - final buttonStyle = ButtonStyle( - backgroundColor: MaterialStateProperty.all(Colors.lightBlue), - foregroundColor: MaterialStateProperty.all(Colors.white), - ); - - List reportTypes = []; - - final primaryColorController = TextEditingController(); - final screenNameController = TextEditingController(); - - void restartInstabug() { - Instabug.setEnabled(false); - Instabug.setEnabled(true); - BugReporting.setInvocationEvents([InvocationEvent.floatingButton]); - } - - void setOnDismissCallback() { - BugReporting.setOnDismissCallback((dismissType, reportType) { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text('On Dismiss'), - content: Text( - 'onDismiss callback called with $dismissType and $reportType', - ), - ); - }, - ); - }); - } - - void show() { - Instabug.show(); - } - - void reportScreenChange() { - Instabug.reportScreenChange(screenNameController.text); - } - - void sendBugReport() { - BugReporting.show(ReportType.bug, [InvocationOption.emailFieldOptional]); - } - - void sendFeedback() { - BugReporting.show( - ReportType.feedback, [InvocationOption.emailFieldOptional]); - } - - void showNpsSurvey() { - Surveys.showSurvey('pcV_mE2ttqHxT1iqvBxL0w'); - } - - void showManualSurvey() { - Surveys.showSurvey('PMqUZXqarkOR2yGKiENB4w'); - } - - final _scaffoldKey = GlobalKey(); - - void getCurrentSessionReplaylink() async { - final result = await SessionReplay.getSessionReplayLink(); - if (result == null) { - const snackBar = SnackBar( - content: Text('No Link Found'), - ); - ScaffoldMessenger.of(_scaffoldKey.currentContext!).showSnackBar(snackBar); - } else { - var snackBar = SnackBar( - content: Text(result), - ); - ScaffoldMessenger.of(_scaffoldKey.currentContext!).showSnackBar(snackBar); - } - } - - void showFeatureRequests() { - FeatureRequests.show(); - } - - void toggleReportType(ReportType reportType) { - if (reportTypes.contains(reportType)) { - reportTypes.remove(reportType); - } else { - reportTypes.add(reportType); - } - BugReporting.setReportTypes(reportTypes); - } - - void changeFloatingButtonEdge() { - BugReporting.setFloatingButtonEdge(FloatingButtonEdge.left, 200); - } - - void setInvocationEvent(InvocationEvent invocationEvent) { - BugReporting.setInvocationEvents([invocationEvent]); - } - - void changePrimaryColor() { - String text = 'FF' + primaryColorController.text.replaceAll('#', ''); - Color color = Color(int.parse(text, radix: 16)); - Instabug.setPrimaryColor(color); - } - - void setColorTheme(ColorTheme colorTheme) { - Instabug.setColorTheme(colorTheme); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - key: _scaffoldKey, - appBar: AppBar(title: Text(widget.title)), - body: SingleChildScrollView( - physics: ClampingScrollPhysics(), - padding: const EdgeInsets.only(top: 20.0, bottom: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - margin: const EdgeInsets.only( - left: 20.0, right: 20.0, bottom: 20.0), - child: const Text( - 'Hello Instabug\'s awesome user! The purpose of this application is to show you the different options for customizing the SDK and how easy it is to integrate it to your existing app', - textAlign: TextAlign.center, - ), - ), - InstabugButton( - onPressed: restartInstabug, - text: 'Restart Instabug', - ), - SectionTitle('Primary Color'), - InstabugTextField( - controller: primaryColorController, - label: 'Enter primary color', - ), - InstabugButton( - text: 'Change Primary Color', - onPressed: changePrimaryColor, - ), - SectionTitle('Change Invocation Event'), - ButtonBar( - mainAxisSize: MainAxisSize.min, - alignment: MainAxisAlignment.start, - children: [ - ElevatedButton( - onPressed: () => setInvocationEvent(InvocationEvent.none), - style: buttonStyle, - child: const Text('None'), - ), - ElevatedButton( - onPressed: () => setInvocationEvent(InvocationEvent.shake), - style: buttonStyle, - child: const Text('Shake'), - ), - ElevatedButton( - onPressed: () => - setInvocationEvent(InvocationEvent.screenshot), - style: buttonStyle, - child: const Text('Screenshot'), - ), - ], - ), - ButtonBar( - mainAxisSize: MainAxisSize.min, - alignment: MainAxisAlignment.start, - children: [ - ElevatedButton( - onPressed: () => - setInvocationEvent(InvocationEvent.floatingButton), - style: buttonStyle, - child: const Text('Floating Button'), - ), - ElevatedButton( - onPressed: () => - setInvocationEvent(InvocationEvent.twoFingersSwipeLeft), - style: buttonStyle, - child: const Text('Two Fingers Swipe Left'), - ), - ], - ), - InstabugButton( - onPressed: show, - text: 'Invoke', - ), - InstabugButton( - onPressed: setOnDismissCallback, - text: 'Set On Dismiss Callback', - ), - SectionTitle('Repro Steps'), - InstabugTextField( - controller: screenNameController, - label: 'Enter screen name', - ), - InstabugButton( - text: 'Report Screen Change', - onPressed: reportScreenChange, - ), - InstabugButton( - onPressed: sendBugReport, - text: 'Send Bug Report', - ), - InstabugButton( - onPressed: showManualSurvey, - text: 'Show Manual Survey', - ), - SectionTitle('Change Report Types'), - ButtonBar( - mainAxisSize: MainAxisSize.min, - alignment: MainAxisAlignment.start, - children: [ - ElevatedButton( - onPressed: () => toggleReportType(ReportType.bug), - style: buttonStyle, - child: const Text('Bug'), - ), - ElevatedButton( - onPressed: () => toggleReportType(ReportType.feedback), - style: buttonStyle, - child: const Text('Feedback'), - ), - ElevatedButton( - onPressed: () => toggleReportType(ReportType.question), - style: buttonStyle, - child: const Text('Question'), - ), - ], - ), - InstabugButton( - onPressed: changeFloatingButtonEdge, - text: 'Move Floating Button to Left', - ), - InstabugButton( - onPressed: sendFeedback, - text: 'Send Feedback', - ), - InstabugButton( - onPressed: showNpsSurvey, - text: 'Show NPS Survey', - ), - InstabugButton( - onPressed: showManualSurvey, - text: 'Show Multiple Questions Survey', - ), - InstabugButton( - onPressed: showFeatureRequests, - text: 'Show Feature Requests', - ), - SectionTitle('Color Theme'), - ButtonBar( - mainAxisSize: MainAxisSize.min, - alignment: MainAxisAlignment.center, - children: [ - ElevatedButton( - onPressed: () => setColorTheme(ColorTheme.light), - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all(Colors.white), - foregroundColor: - MaterialStateProperty.all(Colors.lightBlue), - ), - child: const Text('Light'), - ), - ElevatedButton( - onPressed: () => setColorTheme(ColorTheme.dark), - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all(Colors.black), - foregroundColor: MaterialStateProperty.all(Colors.white), - ), - child: const Text('Dark'), - ), - ], - ), - SectionTitle('Sessions Replay'), - InstabugButton( - onPressed: getCurrentSessionReplaylink, - text: 'Get current session replay link', - ), - ], - )), // This trailing comma makes auto-formatting nicer for build methods. ); } } diff --git a/example/lib/src/app_routes.dart b/example/lib/src/app_routes.dart new file mode 100644 index 000000000..9175d5405 --- /dev/null +++ b/example/lib/src/app_routes.dart @@ -0,0 +1,18 @@ +import 'package:flutter/widgets.dart' show BuildContext; +import 'package:instabug_flutter_example/main.dart'; + +final appRoutes = { + /// ["/"] route name should only be used with [onGenerateRoute:] when no + /// Home Widget specified in MaterialApp() other wise the the Flutter engine + /// will throw a Runtime exception deo to Flutter restrictions + + "/": (BuildContext context) => + const MyHomePage(title: 'Flutter Demo Home Pag'), + CrashesPage.screenName: (BuildContext context) => const CrashesPage(), + ComplexPage.screenName: (BuildContext context) => const ComplexPage(), + ApmPage.screenName: (BuildContext context) => const ApmPage(), + ScreenLoadingPage.screenName: (BuildContext context) => + const ScreenLoadingPage(), + ScreenCapturePrematureExtensionPage.screenName: (BuildContext context) => + const ScreenCapturePrematureExtensionPage(), +}; diff --git a/example/lib/src/components/fatal_crashes_content.dart b/example/lib/src/components/fatal_crashes_content.dart new file mode 100644 index 000000000..024f0f193 --- /dev/null +++ b/example/lib/src/components/fatal_crashes_content.dart @@ -0,0 +1,75 @@ +part of '../../main.dart'; + +class FatalCrashesContent extends StatelessWidget { + const FatalCrashesContent({Key? key}) : super(key: key); + + void throwUnhandledException(dynamic error) { + if (error is! Error) { + const String appName = 'Flutter Test App'; + final errorMessage = error?.toString() ?? 'Unknown Error'; + error = Exception('Unhandled Error: $errorMessage from $appName'); + } + throw error; + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + InstabugButton( + text: 'Throw Exception', + onPressed: () => throwUnhandledException( + Exception('This is a generic exception.')), + ), + InstabugButton( + text: 'Throw StateError', + onPressed: () => + throwUnhandledException(StateError('This is a StateError.')), + ), + InstabugButton( + text: 'Throw ArgumentError', + onPressed: () => throwUnhandledException( + ArgumentError('This is an ArgumentError.')), + ), + InstabugButton( + text: 'Throw RangeError', + onPressed: () => throwUnhandledException( + RangeError.range(5, 0, 3, 'Index out of range')), + ), + InstabugButton( + text: 'Throw FormatException', + onPressed: () => + throwUnhandledException(UnsupportedError('Invalid format.')), + ), + InstabugButton( + text: 'Throw NoSuchMethodError', + onPressed: () { + // This intentionally triggers a NoSuchMethodError + dynamic obj; + throwUnhandledException(obj.methodThatDoesNotExist()); + }, + ), + const InstabugButton( + text: 'Throw Native Fatal Crash', + onPressed: InstabugFlutterExampleMethodChannel.sendNativeFatalCrash, + ), + const InstabugButton( + text: 'Send Native Fatal Hang', + onPressed: InstabugFlutterExampleMethodChannel.sendNativeFatalHang, + ), + Platform.isAndroid + ? const InstabugButton( + text: 'Send Native ANR', + onPressed: InstabugFlutterExampleMethodChannel.sendAnr, + ) + : const SizedBox.shrink(), + const InstabugButton( + text: 'Throw Unhandled Native OOM Exception', + onPressed: InstabugFlutterExampleMethodChannel.sendOom, + ), + ], + ); + } +} diff --git a/example/lib/src/components/flows_content.dart b/example/lib/src/components/flows_content.dart new file mode 100644 index 000000000..ecd8163f9 --- /dev/null +++ b/example/lib/src/components/flows_content.dart @@ -0,0 +1,151 @@ +part of '../../main.dart'; + +class FlowsContent extends StatefulWidget { + const FlowsContent({Key? key}) : super(key: key); + + @override + State createState() => _FlowsContentState(); +} + +class _FlowsContentState extends State { + final flowNameController = TextEditingController(); + final flowKeyAttributeController = TextEditingController(); + final flowValueAttributeController = TextEditingController(); + + bool? didFlowEnd; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Column( + children: [ + InstabugTextField( + label: 'Flow name', + labelStyle: textTheme.labelMedium, + controller: flowNameController, + ), + SizedBox.fromSize( + size: const Size.fromHeight(10.0), + ), + Row( + children: [ + Flexible( + flex: 5, + child: InstabugButton.smallFontSize( + text: 'Start Flow', + onPressed: () => _startFlow(flowNameController.text), + margin: const EdgeInsetsDirectional.only( + start: 20.0, + end: 10.0, + ), + ), + ), + Flexible( + flex: 5, + child: InstabugButton.smallFontSize( + text: 'Start flow With Delay', + onPressed: () => _startFlow( + flowNameController.text, + delayInMilliseconds: 5000, + ), + margin: const EdgeInsetsDirectional.only( + start: 10.0, + end: 20.0, + ), + ), + ), + ], + ), + Row( + children: [ + Flexible( + flex: 5, + child: InstabugTextField( + label: 'Flow Key Attribute', + controller: flowKeyAttributeController, + labelStyle: textTheme.labelMedium, + margin: const EdgeInsetsDirectional.only( + end: 10.0, + start: 20.0, + ), + ), + ), + Flexible( + flex: 5, + child: InstabugTextField( + label: 'Flow Value Attribute', + labelStyle: textTheme.labelMedium, + controller: flowValueAttributeController, + margin: const EdgeInsetsDirectional.only( + start: 10.0, + end: 20.0, + ), + ), + ), + ], + ), + SizedBox.fromSize( + size: const Size.fromHeight(10.0), + ), + InstabugButton( + text: 'Set Flow Attribute', + onPressed: () => _setFlowAttribute( + flowNameController.text, + flowKeyAttribute: flowKeyAttributeController.text, + flowValueAttribute: flowValueAttributeController.text, + ), + ), + InstabugButton( + text: 'End Flow', + onPressed: () => _endFlow(flowNameController.text), + ), + ], + ); + } + + void _startFlow( + String flowName, { + int delayInMilliseconds = 0, + }) { + if (flowName.trim().isNotEmpty) { + log('_startFlow — flowName: $flowName, delay in Milliseconds: $delayInMilliseconds'); + log('flowName: $flowName'); + Future.delayed(Duration(milliseconds: delayInMilliseconds), + () => APM.startFlow(flowName)); + } else { + log('_startFlow - Please enter a flow name'); + } + } + + void _endFlow(String flowName) { + if (flowName.trim().isEmpty) { + log('_endFlow - Please enter a flow name'); + } + if (didFlowEnd == true) { + log('_endFlow — Please, start a new flow before setting attributes.'); + } + log('_endFlow — ending Flow.'); + didFlowEnd = true; + } + + void _setFlowAttribute( + String flowName, { + required String flowKeyAttribute, + required String flowValueAttribute, + }) { + if (flowName.trim().isEmpty) { + log('_endFlow - Please enter a flow name'); + } + if (didFlowEnd == true) { + log('_setFlowAttribute — Please, start a new flow before setting attributes.'); + } + if (flowKeyAttribute.trim().isEmpty) { + log('_setFlowAttribute — Please, fill the flow key attribute input before settings attributes.'); + } + if (flowValueAttribute.trim().isEmpty) { + log('_setFlowAttribute — Please, fill the flow value attribute input before settings attributes.'); + } + log('_setFlowAttribute — setting attributes -> key: $flowKeyAttribute, value: $flowValueAttribute.'); + APM.setFlowAttribute(flowName, flowKeyAttribute, flowValueAttribute); + } +} diff --git a/example/lib/src/components/network_content.dart b/example/lib/src/components/network_content.dart new file mode 100644 index 000000000..c364ff89d --- /dev/null +++ b/example/lib/src/components/network_content.dart @@ -0,0 +1,47 @@ +part of '../../main.dart'; + +class NetworkContent extends StatefulWidget { + const NetworkContent({Key? key}) : super(key: key); + final String defaultRequestUrl = + 'https://jsonplaceholder.typicode.com/posts/1'; + + @override + State createState() => _NetworkContentState(); +} + +class _NetworkContentState extends State { + final endpointUrlController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + InstabugClipboardInput( + label: 'Endpoint Url', + controller: endpointUrlController, + ), + InstabugButton( + text: 'Send Request To Url', + onPressed: () => _sendRequestToUrl(endpointUrlController.text), + ), + ], + ); + } + + void _sendRequestToUrl(String text) async { + try { + String url = text.trim().isEmpty ? widget.defaultRequestUrl : text; + final response = await http.get(Uri.parse(url)); + + // Handle the response here + if (response.statusCode == 200) { + final jsonData = json.decode(response.body); + log(jsonEncode(jsonData)); + } else { + log('Request failed with status: ${response.statusCode}'); + } + } catch (e) { + log('Error sending request: $e'); + } + } +} diff --git a/example/lib/src/components/non_fatal_crashes_content.dart b/example/lib/src/components/non_fatal_crashes_content.dart new file mode 100644 index 000000000..1a6fd6cbd --- /dev/null +++ b/example/lib/src/components/non_fatal_crashes_content.dart @@ -0,0 +1,66 @@ +part of '../../main.dart'; + +class NonFatalCrashesContent extends StatelessWidget { + const NonFatalCrashesContent({Key? key}) : super(key: key); + + void throwHandledException(dynamic error) { + try { + if (error is! Error) { + const String appName = 'Flutter Test App'; + final errorMessage = error?.toString() ?? 'Unknown Error'; + error = Exception('Handled Error: $errorMessage from $appName'); + } + throw error; + } catch (err) { + if (err is Error) { + log('throwHandledException: Crash report for ${err.runtimeType} is Sent!', + name: 'NonFatalCrashesWidget'); + } + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + InstabugButton( + text: 'Throw Exception', + onPressed: () => + throwHandledException(Exception('This is a generic exception.')), + ), + InstabugButton( + text: 'Throw StateError', + onPressed: () => + throwHandledException(StateError('This is a StateError.')), + ), + InstabugButton( + text: 'Throw ArgumentError', + onPressed: () => + throwHandledException(ArgumentError('This is an ArgumentError.')), + ), + InstabugButton( + text: 'Throw RangeError', + onPressed: () => throwHandledException( + RangeError.range(5, 0, 3, 'Index out of range')), + ), + InstabugButton( + text: 'Throw FormatException', + onPressed: () => + throwHandledException(UnsupportedError('Invalid format.')), + ), + InstabugButton( + text: 'Throw NoSuchMethodError', + onPressed: () { + dynamic obj; + throwHandledException(obj.methodThatDoesNotExist()); + }, + ), + const InstabugButton( + text: 'Throw Handled Native Exception', + onPressed: + InstabugFlutterExampleMethodChannel.sendNativeNonFatalCrash, + ), + ], + ); + } +} diff --git a/example/lib/src/components/page.dart b/example/lib/src/components/page.dart new file mode 100644 index 000000000..de61d4b65 --- /dev/null +++ b/example/lib/src/components/page.dart @@ -0,0 +1,35 @@ +part of '../../main.dart'; + +class Page extends StatelessWidget { + final String title; + final GlobalKey? scaffoldKey; + final List children; + final Widget? floatingActionButton; + final FloatingActionButtonLocation? floatingActionButtonLocation; + + const Page({ + Key? key, + required this.title, + this.scaffoldKey, + this.floatingActionButton, + this.floatingActionButtonLocation, + required this.children, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + key: scaffoldKey, + appBar: AppBar(title: Text(title)), + body: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: const EdgeInsets.only(top: 20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: children, + )), + floatingActionButton: floatingActionButton, + floatingActionButtonLocation: floatingActionButtonLocation, + ); + } +} diff --git a/example/lib/src/components/traces_content.dart b/example/lib/src/components/traces_content.dart new file mode 100644 index 000000000..888460d43 --- /dev/null +++ b/example/lib/src/components/traces_content.dart @@ -0,0 +1,157 @@ +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/native/instabug_flutter_example_method_channel.dart b/example/lib/src/native/instabug_flutter_example_method_channel.dart new file mode 100644 index 000000000..9507cc403 --- /dev/null +++ b/example/lib/src/native/instabug_flutter_example_method_channel.dart @@ -0,0 +1,68 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'dart:developer'; + +class InstabugFlutterExampleMethodChannel { + static const MethodChannel _channel = + MethodChannel(Constants.methodChannelName); + + static const String _tag = 'InstabugFlutterExampleMethodChannel'; + + static Future sendNativeNonFatalCrash( + [String? exceptionObjection]) async { + try { + await _channel.invokeMethod( + Constants.sendNativeNonFatalCrashMethodName, exceptionObjection); + } on PlatformException catch (e) { + log("Failed to send native non-fatal crash: '${e.message}'.", name: _tag); + } + } + + static Future sendNativeFatalCrash() async { + try { + await _channel.invokeMethod(Constants.sendNativeFatalCrashMethodName); + } on PlatformException catch (e) { + log("Failed to send native fatal crash: '${e.message}'.", name: _tag); + } + } + + static Future sendNativeFatalHang() async { + try { + await _channel.invokeMethod(Constants.sendNativeFatalHangMethodName); + } on PlatformException catch (e) { + log("Failed to send native fatal hang: '${e.message}'.", name: _tag); + } + } + + static Future sendAnr() async { + if (!Platform.isAndroid) { + return; + } + + try { + await _channel.invokeMethod(Constants.sendAnrMethodName); + } on PlatformException catch (e) { + log("Failed to send ANR: '${e.message}'.", name: _tag); + } + } + + static Future sendOom() async { + try { + await _channel.invokeMethod(Constants.sendOomMethodName); + } on PlatformException catch (e) { + log("Failed to send out of memory: '${e.message}'.", name: _tag); + } + } +} + +class Constants { + static const methodChannelName = "instabug_flutter_example"; + + // Method Names + static const sendNativeNonFatalCrashMethodName = "sendNativeNonFatalCrash"; + static const sendNativeFatalCrashMethodName = "sendNativeFatalCrash"; + static const sendNativeFatalHangMethodName = "sendNativeFatalHang"; + static const sendAnrMethodName = "sendAnr"; + static const sendOomMethodName = "sendOom"; +} diff --git a/example/lib/src/screens/apm_page.dart b/example/lib/src/screens/apm_page.dart new file mode 100644 index 000000000..8580e203f --- /dev/null +++ b/example/lib/src/screens/apm_page.dart @@ -0,0 +1,50 @@ +part of '../../main.dart'; + +class ApmPage extends StatefulWidget { + static const screenName = 'apm'; + + const ApmPage({Key? key}) : super(key: key); + + @override + State createState() => _ApmPageState(); +} + +class _ApmPageState extends State { + void _navigateToScreenLoading() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ScreenLoadingPage(), + settings: const RouteSettings( + name: ScreenLoadingPage.screenName, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Page( + title: 'APM', + children: [ + const SectionTitle('Network'), + const NetworkContent(), + const SectionTitle('Traces'), + const TracesContent(), + const SectionTitle('Flows'), + const FlowsContent(), + const SectionTitle('Screen Loading'), + SizedBox.fromSize( + size: const Size.fromHeight(12), + ), + InstabugButton( + text: 'Screen Loading', + onPressed: _navigateToScreenLoading, + ), + SizedBox.fromSize( + size: const Size.fromHeight(12), + ), + ], + ); + } +} diff --git a/example/lib/src/screens/complex_page.dart b/example/lib/src/screens/complex_page.dart new file mode 100644 index 000000000..65fdd8a57 --- /dev/null +++ b/example/lib/src/screens/complex_page.dart @@ -0,0 +1,140 @@ +part of '../../main.dart'; + +class ComplexPage extends StatefulWidget { + static const initialDepth = 10; + static const initialBreadth = 2; + static const screenName = 'complex'; + final bool isMonitored; + + const ComplexPage({ + Key? key, + this.isMonitored = false, + }) : super(key: key); + + const ComplexPage.monitored({ + Key? key, + this.isMonitored = true, + }) : super(key: key); + + @override + State createState() => _ComplexPageState(); +} + +class _ComplexPageState extends State { + final depthController = TextEditingController(); + final breadthController = TextEditingController(); + int depth = ComplexPage.initialDepth; + int breadth = ComplexPage.initialBreadth; + GlobalKey _reloadKey = GlobalKey(); + + @override + void initState() { + super.initState(); + depthController.text = depth.toString(); + breadthController.text = breadth.toString(); + } + + void _handleRender() { + setState(() { + breadth = + int.tryParse(breadthController.text) ?? ComplexPage.initialBreadth; + depth = int.tryParse(depthController.text) ?? ComplexPage.initialBreadth; + _reloadKey = GlobalKey(); + }); + } + + void _resetDidStartScreenLoading() { + ScreenLoadingManager.I.resetDidStartScreenLoading(); + } + + void _resetDidReportScreenLoading() { + ScreenLoadingManager.I.resetDidReportScreenLoading(); + } + + void _resetDidExtendScreenLoading() { + ScreenLoadingManager.I.resetDidExtendScreenLoading(); + } + + void _enableScreenLoading() { + APM.setScreenLoadingEnabled(true); + } + + void _disableScreenLoading() { + APM.setScreenLoadingEnabled(false); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return _buildPage(textTheme); + } + + Widget _buildPage(TextTheme textTheme) { + final content = [ + InstabugTextField( + label: 'Depth (default: ${ComplexPage.initialDepth})', + labelStyle: textTheme.labelMedium, + controller: depthController, + ), + InstabugTextField( + label: 'Breadth (default: ${ComplexPage.initialBreadth})', + labelStyle: textTheme.labelMedium, + controller: breadthController, + ), + InstabugButton( + onPressed: _handleRender, + text: 'Render', + ), + SizedBox.fromSize( + size: const Size.fromHeight( + 12.0, + ), + ), + InstabugButton( + onPressed: _enableScreenLoading, + text: 'Enable Screen loading', + ), + InstabugButton( + onPressed: _disableScreenLoading, + text: 'Disable Screen Loading', + ), + InstabugButton( + onPressed: _resetDidStartScreenLoading, + text: 'Reset Did Start Screen Loading', + ), + InstabugButton( + onPressed: _resetDidReportScreenLoading, + text: 'Reset Did Report Screen Loading', + ), + InstabugButton( + onPressed: _resetDidExtendScreenLoading, + text: 'Reset Did Extend Screen Loading', + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: NestedView( + depth: depth, + breadth: breadth, + ), + ), + ]; + + if (widget.isMonitored) { + return KeyedSubtree( + key: _reloadKey, + child: InstabugCaptureScreenLoading( + screenName: ComplexPage.screenName, + child: Page( + title: 'Monitored Complex', + children: content, + ), + ), + ); + } else { + return Page( + title: 'Complex', + children: content, + ); + } + } +} diff --git a/example/lib/src/screens/crashes_page.dart b/example/lib/src/screens/crashes_page.dart new file mode 100644 index 000000000..caa6b7f48 --- /dev/null +++ b/example/lib/src/screens/crashes_page.dart @@ -0,0 +1,22 @@ +part of '../../main.dart'; + +class CrashesPage extends StatelessWidget { + static const screenName = 'crashes'; + + const CrashesPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Page( + title: 'Crashes', + children: [ + SectionTitle('Non-Fatal Crashes'), + NonFatalCrashesContent(), + SectionTitle('Fatal Crashes'), + Text('Fatal Crashes can only be tested in release mode'), + Text('Most of these buttons will crash the application'), + FatalCrashesContent(), + ], + ); + } +} diff --git a/example/lib/src/screens/my_home_page.dart b/example/lib/src/screens/my_home_page.dart new file mode 100644 index 000000000..283a09ecf --- /dev/null +++ b/example/lib/src/screens/my_home_page.dart @@ -0,0 +1,331 @@ +part of '../../main.dart'; + +class MyHomePage extends StatefulWidget { + const MyHomePage({Key? key, required this.title}) : super(key: key); + + final String title; + + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final buttonStyle = ButtonStyle( + backgroundColor: MaterialStateProperty.all(Colors.lightBlue), + foregroundColor: MaterialStateProperty.all(Colors.white), + ); + + List reportTypes = []; + + final primaryColorController = TextEditingController(); + final screenNameController = TextEditingController(); + + void restartInstabug() { + Instabug.setEnabled(false); + Instabug.setEnabled(true); + BugReporting.setInvocationEvents([InvocationEvent.floatingButton]); + } + + void setOnDismissCallback() { + BugReporting.setOnDismissCallback((dismissType, reportType) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('On Dismiss'), + content: Text( + 'onDismiss callback called with $dismissType and $reportType', + ), + ); + }, + ); + }); + } + + void show() { + Instabug.show(); + } + + void reportScreenChange() { + Instabug.reportScreenChange(screenNameController.text); + } + + void sendBugReport() { + BugReporting.show(ReportType.bug, [InvocationOption.emailFieldOptional]); + } + + void sendFeedback() { + BugReporting.show( + ReportType.feedback, [InvocationOption.emailFieldOptional]); + } + + void showNpsSurvey() { + Surveys.showSurvey('pcV_mE2ttqHxT1iqvBxL0w'); + } + + void showManualSurvey() { + Surveys.showSurvey('PMqUZXqarkOR2yGKiENB4w'); + } + + final _scaffoldKey = GlobalKey(); + + void getCurrentSessionReplaylink() async { + final result = await SessionReplay.getSessionReplayLink(); + if (result == null) { + const snackBar = SnackBar( + content: Text('No Link Found'), + ); + ScaffoldMessenger.of(_scaffoldKey.currentContext!).showSnackBar(snackBar); + } else { + var snackBar = SnackBar( + content: Text(result), + ); + ScaffoldMessenger.of(_scaffoldKey.currentContext!).showSnackBar(snackBar); + } + } + + void showFeatureRequests() { + FeatureRequests.show(); + } + + void toggleReportType(ReportType reportType) { + if (reportTypes.contains(reportType)) { + reportTypes.remove(reportType); + } else { + reportTypes.add(reportType); + } + BugReporting.setReportTypes(reportTypes); + } + + void changeFloatingButtonEdge() { + BugReporting.setFloatingButtonEdge(FloatingButtonEdge.left, 200); + } + + void setInvocationEvent(InvocationEvent invocationEvent) { + BugReporting.setInvocationEvents([invocationEvent]); + } + + void changePrimaryColor() { + String text = 'FF' + primaryColorController.text.replaceAll('#', ''); + Color color = Color(int.parse(text, radix: 16)); + Instabug.setPrimaryColor(color); + } + + void setColorTheme(ColorTheme colorTheme) { + Instabug.setColorTheme(colorTheme); + } + + void _navigateToCrashes() { + ///This way of navigation utilize screenLoading automatic approach [Navigator 1] + Navigator.pushNamed(context, CrashesPage.screenName); + + ///This way of navigation utilize screenLoading manual approach [Navigator 1] + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => const CrashesPage(), + // settings: const RouteSettings(name: CrashesPage.screenName), + // ), + // ); + } + + void _navigateToApm() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const InstabugCaptureScreenLoading( + screenName: ApmPage.screenName, + child: ApmPage(), + ), + settings: const RouteSettings(name: ApmPage.screenName), + ), + ); + } + + void _navigateToComplex() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ComplexPage(), + settings: const RouteSettings(name: ComplexPage.screenName), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Page( + scaffoldKey: _scaffoldKey, + title: widget.title, + children: [ + Container( + margin: const EdgeInsets.only(left: 20.0, right: 20.0, bottom: 20.0), + child: const Text( + 'Hello Instabug\'s awesome user! The purpose of this application is to show you the different options for customizing the SDK and how easy it is to integrate it to your existing app', + textAlign: TextAlign.center, + ), + ), + InstabugButton( + onPressed: restartInstabug, + text: 'Restart Instabug', + ), + const SectionTitle('Primary Color'), + InstabugTextField( + controller: primaryColorController, + label: 'Enter primary color', + ), + InstabugButton( + text: 'Change Primary Color', + onPressed: changePrimaryColor, + ), + const SectionTitle('Change Invocation Event'), + ButtonBar( + mainAxisSize: MainAxisSize.min, + alignment: MainAxisAlignment.start, + children: [ + ElevatedButton( + onPressed: () => setInvocationEvent(InvocationEvent.none), + style: buttonStyle, + child: const Text('None'), + ), + ElevatedButton( + onPressed: () => setInvocationEvent(InvocationEvent.shake), + style: buttonStyle, + child: const Text('Shake'), + ), + ElevatedButton( + onPressed: () => setInvocationEvent(InvocationEvent.screenshot), + style: buttonStyle, + child: const Text('Screenshot'), + ), + ], + ), + ButtonBar( + mainAxisSize: MainAxisSize.min, + alignment: MainAxisAlignment.start, + children: [ + ElevatedButton( + onPressed: () => + setInvocationEvent(InvocationEvent.floatingButton), + style: buttonStyle, + child: const Text('Floating Button'), + ), + ElevatedButton( + onPressed: () => + setInvocationEvent(InvocationEvent.twoFingersSwipeLeft), + style: buttonStyle, + child: const Text('Two Fingers Swipe Left'), + ), + ], + ), + InstabugButton( + onPressed: show, + text: 'Invoke', + ), + InstabugButton( + onPressed: setOnDismissCallback, + text: 'Set On Dismiss Callback', + ), + const SectionTitle('Repro Steps'), + InstabugTextField( + controller: screenNameController, + label: 'Enter screen name', + ), + InstabugButton( + text: 'Report Screen Change', + onPressed: reportScreenChange, + ), + InstabugButton( + onPressed: sendBugReport, + text: 'Send Bug Report', + ), + InstabugButton( + onPressed: showManualSurvey, + text: 'Show Manual Survey', + ), + const SectionTitle('Change Report Types'), + ButtonBar( + mainAxisSize: MainAxisSize.min, + alignment: MainAxisAlignment.start, + children: [ + ElevatedButton( + onPressed: () => toggleReportType(ReportType.bug), + style: buttonStyle, + child: const Text('Bug'), + ), + ElevatedButton( + onPressed: () => toggleReportType(ReportType.feedback), + style: buttonStyle, + child: const Text('Feedback'), + ), + ElevatedButton( + onPressed: () => toggleReportType(ReportType.question), + style: buttonStyle, + child: const Text('Question'), + ), + ], + ), + InstabugButton( + onPressed: changeFloatingButtonEdge, + text: 'Move Floating Button to Left', + ), + InstabugButton( + onPressed: sendFeedback, + text: 'Send Feedback', + ), + InstabugButton( + onPressed: showNpsSurvey, + text: 'Show NPS Survey', + ), + InstabugButton( + onPressed: showManualSurvey, + text: 'Show Multiple Questions Survey', + ), + InstabugButton( + onPressed: showFeatureRequests, + text: 'Show Feature Requests', + ), + InstabugButton( + onPressed: _navigateToCrashes, + text: 'Crashes', + ), + InstabugButton( + onPressed: _navigateToApm, + text: 'APM', + ), + InstabugButton( + onPressed: _navigateToComplex, + text: 'Complex', + ), + const SectionTitle('Sessions Replay'), + InstabugButton( + onPressed: getCurrentSessionReplaylink, + text: 'Get current session replay link', + ), + const SectionTitle('Color Theme'), + ButtonBar( + mainAxisSize: MainAxisSize.max, + alignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => setColorTheme(ColorTheme.light), + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(Colors.white), + foregroundColor: MaterialStateProperty.all(Colors.lightBlue), + ), + child: const Text('Light'), + ), + ElevatedButton( + onPressed: () => setColorTheme(ColorTheme.dark), + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(Colors.black), + foregroundColor: MaterialStateProperty.all(Colors.white), + ), + child: const Text('Dark'), + ), + ], + ), + ], + ); + } +} diff --git a/example/lib/src/screens/screen_capture_premature_extension_page.dart b/example/lib/src/screens/screen_capture_premature_extension_page.dart new file mode 100644 index 000000000..befbec341 --- /dev/null +++ b/example/lib/src/screens/screen_capture_premature_extension_page.dart @@ -0,0 +1,30 @@ +part of '../../main.dart'; + +class ScreenCapturePrematureExtensionPage extends StatefulWidget { + static const screenName = 'screenCapturePrematureExtension'; + + const ScreenCapturePrematureExtensionPage({Key? key}) : super(key: key); + + @override + State createState() => + _ScreenCapturePrematureExtensionPageState(); +} + +class _ScreenCapturePrematureExtensionPageState + extends State { + void _extendScreenLoading() { + APM.endScreenLoading(); + } + + @override + Widget build(BuildContext context) { + _extendScreenLoading(); + return const Page( + title: 'Screen Capture Premature Extension', + children: [ + Text( + 'This page calls endScreenLoading before it fully renders allowing us to test the scenario of premature extension of screen loading'), + ], + ); + } +} diff --git a/example/lib/src/screens/screen_loading_page.dart b/example/lib/src/screens/screen_loading_page.dart new file mode 100644 index 000000000..a2b49e681 --- /dev/null +++ b/example/lib/src/screens/screen_loading_page.dart @@ -0,0 +1,186 @@ +part of '../../main.dart'; + +class ScreenLoadingPage extends StatefulWidget { + static const screenName = 'screenLoading'; + + const ScreenLoadingPage({Key? key}) : super(key: key); + + @override + State createState() => _ScreenLoadingPageState(); +} + +class _ScreenLoadingPageState extends State { + final durationController = TextEditingController(); + GlobalKey _reloadKey = GlobalKey(); + final List _capturedWidgets = []; + + void _render() { + setState(() { + // Key can be changed to force reload and re-render + _reloadKey = GlobalKey(); + }); + } + + void _addCapturedWidget() { + setState(() { + debugPrint('adding captured widget'); + _capturedWidgets.add(0); + }); + } + + ///This is the production implementation as [APM.endScreenLoading()] is the method which users use from [APM] class + void _extendScreenLoading() async { + APM.endScreenLoading(); + } + + ///This is a testing implementation as [APM.endScreenLoadingCP()] is marked as @internal method, + ///Therefor we check if SCL is enabled before proceeding + ///This check is internally done inside the production method [APM.endScreenLoading()] + void _extendScreenLoadingTestingEnvironment() async { + final isScreenLoadingEnabled = await APM.isScreenLoadingEnabled(); + if (isScreenLoadingEnabled) { + final currentUiTrace = ScreenLoadingManager.I.currentUiTrace; + final currentScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + final extendedEndTime = + (currentScreenLoadingTrace?.endTimeInMicroseconds ?? 0) + + (int.tryParse(durationController.text.toString()) ?? 0); + APM.endScreenLoadingCP( + extendedEndTime, + currentUiTrace?.traceId ?? 0, + ); + } else { + debugPrint( + 'Screen loading monitoring is disabled, skipping ending screen loading monitoring with APM.endScreenLoading().\n' + 'Please refer to the documentation for how to enable screen loading monitoring in your app: ' + 'https://docs.instabug.com/docs/flutter-apm-screen-loading#disablingenabling-screen-loading-tracking' + "If Screen Loading is enabled but you're still seeing this message, please reach out to support.", + ); + } + } + + void _navigateToComplexPage() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ComplexPage.monitored(), + settings: const RouteSettings( + name: ComplexPage.screenName, + ), + ), + ); + } + + void _navigateToMonitoredScreenCapturePrematureExtensionPage() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const InstabugCaptureScreenLoading( + screenName: ScreenCapturePrematureExtensionPage.screenName, + child: ScreenCapturePrematureExtensionPage(), + ), + settings: const RouteSettings( + name: ScreenCapturePrematureExtensionPage.screenName, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Page( + title: 'Screen Loading', + floatingActionButton: Container( + height: 40, + child: FloatingActionButton( + tooltip: 'Add', + onPressed: _addCapturedWidget, + child: const Icon(Icons.add, color: Colors.white, size: 28), + ), + ), + children: [ + SectionTitle('6x InstabugCaptureScreen'), + KeyedSubtree( + key: _reloadKey, + child: InstabugCaptureScreenLoading( + screenName: ScreenLoadingPage.screenName, + child: InstabugCaptureScreenLoading( + screenName: ScreenLoadingPage.screenName, + child: InstabugCaptureScreenLoading( + screenName: 'different screen name', + child: InstabugCaptureScreenLoading( + screenName: ScreenLoadingPage.screenName, + child: InstabugCaptureScreenLoading( + screenName: ScreenLoadingPage.screenName, + child: InstabugCaptureScreenLoading( + screenName: ScreenLoadingPage.screenName, + child: Container( + margin: const EdgeInsets.only(top: 12), + child: InstabugButton( + text: 'Reload', + onPressed: _render, // Call _render function here + ), + ), + ), + ), + ), + ), + ), + ), + ), + InstabugTextField( + label: 'Duration', + controller: durationController, + keyboardType: TextInputType.number, + ), + Container( + margin: const EdgeInsets.only(top: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + InstabugButton( + text: 'Extend Screen Loading (Testing)', + onPressed: _extendScreenLoadingTestingEnvironment, + ), + InstabugButton( + text: 'Extend Screen Loading (Production)', + onPressed: _extendScreenLoading, + ), + ], + )), + InstabugButton( + text: 'Monitored Complex Page', + onPressed: _navigateToComplexPage, + ), + InstabugButton( + text: 'Screen Capture Premature Extension Page', + onPressed: _navigateToMonitoredScreenCapturePrematureExtensionPage, + ), + SectionTitle('Dynamic Screen Loading list'), + SizedBox( + height: 100, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, childAspectRatio: 5), + reverse: false, + shrinkWrap: true, + itemCount: _capturedWidgets.length, + itemBuilder: (context, index) { + return InstabugCaptureScreenLoading( + screenName: ScreenLoadingPage.screenName, + child: Text(index.toString()), + ); + }, + ), + ), + ), + SizedBox.fromSize( + size: const Size.fromHeight(12), + ), + ], + ); + } +} diff --git a/example/lib/src/widget/instabug_button.dart b/example/lib/src/widget/instabug_button.dart new file mode 100644 index 000000000..97e434061 --- /dev/null +++ b/example/lib/src/widget/instabug_button.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +class InstabugButton extends StatelessWidget { + const InstabugButton({ + Key? key, + required this.text, + this.onPressed, + this.fontSize, + this.margin, + }) : super(key: key); + + const InstabugButton.smallFontSize({ + Key? key, + required this.text, + this.onPressed, + this.fontSize = 10.0, + this.margin, + }) : super(key: key); + + final String text; + final Function()? onPressed; + final double? fontSize; + + final EdgeInsetsGeometry? margin; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: margin ?? + const EdgeInsets.symmetric( + horizontal: 20.0, + ), + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.lightBlue, + foregroundColor: Colors.white, + textStyle: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(fontSize: fontSize), + ), + child: Text(text), + ), + ); + } +} diff --git a/example/lib/src/widget/instabug_clipboard_icon_button.dart b/example/lib/src/widget/instabug_clipboard_icon_button.dart new file mode 100644 index 000000000..39ea0342d --- /dev/null +++ b/example/lib/src/widget/instabug_clipboard_icon_button.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class InstabugClipboardIconButton extends StatelessWidget { + const InstabugClipboardIconButton({ + Key? key, + this.onPaste, + }) : super(key: key); + + final Function(String?)? onPaste; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => _getClipboardContent(), + icon: const Icon( + Icons.paste_outlined, + ), + ); + } + + Future _getClipboardContent() async { + final clipboardData = await Clipboard.getData(Clipboard.kTextPlain); + final clipboardText = clipboardData?.text; + if (clipboardText != null && clipboardText.isNotEmpty) { + onPaste?.call(clipboardText); + } + } +} diff --git a/example/lib/src/widget/instabug_clipboard_input.dart b/example/lib/src/widget/instabug_clipboard_input.dart new file mode 100644 index 000000000..584faea81 --- /dev/null +++ b/example/lib/src/widget/instabug_clipboard_input.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:instabug_flutter_example/src/widget/instabug_text_field.dart'; +import 'package:instabug_flutter_example/src/widget/instabug_clipboard_icon_button.dart'; + +class InstabugClipboardInput extends StatelessWidget { + const InstabugClipboardInput({ + Key? key, + required this.label, + required this.controller, + }) : super(key: key); + + final String label; + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: InstabugTextField( + label: label, + margin: const EdgeInsetsDirectional.only( + start: 20.0, + ), + controller: controller, + ), + ), + InstabugClipboardIconButton( + onPaste: (String? clipboardText) { + controller.text = clipboardText ?? controller.text; + }, + ) + ], + ); + } +} diff --git a/example/lib/src/widget/instabug_text_field.dart b/example/lib/src/widget/instabug_text_field.dart new file mode 100644 index 000000000..af2ff88a3 --- /dev/null +++ b/example/lib/src/widget/instabug_text_field.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class InstabugTextField extends StatelessWidget { + const InstabugTextField({ + Key? key, + required this.label, + required this.controller, + this.labelStyle, + this.margin, + this.keyboardType, + }) : super(key: key); + + final String label; + final TextEditingController controller; + final EdgeInsetsGeometry? margin; + final TextStyle? labelStyle; + final TextInputType? keyboardType; + + @override + Widget build(BuildContext context) { + return Container( + margin: margin ?? + const EdgeInsets.symmetric( + horizontal: 20.0, + ), + child: TextField( + controller: controller, + keyboardType: keyboardType, + decoration: InputDecoration( + labelText: label, + labelStyle: labelStyle ?? Theme.of(context).textTheme.labelLarge, + suffixIcon: IconButton( + onPressed: controller.clear, + iconSize: 12.0, + icon: const Icon( + Icons.clear, + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/src/widget/nested_view.dart b/example/lib/src/widget/nested_view.dart new file mode 100644 index 000000000..e61099f8c --- /dev/null +++ b/example/lib/src/widget/nested_view.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:instabug_flutter_example/main.dart'; + +class NestedView extends StatelessWidget { + final int depth; + final int breadth; + final Widget? child; + + const NestedView({ + Key? key, + this.depth = ComplexPage.initialDepth, + this.breadth = ComplexPage.initialDepth, + this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (depth == 0) { + return child ?? const SizedBox.shrink(); + } + + return Container( + decoration: BoxDecoration( + border: Border.all(), + ), + padding: const EdgeInsets.all(1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('$depth'), + Row( + children: List.generate( + breadth, + (index) => NestedView( + depth: depth - 1, + breadth: breadth, + child: child, + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/src/widget/section_title.dart b/example/lib/src/widget/section_title.dart new file mode 100644 index 000000000..2c0509fa8 --- /dev/null +++ b/example/lib/src/widget/section_title.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class SectionTitle extends StatelessWidget { + final String text; + + const SectionTitle(this.text, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + alignment: Alignment.centerLeft, + margin: const EdgeInsets.only(top: 20.0, left: 20.0), + child: Text( + text, + textAlign: TextAlign.left, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ); + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 055c11dd1..26b7b2d67 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.2" espresso: dependency: "direct dev" description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "6.1.4" flutter: dependency: "direct main" description: flutter @@ -75,6 +75,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: b543301ad291598523947dc534aaddc5aaad597b709d2426d3a0e0d44c5cb493 + url: "https://pub.dev" + source: hosted + version: "1.0.4" flutter_test: dependency: "direct dev" description: flutter @@ -85,85 +93,85 @@ packages: description: flutter source: sdk version: "0.0.0" - instabug_flutter: + http: dependency: "direct main" description: - path: ".." - relative: true - source: path - version: "13.0.0" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + name: http + sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" url: "https://pub.dev" source: hosted - version: "10.0.0" - leak_tracker_flutter_testing: + version: "0.13.6" + http_parser: dependency: transitive description: - name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" url: "https://pub.dev" source: hosted - version: "2.0.1" - leak_tracker_testing: + version: "4.0.2" + instabug_flutter: + dependency: "direct main" + description: + path: ".." + relative: true + source: path + version: "13.0.0" + lints: dependency: transitive description: - name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + name: lints + sha256: a2c3d198cb5ea2e179926622d433331d8b58374ab8f29cdda6e863bd62fd369c url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "1.0.1" matcher: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.5.0" meta: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.9.1" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.8.3" platform: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.0" process: dependency: transitive description: name: process - sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "4.2.4" sky_engine: dependency: transitive description: flutter @@ -181,18 +189,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.11.0" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.1" string_scanner: dependency: transitive description: @@ -221,10 +229,18 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" vector_math: dependency: transitive description: @@ -237,18 +253,26 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: c620a6f783fa22436da68e42db7ebbf18b8c44b9a46ab911f666ff09ffd9153f url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "11.7.1" + web: + dependency: transitive + description: + name: web + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + url: "https://pub.dev" + source: hosted + version: "0.1.4-beta" webdriver: dependency: transitive description: name: webdriver - sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.2" sdks: - dart: ">=3.2.0-0 <4.0.0" + dart: ">=3.1.0-185.0.dev <4.0.0" flutter: ">=2.10.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ab9ee0577..69fed929d 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -18,11 +18,12 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev version: 1.0.0+1 environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.12.0 <4.0.0" dependencies: flutter: sdk: flutter + http: ^0.13.0 instabug_flutter: path: ../ @@ -32,6 +33,7 @@ dev_dependencies: sdk: flutter flutter_test: sdk: flutter + flutter_lints: 1.0.4 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/ios/Classes/Modules/ApmApi.m b/ios/Classes/Modules/ApmApi.m index da7fa84be..83efce69d 100644 --- a/ios/Classes/Modules/ApmApi.m +++ b/ios/Classes/Modules/ApmApi.m @@ -1,6 +1,8 @@ #import "Instabug.h" #import "ApmApi.h" #import "ArgsRegistry.h" +#import "IBGAPM+PrivateAPIs.h" +#import "IBGTimeIntervalUnits.h" void InitApmApi(id messenger) { ApmApi *api = [[ApmApi alloc] init]; @@ -21,6 +23,25 @@ - (void)setEnabledIsEnabled:(NSNumber *)isEnabled error:(FlutterError *_Nullable IBGAPM.enabled = [isEnabled boolValue]; } +- (void)isEnabledWithCompletion:(nonnull void (^)(NSNumber * _Nullable, FlutterError * _Nullable))completion { + BOOL isEnabled = IBGAPM.enabled; + + NSNumber *isEnabledNumber = @(isEnabled); + + completion(isEnabledNumber, nil); +} + +- (void)setScreenLoadingEnabledIsEnabled:(nonnull NSNumber *)isEnabled error:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + [IBGAPM setScreenLoadingEnabled:[isEnabled boolValue]]; +} + + +- (void)isScreenLoadingEnabledWithCompletion:(nonnull void (^)(NSNumber * _Nullable, FlutterError * _Nullable))completion { + BOOL isScreenLoadingEnabled = IBGAPM.screenLoadingEnabled; + NSNumber *isEnabledNumber = @(isScreenLoadingEnabled); + completion(isEnabledNumber, nil); +} + - (void)setColdAppLaunchEnabledIsEnabled:(NSNumber *)isEnabled error:(FlutterError *_Nullable *_Nonnull)error { IBGAPM.coldAppLaunchEnabled = [isEnabled boolValue]; } @@ -84,4 +105,30 @@ - (void)networkLogAndroidData:(NSDictionary *)data error:(Flutte // Android Only } + +- (void)startCpUiTraceScreenName:(nonnull NSString *)screenName microTimeStamp:(nonnull NSNumber *)microTimeStamp traceId:(nonnull NSNumber *)traceId error:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + NSTimeInterval startTimeStampMUS = [microTimeStamp doubleValue]; + [IBGAPM startUITraceCPWithName:screenName startTimestampMUS:startTimeStampMUS]; +} + + + +- (void)reportScreenLoadingCPStartTimeStampMicro:(nonnull NSNumber *)startTimeStampMicro durationMicro:(nonnull NSNumber *)durationMicro uiTraceId:(nonnull NSNumber *)uiTraceId error:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + NSTimeInterval startTimeStampMicroMUS = [startTimeStampMicro doubleValue]; + NSTimeInterval durationMUS = [durationMicro doubleValue]; + [IBGAPM reportScreenLoadingCPWithStartTimestampMUS:startTimeStampMicroMUS durationMUS:durationMUS]; +} + +- (void)endScreenLoadingCPTimeStampMicro:(nonnull NSNumber *)timeStampMicro uiTraceId:(nonnull NSNumber *)uiTraceId error:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + NSTimeInterval endScreenLoadingCPWithEndTimestampMUS = [timeStampMicro doubleValue]; + [IBGAPM endScreenLoadingCPWithEndTimestampMUS:endScreenLoadingCPWithEndTimestampMUS]; +} + +- (void)isEndScreenLoadingEnabledWithCompletion:(nonnull void (^)(NSNumber * _Nullable, FlutterError * _Nullable))completion { + BOOL isEndScreenLoadingEnabled = IBGAPM.endScreenLoadingEnabled; + NSNumber *isEnabledNumber = @(isEndScreenLoadingEnabled); + completion(isEnabledNumber, nil); +} + + @end diff --git a/ios/Classes/Modules/InstabugApi.m b/ios/Classes/Modules/InstabugApi.m index 493d1fc19..c6806c805 100644 --- a/ios/Classes/Modules/InstabugApi.m +++ b/ios/Classes/Modules/InstabugApi.m @@ -19,6 +19,15 @@ - (void)setEnabledIsEnabled:(NSNumber *)isEnabled error:(FlutterError *_Nullable Instabug.enabled = [isEnabled boolValue]; } +- (nullable NSNumber *)isBuiltWithError:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + return @(YES); +} + + +- (nullable NSNumber *)isEnabledWithError:(FlutterError * _Nullable __autoreleasing * _Nonnull)error { + return @(Instabug.enabled); +} + - (void)initToken:(NSString *)token invocationEvents:(NSArray *)invocationEvents debugLogsLevel:(NSString *)debugLogsLevel error:(FlutterError *_Nullable *_Nonnull)error { SEL setPrivateApiSEL = NSSelectorFromString(@"setCurrentPlatform:"); if ([[Instabug class] respondsToSelector:setPrivateApiSEL]) { @@ -309,4 +318,5 @@ - (void)willRedirectToStoreWithError:(FlutterError * _Nullable __autoreleasing * [Instabug willRedirectToAppStore]; } + @end diff --git a/ios/Classes/Util/NativeUtils/IBGAPM+PrivateAPIs.h b/ios/Classes/Util/NativeUtils/IBGAPM+PrivateAPIs.h new file mode 100644 index 000000000..2c2158479 --- /dev/null +++ b/ios/Classes/Util/NativeUtils/IBGAPM+PrivateAPIs.h @@ -0,0 +1,26 @@ +// +// IBGAPM+PrivateAPIs.h +// Instabug +// +// Created by Yousef Hamza on 9/7/20. +// Copyright © 2020 Moataz. All rights reserved. +// + +#import +#import "IBGTimeIntervalUnits.h" + +@interface IBGAPM (PrivateAPIs) + +@property (class, atomic, assign) BOOL networkEnabled; + +/// `endScreenLoadingEnabled` will be only true if APM, screenLoadingFeature.enabled and autoUITracesUserPreference are true +@property (class, atomic, assign) BOOL endScreenLoadingEnabled; + ++ (void)startUITraceCPWithName:(NSString *)name startTimestampMUS:(IBGMicroSecondsTimeInterval)startTimestampMUS; + ++ (void)reportScreenLoadingCPWithStartTimestampMUS:(IBGMicroSecondsTimeInterval)startTimestampMUS + durationMUS:(IBGMicroSecondsTimeInterval)durationMUS; + ++ (void)endScreenLoadingCPWithEndTimestampMUS:(IBGMicroSecondsTimeInterval)endTimestampMUS; + +@end diff --git a/ios/Classes/Util/NativeUtils/IBGTimeIntervalUnits.h b/ios/Classes/Util/NativeUtils/IBGTimeIntervalUnits.h new file mode 100644 index 000000000..9a9072a6d --- /dev/null +++ b/ios/Classes/Util/NativeUtils/IBGTimeIntervalUnits.h @@ -0,0 +1,24 @@ +// +// IBGTimeIntervalUnits.h +// InstabugUtilities +// +// Created by Yousef Hamza on 6/4/20. +// Copyright © 2020 Moataz. All rights reserved. +// + +#import + +typedef double IBGMicroSecondsTimeInterval NS_SWIFT_NAME(MicroSecondsTimeInterval); +typedef double IBGMilliSecondsTimeInterval NS_SWIFT_NAME(MilliSecondsTimeInterval); +typedef double IBGMinutesTimeInterval NS_SWIFT_NAME(MinutesTimeInterval); + +/// Convert from milli timestamp to micro timestamp +/// - Parameter timeInterval: micro timestamp +IBGMicroSecondsTimeInterval ibg_microSecondsIntervalFromTimeEpoch(NSTimeInterval timeInterval); +IBGMicroSecondsTimeInterval ibg_microSecondsIntervalFromTimeInterval(NSTimeInterval timeInterval); +IBGMilliSecondsTimeInterval ibg_milliSecondsIntervalFromTimeInterval(NSTimeInterval timeInterval); +IBGMinutesTimeInterval ibg_minutesIntervalFromTimeInterval(NSTimeInterval timeInterval); + +NSTimeInterval ibg_timeIntervalFromMicroSecondsInterval(IBGMicroSecondsTimeInterval timeInterval); +NSTimeInterval ibg_timeIntervalFromMilliSecondsInterval(IBGMilliSecondsTimeInterval timeInterval); +NSTimeInterval ibg_timeIntervalFromMinutesInterval(IBGMinutesTimeInterval timeInterval); diff --git a/ios/instabug_flutter.podspec b/ios/instabug_flutter.podspec index 2f54e05da..ae1ebfaa4 100644 --- a/ios/instabug_flutter.podspec +++ b/ios/instabug_flutter.podspec @@ -17,6 +17,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'OTHER_LDFLAGS' => '-framework "Flutter" -framework "Instabug"'} s.dependency 'Flutter' - s.dependency 'Instabug', '13.0.0' + s.dependency 'Instabug', '13.1.0' end diff --git a/lib/instabug_flutter.dart b/lib/instabug_flutter.dart index c54cb53ac..cfd50ce30 100644 --- a/lib/instabug_flutter.dart +++ b/lib/instabug_flutter.dart @@ -16,3 +16,5 @@ export 'src/modules/session_replay.dart'; 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'; diff --git a/lib/src/modules/apm.dart b/lib/src/modules/apm.dart index 58b27c082..70f7bf6b0 100644 --- a/lib/src/modules/apm.dart +++ b/lib/src/modules/apm.dart @@ -2,15 +2,19 @@ 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/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:meta/meta.dart'; class APM { static var _host = ApmHostApi(); + static String tag = 'Instabug - APM'; /// @nodoc @visibleForTesting @@ -25,6 +29,24 @@ class APM { return _host.setEnabled(isEnabled); } + /// @nodoc + @internal + static Future isEnabled() async { + return _host.isEnabled(); + } + + /// Enables or disables the screenLoading Monitoring feature. + /// [boolean] isEnabled + static Future setScreenLoadingEnabled(bool isEnabled) { + return _host.setScreenLoadingEnabled(isEnabled); + } + + /// @nodoc + @internal + static Future isScreenLoadingEnabled() async { + return _host.isScreenLoadingEnabled(); + } + /// Enables or disables cold app launch tracking. /// [boolean] isEnabled static Future setColdAppLaunchEnabled(bool isEnabled) async { @@ -146,4 +168,69 @@ class APM { return _host.networkLogAndroid(data.toJson()); } } + + /// @nodoc + @internal + static Future startCpUiTrace( + String screenName, + int startTimeInMicroseconds, + int traceId, + ) { + InstabugLogger.I.d( + 'Starting Ui trace — traceId: $traceId, screenName: $screenName, microTimeStamp: $startTimeInMicroseconds', + tag: APM.tag, + ); + return _host.startCpUiTrace(screenName, startTimeInMicroseconds, traceId); + } + + /// @nodoc + @internal + static Future reportScreenLoadingCP( + int startTimeInMicroseconds, + int durationInMicroseconds, + int uiTraceId, + ) { + InstabugLogger.I.d( + 'Reporting screen loading trace — traceId: $uiTraceId, startTimeInMicroseconds: $startTimeInMicroseconds, durationInMicroseconds: $durationInMicroseconds', + tag: APM.tag, + ); + return _host.reportScreenLoadingCP( + startTimeInMicroseconds, + durationInMicroseconds, + uiTraceId, + ); + } + + /// @nodoc + @internal + static Future endScreenLoadingCP( + int endTimeInMicroseconds, + int uiTraceId, + ) { + InstabugLogger.I.d( + 'Extending screen loading trace — traceId: $uiTraceId, endTimeInMicroseconds: $endTimeInMicroseconds', + tag: APM.tag, + ); + return _host.endScreenLoadingCP(endTimeInMicroseconds, uiTraceId); + } + + /// Extends the currently active screen loading trace + static Future endScreenLoading() { + return ScreenLoadingManager.I.endScreenLoading(); + } + + /// @nodoc + @internal + static Future isEndScreenLoadingEnabled() async { + return _host.isEndScreenLoadingEnabled(); + } + + /// Wraps the given routes with [InstabugCaptureScreenLoading] widgets. + /// This allows Instabug to automatically capture screen loading times. + static Map wrapRoutes( + Map routes, { + List exclude = const [], + }) { + return ScreenLoadingManager.wrapRoutes(routes, exclude: exclude); + } } diff --git a/lib/src/modules/instabug.dart b/lib/src/modules/instabug.dart index ac6c49698..b0628f258 100644 --- a/lib/src/modules/instabug.dart +++ b/lib/src/modules/instabug.dart @@ -9,11 +9,14 @@ 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'; import 'package:instabug_flutter/instabug_flutter.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/ibg_build_info.dart'; +import 'package:instabug_flutter/src/utils/instabug_logger.dart'; import 'package:meta/meta.dart'; enum InvocationEvent { @@ -131,6 +134,8 @@ enum ReproStepsMode { enabled, disabled, enabledWithNoScreenshots } class Instabug { static var _host = InstabugHostApi(); + static const tag = 'Instabug'; + /// @nodoc @visibleForTesting // ignore: use_setters_to_change_properties @@ -146,6 +151,18 @@ class Instabug { Surveys.$setup(); } + /// @nodoc + @internal + static Future isEnabled() async { + return _host.isEnabled(); + } + + /// @nodoc + @internal + static Future isBuilt() async { + return _host.isBuilt(); + } + /// Enables or disables Instabug functionality. /// [boolean] isEnabled static Future setEnabled(bool isEnabled) async { @@ -164,6 +181,7 @@ class Instabug { LogLevel debugLogsLevel = LogLevel.error, }) async { $setup(); + InstabugLogger.I.logLevel = debugLogsLevel; return _host.init( token, invocationEvents.mapToString(), diff --git a/lib/src/utils/instabug_logger.dart b/lib/src/utils/instabug_logger.dart new file mode 100644 index 000000000..033dbf3a6 --- /dev/null +++ b/lib/src/utils/instabug_logger.dart @@ -0,0 +1,93 @@ +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; +import 'package:instabug_flutter/instabug_flutter.dart'; +import 'package:instabug_flutter/src/utils/ibg_date_time.dart'; + +abstract class Logger { + void log( + String message, { + required LogLevel level, + required String tag, + }); +} + +class InstabugLogger implements Logger { + InstabugLogger._(); + + static InstabugLogger _instance = InstabugLogger._(); + + static InstabugLogger get instance => _instance; + + /// Shorthand for [instance] + static InstabugLogger get I => instance; + + @visibleForTesting + // ignore: use_setters_to_change_properties + static void setInstance(InstabugLogger instance) { + _instance = instance; + } + + LogLevel _logLevel = LogLevel.error; + + // ignore: avoid_setters_without_getters + set logLevel(LogLevel level) { + _logLevel = level; + } + + @override + void log( + String message, { + required LogLevel level, + String tag = '', + }) { + if (level.getValue() >= _logLevel.getValue()) { + developer.log( + message, + name: tag, + time: IBGDateTime.I.now(), + level: level.getValue(), + ); + } + } + + void e( + String message, { + String tag = '', + }) { + log(message, tag: tag, level: LogLevel.error); + } + + void d( + String message, { + String tag = '', + }) { + log(message, tag: tag, level: LogLevel.debug); + } + + void v( + String message, { + String tag = '', + }) { + log(message, tag: tag, level: LogLevel.verbose); + } +} + +extension LogLevelExtension on LogLevel { + /// Returns the severity level to be used in the `developer.log` function. + /// + /// The severity level is a value between 0 and 2000. + /// The values used here are based on the `package:logging` `Level` class. + int getValue() { + switch (this) { + case LogLevel.none: + return 2000; + case LogLevel.error: + return 1000; + case LogLevel.debug: + return 500; + case LogLevel.verbose: + return 0; + } + } +} diff --git a/lib/src/utils/instabug_montonic_clock.dart b/lib/src/utils/instabug_montonic_clock.dart new file mode 100644 index 000000000..9474a8224 --- /dev/null +++ b/lib/src/utils/instabug_montonic_clock.dart @@ -0,0 +1,22 @@ +import 'dart:developer'; + +import 'package:meta/meta.dart'; + +/// Mockable, monotonic, high-resolution clock. +class InstabugMonotonicClock { + InstabugMonotonicClock._(); + + static InstabugMonotonicClock _instance = InstabugMonotonicClock._(); + static InstabugMonotonicClock get instance => _instance; + + /// Shorthand for [instance] + static InstabugMonotonicClock get I => instance; + + @visibleForTesting + // ignore: use_setters_to_change_properties + static void setInstance(InstabugMonotonicClock instance) { + _instance = instance; + } + + int get now => Timeline.now; +} diff --git a/lib/src/utils/instabug_navigator_observer.dart b/lib/src/utils/instabug_navigator_observer.dart index 7f3cf45e7..3cb39cc05 100644 --- a/lib/src/utils/instabug_navigator_observer.dart +++ b/lib/src/utils/instabug_navigator_observer.dart @@ -1,11 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:instabug_flutter/instabug_flutter.dart'; import 'package:instabug_flutter/src/modules/instabug.dart'; +import 'package:instabug_flutter/src/utils/instabug_logger.dart'; +import 'package:instabug_flutter/src/utils/screen_loading/screen_loading_manager.dart'; class InstabugNavigatorObserver extends NavigatorObserver { final List _steps = []; void screenChanged(Route newRoute) { try { + final screenName = newRoute.settings.name.toString(); + // Starts a the new UI trace which is exclusive to screen loading + ScreenLoadingManager.I.startUiTrace(screenName); // If there is a step that hasn't been pushed yet if (_steps.isNotEmpty) { // Report the last step and remove it from the list @@ -19,12 +25,13 @@ class InstabugNavigatorObserver extends NavigatorObserver { Future.delayed(const Duration(milliseconds: 1000), () { // If this route is in the array, report it and remove it from the list if (_steps.contains(newRoute)) { - Instabug.reportScreenChange(newRoute.settings.name.toString()); + Instabug.reportScreenChange(screenName); _steps.remove(newRoute); } }); } catch (e) { - debugPrint('[INSTABUG] - Reporting screen failed'); + InstabugLogger.I.e('Reporting screen change failed:', tag: Instabug.tag); + InstabugLogger.I.e(e.toString(), tag: Instabug.tag); } } diff --git a/lib/src/utils/screen_loading/flags_config.dart b/lib/src/utils/screen_loading/flags_config.dart new file mode 100644 index 000000000..f18eb1ccb --- /dev/null +++ b/lib/src/utils/screen_loading/flags_config.dart @@ -0,0 +1,23 @@ +import 'package:instabug_flutter/instabug_flutter.dart'; + +enum FlagsConfig { + apm, + uiTrace, + screenLoading, + endScreenLoading, +} + +extension FeatureExtensions on FlagsConfig { + Future isEnabled() async { + switch (this) { + case FlagsConfig.apm: + return APM.isEnabled(); + case FlagsConfig.screenLoading: + return APM.isScreenLoadingEnabled(); + case FlagsConfig.endScreenLoading: + return APM.isEndScreenLoadingEnabled(); + default: + return false; + } + } +} diff --git a/lib/src/utils/screen_loading/instabug_capture_screen_loading.dart b/lib/src/utils/screen_loading/instabug_capture_screen_loading.dart new file mode 100644 index 000000000..3d400ae3d --- /dev/null +++ b/lib/src/utils/screen_loading/instabug_capture_screen_loading.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:instabug_flutter/src/utils/ibg_date_time.dart'; +import 'package:instabug_flutter/src/utils/instabug_montonic_clock.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'; + +class InstabugCaptureScreenLoading extends StatefulWidget { + static const tag = "InstabugCaptureScreenLoading"; + + const InstabugCaptureScreenLoading({ + Key? key, + required this.screenName, + required this.child, + }) : super(key: key); + final Widget child; + final String screenName; + + @override + State createState() => + _InstabugCaptureScreenLoadingState(); +} + +class _InstabugCaptureScreenLoadingState + extends State { + ScreenLoadingTrace? trace; + final startTimeInMicroseconds = IBGDateTime.I.now().microsecondsSinceEpoch; + final startMonotonicTimeInMicroseconds = InstabugMonotonicClock.I.now; + final stopwatch = Stopwatch()..start(); + + @override + void initState() { + super.initState(); + trace = ScreenLoadingTrace( + ScreenLoadingManager.I.sanitizeScreenName(widget.screenName), + startTimeInMicroseconds: startTimeInMicroseconds, + startMonotonicTimeInMicroseconds: startMonotonicTimeInMicroseconds, + ); + + ScreenLoadingManager.I.startScreenLoadingTrace(trace!); + + // to maintain supported versions prior to Flutter 3.0.0 + // ignore: invalid_null_aware_operator + WidgetsBinding.instance?.addPostFrameCallback((_) { + stopwatch.stop(); + final duration = stopwatch.elapsedMicroseconds; + trace?.duration = duration; + trace?.endTimeInMicroseconds = startTimeInMicroseconds + duration; + ScreenLoadingManager.I.reportScreenLoading(trace); + }); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/lib/src/utils/screen_loading/route_matcher.dart b/lib/src/utils/screen_loading/route_matcher.dart new file mode 100644 index 000000000..3388e20e7 --- /dev/null +++ b/lib/src/utils/screen_loading/route_matcher.dart @@ -0,0 +1,92 @@ +import 'package:meta/meta.dart'; + +class RouteMatcher { + RouteMatcher._(); + + static RouteMatcher _instance = RouteMatcher._(); + + static RouteMatcher get instance => _instance; + + /// Shorthand for [instance] + static RouteMatcher get I => instance; + + @visibleForTesting + // ignore: use_setters_to_change_properties + static void setInstance(RouteMatcher instance) { + _instance = instance; + } + + /// Checks whether the given [routePath] definition matches the given [actualPath]. + /// + /// The [routePath] definition can contain parameters in the form of `:param`, + /// or `**` for a wildcard parameter. + /// + /// Returns `true` if the [actualPath] matches the [routePath], otherwise `false`. + /// + /// Example: + /// ```dart + /// RouteMatcher.I.match('/users', '/users'); // true + /// RouteMatcher.I.match('/user/:id', '/user/123'); // true + /// RouteMatcher.I.match('/user/**', '/user/123/profile'); // false + /// ``` + bool match({ + required String? routePath, + required String? actualPath, + }) { + // null paths are considered equal. + if (routePath == null || actualPath == null) { + return routePath == actualPath; + } + + final routePathSegments = _segmentPath(routePath); + final actualPathSegments = _segmentPath(actualPath); + + final hasWildcard = routePathSegments.contains('**'); + + if (routePathSegments.length != actualPathSegments.length && !hasWildcard) { + return false; + } + + for (var i = 0; i < routePathSegments.length; i++) { + final routeSegment = routePathSegments[i]; + + final isWildcard = routeSegment == '**'; + final isParameter = routeSegment.startsWith(':'); + + final noMoreActualSegments = i >= actualPathSegments.length; + + if (noMoreActualSegments) { + // Only wilcard segments match empty segments + return isWildcard; + } + + final pathSegment = actualPathSegments[i]; + + // If the route segment is a parameter, then segments automatically match. + if (isParameter) { + continue; + } + + // A wildcard matches any path, the assumption is that wildcard paths only + // appear at the end of the route so we return a match if we reach this point. + if (isWildcard) { + return true; + } + + if (routeSegment != pathSegment) { + return false; + } + } + + return true; + } + + List _segmentPath(String path) { + final pathWithoutQuery = path.split('?').first; + + return pathWithoutQuery + .split('/') + .where((segment) => segment.isNotEmpty) + .toList(); + } +} diff --git a/lib/src/utils/screen_loading/screen_loading_manager.dart b/lib/src/utils/screen_loading/screen_loading_manager.dart new file mode 100644 index 000000000..2af57f09f --- /dev/null +++ b/lib/src/utils/screen_loading/screen_loading_manager.dart @@ -0,0 +1,436 @@ +import 'package:flutter/widgets.dart' show WidgetBuilder, BuildContext; +import 'package:instabug_flutter/instabug_flutter.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/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:meta/meta.dart'; + +/// @nodoc +@internal +class ScreenLoadingManager { + ScreenLoadingManager._(); + + /// @nodoc + @internal + @visibleForTesting + ScreenLoadingManager.init(); + + static ScreenLoadingManager _instance = ScreenLoadingManager._(); + + static ScreenLoadingManager get instance => _instance; + + /// Shorthand for [instance] + static ScreenLoadingManager get I => instance; + static const tag = "ScreenLoadingManager"; + UiTrace? currentUiTrace; + ScreenLoadingTrace? currentScreenLoadingTrace; + + /// @nodoc + @internal + final List prematurelyEndedTraces = []; + + @visibleForTesting + // ignore: use_setters_to_change_properties + static void setInstance(ScreenLoadingManager instance) { + _instance = instance; + } + + /// @nodoc + @internal + void resetDidStartScreenLoading() { + // Allows starting a new screen loading capture trace in the same ui trace (without navigating out and in to the same screen) + currentUiTrace?.didStartScreenLoading = false; + InstabugLogger.I.d( + 'Resetting didStartScreenLoading — setting didStartScreenLoading: ${currentUiTrace?.didStartScreenLoading}', + tag: APM.tag, + ); + } + + /// @nodoc + void _logExceptionErrorAndStackTrace(Object error, StackTrace stackTrace) { + InstabugLogger.I.e( + '[Error]:$error \n' + '[StackTrace]: $stackTrace', + tag: APM.tag, + ); + } + + /// @nodoc + Future _checkInstabugSDKBuilt(String apiName) async { + // Check if Instabug SDK is Built + final isInstabugSDKBuilt = await Instabug.isBuilt(); + if (!isInstabugSDKBuilt) { + InstabugLogger.I.e( + 'Instabug API {$apiName} 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, + ); + } + return isInstabugSDKBuilt; + } + + /// @nodoc + @internal + void resetDidReportScreenLoading() { + // Allows reporting a new screen loading capture trace in the same ui trace even if one was reported before by resetting the flag which is used for checking. + currentUiTrace?.didReportScreenLoading = false; + InstabugLogger.I.d( + 'Resetting didExtendScreenLoading — setting didExtendScreenLoading: ${currentUiTrace?.didExtendScreenLoading}', + tag: APM.tag, + ); + } + + /// @nodoc + @internal + void resetDidExtendScreenLoading() { + // Allows reporting a new screen loading capture trace in the same ui trace even if one was reported before by resetting the flag which is used for checking. + currentUiTrace?.didExtendScreenLoading = false; + InstabugLogger.I.d( + 'Resetting didReportScreenLoading — setting didReportScreenLoading: ${currentUiTrace?.didReportScreenLoading}', + tag: APM.tag, + ); + } + + /// The function `sanitizeScreenName` removes leading and trailing slashes from a screen name in Dart. + /// + /// Args: + /// screenName (String): The `sanitizeScreenName` function is designed to remove a specific character + /// ('/') from the beginning and end of a given `screenName` string. If the `screenName` is equal to + /// '/', it will return 'ROOT_PAGE'. Otherwise, it will remove the character from the beginning and end + /// if + /// + /// Returns: + /// The `sanitizeScreenName` function returns the sanitized screen name after removing any leading or + /// trailing '/' characters. If the input `screenName` is equal to '/', it returns 'ROOT_PAGE'. + + @internal + String sanitizeScreenName(String screenName) { + const characterToBeRemoved = '/'; + final lastIndex = screenName.length - 1; + var sanitizedScreenName = screenName; + + if (screenName == characterToBeRemoved) { + return 'ROOT_PAGE'; + } + if (screenName[0] == characterToBeRemoved) { + sanitizedScreenName = sanitizedScreenName.substring(1); + } + if (screenName[lastIndex] == characterToBeRemoved) { + sanitizedScreenName = + sanitizedScreenName.substring(0, sanitizedScreenName.length - 1); + } + return sanitizedScreenName; + } + + @internal + Future startUiTrace(String screenName) async { + try { + resetDidStartScreenLoading(); + + 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) { + 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; + } + + final sanitizedScreenName = sanitizeScreenName(screenName); + final microTimeStamp = IBGDateTime.I.now().microsecondsSinceEpoch; + final uiTraceId = IBGDateTime.I.now().millisecondsSinceEpoch; + APM.startCpUiTrace(sanitizedScreenName, microTimeStamp, uiTraceId); + currentUiTrace = UiTrace(sanitizedScreenName, traceId: uiTraceId); + } catch (error, stackTrace) { + _logExceptionErrorAndStackTrace(error, stackTrace); + } + } + + /// @nodoc + @internal + Future startScreenLoadingTrace(ScreenLoadingTrace trace) async { + try { + final isSDKBuilt = + await _checkInstabugSDKBuilt("APM.InstabugCaptureScreenLoading"); + if (!isSDKBuilt) return; + + final isScreenLoadingEnabled = + await FlagsConfig.screenLoading.isEnabled(); + if (!isScreenLoadingEnabled) { + if (IBGBuildInfo.I.isIOS) { + InstabugLogger.I.e( + 'Screen loading monitoring is disabled, skipping starting screen loading monitoring for screen: ${trace.screenName}.\n' + 'Please refer to the documentation for how to enable screen loading monitoring on your app: ' + 'https://docs.instabug.com/docs/flutter-apm-screen-loading#disablingenabling-screen-loading-tracking ' + "If Screen Loading is enabled but you're still seeing this message, please reach out to support.", + tag: APM.tag, + ); + } + return; + } + + final isSameScreen = RouteMatcher.I.match( + routePath: trace.screenName, + actualPath: currentUiTrace?.screenName, + ); + + final didStartLoading = currentUiTrace?.didStartScreenLoading == true; + + if (isSameScreen && !didStartLoading) { + InstabugLogger.I.d( + 'starting screen loading trace — screenName: ${trace.screenName}, startTimeInMicroseconds: ${trace.startTimeInMicroseconds}', + tag: APM.tag, + ); + currentUiTrace?.didStartScreenLoading = true; + currentScreenLoadingTrace = trace; + return; + } + InstabugLogger.I.d( + 'failed to start screen loading trace — screenName: ${trace.screenName}, startTimeInMicroseconds: ${trace.startTimeInMicroseconds}', + tag: APM.tag, + ); + InstabugLogger.I.d( + 'didStartScreenLoading: $didStartLoading, isSameScreen: $isSameScreen', + tag: APM.tag, + ); + } catch (error, stackTrace) { + _logExceptionErrorAndStackTrace(error, stackTrace); + } + } + + /// @nodoc + @internal + Future reportScreenLoading(ScreenLoadingTrace? trace) async { + try { + final isSDKBuilt = + await _checkInstabugSDKBuilt("APM.InstabugCaptureScreenLoading"); + if (!isSDKBuilt) return; + + int? duration; + final isScreenLoadingEnabled = + await FlagsConfig.screenLoading.isEnabled(); + if (!isScreenLoadingEnabled) { + if (IBGBuildInfo.I.isIOS) { + InstabugLogger.I.e( + 'Screen loading monitoring is disabled, skipping reporting screen loading time for screen: ${trace?.screenName}.\n' + 'Please refer to the documentation for how to enable screen loading monitoring on your app: ' + 'https://docs.instabug.com/docs/flutter-apm-screen-loading#disablingenabling-screen-loading-tracking ' + "If Screen Loading is enabled but you're still seeing this message, please reach out to support.", + tag: APM.tag, + ); + } + return; + } + + final isSameScreen = currentScreenLoadingTrace == trace; + + final isReported = currentUiTrace?.didReportScreenLoading == + true; // Changed to isReported + final isValidTrace = trace != null; + + // Only report the first screen loading trace with the same name as the active UiTrace + if (isSameScreen && !isReported && isValidTrace) { + currentUiTrace?.didReportScreenLoading = true; + + APM.reportScreenLoadingCP( + trace?.startTimeInMicroseconds ?? 0, + duration ?? trace?.duration ?? 0, + currentUiTrace?.traceId ?? 0, + ); + return; + } else { + InstabugLogger.I.d( + 'Failed to report screen loading trace — screenName: ${trace?.screenName}, ' + 'startTimeInMicroseconds: ${trace?.startTimeInMicroseconds}, ' + 'duration: $duration, ' + 'trace.duration: ${trace?.duration ?? 0}', + tag: APM.tag, + ); + InstabugLogger.I.d( + 'didReportScreenLoading: $isReported, ' + 'isSameName: $isSameScreen', + tag: APM.tag, + ); + _reportScreenLoadingDroppedError(trace); + } + return; + } catch (error, stackTrace) { + _logExceptionErrorAndStackTrace(error, stackTrace); + } + } + + void _reportScreenLoadingDroppedError(ScreenLoadingTrace? trace) { + InstabugLogger.I.e( + "Screen Loading trace dropped as the trace isn't from the current screen, or another trace was reported before the current one. — $trace", + tag: APM.tag, + ); + } + + /// Extends the already ended screen loading adding a stage to it + Future endScreenLoading() async { + try { + final isSDKBuilt = await _checkInstabugSDKBuilt("endScreenLoading"); + if (!isSDKBuilt) return; + + final isScreenLoadingEnabled = + await FlagsConfig.screenLoading.isEnabled(); + + if (!isScreenLoadingEnabled) { + if (IBGBuildInfo.I.isIOS) { + InstabugLogger.I.e( + 'Screen loading monitoring is disabled, skipping ending screen loading monitoring with APM.endScreenLoading().\n' + 'Please refer to the documentation for how to enable screen loading monitoring in your app: ' + 'https://docs.instabug.com/docs/flutter-apm-screen-loading#disablingenabling-screen-loading-tracking ' + "If Screen Loading is enabled but you're still seeing this message, please reach out to support.", + tag: APM.tag, + ); + } + return; + } + + final isEndScreenLoadingEnabled = + await FlagsConfig.endScreenLoading.isEnabled(); + + if (!isEndScreenLoadingEnabled) { + if (IBGBuildInfo.I.isIOS) { + InstabugLogger.I.e( + 'End Screen loading API is disabled.\n' + 'Please refer to the documentation for how to enable screen loading monitoring in your app: ' + 'https://docs.instabug.com/docs/flutter-apm-screen-loading#disablingenabling-screen-loading-tracking ' + "If Screen Loading is enabled but you're still seeing this message, please reach out to support.", + tag: APM.tag, + ); + } + return; + } + + final didExtendScreenLoading = + currentUiTrace?.didExtendScreenLoading == true; + if (didExtendScreenLoading) { + InstabugLogger.I.e( + 'endScreenLoading has already been called for the current screen visit. Multiple calls to this API are not allowed during a single screen visit, only the first call will be considered.', + tag: APM.tag, + ); + return; + } + + // Handles no active screen loading trace - cannot end + final didStartScreenLoading = + currentScreenLoadingTrace?.startTimeInMicroseconds != null; + if (!didStartScreenLoading) { + InstabugLogger.I.e( + "endScreenLoading wasn’t called as there is no active screen Loading trace.", + tag: APM.tag, + ); + return; + } + + final extendedMonotonicEndTimeInMicroseconds = + InstabugMonotonicClock.I.now; + + var duration = extendedMonotonicEndTimeInMicroseconds - + currentScreenLoadingTrace!.startMonotonicTimeInMicroseconds; + + var extendedEndTimeInMicroseconds = + currentScreenLoadingTrace!.startTimeInMicroseconds + duration; + + // cannot extend as the trace has not ended yet. + // we report the extension timestamp as 0 and can be override later on. + final didEndScreenLoadingPrematurely = + currentScreenLoadingTrace?.endTimeInMicroseconds == null; + if (didEndScreenLoadingPrematurely) { + extendedEndTimeInMicroseconds = 0; + duration = 0; + + InstabugLogger.I.e( + "endScreenLoading was called too early in the Screen Loading cycle. Please make sure to call the API after the screen is done loading.", + tag: APM.tag, + ); + } + InstabugLogger.I.d( + 'endTimeInMicroseconds: ${currentScreenLoadingTrace?.endTimeInMicroseconds}, ' + 'didEndScreenLoadingPrematurely: $didEndScreenLoadingPrematurely, extendedEndTimeInMicroseconds: $extendedEndTimeInMicroseconds.', + tag: APM.tag, + ); + InstabugLogger.I.d( + 'Ending screen loading capture — duration: $extendedEndTimeInMicroseconds', + tag: APM.tag, + ); + + // Ends screen loading trace + APM.endScreenLoadingCP( + extendedEndTimeInMicroseconds, + currentUiTrace?.traceId ?? 0, + ); + currentUiTrace?.didExtendScreenLoading = true; + + return; + } catch (error, stackTrace) { + _logExceptionErrorAndStackTrace(error, stackTrace); + } + } + + /// Wraps the given routes with [InstabugCaptureScreenLoading] widgets. + /// + /// This allows Instabug to automatically capture screen loading times. + /// + /// Example usage: + /// + /// Map routes = { + /// '/home': (context) => const HomePage(), + /// '/settings': (context) => const SettingsPage(), + /// }; + /// + /// Map wrappedRoutes = + /// ScreenLoadingAutomaticManager.wrapRoutes( routes) + static Map wrapRoutes( + Map routes, { + List exclude = const [], + }) { + final excludedRoutes = {}; + for (final route in exclude) { + excludedRoutes[route] = true; + } + + final wrappedRoutes = {}; + for (final entry in routes.entries) { + if (!excludedRoutes.containsKey(entry.key)) { + wrappedRoutes[entry.key] = + (BuildContext context) => InstabugCaptureScreenLoading( + screenName: entry.key, + child: entry.value(context), + ); + } else { + wrappedRoutes[entry.key] = entry.value; + } + } + + return wrappedRoutes; + } +} + +@internal +class DropScreenLoadingError extends Error { + final ScreenLoadingTrace trace; + + DropScreenLoadingError(this.trace); + + @override + String toString() { + return 'DropScreenLoadingError: $trace'; + } +} diff --git a/lib/src/utils/screen_loading/screen_loading_trace.dart b/lib/src/utils/screen_loading/screen_loading_trace.dart new file mode 100644 index 000000000..42b78ad71 --- /dev/null +++ b/lib/src/utils/screen_loading/screen_loading_trace.dart @@ -0,0 +1,50 @@ +class ScreenLoadingTrace { + ScreenLoadingTrace( + this.screenName, { + required this.startTimeInMicroseconds, + required this.startMonotonicTimeInMicroseconds, + this.endTimeInMicroseconds, + this.duration, + }); + + final String screenName; + int startTimeInMicroseconds; + + /// Start time in microseconds from a monotonic clock like [InstabugMontonicClock.now]. + /// This should be preferred when measuring time durations and [startTimeInMicroseconds] + /// should only be used when reporting the timestamps in Unix epoch. + int startMonotonicTimeInMicroseconds; + + // TODO: Only startTimeInMicroseconds should be a Unix epoch timestamp, all + // other timestamps should be sampled from a monotonic clock like [InstabugMontonicClock.now] + // for higher precision and to avoid issues with system clock changes. + + // TODO: endTimeInMicroseconds depend on one another, so we can turn one of + // them into a getter instead of storing both. + int? endTimeInMicroseconds; + int? duration; + + ScreenLoadingTrace copyWith({ + String? screenName, + int? startTimeInMicroseconds, + int? startMonotonicTimeInMicroseconds, + int? endTimeInMicroseconds, + int? duration, + }) { + return ScreenLoadingTrace( + screenName ?? this.screenName, + startTimeInMicroseconds: + startTimeInMicroseconds ?? this.startTimeInMicroseconds, + startMonotonicTimeInMicroseconds: startMonotonicTimeInMicroseconds ?? + this.startMonotonicTimeInMicroseconds, + endTimeInMicroseconds: + endTimeInMicroseconds ?? this.endTimeInMicroseconds, + duration: duration ?? this.duration, + ); + } + + @override + String toString() { + return 'ScreenLoadingTrace{screenName: $screenName, startTimeInMicroseconds: $startTimeInMicroseconds, startMonotonicTimeInMicroseconds: $startMonotonicTimeInMicroseconds, endTimeInMicroseconds: $endTimeInMicroseconds, duration: $duration}'; + } +} diff --git a/lib/src/utils/screen_loading/ui_trace.dart b/lib/src/utils/screen_loading/ui_trace.dart new file mode 100644 index 000000000..7fd03c9fd --- /dev/null +++ b/lib/src/utils/screen_loading/ui_trace.dart @@ -0,0 +1,27 @@ +class UiTrace { + final String screenName; + final int traceId; + bool didStartScreenLoading = false; + bool didReportScreenLoading = false; + bool didExtendScreenLoading = false; + + UiTrace( + this.screenName, { + required this.traceId, + }); + + UiTrace copyWith({ + String? screenName, + int? traceId, + }) { + return UiTrace( + screenName ?? this.screenName, + traceId: traceId ?? this.traceId, + ); + } + + @override + String toString() { + return 'UiTrace{screenName: $screenName, traceId: $traceId, isFirstScreenLoadingReported: $didReportScreenLoading, isFirstScreenLoading: $didStartScreenLoading}'; + } +} diff --git a/pigeons/apm.api.dart b/pigeons/apm.api.dart index dfb23366c..84fe9eb8e 100644 --- a/pigeons/apm.api.dart +++ b/pigeons/apm.api.dart @@ -3,6 +3,11 @@ import 'package:pigeon/pigeon.dart'; @HostApi() abstract class ApmHostApi { void setEnabled(bool isEnabled); + @async + bool isEnabled(); + void setScreenLoadingEnabled(bool isEnabled); + @async + bool isScreenLoadingEnabled(); void setColdAppLaunchEnabled(bool isEnabled); void setAutoUITraceEnabled(bool isEnabled); @@ -22,4 +27,17 @@ abstract class ApmHostApi { void endUITrace(); void endAppLaunch(); void networkLogAndroid(Map data); + + void startCpUiTrace(String screenName, int microTimeStamp, int traceId); + + void reportScreenLoadingCP( + int startTimeStampMicro, + int durationMicro, + int uiTraceId, + ); + + void endScreenLoadingCP(int timeStampMicro, int uiTraceId); + + @async + bool isEndScreenLoadingEnabled(); } diff --git a/pigeons/instabug.api.dart b/pigeons/instabug.api.dart index 7113f164c..6da75bcaa 100644 --- a/pigeons/instabug.api.dart +++ b/pigeons/instabug.api.dart @@ -3,6 +3,8 @@ import 'package:pigeon/pigeon.dart'; @HostApi() abstract class InstabugHostApi { void setEnabled(bool isEnabled); + bool isEnabled(); + bool isBuilt(); void init(String token, List invocationEvents, String debugLogsLevel); void show(); diff --git a/pubspec.yaml b/pubspec.yaml index db1b25f92..eec6e8eef 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,5 +34,5 @@ flutter: pluginClass: InstabugFlutterPlugin environment: - sdk: ">=2.12.0 <3.0.0" + sdk: ">=2.14.0 <4.0.0" flutter: ">=1.17.0" diff --git a/test/apm_test.dart b/test/apm_test.dart index 8551c0c82..c801926f3 100644 --- a/test/apm_test.dart +++ b/test/apm_test.dart @@ -38,6 +38,34 @@ void main() { ).called(1); }); + test('[isEnabled] should call host method', () async { + when(mHost.isEnabled()).thenAnswer((_) async => true); + await APM.isEnabled(); + + verify( + mHost.isEnabled(), + ).called(1); + }); + + test('[setScreenLoadingMonitoringEnabled] should call host method', () async { + const enabled = true; + + await APM.setScreenLoadingEnabled(enabled); + + verify( + mHost.setScreenLoadingEnabled(enabled), + ).called(1); + }); + + test('[isScreenLoadingMonitoringEnabled] should call host method', () async { + when(mHost.isScreenLoadingEnabled()).thenAnswer((_) async => true); + await APM.isScreenLoadingEnabled(); + + verify( + mHost.isScreenLoadingEnabled(), + ).called(1); + }); + test('[setColdAppLaunchEnabled] should call host method', () async { const enabled = true; @@ -175,4 +203,59 @@ void main() { mHost.networkLogAndroid(data.toJson()), ).called(1); }); + + test('[startCpUiTrace] should call host method', () async { + const screenName = 'screen-name'; + final microTimeStamp = DateTime.now().microsecondsSinceEpoch; + final traceId = DateTime.now().millisecondsSinceEpoch; + + await APM.startCpUiTrace(screenName, microTimeStamp, traceId); + + verify( + mHost.startCpUiTrace(screenName, microTimeStamp, traceId), + ).called(1); + verifyNoMoreInteractions(mHost); + }); + + test('[reportScreenLoading] should call host method', () async { + final startTimeStampMicro = DateTime.now().microsecondsSinceEpoch; + final durationMicro = DateTime.now().microsecondsSinceEpoch; + final uiTraceId = DateTime.now().millisecondsSinceEpoch; + + await APM.reportScreenLoadingCP( + startTimeStampMicro, + durationMicro, + uiTraceId, + ); + + verify( + mHost.reportScreenLoadingCP( + startTimeStampMicro, + durationMicro, + uiTraceId, + ), + ).called(1); + verifyNoMoreInteractions(mHost); + }); + + test('[endScreenLoading] should call host method', () async { + final timeStampMicro = DateTime.now().microsecondsSinceEpoch; + final uiTraceId = DateTime.now().millisecondsSinceEpoch; + + await APM.endScreenLoadingCP(timeStampMicro, uiTraceId); + + verify( + mHost.endScreenLoadingCP(timeStampMicro, uiTraceId), + ).called(1); + verifyNoMoreInteractions(mHost); + }); + + test('[isSEndScreenLoadingEnabled] should call host method', () async { + when(mHost.isEndScreenLoadingEnabled()).thenAnswer((_) async => true); + await APM.isEndScreenLoadingEnabled(); + + verify( + mHost.isEndScreenLoadingEnabled(), + ).called(1); + }); } diff --git a/test/instabug_test.dart b/test/instabug_test.dart index 78a370963..a6631859d 100644 --- a/test/instabug_test.dart +++ b/test/instabug_test.dart @@ -37,6 +37,31 @@ void main() { ).called(1); }); + test('[isEnabled] should call host method', () async { + const expected = true; + when(mHost.isEnabled()).thenAnswer((_) async => expected); + + final actual = await Instabug.isEnabled(); + + verify( + mHost.isEnabled(), + ).called(1); + expect(actual, expected); + }); + + test('[isBuilt] should call host method', () async { + const expected = true; + when(mHost.isBuilt()).thenAnswer((_) async => expected); + + final actual = await Instabug.isBuilt(); + + verify( + mHost.isBuilt(), + ).called(1); + + expect(actual, expected); + }); + test('[start] should call host method', () async { const token = "068ba9a8c3615035e163dc5f829c73be"; const events = [InvocationEvent.shake, InvocationEvent.screenshot]; diff --git a/test/route_matcher_test.dart b/test/route_matcher_test.dart new file mode 100644 index 000000000..977c61d88 --- /dev/null +++ b/test/route_matcher_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:instabug_flutter/src/utils/screen_loading/route_matcher.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + WidgetsFlutterBinding.ensureInitialized(); + + test('[match] should return true when static paths match', () { + const routePath = '/user/profile'; + const actualPath = '/user/profile'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isTrue); + }); + + test( + '[match] should return true when static paths match ignoring query parameters', + () { + const routePath = '/user/profile?name=John&premium=true'; + const actualPath = '/user/profile'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isTrue); + }); + + test('[match] should return false when static paths do not match', () { + const routePath = '/user/profile'; + const actualPath = '/user/settings'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isFalse); + }); + + test('[match] should return true when parameterized paths match', () { + const routePath = '/user/:id/profile'; + const actualPath = '/user/123/profile'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isTrue); + }); + + test('[match] should return false when parameterized paths do not match', () { + const routePath = '/user/:id/profile'; + const actualPath = '/user/profile'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isFalse); + }); + + test('[match] should return true when paths match with wildcard', () { + const routePath = '/user/**'; + const actualPath = '/user/123/profile'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isTrue); + }); + + test('[match] should return false when paths do not match with wildcard', () { + const routePath = '/profile/**'; + const actualPath = '/user/123/profile'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isFalse); + }); + + test( + '[match] should return true when paths match with wildcard and parameters', + () { + const routePath = '/user/:id/friends/:friend/**'; + const actualPath = '/user/123/friends/456/profile/about'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isTrue); + }); + + test( + '[match] should return false when paths do not match with wildcard and parameters', + () { + const routePath = '/user/:id/friends/:friend/profile/**'; + const actualPath = '/user/123/friends/123/about'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isFalse); + }); + + test( + '[match] should return true when paths match ignoring leading and trailing slashes', + () { + const routePath = 'user/:id/friends/:friend/profile/'; + const actualPath = '/user/123/friends/123/profile'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isTrue); + }); + + test( + '[match] should return false when paths do not match ignoring leading and trailing slashes', + () { + const routePath = 'user/:id/friends/:friend/profile/'; + const actualPath = '/user/123/friends/123/about'; + + final isMatch = RouteMatcher.I.match( + routePath: routePath, + actualPath: actualPath, + ); + + expect(isMatch, isFalse); + }); +} diff --git a/test/utils/screen_loading/screen_loading_manager_test.dart b/test/utils/screen_loading/screen_loading_manager_test.dart new file mode 100644 index 000000000..14e33c663 --- /dev/null +++ b/test/utils/screen_loading/screen_loading_manager_test.dart @@ -0,0 +1,1031 @@ +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/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:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'screen_loading_manager_test.mocks.dart'; + +class ScreenLoadingManagerNoResets extends ScreenLoadingManager { + ScreenLoadingManagerNoResets.init() : super.init(); + + @override + void resetDidExtendScreenLoading() {} + + @override + void resetDidReportScreenLoading() {} + + @override + void resetDidStartScreenLoading() {} +} + +@GenerateMocks([ + ApmHostApi, + InstabugHostApi, + InstabugLogger, + IBGDateTime, + InstabugMonotonicClock, + IBGBuildInfo, + RouteMatcher, + BuildContext, + Widget, +]) +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + WidgetsFlutterBinding.ensureInitialized(); + + late ScreenLoadingManager mScreenLoadingManager; + late MockApmHostApi mApmHost; + late MockInstabugHostApi mInstabugHost; + late MockInstabugLogger mInstabugLogger; + late IBGDateTime mDateTime; + late IBGBuildInfo mIBGBuildInfo; + late MockRouteMatcher mRouteMatcher; + late InstabugMonotonicClock mInstabugMonotonicClock; + late MockWidget mockScreen; + late MockBuildContext mockBuildContext; + const screenName = 'screen1'; + + setUp(() { + mScreenLoadingManager = ScreenLoadingManager.init(); + mApmHost = MockApmHostApi(); + mInstabugHost = MockInstabugHostApi(); + mInstabugLogger = MockInstabugLogger(); + mDateTime = MockIBGDateTime(); + mIBGBuildInfo = MockIBGBuildInfo(); + mRouteMatcher = MockRouteMatcher(); + mInstabugMonotonicClock = MockInstabugMonotonicClock(); + when(mInstabugHost.isBuilt()).thenAnswer((_) async => true); + + ScreenLoadingManager.setInstance(mScreenLoadingManager); + APM.$setHostApi(mApmHost); + Instabug.$setHostApi(mInstabugHost); + InstabugLogger.setInstance(mInstabugLogger); + IBGDateTime.setInstance(mDateTime); + IBGBuildInfo.setInstance(mIBGBuildInfo); + RouteMatcher.setInstance(mRouteMatcher); + InstabugMonotonicClock.setInstance(mInstabugMonotonicClock); + }); + + group('reset methods tests', () { + test( + '[resetDidStartScreenLoading] should set _currentUITrace?.didStartScreenLoading to false', + () async { + const expected = false; + final uiTrace = UiTrace('screen1', traceId: 1); + uiTrace.didStartScreenLoading = true; + mScreenLoadingManager.currentUiTrace = uiTrace; + + ScreenLoadingManager.I.resetDidStartScreenLoading(); + + final actual = + ScreenLoadingManager.I.currentUiTrace?.didStartScreenLoading; + + expect(actual, expected); + verify( + mInstabugLogger.d( + argThat(contains('Resetting didStartScreenLoading')), + tag: APM.tag, + ), + ).called(1); + }); + + test( + '[resetDidReportScreenLoading] should set _currentUITrace?.didReportScreenLoading to false', + () async { + const expected = false; + final uiTrace = UiTrace('screen1', traceId: 1); + uiTrace.didReportScreenLoading = true; + mScreenLoadingManager.currentUiTrace = uiTrace; + + ScreenLoadingManager.I.resetDidReportScreenLoading(); + + final actual = + ScreenLoadingManager.I.currentUiTrace?.didReportScreenLoading; + + expect(actual, expected); + verify( + mInstabugLogger.d( + argThat(contains('Resetting didExtendScreenLoading')), + tag: APM.tag, + ), + ).called(1); + }); + + test( + '[resetDidExtendScreenLoading] should set _currentUITrace?.didExtendScreenLoading to false', + () async { + const expected = false; + final uiTrace = UiTrace('screen1', traceId: 1); + mScreenLoadingManager.currentUiTrace = uiTrace; + + ScreenLoadingManager.I.resetDidExtendScreenLoading(); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + + expect( + actualUiTrace?.didStartScreenLoading, + false, + ); + expect( + actualUiTrace?.didReportScreenLoading, + false, + ); + expect( + actualUiTrace?.didExtendScreenLoading, + false, + ); + expect(actualUiTrace?.didExtendScreenLoading, expected); + verify( + mInstabugLogger.d( + argThat(contains('Resetting didReportScreenLoading')), + tag: APM.tag, + ), + ).called(1); + }); + }); + + group('startUiTrace tests', () { + late UiTrace uiTrace; + late DateTime time; + + setUp(() { + time = DateTime.now(); + uiTrace = UiTrace(screenName, traceId: time.millisecondsSinceEpoch); + ScreenLoadingManager.setInstance(ScreenLoadingManagerNoResets.init()); + mScreenLoadingManager.currentUiTrace = uiTrace; + 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); + + await ScreenLoadingManager.I.startUiTrace(screenName); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + expect(actualUiTrace, null); + + verify( + mInstabugLogger.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, + ), + ).called(1); + verifyNever(mApmHost.startCpUiTrace(any, any, any)); + }); + + 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); + + await ScreenLoadingManager.I.startUiTrace(screenName); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + + expect( + actualUiTrace?.didStartScreenLoading, + false, + ); + expect(actualUiTrace?.screenName, screenName); + expect(actualUiTrace?.traceId, time.millisecondsSinceEpoch); + verify( + mApmHost.startCpUiTrace( + screenName, + time.microsecondsSinceEpoch, + time.millisecondsSinceEpoch, + ), + ).called(1); + }); + }); + + group('startScreenLoadingTrace tests', () { + late DateTime time; + late UiTrace uiTrace; + late int traceId; + late ScreenLoadingTrace screenLoadingTrace; + setUp(() { + mScreenLoadingManager = ScreenLoadingManagerNoResets.init(); + time = DateTime.now(); + traceId = time.millisecondsSinceEpoch; + uiTrace = UiTrace(screenName, traceId: traceId); + mScreenLoadingManager.currentUiTrace = uiTrace; + when(mDateTime.now()).thenReturn(time); + + screenLoadingTrace = ScreenLoadingTrace( + screenName, + startTimeInMicroseconds: time.microsecondsSinceEpoch, + startMonotonicTimeInMicroseconds: time.microsecondsSinceEpoch, + ); + ScreenLoadingManager.setInstance(mScreenLoadingManager); + }); + + test('[startScreenLoadingTrace] with SDK not build should Log error', + () async { + when(mInstabugHost.isBuilt()).thenAnswer((_) async => false); + + await ScreenLoadingManager.I.startScreenLoadingTrace(screenLoadingTrace); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + expect( + actualUiTrace?.didStartScreenLoading, + false, + ); + expect( + actualScreenLoadingTrace, + 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); + }); + + test( + '[startScreenLoadingTrace] with screen loading disabled on iOS Platform should log error', + () async { + when(FlagsConfig.screenLoading.isEnabled()) + .thenAnswer((_) async => false); + when(IBGBuildInfo.I.isIOS).thenReturn(true); + + await ScreenLoadingManager.I.startScreenLoadingTrace(screenLoadingTrace); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + expect( + actualUiTrace?.didStartScreenLoading, + false, + ); + expect( + actualScreenLoadingTrace, + null, + ); + verify( + mInstabugLogger.e( + 'Screen loading monitoring is disabled, skipping starting screen loading monitoring for screen: $screenName.\n' + 'Please refer to the documentation for how to enable screen loading monitoring on your app: https://docs.instabug.com/docs/flutter-apm-screen-loading#disablingenabling-screen-loading-tracking ' + "If Screen Loading is enabled but you're still seeing this message, please reach out to support.", + tag: APM.tag, + ), + ).called(1); + }); + + test( + '[startScreenLoadingTrace] with screen loading enabled on Android should do nothing', + () async { + ScreenLoadingManager.setInstance(mScreenLoadingManager); + when(FlagsConfig.screenLoading.isEnabled()) + .thenAnswer((_) async => false); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + + await ScreenLoadingManager.I.startScreenLoadingTrace(screenLoadingTrace); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + expect( + actualUiTrace?.didStartScreenLoading, + false, + ); + expect( + actualScreenLoadingTrace, + null, + ); + verify(mApmHost.isScreenLoadingEnabled()).called(1); + }); + + test( + '[startScreenLoadingTrace] with screen loading enabled with different screen should log error', + () async { + const isSameScreen = false; + when(FlagsConfig.screenLoading.isEnabled()).thenAnswer((_) async => true); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + when( + RouteMatcher.I.match( + routePath: screenName, + actualPath: screenName, + ), + ).thenReturn(isSameScreen); + + await ScreenLoadingManager.I.startScreenLoadingTrace(screenLoadingTrace); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + expect( + actualUiTrace?.didStartScreenLoading, + false, + ); + expect( + actualScreenLoadingTrace, + null, + ); + verify( + mInstabugLogger.d( + argThat(contains('failed to start screen loading trace')), + tag: APM.tag, + ), + ).called(1); + }); + + test( + '[startScreenLoadingTrace] with screen loading enabled should start a new UI Trace', + () async { + const isSameScreen = true; + mScreenLoadingManager = ScreenLoadingManagerNoResets.init(); + when(FlagsConfig.screenLoading.isEnabled()).thenAnswer((_) async => true); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + when(mDateTime.now()).thenReturn(time); + when( + RouteMatcher.I.match( + routePath: screenName, + actualPath: screenName, + ), + ).thenReturn(isSameScreen); + + await ScreenLoadingManager.I.startScreenLoadingTrace(screenLoadingTrace); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + + expect( + actualUiTrace?.screenName, + screenName, + ); + expect( + actualUiTrace?.traceId, + traceId, + ); + expect( + actualUiTrace?.didStartScreenLoading, + true, + ); + verify( + mInstabugLogger.d( + argThat(contains('starting screen loading trace')), + tag: APM.tag, + ), + ).called(1); + }); + }); + + group('reportScreenLoading tests', () { + late DateTime time; + late UiTrace uiTrace; + late int traceId; + late ScreenLoadingTrace screenLoadingTrace; + int? duration; + + setUp(() { + time = DateTime.now(); + traceId = time.millisecondsSinceEpoch; + uiTrace = UiTrace(screenName, traceId: traceId); + mScreenLoadingManager.currentUiTrace = uiTrace; + when(mDateTime.now()).thenReturn(time); + screenLoadingTrace = ScreenLoadingTrace( + screenName, + startTimeInMicroseconds: time.microsecondsSinceEpoch, + startMonotonicTimeInMicroseconds: time.microsecondsSinceEpoch, + ); + mScreenLoadingManager.currentUiTrace?.didStartScreenLoading = true; + mScreenLoadingManager.currentScreenLoadingTrace = screenLoadingTrace; + mScreenLoadingManager.currentUiTrace = uiTrace; + }); + + test('[reportScreenLoading] with SDK not build should Log error', () async { + when(mInstabugHost.isBuilt()).thenAnswer((_) async => false); + + await ScreenLoadingManager.I.reportScreenLoading(screenLoadingTrace); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + expect( + actualUiTrace?.didReportScreenLoading, + false, + ); + expect( + actualScreenLoadingTrace?.startTimeInMicroseconds, + time.microsecondsSinceEpoch, + ); + expect( + actualScreenLoadingTrace?.endTimeInMicroseconds, + 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.reportScreenLoadingCP(any, any, any)); + }); + + test( + '[reportScreenLoading] with screen loading disabled on iOS Platform should log error', + () async { + when(FlagsConfig.screenLoading.isEnabled()) + .thenAnswer((_) async => false); + when(IBGBuildInfo.I.isIOS).thenReturn(true); + + await ScreenLoadingManager.I.reportScreenLoading(screenLoadingTrace); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + expect( + actualUiTrace?.didReportScreenLoading, + false, + ); + expect( + actualScreenLoadingTrace?.startTimeInMicroseconds, + time.microsecondsSinceEpoch, + ); + expect( + actualScreenLoadingTrace?.endTimeInMicroseconds, + null, + ); + verify( + mInstabugLogger.e( + 'Screen loading monitoring is disabled, skipping reporting screen loading time for screen: $screenName.\n' + 'Please refer to the documentation for how to enable screen loading monitoring on your app: https://docs.instabug.com/docs/flutter-apm-screen-loading#disablingenabling-screen-loading-tracking ' + "If Screen Loading is enabled but you're still seeing this message, please reach out to support.", + tag: APM.tag, + ), + ).called(1); + verifyNever(mApmHost.reportScreenLoadingCP(any, any, any)); + }); + + test( + '[reportScreenLoading] with screen loading enabled on Android Platform should do nothing', + () async { + mScreenLoadingManager = ScreenLoadingManagerNoResets.init(); + when(FlagsConfig.screenLoading.isEnabled()) + .thenAnswer((_) async => false); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + + await ScreenLoadingManager.I.reportScreenLoading( + screenLoadingTrace, + ); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + expect( + actualUiTrace?.didReportScreenLoading, + false, + ); + expect( + actualScreenLoadingTrace?.startTimeInMicroseconds, + time.microsecondsSinceEpoch, + ); + expect( + actualScreenLoadingTrace?.endTimeInMicroseconds, + null, + ); + verifyNever(mApmHost.reportScreenLoadingCP(any, any, any)); + }); + + test( + '[reportScreenLoading] with screen loading enabled with different screen should log error', + () async { + const isSameScreen = false; + when(FlagsConfig.screenLoading.isEnabled()).thenAnswer((_) async => true); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + when( + RouteMatcher.I.match( + routePath: screenName, + actualPath: screenName, + ), + ).thenReturn(isSameScreen); + + final differentTrace = ScreenLoadingTrace( + 'different screenName', + startTimeInMicroseconds: 2500, + startMonotonicTimeInMicroseconds: 2500, + ); + + await ScreenLoadingManager.I.reportScreenLoading( + differentTrace, + ); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + expect( + actualUiTrace?.didReportScreenLoading, + false, + ); + expect( + actualScreenLoadingTrace?.startTimeInMicroseconds, + time.microsecondsSinceEpoch, + ); + expect( + actualScreenLoadingTrace?.startTimeInMicroseconds, + time.microsecondsSinceEpoch, + ); + verifyNever(mApmHost.reportScreenLoadingCP(any, any, any)); + verify( + mInstabugLogger.e( + "Screen Loading trace dropped as the trace isn't from the current screen, or another trace was reported before the current one. — $differentTrace", + tag: APM.tag, + ), + ); + }); + + test( + '[reportScreenLoading] with screen loading enabled and a previously reported screen loading trace should log error', + () async { + mScreenLoadingManager.currentUiTrace?.didReportScreenLoading = true; + const isSameScreen = true; + when(FlagsConfig.screenLoading.isEnabled()).thenAnswer((_) async => true); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + when( + RouteMatcher.I.match( + routePath: screenName, + actualPath: screenName, + ), + ).thenReturn(isSameScreen); + + await ScreenLoadingManager.I.reportScreenLoading( + screenLoadingTrace, + ); + + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + expect( + actualScreenLoadingTrace?.startTimeInMicroseconds, + time.microsecondsSinceEpoch, + ); + + verifyNever(mApmHost.reportScreenLoadingCP(any, any, any)); + verify( + mInstabugLogger.e( + "Screen Loading trace dropped as the trace isn't from the current screen, or another trace was reported before the current one. — $screenLoadingTrace", + tag: APM.tag, + ), + ); + }); + + test( + '[reportScreenLoading] with screen loading enabled and an invalid screenLoadingTrace should log error', + () async { + const isSameScreen = true; + when(FlagsConfig.screenLoading.isEnabled()).thenAnswer((_) async => true); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + when( + mRouteMatcher.match( + routePath: anyNamed('routePath'), + actualPath: anyNamed('actualPath'), + ), + ).thenReturn(isSameScreen); + const ScreenLoadingTrace? expectedScreenLoadingTrace = null; + + await ScreenLoadingManager.I.reportScreenLoading( + expectedScreenLoadingTrace, + ); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + verifyNever(mApmHost.reportScreenLoadingCP(any, any, any)); + expect( + actualUiTrace?.didReportScreenLoading, + false, + ); + expect( + actualScreenLoadingTrace?.endTimeInMicroseconds, + null, + ); + verify( + mInstabugLogger.e( + "Screen Loading trace dropped as the trace isn't from the current screen, or another trace was reported before the current one. — $expectedScreenLoadingTrace", + tag: APM.tag, + ), + ); + }); + + test( + '[reportScreenLoading] with screen loading enabled and a valid trace should report it', + () async { + duration = 1000; + final endTime = time.add(Duration(microseconds: duration ?? 0)); + const isSameScreen = true; + when(FlagsConfig.screenLoading.isEnabled()).thenAnswer((_) async => true); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + when( + RouteMatcher.I.match( + routePath: screenName, + actualPath: screenName, + ), + ).thenReturn(isSameScreen); + screenLoadingTrace.endTimeInMicroseconds = endTime.microsecondsSinceEpoch; + screenLoadingTrace.duration = duration; + + await ScreenLoadingManager.I.reportScreenLoading( + screenLoadingTrace, + ); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + final actualScreenLoadingTrace = + ScreenLoadingManager.I.currentScreenLoadingTrace; + + expect( + actualUiTrace?.didReportScreenLoading, + true, + ); + expect( + actualScreenLoadingTrace?.endTimeInMicroseconds, + screenLoadingTrace.endTimeInMicroseconds, + ); + expect( + actualScreenLoadingTrace?.duration, + screenLoadingTrace.duration, + ); + verify( + mApmHost.reportScreenLoadingCP( + time.microsecondsSinceEpoch, + duration, + time.millisecondsSinceEpoch, + ), + ).called(1); + verify( + mInstabugLogger.d( + argThat(contains('Reporting screen loading trace')), + tag: APM.tag, + ), + ); + }); + }); + + group('endScreenLoading tests', () { + late DateTime time; + late UiTrace uiTrace; + late int traceId; + late ScreenLoadingTrace screenLoadingTrace; + late DateTime endTime; + int? duration; + late int extendedMonotonic; + + setUp(() { + time = DateTime.now(); + traceId = time.millisecondsSinceEpoch; + uiTrace = UiTrace(screenName, traceId: traceId); + duration = 1000; + extendedMonotonic = 500; + endTime = time.add(Duration(microseconds: duration ?? 0)); + mScreenLoadingManager.currentUiTrace = uiTrace; + when(mDateTime.now()).thenReturn(time); + when(mInstabugMonotonicClock.now).thenReturn(extendedMonotonic); + screenLoadingTrace = ScreenLoadingTrace( + screenName, + startTimeInMicroseconds: time.microsecondsSinceEpoch, + startMonotonicTimeInMicroseconds: time.microsecondsSinceEpoch, + ); + screenLoadingTrace.endTimeInMicroseconds = endTime.microsecondsSinceEpoch; + screenLoadingTrace.duration = duration; + mScreenLoadingManager.currentUiTrace?.didStartScreenLoading = true; + mScreenLoadingManager.currentUiTrace?.didReportScreenLoading = true; + mScreenLoadingManager.currentUiTrace = uiTrace; + mScreenLoadingManager.currentScreenLoadingTrace = screenLoadingTrace; + }); + + test('[endScreenLoading] with SDK not build should Log error', () async { + when(mInstabugHost.isBuilt()).thenAnswer((_) async => false); + + await ScreenLoadingManager.I.endScreenLoading(); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + + expect( + actualUiTrace?.didExtendScreenLoading, + false, + ); + verify( + mInstabugLogger.e( + 'Instabug API {endScreenLoading} 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.endScreenLoadingCP(any, any)); + }); + + test( + '[endScreenLoading] with screen loading disabled on iOS Platform should log error', + () async { + when(FlagsConfig.screenLoading.isEnabled()) + .thenAnswer((_) async => false); + when(IBGBuildInfo.I.isIOS).thenReturn(true); + + await ScreenLoadingManager.I.endScreenLoading(); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + + expect( + actualUiTrace?.didExtendScreenLoading, + false, + ); + verify( + mInstabugLogger.e( + 'Screen loading monitoring is disabled, skipping ending screen loading monitoring with APM.endScreenLoading().\n' + 'Please refer to the documentation for how to enable screen loading monitoring in your app: https://docs.instabug.com/docs/flutter-apm-screen-loading#disablingenabling-screen-loading-tracking ' + "If Screen Loading is enabled but you're still seeing this message, please reach out to support.", + tag: APM.tag, + ), + ).called(1); + verifyNever(mApmHost.endScreenLoadingCP(any, any)); + }); + + test( + '[endScreenLoading] with screen loading enabled on Android Platform should do nothing', + () async { + when(FlagsConfig.screenLoading.isEnabled()) + .thenAnswer((_) async => false); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + + await ScreenLoadingManager.I.endScreenLoading(); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + + expect( + actualUiTrace?.didExtendScreenLoading, + false, + ); + verifyNever(mApmHost.endScreenLoadingCP(any, any)); + }); + + test('[endScreenLoading] with a previously extended trace should log error', + () async { + uiTrace.didExtendScreenLoading = true; + when(FlagsConfig.screenLoading.isEnabled()).thenAnswer((_) async => true); + when(FlagsConfig.endScreenLoading.isEnabled()) + .thenAnswer((_) async => true); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + + await ScreenLoadingManager.I.endScreenLoading(); + + verify( + mInstabugLogger.e( + 'endScreenLoading has already been called for the current screen visit. Multiple calls to this API are not allowed during a single screen visit, only the first call will be considered.', + tag: APM.tag, + ), + ); + verifyNever(mApmHost.endScreenLoadingCP(any, any)); + }); + + test('[endScreenLoading] with no active screen loading should log error', + () async { + uiTrace.didStartScreenLoading = false; + mScreenLoadingManager.currentScreenLoadingTrace = null; + when(FlagsConfig.screenLoading.isEnabled()).thenAnswer((_) async => true); + when(FlagsConfig.endScreenLoading.isEnabled()) + .thenAnswer((_) async => true); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + + await ScreenLoadingManager.I.endScreenLoading(); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + expect( + actualUiTrace?.didExtendScreenLoading, + false, + ); + verify( + mInstabugLogger.e( + 'endScreenLoading wasn’t called as there is no active screen Loading trace.', + tag: APM.tag, + ), + ); + verifyNever(mApmHost.endScreenLoadingCP(any, any)); + }); + + test( + '[endScreenLoading] with prematurely ended screen loading should log error and End screen loading', + () async { + screenLoadingTrace = ScreenLoadingTrace( + screenName, + startTimeInMicroseconds: time.microsecondsSinceEpoch, + startMonotonicTimeInMicroseconds: time.microsecondsSinceEpoch, + ); + const prematureDuration = 0; + mScreenLoadingManager.currentScreenLoadingTrace = screenLoadingTrace; + when(FlagsConfig.screenLoading.isEnabled()).thenAnswer((_) async => true); + when(FlagsConfig.endScreenLoading.isEnabled()) + .thenAnswer((_) async => true); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + + await ScreenLoadingManager.I.endScreenLoading(); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + + expect( + actualUiTrace?.didExtendScreenLoading, + true, + ); + verify( + mInstabugLogger.e( + 'endScreenLoading was called too early in the Screen Loading cycle. Please make sure to call the API after the screen is done loading.', + tag: APM.tag, + ), + ); + verify(mApmHost.endScreenLoadingCP(prematureDuration, uiTrace.traceId)) + .called(1); + }); + + test('[endScreenLoading] should End screen loading', () async { + when(FlagsConfig.screenLoading.isEnabled()).thenAnswer((_) async => true); + when(FlagsConfig.endScreenLoading.isEnabled()) + .thenAnswer((_) async => true); + when(IBGBuildInfo.I.isIOS).thenReturn(false); + when(mDateTime.now()).thenReturn(time); + const startMonotonicTime = 250; + mScreenLoadingManager.currentScreenLoadingTrace + ?.startMonotonicTimeInMicroseconds = startMonotonicTime; + + await ScreenLoadingManager.I.endScreenLoading(); + + final actualUiTrace = ScreenLoadingManager.I.currentUiTrace; + + final extendedDuration = extendedMonotonic - startMonotonicTime; + final extendedEndTimeInMicroseconds = + time.microsecondsSinceEpoch + extendedDuration; + + expect( + actualUiTrace?.didStartScreenLoading, + true, + ); + expect( + actualUiTrace?.didReportScreenLoading, + true, + ); + expect( + actualUiTrace?.didExtendScreenLoading, + true, + ); + verify(mApmHost.isScreenLoadingEnabled()).called(1); + verify( + mApmHost.endScreenLoadingCP( + extendedEndTimeInMicroseconds, + uiTrace.traceId, + ), + ).called(1); + }); + }); + + group('sanitize screen name tests', () { + test('screen name equals to [/] should be replaced bu [ROOT_PAGE]', () { + const screenName = '/'; + final sanitizedScreenName = + ScreenLoadingManager.I.sanitizeScreenName(screenName); + expect(sanitizedScreenName, "ROOT_PAGE"); + }); + + test('screen name prefixed with [/] should omit [/] char', () { + const screenName = '/Home'; + final sanitizedScreenName = + ScreenLoadingManager.I.sanitizeScreenName(screenName); + expect(sanitizedScreenName, "Home"); + }); + + test('screen name suffixed with [/] should omit [/] char', () { + const screenName = '/Home'; + final sanitizedScreenName = + ScreenLoadingManager.I.sanitizeScreenName(screenName); + expect(sanitizedScreenName, "Home"); + }); + + test('screen name without [/] on edges should return the same ', () { + const screenName = 'Home'; + final sanitizedScreenName = + ScreenLoadingManager.I.sanitizeScreenName(screenName); + expect(sanitizedScreenName, "Home"); + }); + test( + 'screen name prefixed with [//] and suffixed with [/] should omit first and last[/] char', + () { + const screenName = '//Home/'; + final sanitizedScreenName = + ScreenLoadingManager.I.sanitizeScreenName(screenName); + expect(sanitizedScreenName, "/Home"); + }); + }); + + group('wrapRoutes', () { + setUp(() { + mockBuildContext = MockBuildContext(); + mockScreen = MockWidget(); + }); + test('wraps routes with InstabugCaptureScreenLoading widgets', () { + // Create a map of routes + final routes = { + '/home': (context) => mockScreen, + '/settings': (context) => mockScreen, + }; + + // Wrap the routes + final wrappedRoutes = ScreenLoadingManager.wrapRoutes(routes); + + // Verify that the routes are wrapped correctly + expect(wrappedRoutes, isA>()); + expect(wrappedRoutes.length, equals(routes.length)); + for (final route in wrappedRoutes.entries) { + expect( + route.value(mockBuildContext), + isA(), + ); + } + }); + + test('does not wrap excluded routes', () { + // Create a map of routes + final routes = { + '/home': (context) => mockScreen, + '/settings': (context) => mockScreen, + }; + + // Exclude the '/home' route + final wrappedRoutes = + ScreenLoadingManager.wrapRoutes(routes, exclude: ['/home']); + + // Verify that the '/home' route is not wrapped + expect(wrappedRoutes['/home'], equals(routes['/home'])); + + // Verify that the '/settings' route is wrapped + expect( + wrappedRoutes['/settings']?.call(mockBuildContext), + isA(), + ); + }); + + test('handles empty routes map', () { + // Create an empty map of routes + final routes = {}; + + // Wrap the routes + final wrappedRoutes = ScreenLoadingManager.wrapRoutes(routes); + + // Verify that the returned map is empty + expect(wrappedRoutes, isEmpty); + }); + + test('handles null routes map', () { + // Create a null map of routes + Map? routes; + + // Wrap the routes + final wrappedRoutes = ScreenLoadingManager.wrapRoutes(routes ?? {}); + + // Verify that the returned map is empty + expect(wrappedRoutes, isEmpty); + }); + }); +}