diff --git a/android/measure/src/main/java/sh/measure/android/events/AttachmentType.kt b/android/measure/src/main/java/sh/measure/android/events/AttachmentType.kt index d43952ca3..8b1d677b6 100644 --- a/android/measure/src/main/java/sh/measure/android/events/AttachmentType.kt +++ b/android/measure/src/main/java/sh/measure/android/events/AttachmentType.kt @@ -3,6 +3,7 @@ package sh.measure.android.events internal object AttachmentType { const val SCREENSHOT = "screenshot" const val LAYOUT_SNAPSHOT = "layout_snapshot" + const val LAYOUT_SNAPSHOT_JSON = "layout_snapshot_json" - val VALID_TYPES = listOf(SCREENSHOT, LAYOUT_SNAPSHOT) + val VALID_TYPES = listOf(SCREENSHOT, LAYOUT_SNAPSHOT, LAYOUT_SNAPSHOT_JSON) } diff --git a/android/measure/src/main/java/sh/measure/android/events/InternalSignalCollector.kt b/android/measure/src/main/java/sh/measure/android/events/InternalSignalCollector.kt index 18f0aba35..3421e4f1a 100644 --- a/android/measure/src/main/java/sh/measure/android/events/InternalSignalCollector.kt +++ b/android/measure/src/main/java/sh/measure/android/events/InternalSignalCollector.kt @@ -117,6 +117,7 @@ internal class InternalSignalCollector( type = eventType, attributes = attributes, userDefinedAttributes = userDefinedAttrs, + attachments = eventAttachments, userTriggered = userTriggered, ) } @@ -129,6 +130,7 @@ internal class InternalSignalCollector( type = eventType, attributes = attributes, userDefinedAttributes = userDefinedAttrs, + attachments = eventAttachments, userTriggered = userTriggered, ) } diff --git a/android/measure/src/main/java/sh/measure/android/exporter/AttachmentExporter.kt b/android/measure/src/main/java/sh/measure/android/exporter/AttachmentExporter.kt index 2994ab086..ca083fd11 100644 --- a/android/measure/src/main/java/sh/measure/android/exporter/AttachmentExporter.kt +++ b/android/measure/src/main/java/sh/measure/android/exporter/AttachmentExporter.kt @@ -134,6 +134,7 @@ internal class DefaultAttachmentExporter( val response = httpClient.uploadFile( url = attachment.url, contentType = attachment.contentType, + contentEncoding = attachment.contentEncoding, headers = attachment.headers, fileSize = file.length(), ) { sink -> diff --git a/android/measure/src/main/java/sh/measure/android/exporter/AttachmentPacket.kt b/android/measure/src/main/java/sh/measure/android/exporter/AttachmentPacket.kt index 235b7988b..a864e2eb7 100644 --- a/android/measure/src/main/java/sh/measure/android/exporter/AttachmentPacket.kt +++ b/android/measure/src/main/java/sh/measure/android/exporter/AttachmentPacket.kt @@ -17,6 +17,7 @@ internal data class AttachmentPacket( ) { val contentType: String = when (type) { AttachmentType.LAYOUT_SNAPSHOT -> "image/svg+xml" + AttachmentType.LAYOUT_SNAPSHOT_JSON -> "application/json" AttachmentType.SCREENSHOT -> { if (name.endsWith(".webp")) { "image/webp" @@ -27,4 +28,9 @@ internal data class AttachmentPacket( else -> "application/octet-stream" } + + val contentEncoding: String? = when (type) { + AttachmentType.LAYOUT_SNAPSHOT_JSON -> "gzip" + else -> null + } } diff --git a/android/measure/src/main/java/sh/measure/android/exporter/HttpUrlConnectionClient.kt b/android/measure/src/main/java/sh/measure/android/exporter/HttpUrlConnectionClient.kt index ce82db761..c2b00c063 100644 --- a/android/measure/src/main/java/sh/measure/android/exporter/HttpUrlConnectionClient.kt +++ b/android/measure/src/main/java/sh/measure/android/exporter/HttpUrlConnectionClient.kt @@ -22,6 +22,7 @@ internal interface HttpClient { fun uploadFile( url: String, contentType: String, + contentEncoding: String?, headers: Map, fileSize: Long, fileWriter: (BufferedSink) -> Unit, @@ -89,13 +90,15 @@ internal class HttpUrlConnectionClient(private val logger: Logger) : HttpClient override fun uploadFile( url: String, contentType: String, + contentEncoding: String?, headers: Map, fileSize: Long, fileWriter: (BufferedSink) -> Unit, ): HttpResponse { var connection: HttpURLConnection? = null return try { - connection = createFileUploadConnection(url, contentType, headers, fileSize) + connection = + createFileUploadConnection(url, contentType, contentEncoding, headers, fileSize) logger.log(LogLevel.Debug, "Uploading file to: $url") connection.outputStream.sink().buffer().use { sink -> fileWriter(sink) @@ -144,6 +147,7 @@ internal class HttpUrlConnectionClient(private val logger: Logger) : HttpClient private fun createFileUploadConnection( url: String, contentType: String, + contentEncoding: String?, headers: Map, fileSize: Long, ): HttpURLConnection = (URL(url).openConnection() as HttpURLConnection).apply { @@ -156,6 +160,7 @@ internal class HttpUrlConnectionClient(private val logger: Logger) : HttpClient setChunkedStreamingMode(0) } setRequestProperty("Content-Type", contentType) + contentEncoding?.let { setRequestProperty("Content-Encoding", contentEncoding) } headers.forEach { (key, value) -> setRequestProperty(key, value) } diff --git a/android/measure/src/test/java/sh/measure/android/exporter/HttpUrlConnectionClientTest.kt b/android/measure/src/test/java/sh/measure/android/exporter/HttpUrlConnectionClientTest.kt index 62d7193e2..4961f2520 100644 --- a/android/measure/src/test/java/sh/measure/android/exporter/HttpUrlConnectionClientTest.kt +++ b/android/measure/src/test/java/sh/measure/android/exporter/HttpUrlConnectionClientTest.kt @@ -306,6 +306,7 @@ class HttpUrlConnectionClientTest { contentType = "image/png", fileSize = 12L, headers = mapOf(), + contentEncoding = null, ) { sink -> sink.writeUtf8("file-content") } @@ -326,6 +327,7 @@ class HttpUrlConnectionClientTest { contentType = "text/plain", fileSize = 0L, headers = mapOf(), + contentEncoding = null, ) { sink -> sink.writeUtf8("test") } @@ -342,6 +344,7 @@ class HttpUrlConnectionClientTest { contentType = "image/jpeg", fileSize = 10L, headers = mapOf(), + contentEncoding = null, ) { sink -> sink.writeUtf8("test-image") } @@ -359,6 +362,7 @@ class HttpUrlConnectionClientTest { contentType = "application/pdf", fileSize = 4L, headers = mapOf(), + contentEncoding = null, ) { sink -> sink.writeUtf8("test") } diff --git a/docs/features/feature-gesture-tracking.md b/docs/features/feature-gesture-tracking.md index 8a7c80408..6644edbdb 100644 --- a/docs/features/feature-gesture-tracking.md +++ b/docs/features/feature-gesture-tracking.md @@ -2,10 +2,11 @@ * [**Overview**](#overview) * [**Layout Snapshots**](#layout-snapshots) + * [**Flutter**](#flutter) * [**How it works**](#how-it-works) * [**Android**](#android) * [**iOS**](#ios) - * [**Flutter**](#flutter) + * [**Flutter**](#flutter-1) * [**Benchmark Results**](#benchmark-results) * [**Android**](#android-1) * [**iOS**](#ios-1) @@ -21,8 +22,8 @@ and the state of the UI at that moment. ## Layout Snapshots Layout snapshots provide a lightweight way to capture the structure of your UI at key user interactions. -They are automatically collected during click events (with throttling) and store the layout hierarchy as SVG rather than -full screenshots. +They are automatically collected during click events (with throttling) and store the layout hierarchy as a wireframe +rather than full screenshots. This approach gives valuable context about the UI state during user interactions while being significantly more efficient to capture and store than traditional screenshots. @@ -32,7 +33,46 @@ efficient to capture and store than traditional screenshots. Layout snapshots are captured along with every gesture click event with throttling (750ms between consecutive snapshots). This ensures that you get a representative snapshot of the UI without overwhelming the system with too many -images. The snapshots are stored in a lightweight SVG format, which is efficient for both storage and rendering. +images. The snapshots are stored in a compressed lightweight format, which is efficient for both storage and rendering. + +### Flutter + +Layout snapshots are collected by default for Flutter applications. However, only the widgets shown in the table are +used to build the layout snapshot. To make the layout snapshot more useful, you can use a build time script to also +add any other widgets that are used in your application. +See [measure_build](../../flutter/packages/measure_build/README.md) package for more details. + +| Default Widget Types | +|-------------------------| +| `FilledButton` | +| `OutlinedButton` | +| `TextButton` | +| `ElevatedButton` | +| `CupertinoButton` | +| `ButtonStyleButton` | +| `MaterialButton` | +| `IconButton` | +| `FloatingActionButton` | +| `ListTile` | +| `PopupMenuButton` | +| `PopupMenuItem` | +| `DropdownButton` | +| `DropdownMenuItem` | +| `ExpansionTile` | +| `Card` | +| `Scaffold` | +| `CupertinoPageScaffold` | +| `MaterialApp` | +| `CupertinoApp` | +| `Container` | +| `Row` | +| `Column` | +| `ListView` | +| `PageView` | +| `SingleChildScrollView` | +| `ScrollView` | +| `Text` | +| `RichText` | ## How it works @@ -76,7 +116,9 @@ for click, long click and scroll respectively. > [!NOTE] > -> Compose currently reports the target_id in the collected data using [testTag](https://developer.android.com/reference/kotlin/androidx/compose/ui/semantics/package-summary#(androidx.compose.ui.semantics.SemanticsPropertyReceiver).testTag()), +> Compose currently reports the target_id in the collected data +> +using [testTag](https://developer.android.com/reference/kotlin/androidx/compose/ui/semantics/package-summary#(androidx.compose.ui.semantics.SemanticsPropertyReceiver).testTag()), > if it is set. While the `target` is always reported as `AndroidComposeView`. #### Gesture target detection @@ -128,6 +170,7 @@ from [Listener](https://api.flutter.dev/flutter/widgets/Listener-class.html) wid added to the root widget of the app using `MeasureWidget`. It processes touch events to classify them into different gesture types: + - **Click**: A touch event that lasts for less than 500 ms. - **Long Click**: A touch event that lasts for more than 500 ms. - **Scroll**: A touch movement exceeding 20 pixels in any direction. @@ -185,6 +228,19 @@ widgets, while for scrolls, it looks for scrollable widgets. | `PageView` | | `SingleChildScrollView` | +### Layout snapshots + +Layout snapshots capture your app's UI structure by traversing the widget tree from the root widget. The SDK collects +key information about each widget—including its type, position, size, and hierarchy—to build a lightweight +representation of your UI. + +The entire layout snapshot is generated in a single pass through the widget tree using the `visitChildElements` method. +Since a typical Flutter screen can contain thousands of widgets, the snapshot is optimized to include only relevant +widget types to maintain performance and clarity. + +To include custom widgets or additional widget types in your snapshots, use +the [measure_build](../../flutter/packages/measure_build/README.md) package to generate a comprehensive list of all +widget types used in your app. ## Benchmark results @@ -204,6 +260,13 @@ TLDR; - You can find the benchmark tests in [GestureTargetFinderTests](../../ios/Tests/MeasureSDKTests/Gestures/GestureTargetFinderTests.swift). +### Flutter + +- On average, it takes **10ms** to generate a layout snapshot and identify the clicked widget in a widget tree with a + depth of **100** widgets. +- The time to generate the layout snapshot increases linearly with the depth of the widget tree. +- The benchmark tests can be found in [](../../flutter/example/integration_test/layout_snapshot_performance_test.dart). + ## Data collected Check out the data collected by Measure in diff --git a/docs/sdk-integration-guide.md b/docs/sdk-integration-guide.md index 3e1793e41..fab9b9967 100644 --- a/docs/sdk-integration-guide.md +++ b/docs/sdk-integration-guide.md @@ -411,6 +411,10 @@ navigation events. See [Network Monitoring](features/feature-network-monitoring.md) for instructions on how to track HTTP requests. +### Layout snapshots + +Layout snapshots are collected by default along with cli + ## 3. Verify Installation diff --git a/flutter/example/android/app/src/main/AndroidManifest.xml b/flutter/example/android/app/src/main/AndroidManifest.xml index 42a9fe8b5..1d17dabcc 100644 --- a/flutter/example/android/app/src/main/AndroidManifest.xml +++ b/flutter/example/android/app/src/main/AndroidManifest.xml @@ -25,10 +25,10 @@ + android:value="msrsh_8456989a9cd452c7a4864d37a1f3cf9b4f8a45395203c19cb5b6d8252c6970fd_95a8744f" /> + android:value="https://staging-ingest.measure.sh" /> main() async { await Measure.instance.init( - () => runApp(MeasureWidget(child: MyApp())), + () => runApp(MeasureWidget(child: MyApp())), config: const MeasureConfig( enableLogging: true, trackScreenshotOnCrash: true, @@ -16,10 +18,11 @@ Future main() async { autoStart: true, traceSamplingRate: 1, samplingRateForErrorFreeSessions: 1, + widgetFilter: widgetFilter, ), clientInfo: ClientInfo( - apiKey: "msrsh-123", - apiUrl: "http://localhost:8080", + apiKey: "msrsh_8456989a9cd452c7a4864d37a1f3cf9b4f8a45395203c19cb5b6d8252c6970fd_95a8744f", + apiUrl: "https://staging-ingest.measure.sh", ), ); } @@ -51,4 +54,4 @@ class MyApp extends StatelessWidget { home: MainScreen(), ); } -} \ No newline at end of file +} diff --git a/flutter/example/lib/msr_widgets.g.dart b/flutter/example/lib/msr_widgets.g.dart new file mode 100644 index 000000000..135d2721d --- /dev/null +++ b/flutter/example/lib/msr_widgets.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: unused_import, implementation_imports + +import 'package:flutter/src/cupertino/nav_bar.dart'; +import 'package:flutter/src/cupertino/page_scaffold.dart'; +import 'package:flutter/src/material/app.dart'; +import 'package:flutter/src/material/app_bar.dart'; +import 'package:flutter/src/material/icon_button.dart'; +import 'package:flutter/src/material/list_tile.dart'; +import 'package:flutter/src/material/scaffold.dart'; +import 'package:flutter/src/material/switch.dart'; +import 'package:flutter/src/widgets/basic.dart'; +import 'package:flutter/src/widgets/container.dart'; +import 'package:flutter/src/widgets/gesture_detector.dart'; +import 'package:flutter/src/widgets/icon.dart'; +import 'package:flutter/src/widgets/layout_builder.dart'; +import 'package:flutter/src/widgets/safe_area.dart'; +import 'package:flutter/src/widgets/scroll_view.dart'; +import 'package:flutter/src/widgets/text.dart'; +import 'package:measure_flutter_example/main.dart'; +import 'package:measure_flutter_example/src/layout_snapshot_page.dart'; +import 'package:measure_flutter_example/src/list_item.dart'; +import 'package:measure_flutter_example/src/screen_main.dart'; +import 'package:measure_flutter_example/src/screen_navigation.dart'; +import 'package:measure_flutter_example/src/screen_text_overflow.dart'; +import 'package:measure_flutter_example/src/toggle_list_item.dart'; + +const Map widgetFilter = { + AppBar: 'AppBar', + Center: 'Center', + Container: 'Container', + CupertinoNavigationBar: 'CupertinoNavigationBar', + CupertinoPageScaffold: 'CupertinoPageScaffold', + GestureDetector: 'GestureDetector', + GridView: 'GridView', + Icon: 'Icon', + IconButton: 'IconButton', + LayoutBuilder: 'LayoutBuilder', + LayoutSnapshotPage: 'LayoutSnapshotPage', + ListItem: 'ListItem', + ListSection: 'ListSection', + ListTile: 'ListTile', + ListView: 'ListView', + MainScreen: 'MainScreen', + MaterialApp: 'MaterialApp', + MyApp: 'MyApp', + Padding: 'Padding', + Row: 'Row', + SafeArea: 'SafeArea', + Scaffold: 'Scaffold', + ScreenNavigation: 'ScreenNavigation', + ScreenTextOverflow: 'ScreenTextOverflow', + Switch: 'Switch', + Text: 'Text', + ToggleListItem: 'ToggleListItem', +}; diff --git a/flutter/example/lib/src/dio_interceptor.dart b/flutter/example/lib/src/dio_interceptor.dart index 21bc65dc6..a6c375929 100644 --- a/flutter/example/lib/src/dio_interceptor.dart +++ b/flutter/example/lib/src/dio_interceptor.dart @@ -32,4 +32,4 @@ class TraceHeaderInterceptor extends Interceptor { span?.end(); super.onError(err, handler); } -} \ No newline at end of file +} diff --git a/flutter/example/lib/src/layout_snapshot_page.dart b/flutter/example/lib/src/layout_snapshot_page.dart new file mode 100644 index 000000000..b5ea80d46 --- /dev/null +++ b/flutter/example/lib/src/layout_snapshot_page.dart @@ -0,0 +1,91 @@ +import 'dart:developer' as developer show log; + +import 'package:flutter/cupertino.dart'; + +class LayoutSnapshotPage extends StatelessWidget { + const LayoutSnapshotPage({super.key}); + + @override + Widget build(BuildContext context) { + final brightness = CupertinoTheme.brightnessOf(context); + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + backgroundColor: + brightness == Brightness.dark ? CupertinoColors.darkBackgroundGray : CupertinoColors.systemBackground, + middle: Text( + 'Grid Test Widget', + style: TextStyle( + color: brightness == Brightness.dark ? CupertinoColors.white : CupertinoColors.black, + ), + ), + ), + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + // Each square is 48px + 4px padding (2px on each side) = 52px total + const double squareSize = 44.0; + const double padding = 2.0; + const double totalItemSize = squareSize + (padding * 2); + + // Calculate how many items can fit per row + final int crossAxisCount = (constraints.maxWidth / totalItemSize).floor(); + + // Fixed total of 5000 items + const int totalItems = 5000; + + return GridView.builder( + padding: const EdgeInsets.all(padding), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: padding * 2, + crossAxisSpacing: padding * 2, + childAspectRatio: 1.0, + ), + itemCount: totalItems, + itemBuilder: (context, index) { + return GestureDetector( + onTap: () { + developer.log('Square $index clicked'); + }, + child: Container( + width: squareSize, + height: squareSize, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + CupertinoColors.systemBlue.withOpacity(0.8), + CupertinoColors.systemPurple.withOpacity(0.8), + ], + ), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: CupertinoColors.systemGrey.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Center( + child: Text( + '$index', + style: const TextStyle( + color: CupertinoColors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/flutter/example/lib/src/screen_main.dart b/flutter/example/lib/src/screen_main.dart index b3d8e4eb2..02dd88291 100644 --- a/flutter/example/lib/src/screen_main.dart +++ b/flutter/example/lib/src/screen_main.dart @@ -9,6 +9,7 @@ import 'package:measure_flutter_example/src/screen_navigation.dart'; import 'package:measure_flutter_example/src/toggle_list_item.dart'; import 'package:stack_trace/stack_trace.dart'; +import 'layout_snapshot_page.dart'; import 'list_item.dart'; import 'screen_text_overflow.dart'; @@ -70,8 +71,7 @@ class _MainScreenState extends State with MsrShakeDetectorMixin { ListSection(title: "Crashes"), ListItem(title: "Track custom event", onPressed: _trackCustomEvent), ListItem(title: "Throw error", onPressed: _trackError), - ListItem( - title: "Error in microtask", onPressed: _trackMicroTaskError), + ListItem(title: "Error in microtask", onPressed: _trackMicroTaskError), ListItem(title: "Error in isolate", onPressed: _trackIsolateError), ListItem(title: "Throw exception", onPressed: _throwException), ListItem( @@ -141,6 +141,7 @@ class _MainScreenState extends State with MsrShakeDetectorMixin { ListItem(title: "Track bug report", onPressed: _trackBugReport), ListItem(title: "Launch bug report", onPressed: _launchBugReport), ListSection(title: "misc"), + ListItem(title: "Layout snapshot", onPressed: _launchLayoutSnapshot), ListItem(title: "Set user", onPressed: _setUserId), ListItem(title: "Clear user", onPressed: _clearUserId), ], @@ -174,8 +175,7 @@ class _MainScreenState extends State with MsrShakeDetectorMixin { Future _throwAsyncException() async { Chain.capture(() async { await Future.delayed(const Duration(seconds: 2)); - throw FormatException( - "This is an exception using Chain.capture from an async block"); + throw FormatException("This is an exception using Chain.capture from an async block"); }); } @@ -185,8 +185,7 @@ class _MainScreenState extends State with MsrShakeDetectorMixin { void _trackMicroTaskError() { Future.microtask(() { - throw FormatException( - "This is an exception from inside Future.microtask"); + throw FormatException("This is an exception from inside Future.microtask"); }); } @@ -212,8 +211,7 @@ class _MainScreenState extends State with MsrShakeDetectorMixin { } void _noMethodChannel() async { - await MethodChannel('non_existent_channel') - .invokeMethod('non_existent_method'); + await MethodChannel('non_existent_channel').invokeMethod('non_existent_method'); } void _makeDioGetHttpRequest() async { @@ -233,12 +231,7 @@ class _MainScreenState extends State with MsrShakeDetectorMixin { try { await dio.post( 'https://fakestoreapi.com/users', - data: { - "id": 0, - "username": "string", - "email": "string", - "password": "string" - }, + data: {"id": 0, "username": "string", "email": "string", "password": "string"}, options: Options( headers: { 'X-Custom-Header': 'custom_value', @@ -274,10 +267,7 @@ class _MainScreenState extends State with MsrShakeDetectorMixin { void _trackNestedSpan() async { // Main operation: Load user profile - final profileAttributes = AttributeBuilder() - .add("user_id", "user_12345") - .add("cache_enabled", true) - .build(); + final profileAttributes = AttributeBuilder().add("user_id", "user_12345").add("cache_enabled", true).build(); final profileSpan = Measure.instance .startSpan("load-user-profile") @@ -293,19 +283,15 @@ class _MainScreenState extends State with MsrShakeDetectorMixin { profileSpan.setCheckpoint("profile-loaded").setStatus(SpanStatus.ok); } catch (e) { - profileSpan - .setCheckpoint("profile-load-failed") - .setStatus(SpanStatus.error); + profileSpan.setCheckpoint("profile-load-failed").setStatus(SpanStatus.error); } finally { profileSpan.end(); } } Future _checkCache(Span parentSpan) async { - final cacheSpan = Measure.instance - .startSpan("check-profile-cache") - .setParent(parentSpan) - .setCheckpoint("cache-check-started"); + final cacheSpan = + Measure.instance.startSpan("check-profile-cache").setParent(parentSpan).setCheckpoint("cache-check-started"); try { await Future.delayed(const Duration(milliseconds: 100)); @@ -316,10 +302,7 @@ class _MainScreenState extends State with MsrShakeDetectorMixin { } Future _fetchFromAPI(Span parentSpan) async { - final apiAttributes = AttributeBuilder() - .add("endpoint", "/api/v1/user/profile") - .add("timeout_ms", 5000) - .build(); + final apiAttributes = AttributeBuilder().add("endpoint", "/api/v1/user/profile").add("timeout_ms", 5000).build(); final apiSpan = Measure.instance .startSpan("fetch-profile-api") @@ -387,6 +370,16 @@ class _MainScreenState extends State with MsrShakeDetectorMixin { disableShakeDetection(); } } + + void _launchLayoutSnapshot() { + final navigatorState = Navigator.of(context); + navigatorState.push( + MaterialPageRoute( + builder: (context) => const LayoutSnapshotPage(), + settings: RouteSettings(name: '/msr_bug_report'), + ), + ); + } } class ListSection extends StatelessWidget { diff --git a/flutter/example/lib/src/toggle_list_item.dart b/flutter/example/lib/src/toggle_list_item.dart index 7175d6710..7d73cb92c 100644 --- a/flutter/example/lib/src/toggle_list_item.dart +++ b/flutter/example/lib/src/toggle_list_item.dart @@ -43,4 +43,4 @@ class _ToggleListItemState extends State { onTap: () => _handleToggle(!_isToggled), ); } -} \ No newline at end of file +} diff --git a/flutter/example/pubspec.lock b/flutter/example/pubspec.lock index ed666f559..9773608bc 100644 --- a/flutter/example/pubspec.lock +++ b/flutter/example/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0 + url: "https://pub.dev" + source: hosted + version: "8.4.0" archive: dependency: transitive description: @@ -9,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -25,6 +49,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9 + url: "https://pub.dev" + source: hosted + version: "4.0.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: a9461b8e586bf018dd4afd2e13b49b08c6a844a4b226c8d1d10f3a723cdd78c3 + url: "https://pub.dev" + source: hosted + version: "2.10.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + url: "https://pub.dev" + source: hosted + version: "8.12.0" characters: dependency: transitive description: @@ -33,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -41,6 +121,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" collection: dependency: transitive description: @@ -49,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" cross_file: dependency: transitive description: @@ -73,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + url: "https://pub.dev" + source: hosted + version: "3.1.2" dio: dependency: "direct main" description: @@ -159,7 +263,7 @@ packages: source: sdk version: "0.0.0" flutter_driver: - dependency: transitive + dependency: "direct dev" description: flutter source: sdk version: "0.0.0" @@ -194,6 +298,22 @@ packages: description: flutter source: sdk version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" http: dependency: transitive description: @@ -202,6 +322,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -287,6 +415,14 @@ packages: description: flutter source: sdk version: "0.0.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" json_annotation: dependency: transitive description: @@ -335,6 +471,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -351,6 +495,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.11.1" + measure_build: + dependency: "direct dev" + description: + path: "../packages/measure_build" + relative: true + source: path + version: "0.0.0" measure_dio: dependency: "direct main" description: @@ -381,6 +532,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" path: dependency: transitive description: @@ -413,6 +572,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" posix: dependency: transitive description: @@ -429,6 +596,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.3" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter @@ -466,6 +665,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -530,6 +737,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + url: "https://pub.dev" + source: hosted + version: "1.1.4" web: dependency: transitive description: @@ -538,6 +753,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" webdriver: dependency: transitive description: @@ -554,6 +785,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: - dart: ">=3.8.0 <4.0.0" + dart: ">=3.9.0 <4.0.0" flutter: ">=3.27.0" diff --git a/flutter/example/pubspec.yaml b/flutter/example/pubspec.yaml index 98328bdeb..a91793ed6 100644 --- a/flutter/example/pubspec.yaml +++ b/flutter/example/pubspec.yaml @@ -23,7 +23,12 @@ dev_dependencies: sdk: flutter flutter_test: sdk: flutter + flutter_driver: + sdk: flutter flutter_lints: ^6.0.0 + build_runner: ^2.4.0 + measure_build: + path: ../packages/measure_build/ flutter: uses-material-design: true diff --git a/flutter/example/test_driver/perf_driver.dart b/flutter/example/test_driver/perf_driver.dart new file mode 100644 index 000000000..74a0df570 --- /dev/null +++ b/flutter/example/test_driver/perf_driver.dart @@ -0,0 +1,25 @@ +// ignore_for_file: avoid_print + +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_driver/flutter_driver.dart' as driver; +import 'package:integration_test/integration_test_driver.dart'; + +Future main() { + return integrationDriver( + responseDataCallback: (data) async { + if (data != null && data['layout_snapshot'] != null) { + final timeline = driver.Timeline.fromJson( + data['layout_snapshot'] as Map, + ); + + final file = File('build/layout_snapshot.timeline.json'); + await file.writeAsString(jsonEncode(timeline.json)); + final absolutePath = file.absolute.path; + print('\n=== Performance Test Results ==='); + print('Open: chrome://tracing'); + print('Then load: $absolutePath\n'); + } + }, + ); +} diff --git a/flutter/melos.yaml b/flutter/melos.yaml index 9949e7aa4..d7719e6e7 100644 --- a/flutter/melos.yaml +++ b/flutter/melos.yaml @@ -51,6 +51,8 @@ scripts: test: run: | - melos exec --dir-exists=test -- \ - flutter test - description: Run `flutter test` in packages that have a test directory. + melos exec --dir-exists=test --ignore="measure_build" -- \ + flutter test && \ + melos exec --dir-exists=test --scope="measure_build" -- \ + dart test + description: Run tests in all packages (dart test for pure Dart packages, flutter test for Flutter packages). diff --git a/flutter/melos_measure-flutter.iml b/flutter/melos_measure-flutter.iml index 01e7ecf63..ff2e871a9 100644 --- a/flutter/melos_measure-flutter.iml +++ b/flutter/melos_measure-flutter.iml @@ -7,6 +7,9 @@ + + + diff --git a/flutter/packages/measure_build/README.md b/flutter/packages/measure_build/README.md new file mode 100644 index 000000000..647e36e39 --- /dev/null +++ b/flutter/packages/measure_build/README.md @@ -0,0 +1,67 @@ +# measure_build + +`measure_build` scans your Flutter project and generates a map of all widget types that extend from +Flutter's base `Widget` class. This generated map is used by the Measure SDK to capture layout +snapshots with proper widget type information. + +* [Usage](#usage) +* [Configuration](#configuration) + +## Usage + +### 1. Install + +Add `measure_build` as a dev dependency: + +```yaml +dev_dependencies: + measure_build: ^0.1.0 + build_runner: ^2.4.0 +``` + +### 2. Run the code generator + +```bash +dart run build_runner build +``` + +This generates `lib/msr_widgets.g.dart` containing a map named `widgetFilter`. + +### 3. Use in Measure SDK initialization + +Import the generated file and pass the map to your Measure configuration: + +```dart +import 'package:your_app/msr_widgets.g.dart'; +import 'package:measure_flutter/measure_flutter.dart'; + +final config = MeasureConfig( + widgetFilter: widgetFilter, +); +``` + +## Configuration + +You can customize the builder's behavior in `build.yaml`: + +```yaml +targets: + $default: + builders: + measure_build|widget_analyzer: + # Custom output path (default: 'lib/msr_widgets.g.dart') + output_path: lib/src/msr/msr_widgets.g.dart + + # Directories to scan for widgets (default: ['lib']) + scan_directories: + - lib + - custom_widgets + + # Variable name for the generated map (default: 'widgetFilter') + variable_name: msrWidgetFilter +``` + +* `output_path` — the path where the generated file will be saved. Defaults to ' + lib/msr_widgets.g.dart'. +* `scan_directories` — a list of directories to scan for widgets. Defaults to ['lib']. +* `variable_name` — the variable name of the generated map. Defaults to 'widgetFilter'. diff --git a/flutter/packages/measure_build/build.yaml b/flutter/packages/measure_build/build.yaml new file mode 100644 index 000000000..62736b024 --- /dev/null +++ b/flutter/packages/measure_build/build.yaml @@ -0,0 +1,7 @@ +builders: + widget_analyzer: + import: "package:measure_build/builder.dart" + builder_factories: ["widgetAnalyzerBuilder"] + auto_apply: root_package + build_to: source + build_extensions: { "$lib$": ["msr_widgets.g.dart"] } \ No newline at end of file diff --git a/flutter/packages/measure_build/lib/builder.dart b/flutter/packages/measure_build/lib/builder.dart new file mode 100644 index 000000000..2afd874e6 --- /dev/null +++ b/flutter/packages/measure_build/lib/builder.dart @@ -0,0 +1,24 @@ +import 'package:build/build.dart'; +import 'package:measure_build/src/builders/widget_analyzer_builder.dart'; + +Builder widgetAnalyzerBuilder(BuilderOptions options) { + final outputPath = options.config['output_path'] as String? ?? kDefaultOutputPath; + final scanDirs = options.config['scan_directories']; + final List scanDirectories; + if (scanDirs is List) { + scanDirectories = scanDirs.cast(); + } else { + scanDirectories = kDefaultScanDirectories; + } + final variableName = options.config['variable_name'] as String? ?? kDefaultVariableName; + final relativePath = outputPath.startsWith(kLibPrefix) ? outputPath.substring(kLibPrefixLength) : outputPath; + + return WidgetAnalyzerBuilder( + buildExtensions: { + r'$lib$': [relativePath], + }, + outputPath: outputPath, + scanDirectories: scanDirectories, + variableName: variableName, + ); +} diff --git a/flutter/packages/measure_build/lib/src/builders/widget_analyzer_builder.dart b/flutter/packages/measure_build/lib/src/builders/widget_analyzer_builder.dart new file mode 100644 index 000000000..8578fdfdc --- /dev/null +++ b/flutter/packages/measure_build/lib/src/builders/widget_analyzer_builder.dart @@ -0,0 +1,347 @@ +import 'dart:async'; + +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; +import 'package:glob/glob.dart'; + +const String kDefaultOutputPath = 'lib/msr_widgets.g.dart'; +const List kDefaultScanDirectories = ['lib']; +const String kLibPrefix = 'lib/'; +const int kLibPrefixLength = 4; // Length of 'lib/' + +const String _kSyntheticInputSuffix = r'lib/$lib$'; +const String _kGeneratedFilePattern = '.g.dart'; +const String _kDartFileGlob = '/**.dart'; + +const Set _kExcludedWidgets = { + 'Widget', + 'StatelessWidget', + 'StatefulWidget', +}; + +const Set _kFlutterBaseWidgetTypes = { + 'Widget', + 'StatelessWidget', + 'StatefulWidget', + 'InheritedWidget', + 'RenderObjectWidget', + 'ProxyWidget', +}; + +const String kDefaultVariableName = 'widgetFilter'; +const String _kGeneratedFileHeader = '// GENERATED CODE - DO NOT MODIFY BY HAND'; +const String _kGeneratedFileIgnoreDirective = '// ignore_for_file: unused_import, implementation_imports'; + +const String _kPackageUriPrefix = 'package:'; +const String _kDartUriPrefix = 'dart:'; +const String _kFileUriPrefix = 'file:'; +const String _kLibPathSeparator = '/lib/'; +const String _kFlutterLibraryIdentifier = 'flutter'; + +/// Finds all Flutter widgets used in a project and writes them +/// to a dart file as a map of widget name to class name. +/// +/// This builder scans all Dart files in configured directories (defaults to `lib`), +/// analyzes classes, methods, and their instantiations to discover +/// all widgets that extend from Flutter's base `Widget` class. +/// +/// By default, the generated dart file is written to +/// `lib/msr_widgets.g.dart` and contains a const map named +/// `widgetFilter` that maps widget types to their names. +/// +/// ## Configuration +/// +/// Configure the builder in your `pubspec.yaml`: +/// +/// ```yaml +/// measure_build: +/// widget_analyzer: +/// # Optional: Custom output path (defaults to 'lib/msr_widgets.g.dart') +/// output_path: lib/src/msr/msr_widgets.g.dart +/// # Optional: Directories to scan (defaults to ['lib']) +/// scan_directories: +/// - lib +/// - path/foo/bar/ +/// # Optional: Variable name for the generated map (defaults to 'widgetFilter') +/// variable_name: customWidgetFilter +/// ``` +/// +/// ## Usage +/// +/// The result can be plugged into Measure SDK initialization in the following way: +/// +/// ```dart +/// final config = MeasureConfig( +/// widgetFilter: widgetFilter, +/// ) +/// Measure.init(context, config) +/// ``` +class WidgetAnalyzerBuilder extends Builder { + @override + final Map> buildExtensions; + + final String outputPath; + final List scanDirectories; + final String variableName; + + WidgetAnalyzerBuilder({ + required this.buildExtensions, + this.outputPath = kDefaultOutputPath, + this.scanDirectories = kDefaultScanDirectories, + this.variableName = kDefaultVariableName, + }); + + @override + FutureOr build(BuildStep buildStep) async { + // Only run on lib/$lib$ synthetic input + if (!buildStep.inputId.path.endsWith(_kSyntheticInputSuffix)) { + return; + } + + final allWidgets = {}; + + // Scan all configured directories + for (final directory in scanDirectories) { + final dartFiles = Glob('$directory$_kDartFileGlob'); + await for (final input in buildStep.findAssets(dartFiles)) { + if (input.path.contains(_kGeneratedFilePattern) || input.path == outputPath) { + continue; + } + try { + final resolver = buildStep.resolver; + if (!await resolver.isLibrary(input)) { + continue; + } + final library = await resolver.libraryFor(input); + for (final classElement in library.classes) { + if (_isWidgetClass(classElement)) { + final name = classElement.name; + if (name != null && !_isPrivateWidget(name)) { + allWidgets[name] = classElement; + } + } + } + await _scanLibraryForWidgets(library, allWidgets); + } catch (e) { + log.warning('Error processing ${input.path}: $e'); + } + } + } + allWidgets.removeWhere((name, element) => _kExcludedWidgets.contains(name)); + final outputId = AssetId(buildStep.inputId.package, outputPath); + final dartCode = _generateDartFile(allWidgets, buildStep.inputId.package, variableName); + await buildStep.writeAsString(outputId, dartCode); + log.info('Generated $outputPath with ${allWidgets.length} widgets'); + } + + bool _isWidgetClass(ClassElement element) { + return _checkExtendsWidgetRecursively(element, {}); + } + + Future _scanLibraryForWidgets(LibraryElement library, Map widgets) async { + for (final element in library.classes) { + final supertype = element.supertype; + if (supertype != null) { + _checkTypeForWidgets(supertype, widgets); + } + + for (final mixin in element.mixins) { + _checkTypeForWidgets(mixin, widgets); + } + + for (final field in element.fields) { + _checkTypeForWidgets(field.type, widgets); + } + for (final method in element.methods) { + _checkTypeForWidgets(method.returnType, widgets); + for (final param in method.formalParameters) { + _checkTypeForWidgets(param.type, widgets); + } + + final session = element.library.session; + try { + final result = await session.getResolvedLibraryByElement(element.library); + if (result is ResolvedLibraryResult) { + for (final unit in result.units) { + for (final declaration in unit.unit.declarations) { + if (declaration is ClassDeclaration) { + for (final member in declaration.members) { + if (member is MethodDeclaration && member.name.lexeme == method.name) { + final visitor = _MethodBodyVisitor(); + member.accept(visitor); + widgets.addAll(visitor.widgets); + } + } + } + } + } + } + } catch (e) { + // Ignore errors getting AST + } + } + } + } + + void _checkTypeForWidgets(DartType type, Map widgets) { + final element = type.element; + if (element is InterfaceElement) { + if (_checkExtendsWidgetRecursively(element, {})) { + final name = element.name; + if (name != null && !_isPrivateWidget(name)) { + widgets[name] = element; + } + } + } + + // Also check generic type arguments + if (type is ParameterizedType) { + for (final typeArg in type.typeArguments) { + _checkTypeForWidgets(typeArg, widgets); + } + } + } + + String _generateDartFile(Map widgets, String packageName, String variableName) { + final buffer = StringBuffer(); + final imports = {}; + + for (final element in widgets.values) { + final libraryUri = element.library.firstFragment.source.uri.toString(); + + if (libraryUri.startsWith(_kPackageUriPrefix) || libraryUri.startsWith(_kDartUriPrefix)) { + imports.add("import '$libraryUri';"); + } else if (libraryUri.startsWith(_kFileUriPrefix) && libraryUri.contains(_kLibPathSeparator)) { + final libPath = libraryUri.split(_kLibPathSeparator).last; + imports.add("import '$_kPackageUriPrefix$packageName/$libPath';"); + } + } + + // Write header comment + buffer.writeln(_kGeneratedFileHeader); + buffer.writeln(_kGeneratedFileIgnoreDirective); + buffer.writeln(); + + // Write imports + final sortedImports = imports.toList()..sort(); + for (final import in sortedImports) { + buffer.writeln(import); + } + buffer.writeln(); + + // Write the map + buffer.writeln('const Map $variableName = {'); + + final sortedWidgets = widgets.keys.toList()..sort(); + for (final widgetName in sortedWidgets) { + buffer.writeln(' $widgetName: \'$widgetName\','); + } + + buffer.writeln('};'); + + return buffer.toString(); + } + + bool _checkExtendsWidgetRecursively(InterfaceElement element, Set visited) { + if (!visited.add(element)) { + return false; + } + + final libraryElement = element.library; + final library = libraryElement.firstFragment.source.uri.toString(); + + if (element.name == 'Widget' && library.contains(_kFlutterLibraryIdentifier)) { + return true; + } + + if (element is ClassElement) { + final supertype = element.supertype; + if (supertype != null) { + if (_checkExtendsWidgetRecursively(supertype.element, visited)) { + return true; + } + } + + for (final mixin in element.mixins) { + if (_checkExtendsWidgetRecursively(mixin.element, visited)) { + return true; + } + } + } + + return false; + } + + bool _isPrivateWidget(String name) { + return name.startsWith('_'); + } +} + +/// AST visitor for method bodies to find widget instantiations +class _MethodBodyVisitor extends RecursiveAstVisitor { + final Map widgets = {}; + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final type = node.staticType; + if (type != null && _isWidget(type)) { + final element = type.element; + if (element is InterfaceElement) { + final name = element.name; + if (name != null && !name.startsWith('_')) { + widgets[name] = element; + } + } + } + super.visitInstanceCreationExpression(node); + } + + bool _isWidget(DartType type) { + final element = type.element; + if (element is! InterfaceElement) { + return false; + } + + return _extendsWidget(element, {}); + } + + bool _extendsWidget(InterfaceElement element, Set visited) { + if (!visited.add(element)) { + return false; + } + + final libraryElement = element.library; + final library = libraryElement.firstFragment.source.uri.toString(); + + if (element.name == 'Widget' && library.contains(_kFlutterLibraryIdentifier)) { + return true; + } + + if (_kFlutterBaseWidgetTypes.contains(element.name) && library.contains(_kFlutterLibraryIdentifier)) { + return true; + } + + if (element is ClassElement) { + final supertype = element.supertype; + if (supertype != null) { + final supertypeElement = supertype.element; + if (_extendsWidget(supertypeElement, visited)) { + return true; + } + } + + for (final mixin in element.mixins) { + final mixinElement = mixin.element; + if (_extendsWidget(mixinElement, visited)) { + return true; + } + } + } + + return false; + } +} diff --git a/flutter/packages/measure_build/pubspec.lock b/flutter/packages/measure_build/pubspec.lock new file mode 100644 index 000000000..eec743dea --- /dev/null +++ b/flutter/packages/measure_build/pubspec.lock @@ -0,0 +1,525 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + url: "https://pub.dev" + source: hosted + version: "91.0.0" + analyzer: + dependency: "direct main" + description: + name: analyzer + sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + url: "https://pub.dev" + source: hosted + version: "8.4.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: "direct main" + description: + name: build + sha256: dfb67ccc9a78c642193e0c2d94cb9e48c2c818b3178a86097d644acdcde6a8d9 + url: "https://pub.dev" + source: hosted + version: "4.0.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "409002f1adeea601018715d613115cfaf0e31f512cb80ae4534c79867ae2363d" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + build_runner: + dependency: transitive + description: + name: build_runner + sha256: a9461b8e586bf018dd4afd2e13b49b08c6a844a4b226c8d1d10f3a723cdd78c3 + url: "https://pub.dev" + source: hosted + version: "2.10.1" + build_test: + dependency: "direct dev" + description: + name: build_test + sha256: "86124991e1df069c0993fd1c29dc4e112b73884543f6d9d1d3c2ddb1d6f61dc0" + url: "https://pub.dev" + source: hosted + version: "3.5.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d + url: "https://pub.dev" + source: hosted + version: "8.12.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" + url: "https://pub.dev" + source: hosted + version: "4.11.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: c87dfe3d56f183ffe9106a18aebc6db431fc7c98c31a54b952a77f3d54a85697 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: "direct main" + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" + url: "https://pub.dev" + source: hosted + version: "1.26.3" + test_api: + dependency: transitive + description: + name: test_api + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + url: "https://pub.dev" + source: hosted + version: "0.7.7" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" + url: "https://pub.dev" + source: hosted + version: "0.6.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "592ab6e2892f67760543fb712ff0177f4ec76c031f02f5b4ff8d3fc5eb9fb61a" + url: "https://pub.dev" + source: hosted + version: "1.1.4" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.9.0 <4.0.0" diff --git a/flutter/packages/measure_build/pubspec.yaml b/flutter/packages/measure_build/pubspec.yaml new file mode 100644 index 000000000..1dff48b1f --- /dev/null +++ b/flutter/packages/measure_build/pubspec.yaml @@ -0,0 +1,11 @@ +name: measure_build +environment: + sdk: ^3.6.0 +dependencies: + build: ^4.0.0 + analyzer: ^8.4.0 + glob: ^2.1.0 + +dev_dependencies: + build_test: ^3.5.1 + test: ^1.25.0 \ No newline at end of file diff --git a/flutter/packages/measure_build/test/src/builders/widget_analyzer_builder_test.dart b/flutter/packages/measure_build/test/src/builders/widget_analyzer_builder_test.dart new file mode 100644 index 000000000..b9e9bb414 --- /dev/null +++ b/flutter/packages/measure_build/test/src/builders/widget_analyzer_builder_test.dart @@ -0,0 +1,319 @@ +import 'package:build_test/build_test.dart'; +import 'package:measure_build/src/builders/widget_analyzer_builder.dart'; +import 'package:test/test.dart'; + +// Mock Flutter SDK classes for testing +const mockFlutterWidgets = ''' +library flutter.widgets; + +abstract class Widget { + const Widget(); +} + +abstract class StatelessWidget extends Widget { + const StatelessWidget(); + Widget build(Object context); +} + +abstract class StatefulWidget extends Widget { + const StatefulWidget(); +} + +abstract class State { + Widget build(Object context); +} + +class Container extends StatelessWidget { + const Container(); + @override + Widget build(Object context) => this; +} +'''; + +void main() { + group('WidgetAnalyzerBuilder', () { + test('detects StatelessWidget subclass', () async { + final builder = WidgetAnalyzerBuilder( + buildExtensions: {r'$lib$': ['msr_widgets.g.dart']}, + ); + + await testBuilder( + builder, + { + 'flutter|lib/widgets.dart': mockFlutterWidgets, + 'test_pkg|lib/my_widget.dart': ''' + import 'package:flutter/widgets.dart'; + + class MyStatelessWidget extends StatelessWidget { + const MyStatelessWidget(); + + @override + Widget build(Object context) => Container(); + } + ''', + }, + outputs: { + 'flutter|lib/msr_widgets.g.dart': anything, // Flutter mock package output + 'test_pkg|lib/msr_widgets.g.dart': decodedMatches(allOf([ + contains('// GENERATED CODE - DO NOT MODIFY BY HAND'), + contains('MyStatelessWidget'), + contains("MyStatelessWidget: 'MyStatelessWidget'"), + ])), + }, + + ); + }); + + test('detects StatefulWidget subclass', () async { + final builder = WidgetAnalyzerBuilder( + buildExtensions: {r'$lib$': ['msr_widgets.g.dart']}, + ); + + await testBuilder( + builder, + { + 'flutter|lib/widgets.dart': mockFlutterWidgets, + 'test_pkg2|lib/my_widget.dart': ''' + import 'package:flutter/widgets.dart'; + + class MyStatefulWidget extends StatefulWidget { + const MyStatefulWidget(); + } + + class _MyStatefulWidgetState extends State { + @override + Widget build(Object context) => Container(); + } + ''', + }, + outputs: { + 'flutter|lib/msr_widgets.g.dart': anything, // Flutter mock package output + 'test_pkg2|lib/msr_widgets.g.dart': decodedMatches(allOf([ + contains('MyStatefulWidget'), + isNot(contains('_MyStatefulWidgetState')), // Private widgets excluded + ])), + }, + + ); + }); + + test('excludes base Flutter widget types', () async { + final builder = WidgetAnalyzerBuilder( + buildExtensions: {r'$lib$': ['msr_widgets.g.dart']}, + ); + + await testBuilder( + builder, + { + 'flutter|lib/widgets.dart': mockFlutterWidgets, + 'test_pkg3|lib/my_widget.dart': ''' + import 'package:flutter/widgets.dart'; + + class MyWidget extends StatelessWidget { + const MyWidget(); + + @override + Widget build(Object context) => Container(); + } + ''', + }, + outputs: { + 'flutter|lib/msr_widgets.g.dart': anything, // Flutter mock package output + 'test_pkg3|lib/msr_widgets.g.dart': decodedMatches(allOf([ + contains('MyWidget'), + isNot(contains("Widget: 'Widget'")), + isNot(contains("StatelessWidget: 'StatelessWidget'")), + isNot(contains("StatefulWidget: 'StatefulWidget'")), + ])), + }, + + ); + }); + + test('handles multiple widgets in single file', () async { + final builder = WidgetAnalyzerBuilder( + buildExtensions: {r'$lib$': ['msr_widgets.g.dart']}, + ); + + await testBuilder( + builder, + { + 'flutter|lib/widgets.dart': mockFlutterWidgets, + 'test_pkg4|lib/widgets.dart': ''' + import 'package:flutter/widgets.dart'; + + class WidgetA extends StatelessWidget { + const WidgetA(); + @override + Widget build(Object context) => Container(); + } + + class WidgetB extends StatelessWidget { + const WidgetB(); + @override + Widget build(Object context) => Container(); + } + + class WidgetC extends StatefulWidget { + const WidgetC(); + } + ''', + }, + outputs: { + 'flutter|lib/msr_widgets.g.dart': anything, // Flutter mock package output + 'test_pkg4|lib/msr_widgets.g.dart': decodedMatches(allOf([ + contains('WidgetA'), + contains('WidgetB'), + contains('WidgetC'), + ])), + }, + + ); + }); + + test('sorts widgets alphabetically', () async { + final builder = WidgetAnalyzerBuilder( + buildExtensions: {r'$lib$': ['msr_widgets.g.dart']}, + ); + + await testBuilder( + builder, + { + 'flutter|lib/widgets.dart': mockFlutterWidgets, + 'test_pkg5|lib/widgets.dart': ''' + import 'package:flutter/widgets.dart'; + + class ZWidget extends StatelessWidget { + const ZWidget(); + @override + Widget build(Object context) => Container(); + } + + class AWidget extends StatelessWidget { + const AWidget(); + @override + Widget build(Object context) => Container(); + } + + class MWidget extends StatelessWidget { + const MWidget(); + @override + Widget build(Object context) => Container(); + } + ''', + }, + outputs: { + 'flutter|lib/msr_widgets.g.dart': anything, // Flutter mock package output + 'test_pkg5|lib/msr_widgets.g.dart': decodedMatches( + matches(RegExp(r'AWidget.*MWidget.*ZWidget', dotAll: true)), + ), + }, + + ); + }); + + test('skips .g.dart files', () async { + final builder = WidgetAnalyzerBuilder( + buildExtensions: {r'$lib$': ['msr_widgets.g.dart']}, + ); + + await testBuilder( + builder, + { + 'flutter|lib/widgets.dart': mockFlutterWidgets, + 'test_pkg6|lib/my_widget.dart': ''' + import 'package:flutter/widgets.dart'; + + class RealWidget extends StatelessWidget { + const RealWidget(); + @override + Widget build(Object context) => Container(); + } + ''', + 'test_pkg6|lib/generated.g.dart': ''' + import 'package:flutter/widgets.dart'; + + class GeneratedWidget extends StatelessWidget { + const GeneratedWidget(); + @override + Widget build(Object context) => Container(); + } + ''', + }, + outputs: { + 'flutter|lib/msr_widgets.g.dart': anything, // Flutter mock package output + 'test_pkg6|lib/msr_widgets.g.dart': decodedMatches(allOf([ + contains('RealWidget'), + isNot(contains('GeneratedWidget')), + ])), + }, + + ); + }); + + test('generates correct file structure', () async { + final builder = WidgetAnalyzerBuilder( + buildExtensions: {r'$lib$': ['msr_widgets.g.dart']}, + ); + + await testBuilder( + builder, + { + 'flutter|lib/widgets.dart': mockFlutterWidgets, + 'test_pkg7|lib/my_widget.dart': ''' + import 'package:flutter/widgets.dart'; + + class TestWidget extends StatelessWidget { + const TestWidget(); + @override + Widget build(Object context) => Container(); + } + ''', + }, + outputs: { + 'flutter|lib/msr_widgets.g.dart': anything, // Flutter mock package output + 'test_pkg7|lib/msr_widgets.g.dart': decodedMatches(allOf([ + contains('// GENERATED CODE - DO NOT MODIFY BY HAND'), + contains('// ignore_for_file: unused_import, implementation_imports'), + contains('const Map widgetFilter = {'), + contains("TestWidget: 'TestWidget',"), + contains('};'), + ])), + }, + + ); + }); + + test('supports custom variable name', () async { + final builder = WidgetAnalyzerBuilder( + buildExtensions: {r'$lib$': ['msr_widgets.g.dart']}, + variableName: 'customWidgetFilter', + ); + + await testBuilder( + builder, + { + 'flutter|lib/widgets.dart': mockFlutterWidgets, + 'test_pkg8|lib/my_widget.dart': ''' + import 'package:flutter/widgets.dart'; + + class MyCustomWidget extends StatelessWidget { + const MyCustomWidget(); + @override + Widget build(Object context) => Container(); + } + ''', + }, + outputs: { + 'flutter|lib/msr_widgets.g.dart': anything, // Flutter mock package output + 'test_pkg8|lib/msr_widgets.g.dart': decodedMatches(allOf([ + contains('const Map customWidgetFilter = {'), + contains("MyCustomWidget: 'MyCustomWidget',"), + isNot(contains('widgetFilter = {')), + ])), + }, + + ); + }); + }); +} diff --git a/flutter/packages/measure_dio/test/fake_measure.dart b/flutter/packages/measure_dio/test/fake_measure.dart index f84b38dcd..42f633b3d 100644 --- a/flutter/packages/measure_dio/test/fake_measure.dart +++ b/flutter/packages/measure_dio/test/fake_measure.dart @@ -32,10 +32,10 @@ class FakeMeasure implements MeasureApi { @override Future trackHandledError( - Object error, - StackTrace stack, { - Map attributes = const {}, - }) async { + Object error, + StackTrace stack, { + Map attributes = const {}, + }) async { throw UnimplementedError(); } @@ -160,11 +160,11 @@ class FakeMeasure implements MeasureApi { } @override - void trackBugReport({ + Future trackBugReport({ required String description, required List attachments, required Map attributes, - }) { + }) async { throw UnimplementedError(); } @@ -184,17 +184,28 @@ class FakeMeasure implements MeasureApi { } @override - void trackClick(ClickData clickData) { + Future trackClick(ClickData clickData, SnapshotNode? snapshot) async { throw UnimplementedError(); } @override - void trackLongClick(LongClickData longClickData) { + Future trackLongClick( + LongClickData longClickData, SnapshotNode? snapshot) async { throw UnimplementedError(); } @override - void trackScroll(ScrollData scrollData) { + Future trackScroll(ScrollData scrollData) async { throw UnimplementedError(); } + + @override + Map getLayoutSnapshotWidgetFilter() { + throw UnimplementedError(); + } + + @override + Logger? getLogger() { + return null; + } } diff --git a/flutter/packages/measure_flutter/android/src/main/AndroidManifest.xml b/flutter/packages/measure_flutter/android/src/main/AndroidManifest.xml index a2f47b605..bdae66c8f 100644 --- a/flutter/packages/measure_flutter/android/src/main/AndroidManifest.xml +++ b/flutter/packages/measure_flutter/android/src/main/AndroidManifest.xml @@ -1,2 +1,2 @@ - + diff --git a/flutter/packages/measure_flutter/android/src/main/kotlin/sh/measure/flutter/AttachmentsConverter.kt b/flutter/packages/measure_flutter/android/src/main/kotlin/sh/measure/flutter/AttachmentsConverter.kt index b2bc5a595..6c0fc9400 100644 --- a/flutter/packages/measure_flutter/android/src/main/kotlin/sh/measure/flutter/AttachmentsConverter.kt +++ b/flutter/packages/measure_flutter/android/src/main/kotlin/sh/measure/flutter/AttachmentsConverter.kt @@ -17,7 +17,8 @@ class AttachmentsConverter { val name = jsonObject.getString("name") val path = jsonObject.getString("path") - attachments.add(MsrAttachment(name = name, path = path, "screenshot")) + val type = jsonObject.getString("type") + attachments.add(MsrAttachment(name = name, path = path, type = type)) } return attachments } catch (e: Exception) { diff --git a/flutter/packages/measure_flutter/example/pubspec.lock b/flutter/packages/measure_flutter/example/pubspec.lock index 04681a4f8..74c9eab18 100644 --- a/flutter/packages/measure_flutter/example/pubspec.lock +++ b/flutter/packages/measure_flutter/example/pubspec.lock @@ -341,7 +341,7 @@ packages: path: ".." relative: true source: path - version: "0.1.2" + version: "0.2.1" meta: dependency: transitive description: diff --git a/flutter/packages/measure_flutter/lib/measure_flutter.dart b/flutter/packages/measure_flutter/lib/measure_flutter.dart index 19dadb5f4..4ba41f71e 100644 --- a/flutter/packages/measure_flutter/lib/measure_flutter.dart +++ b/flutter/packages/measure_flutter/lib/measure_flutter.dart @@ -95,7 +95,6 @@ import 'package:flutter/material.dart'; import 'package:measure_flutter/src/gestures/click_data.dart'; import 'package:measure_flutter/src/gestures/long_click_data.dart'; import 'package:measure_flutter/src/gestures/scroll_data.dart'; -import 'package:measure_flutter/src/logger/log_level.dart'; import 'package:measure_flutter/src/measure_initializer.dart'; import 'package:measure_flutter/src/measure_internal.dart'; import 'package:measure_flutter/src/method_channel/msr_method_channel.dart'; @@ -110,7 +109,11 @@ export 'src/config/client.dart'; export 'src/config/measure_config.dart'; export 'src/events/attachment_type.dart'; export 'src/events/msr_attachment.dart'; +export 'src/gestures/snapshot_node.dart'; +export 'src/gestures/layout_snapshot_capture.dart'; export 'src/http/http_method.dart'; +export 'src/logger/log_level.dart'; +export 'src/logger/logger.dart'; export 'src/measure_api.dart'; export 'src/measure_widget.dart'; export 'src/navigation/navigator_observer.dart'; @@ -493,13 +496,13 @@ class Measure implements MeasureApi { /// - [createBugReportWidget] for the built-in bug reporting UI /// - [captureScreenshot] for capturing screen attachments @override - void trackBugReport({ + Future trackBugReport({ required String description, required List attachments, required Map attributes, - }) { + }) async { if (_isInitialized) { - _measure.trackBugReport( + await _measure.trackBugReport( description, attachments, attributes, @@ -540,7 +543,8 @@ class Measure implements MeasureApi { }) { if (_isInitialized) { final details = FlutterErrorDetails(exception: error, stack: stack); - return _measure.trackError(details, handled: true, attributes: attributes); + return _measure.trackError(details, + handled: true, attributes: attributes); } return Future.value(); } @@ -870,7 +874,8 @@ class Measure implements MeasureApi { attributes: attributes, ); } else { - developer.log('Failed to open bug report, Measure SDK is not initialized'); + developer + .log('Failed to open bug report, Measure SDK is not initialized'); return SizedBox.shrink(key: key); } } @@ -915,6 +920,7 @@ class Measure implements MeasureApi { /// /// **Parameters:** /// - [clickData]: Data containing click position, target widget info, and timestamp + /// - [snapshot]: An optional layout snapshot to attach with the event /// /// **Example:** /// ```dart @@ -932,9 +938,9 @@ class Measure implements MeasureApi { /// **Note:** Consider using [MeasureWidget] wrapper for automatic /// gesture tracking instead of manual tracking. @override - void trackClick(ClickData clickData) { + Future trackClick(ClickData clickData, SnapshotNode? snapshot) async { if (isInitialized) { - _measure.trackClick(clickData); + return _measure.trackClick(clickData, snapshot); } } @@ -945,6 +951,7 @@ class Measure implements MeasureApi { /// /// **Parameters:** /// - [longClickData]: Data containing long press position, target widget info, and timestamp + /// - [snapshot]: An optional layout snapshot to attach with the event /// /// **Example:** /// ```dart @@ -956,15 +963,16 @@ class Measure implements MeasureApi { /// timestamp: Measure.instance.getCurrentTime(), /// ); /// - /// Measure.instance.trackLongClick(longClickData); + /// await Measure.instance.trackLongClick(longClickData); /// ``` /// /// **Note:** Consider using [MeasureWidget] wrapper for automatic /// gesture tracking instead of manual tracking. @override - void trackLongClick(LongClickData longClickData) { + Future trackLongClick( + LongClickData longClickData, SnapshotNode? snapshot) async { if (isInitialized) { - _measure.trackLongClick(longClickData); + return _measure.trackLongClick(longClickData, snapshot); } } @@ -993,12 +1001,29 @@ class Measure implements MeasureApi { /// **Note:** Consider using [MeasureWidget] wrapper for automatic /// gesture tracking instead of manual tracking. @override - void trackScroll(ScrollData scrollData) { + Future trackScroll(ScrollData scrollData) async { if (isInitialized) { - _measure.trackScroll(scrollData); + return _measure.trackScroll(scrollData); + } + } + + @override + Map getLayoutSnapshotWidgetFilter() { + if (_isInitialized) { + return _measure.getLayoutSnapshotWidgetFilter(); + } else { + return {}; } } + @override + Logger? getLogger() { + if (_isInitialized) { + return _measure.logger; + } + return null; + } + Future _initializeMeasureSDK( MeasureConfig config, ClientInfo clientInfo, @@ -1101,7 +1126,8 @@ class Measure implements MeasureApi { } } - void _logInputConfig(bool enableLogging, Map jsonConfig, Map jsonClientInfo) { + void _logInputConfig(bool enableLogging, Map jsonConfig, + Map jsonClientInfo) { if (enableLogging) { developer.log( 'Initializing measure-flutter with config: $jsonConfig', diff --git a/flutter/packages/measure_flutter/lib/src/attribute_builder.dart b/flutter/packages/measure_flutter/lib/src/attribute_builder.dart index ef2fa5e1d..542b971ac 100644 --- a/flutter/packages/measure_flutter/lib/src/attribute_builder.dart +++ b/flutter/packages/measure_flutter/lib/src/attribute_builder.dart @@ -1,20 +1,20 @@ import 'attribute_value.dart'; /// A fluent builder for creating attribute maps with automatic type conversion. -/// +/// /// [AttributeBuilder] provides a convenient way to build maps of [AttributeValue] /// objects from native Dart types. It automatically converts String, int, double, /// and bool values to their corresponding [AttributeValue] types. -/// +/// /// **Example:** /// ```dart /// final attributes = AttributeBuilder() /// .add('user_id', 'abc123') // Becomes StringAttr -/// .add('age', 25) // Becomes IntAttr +/// .add('age', 25) // Becomes IntAttr /// .add('score', 95.5) // Becomes DoubleAttr /// .add('is_premium', true) // Becomes BooleanAttr /// .build(); -/// +/// /// Measure.instance.trackEvent( /// name: 'user_profile_updated', /// attributes: attributes, @@ -24,17 +24,17 @@ final class AttributeBuilder { final Map _attributes = {}; /// Adds a key-value pair to the attributes map. - /// + /// /// The [value] is automatically converted to the appropriate [AttributeValue] type: /// - String → [StringAttr] /// - int → [IntAttr] /// - double → [DoubleAttr] /// - bool → [BooleanAttr] - /// + /// /// **Parameters:** /// - [key]: The attribute key /// - [value]: The attribute value (String, int, double, or bool) - /// + /// /// **Returns:** This [AttributeBuilder] instance for method chaining AttributeBuilder add(String key, Object value) { _attributes[key] = value.toAttr(); @@ -42,7 +42,7 @@ final class AttributeBuilder { } /// Builds and returns the final attributes map. - /// + /// /// **Returns:** A map of attribute keys to [AttributeValue] objects Map build() => _attributes; } diff --git a/flutter/packages/measure_flutter/lib/src/bug_report/attachment_processing.dart b/flutter/packages/measure_flutter/lib/src/bug_report/attachment_processing.dart deleted file mode 100644 index 8b625a387..000000000 --- a/flutter/packages/measure_flutter/lib/src/bug_report/attachment_processing.dart +++ /dev/null @@ -1,116 +0,0 @@ -import 'dart:io'; -import 'dart:isolate'; -import 'dart:typed_data'; - -import 'package:image/image.dart' as img; - -// Parameter classes -class RgbaToJpegParams { - final Uint8List rgbaBytes; - final int width; - final int height; - final int jpegQuality; - - const RgbaToJpegParams({ - required this.rgbaBytes, - required this.width, - required this.height, - required this.jpegQuality, - }); -} - -class ImageToJpegParams { - final Uint8List originalBytes; - final int jpegQuality; - - const ImageToJpegParams({ - required this.originalBytes, - required this.jpegQuality, - }); -} - -class CompressAndSaveParams { - final Uint8List originalBytes; - final int jpegQuality; - final String fileName; - final String rootPath; - - const CompressAndSaveParams({ - required this.originalBytes, - required this.jpegQuality, - required this.fileName, - required this.rootPath, - }); -} - -class FileProcessingResult { - final String? filePath; - final String? error; - final int? compressedSize; - - const FileProcessingResult({this.filePath, this.error, this.compressedSize}); -} - -// Core processing functions -Future convertRgbaToJpegInIsolate(RgbaToJpegParams params) async { - final rgbaImage = img.Image.fromBytes( - width: params.width, - height: params.height, - bytes: params.rgbaBytes.buffer, - order: img.ChannelOrder.rgba, - ); - - final encodedJpg = img.encodeJpg(rgbaImage, quality: params.jpegQuality); - return Uint8List.fromList(encodedJpg); -} - -Future convertImageToJpegInIsolate(ImageToJpegParams params) async { - final originalImage = img.decodeImage(params.originalBytes); - if (originalImage == null) { - throw Exception('Failed to decode image'); - } - - final encodedJpg = img.encodeJpg(originalImage, quality: params.jpegQuality); - return Uint8List.fromList(encodedJpg); -} - -Future compressAndSaveInIsolate( - CompressAndSaveParams params) async { - return await Isolate.run(() => _compressAndSave(params)); -} - -// Private helpers -Future _compressAndSave( - CompressAndSaveParams params) async { - try { - final compressedBytes = await convertImageToJpegInIsolate( - ImageToJpegParams( - originalBytes: params.originalBytes, - jpegQuality: params.jpegQuality, - ), - ); - - final filePath = - await _writeFile(compressedBytes, params.fileName, params.rootPath); - - return filePath != null - ? FileProcessingResult( - filePath: filePath, - compressedSize: compressedBytes.length, - ) - : FileProcessingResult(error: 'Failed to write file'); - } catch (e) { - return FileProcessingResult(error: e.toString()); - } -} - -Future _writeFile( - Uint8List data, String fileName, String rootPath) async { - try { - final file = File('$rootPath/$fileName'); - await file.writeAsBytes(data); - return file.path; - } catch (e) { - return null; - } -} diff --git a/flutter/packages/measure_flutter/lib/src/bug_report/bug_report_collector.dart b/flutter/packages/measure_flutter/lib/src/bug_report/bug_report_collector.dart index d91da16a2..86fed0664 100644 --- a/flutter/packages/measure_flutter/lib/src/bug_report/bug_report_collector.dart +++ b/flutter/packages/measure_flutter/lib/src/bug_report/bug_report_collector.dart @@ -6,15 +6,13 @@ import 'package:measure_flutter/src/bug_report/ui/bug_report.dart'; import 'package:measure_flutter/src/bug_report/ui/image_picker.dart'; import 'package:measure_flutter/src/config/config_provider.dart'; import 'package:measure_flutter/src/events/event_type.dart'; -import 'package:measure_flutter/src/logger/log_level.dart'; import 'package:measure_flutter/src/method_channel/signal_processor.dart'; import 'package:measure_flutter/src/time/time_provider.dart'; import 'package:measure_flutter/src/utils/id_provider.dart'; import '../../measure_flutter.dart'; -import '../logger/logger.dart'; +import '../isolate/file_processor.dart'; import '../storage/file_storage.dart'; -import 'attachment_processing.dart'; import 'bug_report_data.dart'; class BugReportCollector { @@ -89,25 +87,30 @@ class BugReportCollector { ); final filePath = result.filePath; - final compressedSize = result.compressedSize; + final compressedSize = result.size; if (filePath != null && compressedSize != null) { + _logger.log( + LogLevel.debug, + 'BugReportCollector: Successfully stored screenshot attachment (id: $uuid, size: $compressedSize bytes, path: $filePath)', + ); storedAttachments.add( MsrAttachment( name: uuid, path: filePath, - type: AttachmentType.screenshot, + type: attachment.type, id: uuid, size: compressedSize, bytes: null, ), ); } else { - _logger.log(LogLevel.error, "Failed to process attachment"); + _logger.log(LogLevel.error, + "BugReportCollector: Failed to process attachment: ${result.error}"); } } _logger.log(LogLevel.debug, - "Processed ${attachments.length} attachments for bug_report"); + "BugReportCollector: Processed ${attachments.length} attachments for bug_report"); final data = BugReportData(description: description); _signalProcessor.trackEvent( diff --git a/flutter/packages/measure_flutter/lib/src/bug_report/msr_shake_detector_mixin.dart b/flutter/packages/measure_flutter/lib/src/bug_report/msr_shake_detector_mixin.dart index 142581a95..aa6ec20b9 100644 --- a/flutter/packages/measure_flutter/lib/src/bug_report/msr_shake_detector_mixin.dart +++ b/flutter/packages/measure_flutter/lib/src/bug_report/msr_shake_detector_mixin.dart @@ -3,21 +3,21 @@ import 'package:flutter/cupertino.dart'; import '../../measure_flutter.dart'; /// A mixin that enables automatic shake detection for displaying bug reports. -/// +/// /// [MsrShakeDetectorMixin] automatically sets up and manages shake detection /// lifecycle for StatefulWidget classes. It automatically enables shake detection /// when the widget is initialized and disables it when disposed. -/// +/// /// **Usage:** /// ```dart /// class MyHomeScreen extends StatefulWidget { /// @override /// _MyHomeScreenState createState() => _MyHomeScreenState(); /// } -/// -/// class _MyHomeScreenState extends State +/// +/// class _MyHomeScreenState extends State /// with MsrShakeDetectorMixin { -/// +/// /// @override /// void onShakeDetected() { /// // Navigate to bug report screen @@ -34,7 +34,7 @@ import '../../measure_flutter.dart'; /// ), /// ); /// } -/// +/// /// @override /// Widget build(BuildContext context) { /// return Scaffold( @@ -46,7 +46,7 @@ import '../../measure_flutter.dart'; /// ``` mixin MsrShakeDetectorMixin on State { /// Called when a shake gesture is detected. - /// + /// /// Override this method to define what happens when the user shakes their device. /// Typically used to navigate to a bug report screen or show a feedback form. void onShakeDetected(); @@ -58,14 +58,14 @@ mixin MsrShakeDetectorMixin on State { } /// Enables shake detection by registering the shake listener with the SDK. - /// + /// /// This is automatically called during [initState]. void enableShakeDetection() { Measure.instance.setShakeListener(onShakeDetected); } /// Disables shake detection by removing the shake listener from the SDK. - /// + /// /// This is automatically called during [dispose]. void disableShakeDetection() { Measure.instance.setShakeListener(null); diff --git a/flutter/packages/measure_flutter/lib/src/bug_report/ui/add_image_button.dart b/flutter/packages/measure_flutter/lib/src/bug_report/ui/add_image_button.dart index 8f082e93a..e44e9c70d 100644 --- a/flutter/packages/measure_flutter/lib/src/bug_report/ui/add_image_button.dart +++ b/flutter/packages/measure_flutter/lib/src/bug_report/ui/add_image_button.dart @@ -99,7 +99,8 @@ class AddImageButton extends StatelessWidget { width: 12, height: 12, child: CircularProgressIndicator( - color: bugReportTheme.colors.primaryColor ?? colorScheme.primary, + color: + bugReportTheme.colors.primaryColor ?? colorScheme.primary, strokeWidth: 2, ), ) diff --git a/flutter/packages/measure_flutter/lib/src/bug_report/ui/bug_report.dart b/flutter/packages/measure_flutter/lib/src/bug_report/ui/bug_report.dart index 69a559368..bd75675ca 100644 --- a/flutter/packages/measure_flutter/lib/src/bug_report/ui/bug_report.dart +++ b/flutter/packages/measure_flutter/lib/src/bug_report/ui/bug_report.dart @@ -4,12 +4,10 @@ import 'package:measure_flutter/src/bug_report/shake_detector.dart'; import 'package:measure_flutter/src/bug_report/ui/image_picker.dart'; import 'package:measure_flutter/src/bug_report/ui/platform_wrapper.dart'; import 'package:measure_flutter/src/bug_report/ui/send_button.dart'; -import 'package:measure_flutter/src/logger/log_level.dart'; import 'package:measure_flutter/src/utils/id_provider.dart'; import '../../../measure_flutter.dart'; import '../../config/config_provider.dart'; -import '../../logger/logger.dart'; import 'add_image_button.dart'; import 'bug_report_input.dart'; import 'bug_report_state.dart'; diff --git a/flutter/packages/measure_flutter/lib/src/bug_report/ui/bug_report_theme.dart b/flutter/packages/measure_flutter/lib/src/bug_report/ui/bug_report_theme.dart index dadd08b79..ea3d08aee 100644 --- a/flutter/packages/measure_flutter/lib/src/bug_report/ui/bug_report_theme.dart +++ b/flutter/packages/measure_flutter/lib/src/bug_report/ui/bug_report_theme.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; /// Text configuration for customizing the bug report UI labels and messages. -/// +/// /// [BugReportText] allows you to customize all user-facing text in the /// bug report widget to match your app's tone or support localization. -/// +/// /// **Example:** /// ```dart /// final customText = BugReportText( @@ -63,10 +63,10 @@ class BugReportText { } /// Color configuration for customizing the bug report UI appearance. -/// +/// /// [BugReportColors] allows you to customize the colors used in the /// bug report widget to match your app's brand and design system. -/// +/// /// **Example:** /// ```dart /// final customColors = BugReportColors( @@ -101,10 +101,10 @@ class BugReportColors { } /// Complete theme configuration for the bug report UI. -/// +/// /// [BugReportTheme] combines text and color customizations to provide /// a complete theming solution for the bug report widget. -/// +/// /// **Example:** /// ```dart /// final customTheme = BugReportTheme( @@ -116,7 +116,7 @@ class BugReportColors { /// primaryColor: Theme.of(context).primaryColor, /// ), /// ); -/// +/// /// // Use in bug report widget /// showDialog( /// context: context, @@ -155,4 +155,4 @@ class BugReportTheme { @override int get hashCode => Object.hash(text, colors); -} \ No newline at end of file +} diff --git a/flutter/packages/measure_flutter/lib/src/bug_report/ui/image_picker.dart b/flutter/packages/measure_flutter/lib/src/bug_report/ui/image_picker.dart index 203495b7b..98a17a529 100644 --- a/flutter/packages/measure_flutter/lib/src/bug_report/ui/image_picker.dart +++ b/flutter/packages/measure_flutter/lib/src/bug_report/ui/image_picker.dart @@ -1,8 +1,6 @@ import 'package:image_picker/image_picker.dart'; import 'package:measure_flutter/measure_flutter.dart'; -import '../../logger/log_level.dart'; -import '../../logger/logger.dart'; import '../../utils/id_provider.dart'; class ImagePickerWrapper { diff --git a/flutter/packages/measure_flutter/lib/src/bug_report/ui/screenshot_list_item.dart b/flutter/packages/measure_flutter/lib/src/bug_report/ui/screenshot_list_item.dart index 4fd3a00e4..40b2fe46b 100644 --- a/flutter/packages/measure_flutter/lib/src/bug_report/ui/screenshot_list_item.dart +++ b/flutter/packages/measure_flutter/lib/src/bug_report/ui/screenshot_list_item.dart @@ -105,4 +105,4 @@ class _ScreenshotListItemState extends State { ], ); } -} \ No newline at end of file +} diff --git a/flutter/packages/measure_flutter/lib/src/bug_report/ui/send_button.dart b/flutter/packages/measure_flutter/lib/src/bug_report/ui/send_button.dart index d608a26f0..0d5757915 100644 --- a/flutter/packages/measure_flutter/lib/src/bug_report/ui/send_button.dart +++ b/flutter/packages/measure_flutter/lib/src/bug_report/ui/send_button.dart @@ -42,11 +42,9 @@ class SendButton extends StatelessWidget { onPressed: enabled ? onSend : null, style: TextButton.styleFrom( foregroundColor: enabled - ? (bugReportTheme.colors.primaryColor ?? - colorScheme.primary) - : (bugReportTheme.colors.primaryColor ?? - colorScheme.primary) - .withValues(alpha: 0.4), + ? (bugReportTheme.colors.primaryColor ?? colorScheme.primary) + : (bugReportTheme.colors.primaryColor ?? colorScheme.primary) + .withValues(alpha: 0.4), padding: const EdgeInsets.symmetric(horizontal: 16), ), child: Text( @@ -54,11 +52,9 @@ class SendButton extends StatelessWidget { style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w600, color: enabled - ? (bugReportTheme.colors.primaryColor ?? - colorScheme.primary) - : (bugReportTheme.colors.primaryColor ?? - colorScheme.primary) - .withValues(alpha: 0.4), + ? (bugReportTheme.colors.primaryColor ?? colorScheme.primary) + : (bugReportTheme.colors.primaryColor ?? colorScheme.primary) + .withValues(alpha: 0.4), ), ), ); diff --git a/flutter/packages/measure_flutter/lib/src/config/config.dart b/flutter/packages/measure_flutter/lib/src/config/config.dart index faf4f3875..f1746683f 100644 --- a/flutter/packages/measure_flutter/lib/src/config/config.dart +++ b/flutter/packages/measure_flutter/lib/src/config/config.dart @@ -31,9 +31,13 @@ class Config implements InternalConfig, IMeasureConfig { this.maxEventNameLength = DefaultConfig.maxEventNameLength, this.customEventNameRegex = DefaultConfig.customEventNameRegex, this.maxDiskUsageInMb = DefaultConfig.maxDiskUsageInMb, - this.maxUserDefinedAttributeKeyLength = DefaultConfig.maxUserDefinedAttributeKeyLength, - this.maxUserDefinedAttributeValueLength = DefaultConfig.maxUserDefinedAttributeValueLength, - this.maxUserDefinedAttributesPerEvent = DefaultConfig.maxUserDefinedAttributesPerEvent, + this.maxUserDefinedAttributeKeyLength = + DefaultConfig.maxUserDefinedAttributeKeyLength, + this.maxUserDefinedAttributeValueLength = + DefaultConfig.maxUserDefinedAttributeValueLength, + this.maxUserDefinedAttributesPerEvent = + DefaultConfig.maxUserDefinedAttributesPerEvent, + this.widgetFilter = DefaultConfig.widgetFilter, }); @override @@ -90,6 +94,8 @@ class Config implements InternalConfig, IMeasureConfig { final int maxUserDefinedAttributeKeyLength; @override final int maxUserDefinedAttributeValueLength; + @override + final Map widgetFilter; @override List get defaultHttpContentTypeAllowlist => diff --git a/flutter/packages/measure_flutter/lib/src/config/config_provider.dart b/flutter/packages/measure_flutter/lib/src/config/config_provider.dart index 4e64c564a..fe663bcd7 100644 --- a/flutter/packages/measure_flutter/lib/src/config/config_provider.dart +++ b/flutter/packages/measure_flutter/lib/src/config/config_provider.dart @@ -105,13 +105,20 @@ class ConfigProviderImpl implements ConfigProvider { int get maxDiskUsageInMb => _defaultConfig.maxDiskUsageInMb; @override - int get maxUserDefinedAttributeValueLength => _defaultConfig.maxUserDefinedAttributeValueLength; + int get maxUserDefinedAttributeValueLength => + _defaultConfig.maxUserDefinedAttributeValueLength; @override - int get maxUserDefinedAttributeKeyLength => _defaultConfig.maxUserDefinedAttributeKeyLength; + int get maxUserDefinedAttributeKeyLength => + _defaultConfig.maxUserDefinedAttributeKeyLength; @override - int get maxUserDefinedAttributesPerEvent => _defaultConfig.maxUserDefinedAttributesPerEvent; + int get maxUserDefinedAttributesPerEvent => + _defaultConfig.maxUserDefinedAttributesPerEvent; + + @override + Map get widgetFilter => + _defaultConfig.widgetFilter; @override bool shouldTrackHttpBody(String url, String? contentType) { diff --git a/flutter/packages/measure_flutter/lib/src/config/default_config.dart b/flutter/packages/measure_flutter/lib/src/config/default_config.dart index 5c76d2753..56a95ee05 100644 --- a/flutter/packages/measure_flutter/lib/src/config/default_config.dart +++ b/flutter/packages/measure_flutter/lib/src/config/default_config.dart @@ -26,4 +26,5 @@ class DefaultConfig { static const int maxUserDefinedAttributesPerEvent = 100; static const int maxUserDefinedAttributeKeyLength = 256; static const int maxUserDefinedAttributeValueLength = 256; + static const Map widgetFilter = {}; } diff --git a/flutter/packages/measure_flutter/lib/src/config/measure_config.dart b/flutter/packages/measure_flutter/lib/src/config/measure_config.dart index e3d24959a..a9a8c68d5 100644 --- a/flutter/packages/measure_flutter/lib/src/config/measure_config.dart +++ b/flutter/packages/measure_flutter/lib/src/config/measure_config.dart @@ -37,6 +37,8 @@ abstract class IMeasureConfig { bool get trackViewControllerLoadTime; int get maxDiskUsageInMb; + + Map get widgetFilter; } /// Configuration class for Measure SDK @@ -213,6 +215,39 @@ class MeasureConfig implements IMeasureConfig { @override final int maxDiskUsageInMb; + /// Specifies which widget types to capture when creating layout snapshots. + /// + /// Layout snapshots capture the structure of the widget tree at the time of user + /// interactions. To keep snapshots manageable, only widgets matching the types in + /// this map are included in the captured tree structure. + /// + /// The map keys are widget Types and the values are their display names. Since + /// widget type names can be obfuscated in release builds, you must provide + /// explicit display names that will be used in the snapshots. + /// + /// By default, common Flutter framework widgets (like `Text`, `Container`, `Row`, + /// `Column`, etc.) are automatically included. Use this property to add custom + /// widget types from your application that you want to track in layout snapshots. + /// + /// When a widget in the tree matches any type in this map, it will be captured + /// along with its position and properties in the snapshot, using the provided + /// display name. + /// + /// Example: + /// + /// ```dart + /// MeasureConfig( + /// widgetFilter: { + /// MyCustomButton: 'MyCustomButton', + /// ProductCard: 'ProductCard', + /// UserProfileWidget: 'UserProfileWidget', + /// } + /// ) + /// ``` + @override + @JsonKey(includeToJson: false, includeFromJson: false) + final Map widgetFilter; + /// Creates a new MeasureConfig instance const MeasureConfig({ this.enableLogging = DefaultConfig.enableLogging, @@ -232,6 +267,7 @@ class MeasureConfig implements IMeasureConfig { this.maxDiskUsageInMb = DefaultConfig.maxDiskUsageInMb, this.trackViewControllerLoadTime = DefaultConfig.trackViewControllerLoadTime, + this.widgetFilter = DefaultConfig.widgetFilter, }) : assert( samplingRateForErrorFreeSessions >= 0.0 && samplingRateForErrorFreeSessions <= 1.0, diff --git a/flutter/packages/measure_flutter/lib/src/events/attachment_type.dart b/flutter/packages/measure_flutter/lib/src/events/attachment_type.dart index 7cd508b0a..00286119e 100644 --- a/flutter/packages/measure_flutter/lib/src/events/attachment_type.dart +++ b/flutter/packages/measure_flutter/lib/src/events/attachment_type.dart @@ -1,3 +1,8 @@ +import 'package:json_annotation/json_annotation.dart'; + enum AttachmentType { - screenshot -} \ No newline at end of file + @JsonValue('screenshot') + screenshot, + @JsonValue('layout_snapshot_json') + layoutSnapshotJson, +} diff --git a/flutter/packages/measure_flutter/lib/src/events/msr_attachment.dart b/flutter/packages/measure_flutter/lib/src/events/msr_attachment.dart index 52cc7d1d9..0cab1b01e 100644 --- a/flutter/packages/measure_flutter/lib/src/events/msr_attachment.dart +++ b/flutter/packages/measure_flutter/lib/src/events/msr_attachment.dart @@ -6,7 +6,7 @@ import 'package:measure_flutter/measure_flutter.dart'; part 'msr_attachment.g.dart'; /// Represents a file attachment that can be included with bug reports or events. -/// +/// /// [MsrAttachment] encapsulates file data, metadata, and type information /// for attachments like screenshots, logs, or user-selected files. /// @@ -33,10 +33,10 @@ class MsrAttachment { }); /// Creates an [MsrAttachment] from binary data. - /// + /// /// Use this factory when you have file content as bytes, such as /// from a screenshot capture or downloaded file. - /// + /// /// **Parameters:** /// - [bytes]: The binary file content /// - [type]: The type of attachment (screenshot, image, etc.) @@ -56,9 +56,9 @@ class MsrAttachment { } /// Creates an [MsrAttachment] from a file path. - /// + /// /// Use this factory when referencing an existing file on the filesystem. - /// + /// /// **Parameters:** /// - [path]: The file system path to the file /// - [type]: The type of attachment diff --git a/flutter/packages/measure_flutter/lib/src/events/msr_attachment.g.dart b/flutter/packages/measure_flutter/lib/src/events/msr_attachment.g.dart index de1a8d071..58095fb32 100644 --- a/flutter/packages/measure_flutter/lib/src/events/msr_attachment.g.dart +++ b/flutter/packages/measure_flutter/lib/src/events/msr_attachment.g.dart @@ -26,4 +26,5 @@ Map _$MsrAttachmentToJson(MsrAttachment instance) => const _$AttachmentTypeEnumMap = { AttachmentType.screenshot: 'screenshot', + AttachmentType.layoutSnapshotJson: 'layout_snapshot_json', }; diff --git a/flutter/packages/measure_flutter/lib/src/exception/exception_collector.dart b/flutter/packages/measure_flutter/lib/src/exception/exception_collector.dart index 255933be7..0173df033 100644 --- a/flutter/packages/measure_flutter/lib/src/exception/exception_collector.dart +++ b/flutter/packages/measure_flutter/lib/src/exception/exception_collector.dart @@ -5,14 +5,12 @@ import 'package:measure_flutter/measure_flutter.dart'; import 'package:measure_flutter/src/config/config_provider.dart'; import 'package:measure_flutter/src/exception/exception_data.dart'; import 'package:measure_flutter/src/exception/exception_factory.dart'; -import 'package:measure_flutter/src/logger/log_level.dart'; -import 'package:measure_flutter/src/logger/logger.dart'; import 'package:measure_flutter/src/method_channel/signal_processor.dart'; import 'package:measure_flutter/src/screenshot/screenshot_collector.dart'; import 'package:measure_flutter/src/time/time_provider.dart'; -import '../bug_report/attachment_processing.dart'; import '../events/event_type.dart'; +import '../isolate/file_processor.dart'; import '../storage/file_storage.dart'; final class ExceptionCollector { @@ -21,8 +19,7 @@ final class ExceptionCollector { final ConfigProvider configProvider; final FileStorage fileStorage; final ScreenshotCollector screenshotCollector; - final Future Function(CompressAndSaveParams) - compressAndSave; + final Future Function(CompressAndSaveParams) compressAndSave; final TimeProvider timeProvider; bool _enabled = false; @@ -50,8 +47,7 @@ final class ExceptionCollector { required Map attributes, }) async { if (!_enabled) return; - final ExceptionData? exceptionData = - ExceptionFactory.from(details, handled); + final ExceptionData? exceptionData = ExceptionFactory.from(details, handled); if (exceptionData == null) { logger.log(LogLevel.error, "Failed to parse exception"); return; @@ -98,8 +94,12 @@ final class ExceptionCollector { ); final filePath = result.filePath; - final compressedSize = result.compressedSize; + final compressedSize = result.size; if (filePath != null && compressedSize != null) { + logger.log( + LogLevel.debug, + 'ExceptionCollector: Successfully stored screenshot attachment (id: ${screenshot.id}, size: $compressedSize bytes, path: $filePath)', + ); attachments.add( MsrAttachment( name: screenshot.id, @@ -110,6 +110,11 @@ final class ExceptionCollector { bytes: null, ), ); + } else { + logger.log( + LogLevel.debug, + 'ExceptionCollector: Failed to store screenshot attachment: ${result.error}', + ); } } } diff --git a/flutter/packages/measure_flutter/lib/src/gestures/click_data.dart b/flutter/packages/measure_flutter/lib/src/gestures/click_data.dart index 90473128c..b81e927bf 100644 --- a/flutter/packages/measure_flutter/lib/src/gestures/click_data.dart +++ b/flutter/packages/measure_flutter/lib/src/gestures/click_data.dart @@ -17,16 +17,15 @@ class ClickData implements JsonSerialized { final int? width; final int? height; - ClickData({ - required this.target, - this.targetId, - required this.x, - required this.y, - required this.touchDownTime, - required this.touchUpTime, - this.width, - this.height - }); + ClickData( + {required this.target, + this.targetId, + required this.x, + required this.y, + required this.touchDownTime, + required this.touchUpTime, + this.width, + this.height}); @override Map toJson() => _$ClickDataToJson(this); diff --git a/flutter/packages/measure_flutter/lib/src/gestures/gesture_collector.dart b/flutter/packages/measure_flutter/lib/src/gestures/gesture_collector.dart index 50cae0d46..574e60200 100644 --- a/flutter/packages/measure_flutter/lib/src/gestures/gesture_collector.dart +++ b/flutter/packages/measure_flutter/lib/src/gestures/gesture_collector.dart @@ -1,23 +1,37 @@ +import 'dart:developer'; import 'dart:isolate'; +import 'package:measure_flutter/measure_flutter.dart'; import 'package:measure_flutter/src/events/event_type.dart'; import 'package:measure_flutter/src/gestures/long_click_data.dart'; import 'package:measure_flutter/src/gestures/scroll_data.dart'; import 'package:measure_flutter/src/method_channel/signal_processor.dart'; +import 'package:measure_flutter/src/storage/file_storage.dart'; import 'package:measure_flutter/src/time/time_provider.dart'; +import 'package:measure_flutter/src/utils/id_provider.dart'; +import '../isolate/file_processor.dart'; import 'click_data.dart'; class GestureCollector { final SignalProcessor _signalProcessor; final TimeProvider _timeProvider; + final FileStorage _fileStorage; + final Logger _logger; + final IdProvider _idProvider; bool _isRegistered = false; GestureCollector( SignalProcessor signalProcessor, TimeProvider timeProvider, + FileStorage fileStorage, + Logger logger, + IdProvider idProvider, ) : _signalProcessor = signalProcessor, - _timeProvider = timeProvider; + _timeProvider = timeProvider, + _fileStorage = fileStorage, + _logger = logger, + _idProvider = idProvider; void register() { _isRegistered = true; @@ -27,47 +41,136 @@ class GestureCollector { _isRegistered = false; } - void trackGestureClick(ClickData data, {bool isUserTriggered = false}) { - if (!_isRegistered) { - return; + Future trackGestureClick( + ClickData data, { + bool isUserTriggered = false, + SnapshotNode? snapshot, + int? timestamp, + }) async { + final task = TimelineTask()..start('msr-trackGestureClick'); + try { + if (!_isRegistered) { + return; + } + MsrAttachment? attachment; + if (snapshot != null) { + attachment = await createAttachment(snapshot); + } + _signalProcessor.trackEvent( + data: data, + type: EventType.gestureClick, + timestamp: timestamp ?? _timeProvider.now(), + userDefinedAttrs: {}, + userTriggered: isUserTriggered, + threadName: Isolate.current.debugName ?? "unknown", + attachments: attachment != null ? [attachment] : null, + ); + } finally { + task.finish(); } - _signalProcessor.trackEvent( - data: data, - type: EventType.gestureClick, - timestamp: _timeProvider.now(), - userDefinedAttrs: {}, - userTriggered: isUserTriggered, - threadName: Isolate.current.debugName ?? "unknown", - ); } void trackGestureScroll(ScrollData scrollData) { - if (!_isRegistered) { - return; - } + Timeline.startSync('msr-trackGestureScroll'); + try { + if (!_isRegistered) { + return; + } - _signalProcessor.trackEvent( - data: scrollData, - type: EventType.gestureScroll, - timestamp: _timeProvider.now(), - userDefinedAttrs: {}, - userTriggered: false, - threadName: Isolate.current.debugName ?? "unknown", - ); + _signalProcessor.trackEvent( + data: scrollData, + type: EventType.gestureScroll, + timestamp: _timeProvider.now(), + userDefinedAttrs: {}, + userTriggered: false, + threadName: Isolate.current.debugName ?? "unknown", + ); + } finally { + Timeline.finishSync(); + } } - void trackGestureLongClick(LongClickData longClickData) { - if (!_isRegistered) { - return; + Future trackGestureLongClick( + LongClickData longClickData, { + SnapshotNode? snapshot, + int? timestamp, + }) async { + final task = TimelineTask()..start('msr-trackGestureLongClick'); + try { + if (!_isRegistered) { + return; + } + MsrAttachment? attachment; + if (snapshot != null) { + attachment = await createAttachment(snapshot); + } + _signalProcessor.trackEvent( + data: longClickData, + type: EventType.gestureLongClick, + timestamp: timestamp ?? _timeProvider.now(), + userDefinedAttrs: {}, + userTriggered: false, + threadName: Isolate.current.debugName ?? "unknown", + attachments: attachment != null ? [attachment] : null, + ); + } finally { + task.finish(); } + } + + /// Creates an attachment from an already-captured layout snapshot. + /// + /// Serializes the [snapshot] to JSON and writes it to a file in an isolate. + /// Returns an [MsrAttachment] with the file path, or null if the operation fails. + Future createAttachment(SnapshotNode snapshot) async { + try { + final rootPath = await _fileStorage.getRootPath(); + if (rootPath == null) { + _logger.log( + LogLevel.debug, + 'LayoutSnapshotCollector: Root path is null', + ); + return null; + } + + final uuid = _idProvider.uuid(); + final result = await writeJsonToFileInIsolate( + WriteLayoutSnapshotParams( + snapshot: snapshot, + fileName: uuid, + rootPath: rootPath, + compress: true, + ), + ); - _signalProcessor.trackEvent( - data: longClickData, - type: EventType.gestureLongClick, - timestamp: _timeProvider.now(), - userDefinedAttrs: {}, - userTriggered: false, - threadName: Isolate.current.debugName ?? "unknown", - ); + final filePath = result.filePath; + final fileSize = result.size; + + if (filePath == null || fileSize == null) { + _logger.log( + LogLevel.debug, + 'LayoutSnapshotCollector: Failed to write JSON file: ${result.error}', + ); + return null; + } + + _logger.log( + LogLevel.debug, + 'LayoutSnapshotCollector: Successfully stored layout snapshot attachment (id: $uuid, size: $fileSize bytes, path: $filePath)', + ); + + return MsrAttachment.fromPath( + path: filePath, + type: AttachmentType.layoutSnapshotJson, + size: fileSize, + uuid: uuid, + ); + } catch (e) { + _logger.log( + LogLevel.debug, + 'LayoutSnapshotCollector: Error capturing layout snapshot: $e', + ); + return null; + } } } diff --git a/flutter/packages/measure_flutter/lib/src/gestures/layout_snapshot_capture.dart b/flutter/packages/measure_flutter/lib/src/gestures/layout_snapshot_capture.dart new file mode 100644 index 000000000..7a08c6c99 --- /dev/null +++ b/flutter/packages/measure_flutter/lib/src/gestures/layout_snapshot_capture.dart @@ -0,0 +1,278 @@ +import 'dart:developer'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import '../../measure_flutter.dart'; +import 'layout_snapshot_widget_filters.dart'; + +enum GestureDetectionMode { + /// Detect clicked or long clicked element + click, + + /// Detect scrolled element + scroll, +} + +/// Result of capturing a layout snapshot +/// with optional detected element info +class LayoutSnapshotCaptureResult { + final SnapshotNode snapshot; + final Element? gestureElement; + final String? gestureElementType; + + LayoutSnapshotCaptureResult({ + required this.snapshot, + this.gestureElement, + this.gestureElementType, + }); +} + +/// State for tracking information during tree capture +class _CaptureState { + final Rect? screenBounds; + final Offset? detectionPosition; + final GestureDetectionMode? detectionMode; + final Element rootElement; + final Map? widgetFilter; + Element? gestureElement; + String? gestureElementType; + String? gestureElementLabel; + + _CaptureState({ + required this.rootElement, + this.screenBounds, + this.detectionPosition, + this.detectionMode, + this.widgetFilter, + }); +} + +/// Captures the layout snapshot starting from a given element. +class LayoutSnapshotCapture { + /// Captures a hierarchical snapshot of the widget tree starting from [rootElement]. + /// + /// If [screenBounds] is provided, only widgets within the bounds are included. + /// + /// If [detectionPosition] and [detectionMode] are provided, then it detects + /// and "highlights" the widget at that position. The detected element is returned + /// in [LayoutSnapshotCaptureResult.gestureElement]. + /// + /// The [LayoutSnapshotCaptureResult.gestureElementType] is the deobfuscated type + /// of the widget, it's preferable to use than instead of [gestureElement]. + /// + /// The [widgetFilter] map specifies which widget types to include. When null or + /// empty, a default set of framework widgets is used. + /// + /// Returns null if [rootElement] is null or has no valid render box. + /// + /// See also: + /// + /// * [GestureDetectionMode], which specifies click or scroll detection. + /// * [SnapshotNode], the tree node structure returned. + static LayoutSnapshotCaptureResult? capture( + Element? rootElement, { + Rect? screenBounds, + Offset? detectionPosition, + GestureDetectionMode? detectionMode, + Map? widgetFilter, + }) { + Timeline.startSync('msr-layoutSnapshot-capture'); + try { + if (rootElement == null || !_hasValidRenderBox(rootElement)) { + return null; + } + final state = _initState(rootElement, screenBounds, detectionPosition, detectionMode, widgetFilter); + final snapshot = _traverseNodesRecursively(rootElement, state); + return LayoutSnapshotCaptureResult( + snapshot: snapshot.last, + gestureElement: state.gestureElement, + gestureElementType: state.gestureElementType, + ); + } finally { + Timeline.finishSync(); + } + } + + static bool _hitTest(Element element, Offset position, Element rootElement) { + Timeline.startSync('msr-layoutSnapshot-hitTest'); + try { + final renderObject = element.renderObject; + if (renderObject == null || (renderObject is RenderBox && !renderObject.hasSize)) { + return false; + } + final transform = renderObject.getTransformTo(rootElement.renderObject); + final transformedBounds = MatrixUtils.transformRect(transform, renderObject.paintBounds); + return transformedBounds.contains(position); + } finally { + Timeline.finishSync(); + } + } + + static String? _getUserProvidedWidget(Widget widget, Map? providedWidgets) { + return providedWidgets?[widget.runtimeType]; + } + + /// Checks if an element has a valid RenderBox with size + static bool _hasValidRenderBox(Element element) { + final renderObject = element.renderObject; + return renderObject != null && renderObject is RenderBox && renderObject.hasSize; + } + + static _CaptureState _initState(Element rootElement, Rect? screenBounds, Offset? detectionPosition, + GestureDetectionMode? detectionMode, Map? widgetFilter) { + return _CaptureState( + rootElement: rootElement, + screenBounds: screenBounds, + detectionPosition: detectionPosition, + detectionMode: detectionMode, + widgetFilter: widgetFilter, + ); + } + + static List _traverseNodesRecursively( + Element element, + _CaptureState state, { + bool skipHitTest = false, + }) { + final List allChildren = []; + final renderObject = element.renderObject; + + if (!_hasValidRenderBox(element)) { + final List promotedChildren = []; + element.visitChildElements((child) { + promotedChildren.addAll(_traverseNodesRecursively( + child, + state, + skipHitTest: skipHitTest, + )); + }); + return promotedChildren; + } + + if (renderObject is! RenderBox) { + final List promotedChildren = []; + element.visitChildElements((child) { + promotedChildren.addAll(_traverseNodesRecursively( + child, + state, + skipHitTest: skipHitTest, + )); + }); + return promotedChildren; + } + + var isVisible = true; + if (element.widget is Visibility) { + final visibility = element.widget as Visibility; + if (!visibility.visible) { + isVisible = false; + } + } + + if (element.widget is Opacity) { + final opacity = element.widget as Opacity; + if (opacity.opacity == 0) { + isVisible = false; + } + } + + if (element.widget is Offstage) { + final offstage = element.widget as Offstage; + if (offstage.offstage == true) { + isVisible = false; + } + } + + final bounds = _getBounds(renderObject); + final isInScreenBounds = _isInScreenBounds(bounds, state.screenBounds); + final matchedType = + _getUserProvidedWidget(element.widget, state.widgetFilter) ?? getFrameworkWidgetName(element.widget); + + if (isVisible) { + var skipHitTestForChildren = skipHitTest; + if (!skipHitTest && state.detectionPosition != null && state.detectionMode != null) { + final hitTestPassed = _updateGestureElementIfMatch(element, state); + skipHitTestForChildren = !hitTestPassed; + } + + element.visitChildElements((child) { + allChildren.addAll(_traverseNodesRecursively( + child, + state, + skipHitTest: skipHitTestForChildren, + )); + }); + + final isGestureElement = state.gestureElement == element; + + if (isGestureElement || (isInScreenBounds && matchedType != null)) { + return [_createNode(element, state, allChildren, isGestureElement, matchedType, bounds)]; + } + } + + return allChildren; + } + + /// Checks if this element matches the hit test and updates state. + /// Returns true if hit test passed (position is within element bounds). + static bool _updateGestureElementIfMatch(Element element, _CaptureState state) { + if (!_hitTest(element, state.detectionPosition!, state.rootElement)) { + return false; + } + + final widgetType = state.detectionMode == GestureDetectionMode.click + ? getClickableWidgetName(element.widget) + : getScrollableWidgetName(element.widget); + + if (widgetType == null) { + return true; + } + + // Keep updating elements higher in z-order, which + // is the last update wins. + state.gestureElement = element; + state.gestureElementType = widgetType; + state.gestureElementLabel = widgetType; + return true; + } + + /// Checks if bounds are within screen bounds + static bool _isInScreenBounds(Rect bounds, Rect? screenBounds) { + if (screenBounds == null) return true; + return screenBounds.overlaps(bounds); + } + + static Rect _getBounds(RenderBox renderBox) { + if (!renderBox.hasSize) { + return Rect.zero; + } + + try { + final offset = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + return Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height); + } catch (e) { + return Rect.zero; + } + } + + /// Creates a [SnapshotNode] from an element with its children + static SnapshotNode _createNode( + Element element, _CaptureState state, List children, bool isGestureElement, String? widgetName, Rect bounds) { + final isScrollable = getScrollableWidgetName(element.widget) != null; + final elementType = getWidgetElementType(element.widget); + String label = widgetName ?? element.widget.runtimeType.toString(); + return SnapshotNode( + label: label, + type: elementType, + x: bounds.left, + y: bounds.top, + width: bounds.width, + height: bounds.height, + highlighted: isGestureElement, + scrollable: isScrollable, + children: children, + ); + } +} diff --git a/flutter/packages/measure_flutter/lib/src/gestures/layout_snapshot_widget_filters.dart b/flutter/packages/measure_flutter/lib/src/gestures/layout_snapshot_widget_filters.dart new file mode 100644 index 000000000..46bc0753c --- /dev/null +++ b/flutter/packages/measure_flutter/lib/src/gestures/layout_snapshot_widget_filters.dart @@ -0,0 +1,161 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +/// Returns an un-obfuscated name for the given widget +/// type, null if no match is found. +String? getFrameworkWidgetName(Widget widget) { + final type = switch (widget) { + FilledButton _ => 'FilledButton', + OutlinedButton _ => 'OutlinedButton', + TextButton _ => 'TextButton', + ElevatedButton _ => 'ElevatedButton', + CupertinoButton _ => 'CupertinoButton', + ButtonStyleButton _ => 'ButtonStyleButton', + MaterialButton _ => 'MaterialButton', + IconButton _ => 'IconButton', + FloatingActionButton _ => 'FloatingActionButton', + ListTile _ => 'ListTile', + PopupMenuButton _ => 'PopupMenuButton', + PopupMenuItem _ => 'PopupMenuItem', + DropdownButton w when w.onChanged != null => 'DropdownButton', + DropdownMenuItem _ => 'DropdownMenuItem', + ExpansionTile _ => 'ExpansionTile', + Card _ => 'Card', + Scaffold _ => 'Scaffold', + CupertinoPageScaffold _ => 'CupertinoPageScaffold', + MaterialApp _ => 'MaterialApp', + CupertinoApp _ => 'CupertinoApp', + Container _ => 'Container', + Row _ => 'Row', + Column _ => 'Column', + ListView _ => 'ListView', + PageView _ => 'PageView', + SingleChildScrollView _ => 'SingleChildScrollView', + ScrollView _ => 'ScrollView', + Text _ => 'Text', + RichText _ => 'RichText', + _ => null, + }; + return type; +} + +/// Returns an un-obfuscated name for the given widget +/// type, null if no match is found. +String? getClickableWidgetName(Widget widget) { + return switch (widget) { + FilledButton w when w.enabled => 'FilledButton', + OutlinedButton w when w.enabled => 'OutlinedButton', + CupertinoButton w when w.enabled => 'CupertinoButton', + TextButton w when w.enabled => 'TextButton', + ElevatedButton w when w.enabled => 'ElevatedButton', + ButtonStyleButton w when w.enabled => 'ButtonStyleButton', + MaterialButton w when w.enabled => 'MaterialButton', + IconButton w when w.onPressed != null => 'IconButton', + FloatingActionButton w when w.onPressed != null => 'FloatingActionButton', + CupertinoButton w when w.enabled => 'CupertinoButton', + ListTile _ => 'ListTile', + PopupMenuButton w when w.enabled => 'PopupMenuButton', + PopupMenuItem w when w.enabled => 'PopupMenuItem', + DropdownButton w when w.onChanged != null => 'DropdownButton', + DropdownMenuItem _ => 'DropdownMenuItem', + ExpansionTile _ => 'ExpansionTile', + Card _ => 'Card', + InkWell w when w.onTap != null => 'InkWell', + GestureDetector w when w.onTap != null || w.onDoubleTap != null || w.onLongPress != null => 'GestureDetector', + InkResponse w when w.onTap != null => 'InkResponse', + InputChip w when w.onPressed != null => 'InputChip', + ActionChip w when w.onPressed != null => 'ActionChip', + FilterChip w when w.onSelected != null => 'FilterChip', + ChoiceChip w when w.onSelected != null => 'ChoiceChip', + Checkbox w when w.onChanged != null => 'Checkbox', + Switch w when w.onChanged != null => 'Switch', + Radio _ => 'Radio', + CupertinoSwitch w when w.onChanged != null => 'CupertinoSwitch', + CheckboxListTile w when w.onChanged != null => 'CheckboxListTile', + SwitchListTile w when w.onChanged != null => 'SwitchListTile', + RadioListTile _ => 'RadioListTile', + Slider w when w.onChanged != null => 'Slider', + RangeSlider w when w.onChanged != null => 'RangeSlider', + CupertinoSlider w when w.onChanged != null => 'CupertinoSlider', + TextField _ => 'TextField', + TextFormField _ => 'TextFormField', + CupertinoTextField _ => 'CupertinoTextField', + Stepper _ => 'Stepper', + _ => null, + }; +} + +/// Returns an un-obfuscated name for the given widget +/// type, null if no match is found. +String? getScrollableWidgetName(Widget widget) { + return switch (widget) { + ListView _ => 'ListView', + PageView _ => 'PageView', + SingleChildScrollView _ => 'SingleChildScrollView', + ScrollView _ => 'ScrollView', + _ => null, + }; +} + +/// Returns the element type for the given widget based on its semantic purpose. +/// Returns "container" as the default if no specific match is found. +String getWidgetElementType(Widget widget) { + return switch (widget) { + // Button types + FilledButton _ => 'button', + OutlinedButton _ => 'button', + TextButton _ => 'button', + ElevatedButton _ => 'button', + CupertinoButton _ => 'button', + ButtonStyleButton _ => 'button', + MaterialButton _ => 'button', + IconButton _ => 'button', + FloatingActionButton _ => 'button', + PopupMenuButton _ => 'button', + PopupMenuItem _ => 'button', + ExpansionTile _ => 'button', + InkWell _ => 'button', + GestureDetector _ => 'button', + InkResponse _ => 'button', + InputChip _ => 'button', + ActionChip _ => 'button', + FilterChip _ => 'button', + ChoiceChip _ => 'button', + + // Text types + Text _ => 'text', + RichText _ => 'text', + + // Input types + TextField _ => 'input', + TextFormField _ => 'input', + CupertinoTextField _ => 'input', + + // Checkbox types + Checkbox _ => 'checkbox', + CheckboxListTile _ => 'checkbox', + Switch _ => 'checkbox', + CupertinoSwitch _ => 'checkbox', + SwitchListTile _ => 'checkbox', + + // Radio types + Radio _ => 'radio', + RadioListTile _ => 'radio', + + // Dropdown types + DropdownButton _ => 'dropdown', + DropdownMenuItem _ => 'dropdown', + + // Slider types + Slider _ => 'slider', + RangeSlider _ => 'slider', + CupertinoSlider _ => 'slider', + + // List types + ListView _ => 'list', + ListTile _ => 'list', + + // Default to container for unknown types + _ => 'container', + }; +} diff --git a/flutter/packages/measure_flutter/lib/src/gestures/long_click_data.dart b/flutter/packages/measure_flutter/lib/src/gestures/long_click_data.dart index 01226b431..fa920a86b 100644 --- a/flutter/packages/measure_flutter/lib/src/gestures/long_click_data.dart +++ b/flutter/packages/measure_flutter/lib/src/gestures/long_click_data.dart @@ -17,16 +17,15 @@ class LongClickData implements JsonSerialized { final int? width; final int? height; - LongClickData({ - required this.target, - this.targetId, - required this.x, - required this.y, - required this.touchDownTime, - required this.touchUpTime, - this.width, - this.height - }); + LongClickData( + {required this.target, + this.targetId, + required this.x, + required this.y, + required this.touchDownTime, + required this.touchUpTime, + this.width, + this.height}); @override Map toJson() => _$LongClickDataToJson(this); diff --git a/flutter/packages/measure_flutter/lib/src/gestures/msr_gesture_detector.dart b/flutter/packages/measure_flutter/lib/src/gestures/msr_gesture_detector.dart index d94f8e03e..4ddc52bae 100644 --- a/flutter/packages/measure_flutter/lib/src/gestures/msr_gesture_detector.dart +++ b/flutter/packages/measure_flutter/lib/src/gestures/msr_gesture_detector.dart @@ -1,13 +1,10 @@ -import 'dart:developer' as developer; - -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:measure_flutter/src/gestures/click_data.dart'; +import 'package:measure_flutter/src/gestures/layout_snapshot_widget_filters.dart'; +import 'package:measure_flutter/src/gestures/msr_scroll_direction.dart'; import 'package:measure_flutter/src/gestures/scroll_data.dart'; -import 'package:measure_flutter/src/gestures/scroll_direction.dart'; -import 'detected_element.dart'; +import '../../measure_flutter.dart'; import 'long_click_data.dart'; const _tapDeltaArea = 20 * 20; @@ -16,16 +13,18 @@ Element? _clickTrackerElement; class MsrGestureDetector extends StatefulWidget { final Widget child; - final Function(ClickData) onClick; - final Function(LongClickData) onLongClick; - final Function(ScrollData) onScroll; + final Map layoutSnapshotWidgetFilter; + final Future Function(ClickData, SnapshotNode?) onClick; + final Future Function(LongClickData, SnapshotNode?) onLongClick; + final Future Function(ScrollData) onScroll; const MsrGestureDetector({ super.key, - required this.child, + required this.layoutSnapshotWidgetFilter, required this.onClick, required this.onLongClick, required this.onScroll, + required this.child, }); @override @@ -48,10 +47,11 @@ class MsrGestureDetectorState extends State { @override Widget build(BuildContext context) { final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + final screenSize = MediaQuery.of(context).size; return Listener( behavior: HitTestBehavior.translucent, - onPointerDown: onPointerDown, - onPointerUp: (event) => onPointerUp(event, devicePixelRatio), + onPointerDown: _onPointerDown, + onPointerUp: (event) => _onPointerUp(event, devicePixelRatio, screenSize), onPointerMove: _onPointerMove, onPointerCancel: _onPointerCancel, child: widget.child, @@ -59,7 +59,9 @@ class MsrGestureDetectorState extends State { } @visibleForTesting - void onPointerDown(PointerDownEvent event) { + void onPointerDown(PointerDownEvent event) => _onPointerDown(event); + + void _onPointerDown(PointerDownEvent event) { try { _lastPointerId = event.pointer; _lastPointerDownLocation = event.position; @@ -70,13 +72,14 @@ class MsrGestureDetectorState extends State { } @visibleForTesting - void onPointerUp(PointerUpEvent event, double devicePixelRatio) { + void onPointerUp(PointerUpEvent event, double devicePixelRatio, Size screenSize) => + _onPointerUp(event, devicePixelRatio, screenSize); + + void _onPointerUp(PointerUpEvent event, double devicePixelRatio, Size screenSize) { try { final location = _lastPointerDownLocation; final downTime = _pointerDownTime; - if (location == null || - event.pointer != _lastPointerId || - downTime == null) { + if (location == null || event.pointer != _lastPointerId || downTime == null) { return; } @@ -85,10 +88,9 @@ class MsrGestureDetectorState extends State { if (delta.distanceSquared < _tapDeltaArea) { if (duration >= _longClickDuration) { - _handleLongClick( - event.position, downTime, event.timeStamp, devicePixelRatio); + _handleLongClick(event.position, downTime, event.timeStamp, devicePixelRatio, screenSize); } else { - _handleClick(event.position, devicePixelRatio); + _handleClick(event.position, devicePixelRatio, screenSize); } } @@ -96,7 +98,7 @@ class MsrGestureDetectorState extends State { _handleScrollEnd(event.position, delta, devicePixelRatio); } - _resetState(); + _resetPointerState(); } catch (exception, stacktrace) { _logError('onPointerUp', exception, stacktrace); } @@ -123,30 +125,48 @@ class MsrGestureDetectorState extends State { } void _resetPointerState() { - _lastPointerDownLocation = null; _lastPointerId = null; + _lastPointerDownLocation = null; _pointerDownTime = null; _isScrolling = false; } - void _handleClick(Offset position, double devicePixelRatio) { - final tapInfo = _findElementAt(position, _getClickableElementType, true); - if (tapInfo == null) { - developer.log("No clickable element found at $position", name: 'measure'); - return; - } + void _handleClick(Offset position, double devicePixelRatio, Size screenSize) { + final screenBounds = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height); - final label = _extractLabel(tapInfo.element); - widget.onClick( - ClickData( - target: tapInfo.type, - x: (position.dx * devicePixelRatio).roundToDouble(), - y: (position.dy * devicePixelRatio).roundToDouble(), - targetId: truncateLabel(label, maxLength: 32), - touchDownTime: null, - touchUpTime: null, - ), + final result = LayoutSnapshotCapture.capture( + _clickTrackerElement, + screenBounds: screenBounds, + detectionPosition: position, + detectionMode: GestureDetectionMode.click, + widgetFilter: Measure.instance.getLayoutSnapshotWidgetFilter(), ); + + if (result != null) { + // Check if we detected a clickable element + if (result.gestureElement == null || result.gestureElementType == null) { + _log(LogLevel.debug, "No clickable element found at $position"); + return; + } + + widget + .onClick( + ClickData( + target: result.gestureElementType!, + x: (position.dx * devicePixelRatio).roundToDouble(), + y: (position.dy * devicePixelRatio).roundToDouble(), + targetId: null, + touchDownTime: null, + touchUpTime: null, + width: result.gestureElement?.size?.width.toInt(), + height: result.gestureElement?.size?.height.toInt(), + ), + result.snapshot, + ) + .catchError((error, stackTrace) { + _logError('onClick', error, stackTrace); + }); + } } void _handleLongClick( @@ -154,44 +174,59 @@ class MsrGestureDetectorState extends State { Duration downTime, Duration upTime, double devicePixelRatio, + Size screenSize, ) { - final tapInfo = _findElementAt(position, _getClickableElementType, true); - if (tapInfo == null) { - developer.log("No clickable element found at $position", name: 'measure'); + final screenBounds = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height); + final result = LayoutSnapshotCapture.capture( + _clickTrackerElement, + detectionPosition: position, + detectionMode: GestureDetectionMode.click, + widgetFilter: Measure.instance.getLayoutSnapshotWidgetFilter(), + screenBounds: screenBounds, + ); + + if (result?.gestureElement == null || result?.gestureElementType == null) { + _log(LogLevel.debug, "No clickable element found at $position"); return; } - final label = _extractLabel(tapInfo.element); - widget.onLongClick( + widget + .onLongClick( LongClickData( - target: tapInfo.type, + target: result!.gestureElementType!, x: (position.dx * devicePixelRatio).roundToDouble(), y: (position.dy * devicePixelRatio).roundToDouble(), - targetId: truncateLabel(label, maxLength: 32), + targetId: null, touchDownTime: null, touchUpTime: null, + width: result.gestureElement?.size?.width.toInt(), + height: result.gestureElement?.size?.height.toInt(), ), - ); + result.snapshot, + ) + .catchError((error, stackTrace) { + _logError('onLongClick', error, stackTrace); + }); } - void _handleScrollEnd( - Offset position, Offset delta, double devicePixelRatio) { - final scrollInfo = - _findElementAt(position, _getScrollableElementType, false); - if (scrollInfo == null) { + void _handleScrollEnd(Offset position, Offset delta, double devicePixelRatio) { + final (scrollableElement, scrollableType) = _findScrollableElement(position); + + if (scrollableElement == null || scrollableType == null) { return; } - final scrollAxis = _findScrollAxis(scrollInfo.element.widget); + final scrollAxis = _findScrollAxis(scrollableElement.widget); final scrollDirection = _findScrollDirection(delta); final isValidScroll = _validateScroll(scrollAxis, scrollDirection); if (!isValidScroll) { return; } - widget.onScroll( + widget + .onScroll( ScrollData( - target: scrollInfo.type, + target: scrollableType, x: ((position.dx - delta.dx) * devicePixelRatio).roundToDouble(), y: ((position.dy - delta.dy) * devicePixelRatio).roundToDouble(), endX: (position.dx * devicePixelRatio).roundToDouble(), @@ -201,144 +236,10 @@ class MsrGestureDetectorState extends State { touchDownTime: null, touchUpTime: null, ), - ); - } - - DetectedElement? _findElementAt( - Offset position, - String? Function(Element) predicate, - bool isClickable, - ) { - final rootElement = _clickTrackerElement; - if (rootElement == null) return null; - - DetectedElement? result; - - void elementFinder(Element element) { - if (result != null) return; - - if (!_isElementHitTestable(element, position)) return; - - final type = predicate(element); - if (type != null) { - result = DetectedElement(element: element, type: type); - } - - if (result == null) { - element.visitChildElements(elementFinder); - } - } - - rootElement.visitChildElements(elementFinder); - return result; - } - - bool _isElementHitTestable(Element element, Offset position) { - final rootElement = _clickTrackerElement; - if (rootElement == null) return false; - - final renderObject = element.renderObject; - if (renderObject == null || - (renderObject is RenderBox && !renderObject.hasSize)) { - return false; - } - - // Check hit test - if (renderObject is RenderPointerListener) { - final hitResult = BoxHitTestResult(); - if (!renderObject.hitTest(hitResult, position: position)) { - return false; - } - } - - // Check bounds - final transform = renderObject.getTransformTo(rootElement.renderObject); - final paintBounds = - MatrixUtils.transformRect(transform, renderObject.paintBounds); - return paintBounds.contains(position); - } - - String? _getClickableElementType(Element element) { - final widget = element.widget; - return _getClickableType(widget); - } - - String? _getScrollableElementType(Element element) { - final widget = element.widget; - return _getScrollableType(widget); - } - - String? _extractLabel(Element element) { - final widget = element.widget; - - return switch (widget) { - Text w => w.data, - Semantics w => w.properties.label, - Icon w => w.semanticLabel, - Tooltip w => w.message, - ButtonStyleButton w when w.child is Text => (w.child as Text).data, - ListTile w when w.title is Text => (w.title as Text).data, - InkWell w when w.child is Text => (w.child as Text).data, - _ => null, - }; - } - - String? _getClickableType(Widget widget) { - return switch (widget) { - FilledButton w when w.enabled => 'FilledButton', - OutlinedButton w when w.enabled => 'OutlinedButton', - CupertinoButton w when w.enabled => 'CupertinoButton', - TextButton w when w.enabled => 'TextButton', - ElevatedButton w when w.enabled => 'ElevatedButton', - ButtonStyleButton w when w.enabled => 'ButtonStyleButton', - MaterialButton w when w.enabled => 'MaterialButton', - IconButton w when w.onPressed != null => 'IconButton', - FloatingActionButton w when w.onPressed != null => 'FloatingActionButton', - CupertinoButton w when w.enabled => 'CupertinoButton', - ListTile _ => 'ListTile', - PopupMenuButton w when w.enabled => 'PopupMenuButton', - PopupMenuItem w when w.enabled => 'PopupMenuItem', - DropdownButton w when w.onChanged != null => 'DropdownButton', - DropdownMenuItem _ => 'DropdownMenuItem', - ExpansionTile _ => 'ExpansionTile', - Card _ => 'Card', - InkWell w when w.onTap != null => 'InkWell', - GestureDetector w - when w.onTap != null || - w.onDoubleTap != null || - w.onLongPress != null => - 'GestureDetector', - InkResponse w when w.onTap != null => 'InkResponse', - InputChip w when w.onPressed != null => 'InputChip', - ActionChip w when w.onPressed != null => 'ActionChip', - FilterChip w when w.onSelected != null => 'FilterChip', - ChoiceChip w when w.onSelected != null => 'ChoiceChip', - Checkbox w when w.onChanged != null => 'Checkbox', - Switch w when w.onChanged != null => 'Switch', - Radio _ => 'Radio', - CupertinoSwitch w when w.onChanged != null => 'CupertinoSwitch', - CheckboxListTile w when w.onChanged != null => 'CheckboxListTile', - SwitchListTile w when w.onChanged != null => 'SwitchListTile', - RadioListTile _ => 'RadioListTile', - Slider w when w.onChanged != null => 'Slider', - RangeSlider w when w.onChanged != null => 'RangeSlider', - CupertinoSlider w when w.onChanged != null => 'CupertinoSlider', - TextField _ => 'TextField', - TextFormField _ => 'TextFormField', - CupertinoTextField _ => 'CupertinoTextField', - Stepper _ => 'Stepper', - _ => null, - }; - } - - String? _getScrollableType(Widget widget) { - return switch (widget) { - ListView _ => 'ListView', - PageView _ => 'PageView', - SingleChildScrollView _ => 'SingleChildScrollView', - ScrollView _ => 'ScrollView', - _ => null, - }; + ) + .catchError((error, stackTrace) { + _logError('onScroll', error, stackTrace); + }); } Axis? _findScrollAxis(Widget widget) { @@ -364,30 +265,66 @@ class MsrGestureDetectorState extends State { case null: return false; case Axis.horizontal: - return scrollDirection == MsrScrollDirection.left || - scrollDirection == MsrScrollDirection.right; + return scrollDirection == MsrScrollDirection.left || scrollDirection == MsrScrollDirection.right; case Axis.vertical: - return scrollDirection == MsrScrollDirection.up || - scrollDirection == MsrScrollDirection.down; + return scrollDirection == MsrScrollDirection.up || scrollDirection == MsrScrollDirection.down; } } - String? truncateLabel(String? label, {int maxLength = 32}) { - if (label == null || label.length <= maxLength) { - return label; + /// Finds a scrollable element at the given position. + (Element?, String?) _findScrollableElement(Offset position) { + if (_clickTrackerElement == null) { + return (null, null); } - return '${label.substring(0, maxLength - 3)}...'; + + Element? foundElement; + String? foundType; + + void traverse(Element element) { + final renderObject = element.renderObject; + if (renderObject == null || renderObject is! RenderBox || !renderObject.hasSize) { + element.visitChildElements(traverse); + return; + } + final scrollableType = getScrollableWidgetName(element.widget); + if (scrollableType != null) { + if (_hitTest(element, position, _clickTrackerElement!)) { + foundElement = element; + foundType = scrollableType; + } + } + element.visitChildElements(traverse); + } + + traverse(_clickTrackerElement!); + return (foundElement, foundType); } - void _resetState() { - _pointerDownTime = null; - _lastPointerDownLocation = null; - _isScrolling = false; - _lastPointerId = null; + /// Performs hit test to check if position is within element bounds + bool _hitTest(Element element, Offset position, Element rootElement) { + final renderObject = element.renderObject; + if (renderObject == null || (renderObject is RenderBox && !renderObject.hasSize)) { + return false; + } + try { + final transform = renderObject.getTransformTo(rootElement.renderObject); + final transformedBounds = MatrixUtils.transformRect(transform, renderObject.paintBounds); + return transformedBounds.contains(position); + } catch (e) { + return false; + } + } + + void _log(LogLevel level, String message) { + Measure.instance.getLogger()?.log(level, message); } void _logError(String method, Object exception, StackTrace stackTrace) { - developer.log('Error in $method: $exception', - stackTrace: stackTrace, name: 'measure'); + Measure.instance.getLogger()?.log( + LogLevel.error, + 'Error tracking gesture $method: $exception', + exception, + stackTrace, + ); } } diff --git a/flutter/packages/measure_flutter/lib/src/gestures/scroll_direction.dart b/flutter/packages/measure_flutter/lib/src/gestures/msr_scroll_direction.dart similarity index 96% rename from flutter/packages/measure_flutter/lib/src/gestures/scroll_direction.dart rename to flutter/packages/measure_flutter/lib/src/gestures/msr_scroll_direction.dart index 6fbdb0337..d755f80fb 100644 --- a/flutter/packages/measure_flutter/lib/src/gestures/scroll_direction.dart +++ b/flutter/packages/measure_flutter/lib/src/gestures/msr_scroll_direction.dart @@ -1,7 +1,6 @@ - enum MsrScrollDirection { left, up, right, down, -} \ No newline at end of file +} diff --git a/flutter/packages/measure_flutter/lib/src/gestures/scroll_data.dart b/flutter/packages/measure_flutter/lib/src/gestures/scroll_data.dart index 512847707..c97d41950 100644 --- a/flutter/packages/measure_flutter/lib/src/gestures/scroll_data.dart +++ b/flutter/packages/measure_flutter/lib/src/gestures/scroll_data.dart @@ -1,5 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; -import 'package:measure_flutter/src/gestures/scroll_direction.dart'; +import 'package:measure_flutter/src/gestures/msr_scroll_direction.dart'; import 'package:measure_flutter/src/serialization/json_serializable.dart'; part 'scroll_data.g.dart'; diff --git a/flutter/packages/measure_flutter/lib/src/gestures/snapshot_node.dart b/flutter/packages/measure_flutter/lib/src/gestures/snapshot_node.dart new file mode 100644 index 000000000..006153439 --- /dev/null +++ b/flutter/packages/measure_flutter/lib/src/gestures/snapshot_node.dart @@ -0,0 +1,37 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'snapshot_node.g.dart'; + +/// Represents a single node in the layout snapshot +/// which can have nested children of the same type. +@JsonSerializable() +class SnapshotNode { + final String label; + final String type; + final double x; + final double y; + final double width; + final double height; + final String? id; + final bool highlighted; + final bool scrollable; + final List children; + + /// Creates a new [SnapshotNode]. + SnapshotNode({ + this.id, + this.highlighted = false, + this.scrollable = false, + this.type = "container", + required this.label, + required this.x, + required this.y, + required this.width, + required this.height, + required this.children, + }); + + Map toJson() => _$SnapshotNodeToJson(this); + + static SnapshotNode fromJson(Map json) => _$SnapshotNodeFromJson(json); +} diff --git a/flutter/packages/measure_flutter/lib/src/gestures/snapshot_node.g.dart b/flutter/packages/measure_flutter/lib/src/gestures/snapshot_node.g.dart new file mode 100644 index 000000000..1c4879c9a --- /dev/null +++ b/flutter/packages/measure_flutter/lib/src/gestures/snapshot_node.g.dart @@ -0,0 +1,36 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'snapshot_node.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SnapshotNode _$SnapshotNodeFromJson(Map json) => SnapshotNode( + id: json['id'] as String?, + highlighted: json['highlighted'] as bool? ?? false, + scrollable: json['scrollable'] as bool? ?? false, + type: json['type'] as String? ?? "container", + label: json['label'] as String, + x: (json['x'] as num).toDouble(), + y: (json['y'] as num).toDouble(), + width: (json['width'] as num).toDouble(), + height: (json['height'] as num).toDouble(), + children: (json['children'] as List) + .map((e) => SnapshotNode.fromJson(e as Map)) + .toList(), + ); + +Map _$SnapshotNodeToJson(SnapshotNode instance) => + { + 'label': instance.label, + 'type': instance.type, + 'x': instance.x, + 'y': instance.y, + 'width': instance.width, + 'height': instance.height, + 'id': instance.id, + 'highlighted': instance.highlighted, + 'scrollable': instance.scrollable, + 'children': instance.children, + }; diff --git a/flutter/packages/measure_flutter/lib/src/http/http_method.dart b/flutter/packages/measure_flutter/lib/src/http/http_method.dart index 16305a420..85ff5b981 100644 --- a/flutter/packages/measure_flutter/lib/src/http/http_method.dart +++ b/flutter/packages/measure_flutter/lib/src/http/http_method.dart @@ -1,8 +1,8 @@ /// Enumeration of HTTP methods supported for tracking network requests. -/// +/// /// [HttpMethod] represents the standard HTTP methods that can be tracked /// by the Measure SDK when monitoring network requests. -/// +/// /// **Usage:** /// ```dart /// Measure.instance.trackHttpEvent( @@ -16,24 +16,29 @@ enum HttpMethod { /// HTTP GET method for retrieving data get, - /// HTTP POST method for creating data + + /// HTTP POST method for creating data post, + /// HTTP PUT method for updating/replacing data put, + /// HTTP DELETE method for removing data delete, + /// HTTP PATCH method for partial updates patch, + /// Fallback for unrecognized HTTP methods unknown; /// Converts a string representation to an [HttpMethod]. - /// + /// /// **Parameters:** /// - [value]: The HTTP method string (case-insensitive) - /// + /// /// **Returns:** The corresponding [HttpMethod], or [HttpMethod.unknown] if not recognized - /// + /// /// **Example:** /// ```dart /// final method = HttpMethod.fromString('GET'); // Returns HttpMethod.get @@ -56,4 +61,3 @@ enum HttpMethod { } } } - diff --git a/flutter/packages/measure_flutter/lib/src/isolate/file_processing_isolate.dart b/flutter/packages/measure_flutter/lib/src/isolate/file_processing_isolate.dart new file mode 100644 index 000000000..03e52eecd --- /dev/null +++ b/flutter/packages/measure_flutter/lib/src/isolate/file_processing_isolate.dart @@ -0,0 +1,366 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:measure_flutter/src/logger/log_level.dart'; +import 'package:measure_flutter/src/logger/logger.dart'; + +import '../gestures/snapshot_node.dart'; +import 'file_processor.dart'; + +const Duration _initTimeout = Duration(seconds: 10); +const Duration _writeTimeout = Duration(seconds: 30); + +/// Request types for the file processing isolate +sealed class FileProcessingRequest { + final String requestId; + final SendPort responsePort; + + FileProcessingRequest({ + required this.requestId, + required this.responsePort, + }); +} + +/// Request to compress an image and save to file +class ImageCompressionRequest extends FileProcessingRequest { + final CompressAndSaveParams params; + + ImageCompressionRequest({ + required super.requestId, + required super.responsePort, + required this.params, + }); +} + +/// Request to serialize JSON and save to file +class LayoutSnapshotWriteRequest extends FileProcessingRequest { + final SnapshotNode snapshot; + final String fileName; + final String rootPath; + final bool compress; + + LayoutSnapshotWriteRequest({ + required super.requestId, + required super.responsePort, + required this.snapshot, + required this.fileName, + required this.rootPath, + required this.compress, + }); +} + +/// Response from the file processing isolate +class FileProcessingResponse { + final String requestId; + final FileProcessingResult result; + + FileProcessingResponse({ + required this.requestId, + required this.result, + }); +} + +/// Manages a persistent isolate for file processing operations +/// +/// This isolate handles expensive operations like JSON serialization and +/// image compression off the main thread, improving UI performance. +class FileProcessingIsolate { + final Logger _logger; + Isolate? _isolate; + SendPort? _sendPort; + ReceivePort? _receivePort; + final Map> _pendingRequests = {}; + bool _isInitialized = false; + + FileProcessingIsolate({required Logger logger}) : _logger = logger; + + /// Initialize the persistent isolate worker + Future init() async { + if (_isInitialized) { + return; + } + + try { + _receivePort = ReceivePort(); + final receivePort = _receivePort; + + if (receivePort == null) { + throw StateError('Failed to create ReceivePort'); + } + + // Spawn the isolate + _isolate = await Isolate.spawn( + _isolateWorker, + receivePort.sendPort, + debugName: 'MeasureFileProcessing', + ); + + // Wait for the isolate to send its SendPort and then handle all subsequent messages + final completer = Completer(); + + receivePort.listen( + (message) { + if (message is SendPort && !completer.isCompleted) { + // First message is the SendPort + completer.complete(message); + } else { + // All subsequent messages are responses + _handleResponse(message); + } + }, + onError: (Object error, StackTrace stackTrace) { + _logger.log( + LogLevel.error, + 'FileProcessingIsolate: Error in receive port: $error', + error, + stackTrace, + ); + if (!completer.isCompleted) { + completer.completeError(error, stackTrace); + } + }, + onDone: () { + if (!completer.isCompleted) { + completer.completeError( + StateError('Receive port closed before initialization'), + ); + } + }, + ); + + _sendPort = await completer.future.timeout( + _initTimeout, + onTimeout: () { + throw TimeoutException('Failed to initialize file processing isolate'); + }, + ); + + _isInitialized = true; + } catch (e, stackTrace) { + _logger.log( + LogLevel.error, + 'FileProcessingIsolate: Failed to initialize: $e', + e, + stackTrace, + ); + await dispose(); + } + } + + /// Dispose the isolate and clean up resources + Future dispose() async { + // Complete all pending requests with error + for (final completer in _pendingRequests.values) { + if (!completer.isCompleted) { + completer.complete( + const FileProcessingResult( + error: 'Isolate disposed before request completed', + ), + ); + } + } + _pendingRequests.clear(); + + _isolate?.kill(priority: Isolate.immediate); + _isolate = null; + _receivePort?.close(); + _receivePort = null; + _sendPort = null; + _isInitialized = false; + } + + /// Process image compression request + Future processImageCompression( + CompressAndSaveParams params, + ) async { + if (!_isInitialized || _sendPort == null) { + return const FileProcessingResult( + error: 'Isolate not initialized', + ); + } + + final requestId = params.fileName; + final completer = Completer(); + _pendingRequests[requestId] = completer; + + final sendPort = _sendPort; + final receivePort = _receivePort; + + if (sendPort == null || receivePort == null) { + return const FileProcessingResult( + error: 'Isolate ports not available', + ); + } + + try { + sendPort.send( + ImageCompressionRequest( + requestId: requestId, + responsePort: receivePort.sendPort, + params: params, + ), + ); + + // Wait for response with timeout + return await completer.future.timeout( + _writeTimeout, + onTimeout: () { + return const FileProcessingResult( + error: 'Image compression timed out', + ); + }, + ); + } catch (e, stackTrace) { + _logger.log( + LogLevel.error, + 'FileProcessingIsolate: Error processing image: $e', + e, + stackTrace, + ); + return FileProcessingResult(error: e.toString()); + } finally { + _pendingRequests.remove(requestId); + } + } + + /// Process JSON write request + Future processLayoutSnapshotWrite( + SnapshotNode snapshot, + String fileName, + String rootPath, { + bool compress = true, + }) async { + if (!_isInitialized || _sendPort == null) { + return const FileProcessingResult( + error: 'Isolate not initialized', + ); + } + + final requestId = fileName; + final completer = Completer(); + _pendingRequests[requestId] = completer; + + final sendPort = _sendPort; + final receivePort = _receivePort; + + if (sendPort == null || receivePort == null) { + return const FileProcessingResult( + error: 'Isolate ports not available', + ); + } + + try { + sendPort.send( + LayoutSnapshotWriteRequest( + requestId: requestId, + responsePort: receivePort.sendPort, + snapshot: snapshot, + fileName: fileName, + rootPath: rootPath, + compress: compress, + ), + ); + + // Wait for response with timeout + return await completer.future.timeout( + _writeTimeout, + onTimeout: () { + return const FileProcessingResult( + error: 'JSON write timed out', + ); + }, + ); + } catch (e, stackTrace) { + _logger.log( + LogLevel.error, + 'FileProcessingIsolate: Error processing JSON: $e', + e, + stackTrace, + ); + return FileProcessingResult(error: e.toString()); + } finally { + _pendingRequests.remove(requestId); + } + } + + /// Handle responses from the isolate + void _handleResponse(Object? message) { + if (message is FileProcessingResponse) { + final completer = _pendingRequests[message.requestId]; + if (completer != null && !completer.isCompleted) { + completer.complete(message.result); + } + } else { + _logger.log( + LogLevel.warning, + 'FileProcessingIsolate: Received unexpected message type: ${message.runtimeType}', + ); + } + } + + /// Isolate worker entry point + static void _isolateWorker(SendPort mainSendPort) { + final receivePort = ReceivePort(); + + // Send our SendPort to the main isolate + mainSendPort.send(receivePort.sendPort); + + // Listen for requests + receivePort.listen((message) async { + if (message is ImageCompressionRequest) { + await _handleImageCompression(message); + } else if (message is LayoutSnapshotWriteRequest) { + await _handleJsonWrite(message); + } + }); + } + + /// Handle image compression in isolate + static Future _handleImageCompression( + ImageCompressionRequest request, + ) async { + try { + final result = await compressAndSaveInIsolateWorker(request.params); + + request.responsePort.send( + FileProcessingResponse( + requestId: request.requestId, + result: result, + ), + ); + } catch (e) { + request.responsePort.send( + FileProcessingResponse( + requestId: request.requestId, + result: FileProcessingResult(error: e.toString()), + ), + ); + } + } + + /// Handle JSON write in isolate + static Future _handleJsonWrite(LayoutSnapshotWriteRequest request) async { + try { + final result = await writeJsonToFileInIsolateWorker( + request.snapshot, + request.fileName, + request.rootPath, + request.compress, + ); + + request.responsePort.send( + FileProcessingResponse( + requestId: request.requestId, + result: result, + ), + ); + } catch (e) { + request.responsePort.send( + FileProcessingResponse( + requestId: request.requestId, + result: FileProcessingResult(error: e.toString()), + ), + ); + } + } +} diff --git a/flutter/packages/measure_flutter/lib/src/isolate/file_processor.dart b/flutter/packages/measure_flutter/lib/src/isolate/file_processor.dart new file mode 100644 index 000000000..ae5282414 --- /dev/null +++ b/flutter/packages/measure_flutter/lib/src/isolate/file_processor.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; +import 'package:measure_flutter/src/isolate/file_processing_isolate.dart'; + +import '../gestures/snapshot_node.dart'; + +// Module-level reference to shared file processing isolate +FileProcessingIsolate? _sharedWorker; + +/// Initialize the shared file processing isolate worker +void initializeFileProcessingIsolate(FileProcessingIsolate worker) { + _sharedWorker = worker; +} + +// Parameter classes +class RgbaToJpegParams { + final Uint8List rgbaBytes; + final int width; + final int height; + final int jpegQuality; + + const RgbaToJpegParams({ + required this.rgbaBytes, + required this.width, + required this.height, + required this.jpegQuality, + }); +} + +class ImageToJpegParams { + final Uint8List originalBytes; + final int jpegQuality; + + const ImageToJpegParams({ + required this.originalBytes, + required this.jpegQuality, + }); +} + +class CompressAndSaveParams { + final Uint8List originalBytes; + final int jpegQuality; + final String fileName; + final String rootPath; + + const CompressAndSaveParams({ + required this.originalBytes, + required this.jpegQuality, + required this.fileName, + required this.rootPath, + }); +} + +class WriteLayoutSnapshotParams { + final SnapshotNode snapshot; + final String fileName; + final String rootPath; + final bool compress; + + const WriteLayoutSnapshotParams({ + required this.snapshot, + required this.fileName, + required this.rootPath, + this.compress = true, + }); +} + +class FileProcessingResult { + final String? filePath; + final String? error; + final int? size; + + const FileProcessingResult({this.filePath, this.error, this.size}); +} + +// Core processing functions +Future convertRgbaToJpegInIsolate(RgbaToJpegParams params) async { + final rgbaImage = img.Image.fromBytes( + width: params.width, + height: params.height, + bytes: params.rgbaBytes.buffer, + order: img.ChannelOrder.rgba, + ); + + final encodedJpg = img.encodeJpg(rgbaImage, quality: params.jpegQuality); + return Uint8List.fromList(encodedJpg); +} + +Future convertImageToJpegInIsolate(ImageToJpegParams params) async { + final originalImage = img.decodeImage(params.originalBytes); + if (originalImage == null) { + throw Exception('Failed to decode image'); + } + + final encodedJpg = img.encodeJpg(originalImage, quality: params.jpegQuality); + return Uint8List.fromList(encodedJpg); +} + +Future compressAndSaveInIsolate(CompressAndSaveParams params) async { + final worker = _sharedWorker; + if (worker == null) { + return const FileProcessingResult(error: 'File processing isolate not initialized'); + } + return worker.processImageCompression(params); +} + +Future writeJsonToFileInIsolate(WriteLayoutSnapshotParams params) async { + final worker = _sharedWorker; + if (worker == null) { + return const FileProcessingResult(error: 'File processing isolate not initialized'); + } + return worker.processLayoutSnapshotWrite( + params.snapshot, + params.fileName, + params.rootPath, + compress: params.compress, + ); +} + +/// Compress image and write to file +Future compressAndSaveInIsolateWorker(CompressAndSaveParams params) async { + try { + final compressedBytes = await convertImageToJpegInIsolate( + ImageToJpegParams( + originalBytes: params.originalBytes, + jpegQuality: params.jpegQuality, + ), + ); + + final filePath = await _writeFile(compressedBytes, params.fileName, params.rootPath); + + return FileProcessingResult( + filePath: filePath, + size: compressedBytes.length, + ); + } catch (e) { + return FileProcessingResult(error: e.toString()); + } +} + +Future _writeFile(Uint8List data, String fileName, String rootPath) async { + final file = File('$rootPath/$fileName'); + await file.writeAsBytes(data); + return file.path; +} + +/// Serialize JSON and write to file +Future writeJsonToFileInIsolateWorker( + SnapshotNode snapshot, + String fileName, + String rootPath, + bool compress, +) async { + try { + final jsonString = jsonEncode(snapshot.toJson()); + final jsonBytes = utf8.encode(jsonString); + + final Uint8List dataToWrite; + if (compress) { + final compressedBytes = gzip.encode(jsonBytes); + dataToWrite = Uint8List.fromList(compressedBytes); + } else { + dataToWrite = Uint8List.fromList(jsonBytes); + } + + final filePath = await _writeFile( + dataToWrite, + fileName, + rootPath, + ); + + return FileProcessingResult( + filePath: filePath, + size: dataToWrite.length, + ); + } catch (e) { + return FileProcessingResult(error: e.toString()); + } +} diff --git a/flutter/packages/measure_flutter/lib/src/measure_api.dart b/flutter/packages/measure_flutter/lib/src/measure_api.dart index f97adcee9..446651388 100644 --- a/flutter/packages/measure_flutter/lib/src/measure_api.dart +++ b/flutter/packages/measure_flutter/lib/src/measure_api.dart @@ -43,7 +43,7 @@ abstract class MeasureApi { bool shouldTrackHttpHeader(String key); - void trackBugReport({ + Future trackBugReport({ required String description, required List attachments, required Map attributes, @@ -94,9 +94,13 @@ abstract class MeasureApi { void setShakeListener(Function? onShake); - void trackClick(ClickData clickData); + Future trackClick(ClickData clickData, SnapshotNode? snapshot); - void trackLongClick(LongClickData longClickData); + Future trackLongClick(LongClickData longClickData, SnapshotNode? snapshot); - void trackScroll(ScrollData scrollData); + Future trackScroll(ScrollData scrollData); + + Map getLayoutSnapshotWidgetFilter(); + + Logger? getLogger(); } diff --git a/flutter/packages/measure_flutter/lib/src/measure_initializer.dart b/flutter/packages/measure_flutter/lib/src/measure_initializer.dart index addbcd3dd..3cc3def5c 100644 --- a/flutter/packages/measure_flutter/lib/src/measure_initializer.dart +++ b/flutter/packages/measure_flutter/lib/src/measure_initializer.dart @@ -6,6 +6,8 @@ import 'package:measure_flutter/src/events/custom_event_collector.dart'; import 'package:measure_flutter/src/exception/exception_collector.dart'; import 'package:measure_flutter/src/gestures/gesture_collector.dart'; import 'package:measure_flutter/src/http/http_collector.dart'; +import 'package:measure_flutter/src/isolate/file_processing_isolate.dart'; +import 'package:measure_flutter/src/isolate/file_processor.dart'; import 'package:measure_flutter/src/logger/flutter_logger.dart'; import 'package:measure_flutter/src/logger/logger.dart'; import 'package:measure_flutter/src/method_channel/method_channel_callbacks.dart'; @@ -43,6 +45,7 @@ final class MeasureInitializer { late final Tracer _tracer; late final FileStorage _fileStorage; late final ShakeDetector _shakeDetector; + late final FileProcessingIsolate _fileProcessingIsolate; Logger get logger => _logger; @@ -80,6 +83,8 @@ final class MeasureInitializer { ShakeDetector get shakeDetector => _shakeDetector; + FileProcessingIsolate get fileProcessingIsolate => _fileProcessingIsolate; + MeasureInitializer(MeasureConfig inputConfig) { _initializeDependencies(inputConfig); } @@ -99,8 +104,7 @@ final class MeasureInitializer { httpUrlAllowlist: inputConfig.httpUrlAllowlist, autoInitializeNativeSDK: inputConfig.autoInitializeNativeSDK, trackActivityIntentData: inputConfig.trackActivityIntentData, - samplingRateForErrorFreeSessions: - inputConfig.samplingRateForErrorFreeSessions, + samplingRateForErrorFreeSessions: inputConfig.samplingRateForErrorFreeSessions, traceSamplingRate: inputConfig.traceSamplingRate, trackActivityLoadTime: inputConfig.trackActivityLoadTime, trackFragmentLoadTime: inputConfig.trackFragmentLoadTime, @@ -112,20 +116,22 @@ final class MeasureInitializer { final randomizer = RandomizerImpl(); _idProvider = IdProviderImpl(randomizer); _methodChannelCallbacks = MethodChannelCallbacks(_methodChannel, _logger); - _signalProcessor = - DefaultSignalProcessor(logger: logger, channel: _methodChannel, configProvider: _configProvider); + _signalProcessor = DefaultSignalProcessor(logger: logger, channel: _methodChannel, configProvider: _configProvider); _screenshotCollector = DefaultScreenshotCollector( logger: logger, idProvider: _idProvider, configProvider: _configProvider, ); + _fileStorage = FileStorage(methodChannel, logger); + + _fileProcessingIsolate = FileProcessingIsolate(logger: _logger); + initializeFileProcessingIsolate(_fileProcessingIsolate); _customEventCollector = CustomEventCollector( logger: logger, signalProcessor: signalProcessor, timeProvider: timeProvider, configProvider: configProvider, ); - _fileStorage = FileStorage(methodChannel, logger); _exceptionCollector = ExceptionCollector( logger: logger, signalProcessor: signalProcessor, @@ -142,7 +148,7 @@ final class MeasureInitializer { signalProcessor: signalProcessor, configProvider: configProvider, ); - _gestureCollector = GestureCollector(signalProcessor, timeProvider); + _gestureCollector = GestureCollector(signalProcessor, timeProvider, _fileStorage, logger, _idProvider); _shakeDetector = ShakeDetectorImpl( methodChannel: _methodChannel, methodChannelCallbacks: methodChannelCallbacks, diff --git a/flutter/packages/measure_flutter/lib/src/measure_internal.dart b/flutter/packages/measure_flutter/lib/src/measure_internal.dart index bb1487cf5..aca0d932b 100644 --- a/flutter/packages/measure_flutter/lib/src/measure_internal.dart +++ b/flutter/packages/measure_flutter/lib/src/measure_internal.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:developer' as developer; + import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:measure_flutter/src/bug_report/bug_report_collector.dart'; @@ -8,7 +11,7 @@ import 'package:measure_flutter/src/gestures/click_data.dart'; import 'package:measure_flutter/src/gestures/gesture_collector.dart'; import 'package:measure_flutter/src/gestures/long_click_data.dart'; import 'package:measure_flutter/src/http/http_collector.dart'; -import 'package:measure_flutter/src/logger/logger.dart'; +import 'package:measure_flutter/src/isolate/file_processing_isolate.dart'; import 'package:measure_flutter/src/measure_initializer.dart'; import 'package:measure_flutter/src/method_channel/msr_method_channel.dart'; import 'package:measure_flutter/src/navigation/navigation_collector.dart'; @@ -37,6 +40,7 @@ final class MeasureInternal { final MsrMethodChannel methodChannel; final IdProvider _idProvider; final ShakeDetector _shakeDetector; + final FileProcessingIsolate _fileProcessingIsolate; MeasureInternal({ required this.initializer, @@ -53,15 +57,17 @@ final class MeasureInternal { _timeProvider = initializer.timeProvider, _tracer = initializer.tracer, _idProvider = initializer.idProvider, - _shakeDetector = initializer.shakeDetector; + _shakeDetector = initializer.shakeDetector, + _fileProcessingIsolate = initializer.fileProcessingIsolate; Future init() async { if (configProvider.autoStart) { - registerCollectors(); + await registerCollectors(); } } - void registerCollectors() { + Future registerCollectors() async { + await _fileProcessingIsolate.init(); _exceptionCollector.register(); _customEventCollector.register(); _httpCollector.register(); @@ -71,7 +77,7 @@ final class MeasureInternal { _gestureCollector.register(); } - void unregisterCollectors() { + Future unregisterCollectors() async { _exceptionCollector.unregister(); _customEventCollector.unregister(); _httpCollector.unregister(); @@ -79,6 +85,7 @@ final class MeasureInternal { _bugReportCollector.unregister(); _shakeDetector.unregister(); _gestureCollector.unregister(); + await initializer.fileProcessingIsolate.dispose(); } void trackCustomEvent(String name, int? timestamp, Map attributes) { @@ -140,12 +147,12 @@ final class MeasureInternal { } Future start() async { - registerCollectors(); + await registerCollectors(); return methodChannel.start(); } Future stop() async { - unregisterCollectors(); + await unregisterCollectors(); return methodChannel.stop(); } @@ -177,8 +184,9 @@ final class MeasureInternal { return methodChannel.getSessionId(); } - void trackBugReport(String description, List attachments, Map attributes) { - _bugReportCollector.trackBugReport(description, attachments, attributes); + Future trackBugReport( + String description, List attachments, Map attributes) async { + await _bugReportCollector.trackBugReport(description, attachments, attributes); } Future captureScreenshot() { @@ -203,15 +211,26 @@ final class MeasureInternal { _shakeDetector.setShakeListener(onShake); } - void trackClick(ClickData clickData) { - _gestureCollector.trackGestureClick(clickData); + Future trackClick(ClickData clickData, SnapshotNode? snapshot) async { + developer.log("snapshot: ${json.encode(snapshot?.toJson())}"); + _gestureCollector.trackGestureClick( + clickData, + snapshot: snapshot, + ); } - void trackLongClick(LongClickData longClickData) { - _gestureCollector.trackGestureLongClick(longClickData); + Future trackLongClick(LongClickData longClickData, SnapshotNode? snapshot) async { + _gestureCollector.trackGestureLongClick( + longClickData, + snapshot: snapshot, + ); } - void trackScroll(ScrollData scrollData) { + Future trackScroll(ScrollData scrollData) async { _gestureCollector.trackGestureScroll(scrollData); } + + Map getLayoutSnapshotWidgetFilter() { + return configProvider.widgetFilter; + } } diff --git a/flutter/packages/measure_flutter/lib/src/measure_widget.dart b/flutter/packages/measure_flutter/lib/src/measure_widget.dart index 907e15534..4409d3d12 100644 --- a/flutter/packages/measure_flutter/lib/src/measure_widget.dart +++ b/flutter/packages/measure_flutter/lib/src/measure_widget.dart @@ -5,16 +5,16 @@ import 'package:measure_flutter/src/screenshot/screenshot_collector.dart'; import 'gestures/msr_gesture_detector.dart'; /// A wrapper widget that enables automatic gesture tracking and screenshot capture. -/// +/// /// [MeasureWidget] should wrap your app's root widget to enable automatic /// tracking of user interactions (taps, long presses, scrolls) and provide /// screenshot capture capabilities for bug reports. -/// +/// /// **Features:** /// - Automatic gesture tracking (clicks, long presses, scrolls) /// - Screenshot capture capability via [RepaintBoundary] /// - Seamless integration with bug reporting -/// +/// /// **Usage:** /// ```dart /// class MyApp extends StatelessWidget { @@ -29,7 +29,7 @@ import 'gestures/msr_gesture_detector.dart'; /// } /// } /// ``` -/// +/// /// **Note:** Place [MeasureWidget] as high as possible in your widget tree /// to capture the maximum screen area for screenshots and gesture tracking. class MeasureWidget extends StatefulWidget { @@ -58,11 +58,13 @@ class _MeasureWidgetState extends State { return RepaintBoundary( key: _repaintBoundaryKey, child: MsrGestureDetector( - child: widget.child, - onClick: (clickData) => Measure.instance.trackClick(clickData), - onLongClick: (longClickData) => - Measure.instance.trackLongClick(longClickData), + layoutSnapshotWidgetFilter: Measure.instance.getLayoutSnapshotWidgetFilter(), + onClick: (clickData, snapshot) => + Measure.instance.trackClick(clickData, snapshot), + onLongClick: (longClickData, snapshot) => + Measure.instance.trackLongClick(longClickData, snapshot), onScroll: (scrollData) => Measure.instance.trackScroll(scrollData), + child: widget.child, ), ); } diff --git a/flutter/packages/measure_flutter/lib/src/method_channel/signal_processor.dart b/flutter/packages/measure_flutter/lib/src/method_channel/signal_processor.dart index 1b1122ec4..5d75555be 100644 --- a/flutter/packages/measure_flutter/lib/src/method_channel/signal_processor.dart +++ b/flutter/packages/measure_flutter/lib/src/method_channel/signal_processor.dart @@ -1,7 +1,5 @@ import 'package:measure_flutter/measure_flutter.dart'; import 'package:measure_flutter/src/config/config_provider.dart'; -import 'package:measure_flutter/src/logger/log_level.dart'; -import 'package:measure_flutter/src/logger/logger.dart'; import 'package:measure_flutter/src/method_channel/msr_method_channel.dart'; import 'package:measure_flutter/src/serialization/json_serializable.dart'; import 'package:measure_flutter/src/tracing/span_data.dart'; @@ -25,7 +23,10 @@ final class DefaultSignalProcessor extends SignalProcessor { final MsrMethodChannel channel; final ConfigProvider configProvider; - DefaultSignalProcessor({required this.logger, required this.channel, required this.configProvider}); + DefaultSignalProcessor( + {required this.logger, + required this.channel, + required this.configProvider}); @override Future trackEvent({ @@ -43,13 +44,13 @@ final class DefaultSignalProcessor extends SignalProcessor { var json = data.toJson(); logger.log(LogLevel.debug, "$type: $json"); return channel.trackEvent( - data: json, - type: type, - timestamp: timestamp, - userDefinedAttrs: userDefinedAttrs, - userTriggered: userTriggered, - threadName: threadName, - attachments: attachments, + data: json, + type: type, + timestamp: timestamp, + userDefinedAttrs: userDefinedAttrs, + userTriggered: userTriggered, + threadName: threadName, + attachments: attachments, ); } @@ -59,8 +60,10 @@ final class DefaultSignalProcessor extends SignalProcessor { return channel.trackSpan(spanData); } - bool validateUserDefinedAttrs(String event, Map userDefinedAttrs) { - if (userDefinedAttrs.length > configProvider.maxUserDefinedAttributesPerEvent) { + bool validateUserDefinedAttrs( + String event, Map userDefinedAttrs) { + if (userDefinedAttrs.length > + configProvider.maxUserDefinedAttributesPerEvent) { logger.log( LogLevel.error, 'Invalid event($event): exceeds maximum of ${configProvider.maxUserDefinedAttributesPerEvent} attributes', diff --git a/flutter/packages/measure_flutter/lib/src/navigation/navigator_observer.dart b/flutter/packages/measure_flutter/lib/src/navigation/navigator_observer.dart index 73f4dd8c9..eff9ed523 100644 --- a/flutter/packages/measure_flutter/lib/src/navigation/navigator_observer.dart +++ b/flutter/packages/measure_flutter/lib/src/navigation/navigator_observer.dart @@ -3,11 +3,11 @@ import 'package:flutter/material.dart'; import '../../measure_flutter.dart'; /// A [NavigatorObserver] that automatically tracks screen navigation events. -/// +/// /// [MsrNavigatorObserver] monitors route changes in your Flutter app and /// automatically reports screen view events to the Measure SDK. This provides /// insight into user navigation patterns and screen popularity. -/// +/// /// **Usage:** /// ```dart /// class MyApp extends StatelessWidget { @@ -24,13 +24,13 @@ import '../../measure_flutter.dart'; /// } /// } /// ``` -/// +/// /// **Named Routes:** /// For automatic screen tracking to work properly, use named routes: /// ```dart /// // Good - will be tracked as 'ProfileScreen' /// Navigator.pushNamed(context, '/profile'); -/// +/// /// // Or provide explicit names /// Navigator.push( /// context, diff --git a/flutter/packages/measure_flutter/lib/src/screenshot/screenshot_collector.dart b/flutter/packages/measure_flutter/lib/src/screenshot/screenshot_collector.dart index e6791b86a..3a3ed90f6 100644 --- a/flutter/packages/measure_flutter/lib/src/screenshot/screenshot_collector.dart +++ b/flutter/packages/measure_flutter/lib/src/screenshot/screenshot_collector.dart @@ -4,8 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:measure_flutter/measure_flutter.dart'; import 'package:measure_flutter/src/config/config_provider.dart'; -import 'package:measure_flutter/src/logger/log_level.dart'; -import 'package:measure_flutter/src/logger/logger.dart'; import 'package:measure_flutter/src/utils/id_provider.dart'; /// A global key used by a [RepaintBoundary] in [MeasureWidget] to allow @@ -16,7 +14,6 @@ abstract class ScreenshotCollector { Future capture(); } - /// Captures screenshots of Flutter widget, stores it as a File and returns /// a [MsrAttachment]. Must be used along with [MeasureWidget]. class DefaultScreenshotCollector extends ScreenshotCollector { diff --git a/flutter/packages/measure_flutter/lib/src/time/system_clock.dart b/flutter/packages/measure_flutter/lib/src/time/system_clock.dart index 71800040d..81e5d6dde 100644 --- a/flutter/packages/measure_flutter/lib/src/time/system_clock.dart +++ b/flutter/packages/measure_flutter/lib/src/time/system_clock.dart @@ -2,4 +2,4 @@ abstract class SystemClock { /// Returns current time in epoch milliseconds. int epochTime(); -} \ No newline at end of file +} diff --git a/flutter/packages/measure_flutter/lib/src/tracing/span_builder.dart b/flutter/packages/measure_flutter/lib/src/tracing/span_builder.dart index 8b94dfe9d..68182a3c6 100644 --- a/flutter/packages/measure_flutter/lib/src/tracing/span_builder.dart +++ b/flutter/packages/measure_flutter/lib/src/tracing/span_builder.dart @@ -14,4 +14,4 @@ abstract class SpanBuilder { /// Note: After calling this method, any further builder configurations will be ignored. /// The start time is automatically set using [Measure.getCurrentTime]. Span startSpan({int? timestamp}); -} \ No newline at end of file +} diff --git a/flutter/packages/measure_flutter/pubspec.yaml b/flutter/packages/measure_flutter/pubspec.yaml index e64bb82be..f10dfd7aa 100644 --- a/flutter/packages/measure_flutter/pubspec.yaml +++ b/flutter/packages/measure_flutter/pubspec.yaml @@ -27,7 +27,8 @@ dev_dependencies: mocktail: ^1.0.4 build_runner: ^2.4.15 json_serializable: ^6.9.4 - + integration_test: + sdk: flutter flutter: plugin: platforms: diff --git a/flutter/packages/measure_flutter/test/bug_report/bug_report_collector_test.dart b/flutter/packages/measure_flutter/test/bug_report/bug_report_collector_test.dart index f3d176c79..8d30a2f98 100644 --- a/flutter/packages/measure_flutter/test/bug_report/bug_report_collector_test.dart +++ b/flutter/packages/measure_flutter/test/bug_report/bug_report_collector_test.dart @@ -14,8 +14,8 @@ import '../utils/fake_id_provider.dart'; import '../utils/fake_shake_detector.dart'; import '../utils/fake_signal_processor.dart'; import '../utils/noop_logger.dart'; -import '../utils/test_png.dart'; import '../utils/test_clock.dart'; +import '../utils/test_png.dart'; void main() { group('BugReportCollector', () { diff --git a/flutter/packages/measure_flutter/test/bug_report/bug_report_widget_test.dart b/flutter/packages/measure_flutter/test/bug_report/bug_report_widget_test.dart index 18feff8c1..0b78f532d 100644 --- a/flutter/packages/measure_flutter/test/bug_report/bug_report_widget_test.dart +++ b/flutter/packages/measure_flutter/test/bug_report/bug_report_widget_test.dart @@ -6,7 +6,6 @@ import 'package:measure_flutter/src/bug_report/ui/add_image_button.dart'; import 'package:measure_flutter/src/bug_report/ui/bug_report.dart'; import 'package:measure_flutter/src/bug_report/ui/bug_report_input.dart'; import 'package:measure_flutter/src/bug_report/ui/screenshot_list_item.dart'; -import 'package:measure_flutter/src/logger/logger.dart'; import '../utils/fake_config_provider.dart'; import '../utils/fake_id_provider.dart'; @@ -14,19 +13,16 @@ import '../utils/fake_image_picker_wrapper.dart'; import '../utils/fake_measure.dart'; import '../utils/fake_shake_detector.dart'; import '../utils/noop_logger.dart'; -import '../utils/test_method_channel.dart'; void main() { group('BugReport Widget Tests', () { final Logger logger = NoopLogger(); late FakeMeasure fakeMeasure; late FakeConfigProvider configProvider; - late TestMethodChannel testMethodChannel; setUp(() { fakeMeasure = FakeMeasure(); configProvider = FakeConfigProvider(); - testMethodChannel = TestMethodChannel(); }); tearDown(() { @@ -177,15 +173,6 @@ void main() { }); testWidgets('closes screen after sending bug report', (tester) async { - final measure = Measure.withMethodChannel(testMethodChannel); - await measure.init( - () {}, - clientInfo: ClientInfo( - apiKey: "msrsh-123", - apiUrl: "https://example.com", - ), - ); - await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -219,7 +206,8 @@ void main() { await tester.pump(); await tester.tap(find.widgetWithText(TextButton, 'Send')); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); expect(find.text('Report a Bug'), findsNothing); expect(find.text('Open Bug Report'), findsOneWidget); diff --git a/flutter/packages/measure_flutter/test/exception/exception_collector_test.dart b/flutter/packages/measure_flutter/test/exception/exception_collector_test.dart index bf01a3319..cc31d3569 100644 --- a/flutter/packages/measure_flutter/test/exception/exception_collector_test.dart +++ b/flutter/packages/measure_flutter/test/exception/exception_collector_test.dart @@ -1,11 +1,11 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:measure_flutter/measure_flutter.dart'; -import 'package:measure_flutter/src/bug_report/attachment_processing.dart'; import 'package:measure_flutter/src/events/event_type.dart'; import 'package:measure_flutter/src/exception/exception_collector.dart'; import 'package:measure_flutter/src/exception/exception_data.dart'; import 'package:measure_flutter/src/exception/exception_framework.dart'; +import 'package:measure_flutter/src/isolate/file_processor.dart'; import 'package:measure_flutter/src/time/time_provider.dart'; import '../utils/fake_config_provider.dart'; @@ -16,10 +16,11 @@ import '../utils/noop_logger.dart'; import '../utils/test_clock.dart'; // Mock the isolate function -Future mockCompressAndSaveInIsolate(CompressAndSaveParams params) async { +Future mockCompressAndSaveInIsolate( + CompressAndSaveParams params) async { return FileProcessingResult( filePath: '/mock/path/screenshot.jpg', - compressedSize: 1024, + size: 1024, ); } diff --git a/flutter/packages/measure_flutter/test/gestures/layout_snapshot_capture_test.dart b/flutter/packages/measure_flutter/test/gestures/layout_snapshot_capture_test.dart new file mode 100644 index 000000000..c13e2d4d0 --- /dev/null +++ b/flutter/packages/measure_flutter/test/gestures/layout_snapshot_capture_test.dart @@ -0,0 +1,527 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:measure_flutter/src/gestures/layout_snapshot_capture.dart'; +import 'package:measure_flutter/src/gestures/snapshot_node.dart'; + +void main() { + group('LayoutSnapshotCapture', () { + testWidgets('returns null for null element', (WidgetTester tester) async { + final result = LayoutSnapshotCapture.capture(null); + + expect(result, isNull); + }); + + testWidgets('captures basic widget tree structure', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () {}, + child: const Text('Button 1'), + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.add), + ), + ], + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final result = LayoutSnapshotCapture.capture(element); + + expect(result, isNotNull); + // With ancestor support, MaterialApp is now the root with Scaffold nested inside + expect(result!.snapshot.label, equals('MaterialApp')); + expect(result.snapshot.children, isNotEmpty); + + // Should contain the Scaffold and the included widgets + final hasScaffold = _containsWidgetType(result.snapshot, 'Scaffold'); + final hasButton = _containsWidgetType(result.snapshot, 'ElevatedButton'); + final hasIconButton = _containsWidgetType(result.snapshot, 'IconButton'); + expect(hasScaffold, isTrue); + expect(hasButton, isTrue); + expect(hasIconButton, isTrue); + }); + + testWidgets('detects clickable widget at position', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ElevatedButton( + key: const ValueKey('test-button'), + onPressed: () {}, + child: const Text('Click me'), + ), + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final buttonCenter = tester.getCenter(find.byKey(const ValueKey('test-button'))); + + final result = LayoutSnapshotCapture.capture( + element, + detectionPosition: buttonCenter, + detectionMode: GestureDetectionMode.click, + ); + + // We detect the inner most widget which can take the gesture, + // ElevatedButton uses a GestureDetector internally. + expect(result, isNotNull); + expect(result!.gestureElement, isNotNull); + expect(result.gestureElementType, equals('GestureDetector')); + + final gdSnapshot = _findWidgetByType(result.snapshot, 'GestureDetector'); + expect(gdSnapshot, isNotNull); + expect(gdSnapshot!.highlighted, isTrue); + }); + + testWidgets('detects scrollable widget at position', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ListView( + children: List.generate( + 10, + (index) => ListTile(title: Text('Item $index')), + ), + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final listViewCenter = tester.getCenter(find.byType(ListView)); + + final result = LayoutSnapshotCapture.capture( + element, + detectionPosition: listViewCenter, + detectionMode: GestureDetectionMode.scroll, + ); + + expect(result, isNotNull); + expect(result!.gestureElement, isNotNull); + expect(result.gestureElementType, equals('ListView')); + + // ListView should be marked as scrollable + final listViewSnapshot = _findWidgetByType(result.snapshot, 'ListView'); + expect(listViewSnapshot, isNotNull); + expect(listViewSnapshot!.scrollable, isTrue); + }); + + testWidgets('captures nested widget hierarchy', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + Row( + children: [ + ElevatedButton( + onPressed: () {}, + child: const Text('Button 1'), + ), + IconButton( + onPressed: () {}, + icon: const Icon(Icons.star), + ), + ], + ), + ], + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final result = LayoutSnapshotCapture.capture(element); + + expect(result, isNotNull); + // With ancestor support, MaterialApp is the root with nested structure inside + expect(result!.snapshot.label, equals('MaterialApp')); + expect(result.snapshot.children, isNotEmpty); + + // Verify nested widgets are captured: MaterialApp -> Scaffold -> Column -> Row -> Buttons + expect(_containsWidgetType(result.snapshot, 'Scaffold'), isTrue); + expect(_containsWidgetType(result.snapshot, 'Column'), isTrue); + expect(_containsWidgetType(result.snapshot, 'Row'), isTrue); + expect(_containsWidgetType(result.snapshot, 'ElevatedButton'), isTrue); + expect(_containsWidgetType(result.snapshot, 'IconButton'), isTrue); + }); + + testWidgets('filters by screen bounds', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + key: const ValueKey('button-1'), + onPressed: () {}, + child: const Text('Button 1'), + ), + const SizedBox(height: 500), + ElevatedButton( + key: const ValueKey('button-2'), + onPressed: () {}, + child: const Text('Button 2'), + ), + ], + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final button1Rect = tester.getRect(find.byKey(const ValueKey('button-1'))); + final button2Rect = tester.getRect(find.byKey(const ValueKey('button-2'))); + + // Only capture widgets in the top portion of the screen + final screenBounds = Rect.fromLTWH(0, 0, 400, button1Rect.bottom + 10); + + final result = LayoutSnapshotCapture.capture( + element, + screenBounds: screenBounds, + ); + + expect(result, isNotNull); + + // Collect all button bounds from the snapshot + final buttonBounds = []; + _collectButtonBounds(result!.snapshot, buttonBounds); + + // Button 1 should be in the tree (within bounds) - check overlap with button1Rect + final hasButton1 = buttonBounds.any((bounds) => bounds.overlaps(button1Rect)); + expect(hasButton1, isTrue); + + // Button 2 should not be in the tree (outside bounds) - check no overlap with button2Rect + final hasButton2 = buttonBounds.any((bounds) => bounds.overlaps(button2Rect)); + expect(hasButton2, isFalse); + }); + + testWidgets('skips offstage widgets', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () {}, + child: const Text('Visible Button'), + ), + Offstage( + offstage: true, + child: ElevatedButton( + key: const ValueKey('hidden-button'), + onPressed: () {}, + child: const Text('Hidden Button'), + ), + ), + ], + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final result = LayoutSnapshotCapture.capture(element); + + expect(result, isNotNull); + // Visible button should be captured + expect(_containsWidgetType(result!.snapshot, 'ElevatedButton'), isTrue); + + // Hidden button should not be captured + final hiddenButton = _findWidgetById(result.snapshot, 'hidden-button'); + expect(hiddenButton, isNull); + }); + + testWidgets('skips invisible widgets', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () {}, + child: const Text('Visible Button'), + ), + Visibility( + visible: false, + child: ElevatedButton( + key: const ValueKey('hidden-button'), + onPressed: () {}, + child: const Text('Hidden Button'), + ), + ), + ], + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final result = LayoutSnapshotCapture.capture(element); + + expect(result, isNotNull); + // Visible button should be captured + expect(_containsWidgetType(result!.snapshot, 'ElevatedButton'), isTrue); + + // Hidden button should not be captured + final hiddenButton = _findWidgetById(result.snapshot, 'hidden-button'); + expect(hiddenButton, isNull); + }); + + testWidgets('skips widgets with no opacity', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () {}, + child: const Text('Visible Button'), + ), + Opacity( + opacity: 0, + child: ElevatedButton( + key: const ValueKey('hidden-button'), + onPressed: () {}, + child: const Text('Hidden Button'), + ), + ), + ], + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final result = LayoutSnapshotCapture.capture(element); + + expect(result, isNotNull); + // Visible button should be captured + expect(_containsWidgetType(result!.snapshot, 'ElevatedButton'), isTrue); + + // Hidden button should not be captured + final hiddenButton = _findWidgetById(result.snapshot, 'hidden-button'); + expect(hiddenButton, isNull); + }); + + testWidgets('captures bounds correctly', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 200, + height: 100, + child: ElevatedButton( + onPressed: () {}, + child: const Text('Button'), + ), + ), + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final result = LayoutSnapshotCapture.capture(element); + + expect(result, isNotNull); + final buttonSnapshot = _findWidgetByType(result!.snapshot, 'ElevatedButton'); + + expect(buttonSnapshot, isNotNull); + expect(buttonSnapshot!.width, greaterThan(0)); + expect(buttonSnapshot.height, greaterThan(0)); + expect(buttonSnapshot.x, greaterThanOrEqualTo(0)); + expect(buttonSnapshot.y, greaterThanOrEqualTo(0)); + }); + + testWidgets('uses framework widgets when widgets filter input is null', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () {}, + child: const Text('Button'), + ), + const Text('Text content'), + ], + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final result = LayoutSnapshotCapture.capture( + element, + widgetFilter: null, + ); + + expect(result, isNotNull); + // Framework widgets should be included + expect(_containsWidgetType(result!.snapshot, 'ElevatedButton'), isTrue); + expect(_containsWidgetType(result.snapshot, 'Text'), isTrue); + expect(_containsWidgetType(result.snapshot, 'Column'), isTrue); + }); + + testWidgets('uses framework widgets when widgets filter input is empty', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () {}, + child: const Text('Button'), + ), + const Text('Text content'), + ], + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final result = LayoutSnapshotCapture.capture( + element, + widgetFilter: {}, + ); + + expect(result, isNotNull); + // Framework widgets should be included + expect(_containsWidgetType(result!.snapshot, 'ElevatedButton'), isTrue); + expect(_containsWidgetType(result.snapshot, 'Text'), isTrue); + expect(_containsWidgetType(result.snapshot, 'Column'), isTrue); + }); + + testWidgets('uses widgets in widgets filter if provided', (WidgetTester tester) async { + // Create a custom widget + final customWidget = _CustomWidget(key: const ValueKey('custom-widget')); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + ElevatedButton( + onPressed: () {}, + child: const Text('Button'), + ), + const Text('Text content'), + customWidget, + ], + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final result = LayoutSnapshotCapture.capture( + element, + widgetFilter: { + _CustomWidget: 'CustomWidget', + }, + ); + + expect(result, isNotNull); + expect(_containsWidgetType(result!.snapshot, 'CustomWidget'), isTrue); + }); + + testWidgets('sets element type for button widgets', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: ElevatedButton( + onPressed: () {}, + child: const Text('Test Button'), + ), + ), + ), + ), + ); + + final element = tester.element(find.byType(MaterialApp)); + final result = LayoutSnapshotCapture.capture(element); + + expect(result, isNotNull); + final buttonSnapshot = _findWidgetByType(result!.snapshot, 'ElevatedButton'); + + expect(buttonSnapshot, isNotNull); + expect(buttonSnapshot!.type, equals('button')); + }); + }); +} + +// Helper function to check if a widget type exists in the tree +bool _containsWidgetType(SnapshotNode snapshot, String widgetType) { + if (snapshot.label == widgetType) { + return true; + } + for (final child in snapshot.children) { + if (_containsWidgetType(child, widgetType)) { + return true; + } + } + return false; +} + +// Helper function to find a widget by type +SnapshotNode? _findWidgetByType(SnapshotNode snapshot, String widgetType) { + if (snapshot.label == widgetType) { + return snapshot; + } + for (final child in snapshot.children) { + final result = _findWidgetByType(child, widgetType); + if (result != null) { + return result; + } + } + return null; +} + +SnapshotNode? _findWidgetById(SnapshotNode snapshot, String id) { + if (snapshot.id == id) { + return snapshot; + } + for (final child in snapshot.children) { + final result = _findWidgetById(child, id); + if (result != null) { + return result; + } + } + return null; +} + +void _collectButtonBounds(SnapshotNode snapshot, List output) { + if (snapshot.label == 'ElevatedButton') { + output.add(Rect.fromLTWH(snapshot.x, snapshot.y, snapshot.width, snapshot.height)); + } + for (final child in snapshot.children) { + _collectButtonBounds(child, output); + } +} + +class _CustomWidget extends StatelessWidget { + const _CustomWidget({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + width: 100, + height: 100, + color: Colors.blue, + ); + } +} diff --git a/flutter/packages/measure_flutter/test/gestures/layout_snapshot_collector_test.dart b/flutter/packages/measure_flutter/test/gestures/layout_snapshot_collector_test.dart new file mode 100644 index 000000000..24fee2db9 --- /dev/null +++ b/flutter/packages/measure_flutter/test/gestures/layout_snapshot_collector_test.dart @@ -0,0 +1,183 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:measure_flutter/measure_flutter.dart'; +import 'package:measure_flutter/src/gestures/gesture_collector.dart'; +import 'package:measure_flutter/src/isolate/file_processor.dart'; +import 'package:measure_flutter/src/method_channel/signal_processor.dart'; +import 'package:measure_flutter/src/time/time_provider.dart'; + +import '../utils/fake_file_processing_isolate.dart'; +import '../utils/fake_file_storage.dart'; +import '../utils/fake_id_provider.dart'; +import '../utils/fake_signal_processor.dart'; +import '../utils/noop_logger.dart'; + +void main() { + group('GestureCollector createAttachment', () { + late GestureCollector collector; + late FakeIdProvider idProvider; + late FakeFileStorage fileStorage; + late NoopLogger logger; + late FakeFileProcessingIsolate fakeWorker; + late SignalProcessor signalProcessor; + late TimeProvider timeProvider; + + setUp(() { + idProvider = FakeIdProvider(); + fileStorage = FakeFileStorage(); + logger = NoopLogger(); + fakeWorker = FakeFileProcessingIsolate(); + signalProcessor = FakeSignalProcessor(); + timeProvider = FakeTimeProvider(); + + // Initialize the shared worker used by writeJsonToFileInIsolate + initializeFileProcessingIsolate(fakeWorker); + + collector = GestureCollector( + signalProcessor, + timeProvider, + fileStorage, + logger, + idProvider, + ); + }); + + test('creates attachment successfully with valid snapshot', () async { + final snapshot = SnapshotNode( + label: 'TestWidget', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + final result = await collector.createAttachment(snapshot); + + expect(result, isNotNull); + expect(result!.type, equals(AttachmentType.layoutSnapshotJson)); + expect(result.id, equals('uuid-1')); + expect(result.path, contains('uuid-1')); + expect(result.size, greaterThan(0)); + }); + + test('creates attachment with nested children', () async { + final snapshot = SnapshotNode( + label: 'Parent', + x: 0, + y: 0, + width: 200, + height: 200, + children: [ + SnapshotNode( + label: 'Child1', + x: 10, + y: 10, + width: 50, + height: 50, + children: [], + ), + SnapshotNode( + label: 'Child2', + x: 70, + y: 70, + width: 50, + height: 50, + id: 'child-2-id', + highlighted: true, + children: [], + ), + ], + ); + + final result = await collector.createAttachment(snapshot); + + expect(result, isNotNull); + expect(result!.type, equals(AttachmentType.layoutSnapshotJson)); + expect(result.size, greaterThan(0)); + }); + + test('returns null when root path is null', () async { + fileStorage.shouldReturnNullPath = true; + final snapshot = SnapshotNode( + label: 'TestWidget', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + final result = await collector.createAttachment(snapshot); + + expect(result, isNull); + }); + + test('returns null when file writing fails', () async { + fakeWorker.shouldReturnError = true; + final snapshot = SnapshotNode( + label: 'TestWidget', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + final result = await collector.createAttachment(snapshot); + + expect(result, isNull); + }); + + test('handles exceptions gracefully', () async { + fakeWorker.shouldThrowException = true; + final snapshot = SnapshotNode( + label: 'TestWidget', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + final result = await collector.createAttachment(snapshot); + + expect(result, isNull); + }); + + test('generates unique IDs for multiple attachments', () async { + final snapshot1 = SnapshotNode( + label: 'Widget1', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + final snapshot2 = SnapshotNode( + label: 'Widget2', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + final result1 = await collector.createAttachment(snapshot1); + final result2 = await collector.createAttachment(snapshot2); + + expect(result1, isNotNull); + expect(result2, isNotNull); + expect(result1!.id, equals('uuid-1')); + expect(result2!.id, equals('uuid-2')); + expect(result1.id, isNot(equals(result2.id))); + }); + }); +} + +class FakeTimeProvider implements TimeProvider { + @override + int now() => 0; + + @override + int get elapsedRealtime => 0; +} diff --git a/flutter/packages/measure_flutter/test/gestures/msr_gesture_detector_test.dart b/flutter/packages/measure_flutter/test/gestures/msr_gesture_detector_test.dart index 89bd1cf81..ba79a3a43 100644 --- a/flutter/packages/measure_flutter/test/gestures/msr_gesture_detector_test.dart +++ b/flutter/packages/measure_flutter/test/gestures/msr_gesture_detector_test.dart @@ -1,25 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:measure_flutter/src/gestures/click_data.dart'; +import 'package:measure_flutter/src/gestures/snapshot_node.dart'; import 'package:measure_flutter/src/gestures/long_click_data.dart'; import 'package:measure_flutter/src/gestures/msr_gesture_detector.dart'; import 'package:measure_flutter/src/gestures/scroll_data.dart'; -import 'package:measure_flutter/src/gestures/scroll_direction.dart'; +import 'package:measure_flutter/src/gestures/msr_scroll_direction.dart'; void main() { group('MsrGestureDetector', () { Widget createTestWidget({ Widget? child, - Function(ClickData)? onClick, - Function(LongClickData)? onLongClick, - Function(ScrollData)? onScroll, + Future Function(ClickData, SnapshotNode?)? onClick, + Future Function(LongClickData, SnapshotNode?)? onLongClick, + Future Function(ScrollData)? onScroll, }) { return MaterialApp( home: Scaffold( body: MsrGestureDetector( - onClick: onClick ?? (data) {}, - onLongClick: onLongClick ?? (data) {}, - onScroll: onScroll ?? (data) {}, + onClick: onClick ?? (data, snapshot) async {}, + onLongClick: onLongClick ?? (data, snapshot) async {}, + onScroll: onScroll ?? (data) async {}, + layoutSnapshotWidgetFilter: {}, child: child ?? SizedBox( width: 200, @@ -52,7 +54,7 @@ void main() { final clickEvents = []; await tester.pumpWidget(createTestWidget( - onClick: (data) => clickEvents.add(data), + onClick: (data, snapshot) async => clickEvents.add(data), )); // Tap the button @@ -60,10 +62,10 @@ void main() { await tester.pump(); expect(clickEvents, hasLength(1)); - expect(clickEvents.first.target, 'ElevatedButton'); + expect(clickEvents.first.target, 'GestureDetector'); expect(clickEvents.first.x, greaterThan(0)); expect(clickEvents.first.y, greaterThan(0)); - expect(clickEvents.first.targetId, 'Test Button'); + expect(clickEvents.first.targetId, null); }); testWidgets('should not detect click on non-clickable element', @@ -71,7 +73,7 @@ void main() { final clickEvents = []; await tester.pumpWidget(createTestWidget( - onClick: (data) => clickEvents.add(data), + onClick: (data, snapshot) async => clickEvents.add(data), child: SizedBox( width: 200, height: 200, @@ -85,99 +87,6 @@ void main() { expect(clickEvents, isEmpty); }); - - testWidgets('should detect click on various clickable widgets', - (WidgetTester tester) async { - final clickEvents = []; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: MsrGestureDetector( - onClick: (data) => clickEvents.add(data), - onLongClick: (data) {}, - onScroll: (data) {}, - child: Column( - children: [ - IconButton( - key: ValueKey("IconButton"), - onPressed: () {}, - icon: const Icon(Icons.add), - ), - const ListTile( - key: ValueKey("ListTile"), - title: Text('List Item'), - ), - InkWell( - key: ValueKey("InkWell"), - onTap: () {}, - child: const Text('InkWell'), - ), - Checkbox( - key: ValueKey("Checkbox"), - value: true, - onChanged: (value) {}, - ), - ], - ), - ), - ), - )); - - // Test IconButton - await tester.tap(find.byKey(ValueKey("IconButton"))); - await tester.pump(); - expect(clickEvents, hasLength(1)); - expect(clickEvents.last.target, 'IconButton'); - expect(clickEvents.last.targetId, null); - - // Test ListTile - await tester.tap(find.byKey(ValueKey("ListTile"))); - await tester.pump(); - expect(clickEvents, hasLength(2)); - expect(clickEvents.last.target, 'ListTile'); - expect(clickEvents.last.targetId, 'List Item'); - - // Test InkWell - await tester.tap(find.byKey(ValueKey("InkWell"))); - await tester.pump(); - expect(clickEvents, hasLength(3)); - expect(clickEvents.last.target, 'InkWell'); - expect(clickEvents.last.targetId, 'InkWell'); - - // Test Checkbox - await tester.tap(find.byKey(ValueKey("Checkbox"))); - await tester.pump(); - expect(clickEvents, hasLength(4)); - expect(clickEvents.last.target, 'Checkbox'); - expect(clickEvents.last.targetId, null); - }); - - testWidgets('should truncate long labels', (WidgetTester tester) async { - final clickEvents = []; - const longText = - 'This is a very long text that should be truncated to fit within the maximum length limit'; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: MsrGestureDetector( - onClick: (data) => clickEvents.add(data), - onLongClick: (data) {}, - onScroll: (data) {}, - child: ElevatedButton( - onPressed: () {}, - child: const Text(longText), - ), - ), - ), - )); - - await tester.tap(find.byType(ElevatedButton)); - await tester.pump(); - - expect(clickEvents, hasLength(1)); - expect(clickEvents.first.targetId, hasLength(32)); - expect(clickEvents.first.targetId, endsWith('...')); - }); }); group('Long Click Detection', () { @@ -188,8 +97,8 @@ void main() { await tester.pumpWidget( createTestWidget( - onClick: (data) => clickEvents.add(data), - onLongClick: (data) => longClickEvents.add(data), + onClick: (data, snapshot) async => clickEvents.add(data), + onLongClick: (data, snapshot) async => longClickEvents.add(data), ), ); @@ -210,15 +119,17 @@ void main() { ); // Directly test the event handlers + final screenSize = + MediaQuery.of(tester.element(find.byType(ElevatedButton))).size; state.onPointerDown(downEvent); - state.onPointerUp(upEvent, 1); + state.onPointerUp(upEvent, 1.0, screenSize); await tester.pumpAndSettle(); expect(clickEvents, isEmpty); expect(longClickEvents, hasLength(1)); - expect(longClickEvents.first.target, 'ElevatedButton'); - expect(longClickEvents.first.targetId, 'Test Button'); + expect(longClickEvents.first.target, 'GestureDetector'); + expect(longClickEvents.first.targetId, null); }); }); @@ -230,9 +141,10 @@ void main() { await tester.pumpWidget(MaterialApp( home: Scaffold( body: MsrGestureDetector( - onClick: (data) {}, - onLongClick: (data) {}, - onScroll: (data) => scrollEvents.add(data), + onClick: (data, snapshot) async {}, + onLongClick: (data, snapshot) async {}, + onScroll: (data) async => scrollEvents.add(data), + layoutSnapshotWidgetFilter: {}, child: SizedBox( height: 200, child: ListView( @@ -263,9 +175,12 @@ void main() { await tester.pumpWidget(MaterialApp( home: Scaffold( body: MsrGestureDetector( - onClick: (data) {}, - onLongClick: (data) {}, - onScroll: (data) => scrollEvents.add(data), + onClick: (data, snapshot) async {}, + onLongClick: (data, snapshot) async {}, + onScroll: (data) async { + scrollEvents.add(data); + }, + layoutSnapshotWidgetFilter: {}, child: SizedBox( height: 200, child: ListView( @@ -296,7 +211,7 @@ void main() { final scrollEvents = []; await tester.pumpWidget(createTestWidget( - onScroll: (data) => scrollEvents.add(data), + onScroll: (data) async => scrollEvents.add(data), child: SizedBox( width: 200, height: 200, @@ -318,9 +233,12 @@ void main() { await tester.pumpWidget(MaterialApp( home: Scaffold( body: MsrGestureDetector( - onClick: (data) {}, - onLongClick: (data) {}, - onScroll: (data) => scrollEvents.add(data), + onClick: (data, snapshot) async {}, + onLongClick: (data, snapshot) async {}, + onScroll: (data) async { + scrollEvents.add(data); + }, + layoutSnapshotWidgetFilter: {}, child: SizedBox( height: 200, child: ListView( @@ -354,9 +272,9 @@ void main() { final scrollEvents = []; await tester.pumpWidget(createTestWidget( - onClick: (data) => clickEvents.add(data), - onLongClick: (data) => longClickEvents.add(data), - onScroll: (data) => scrollEvents.add(data), + onClick: (data, snapshot) async => clickEvents.add(data), + onLongClick: (data, snapshot) async => longClickEvents.add(data), + onScroll: (data) async => scrollEvents.add(data), )); // Start a gesture @@ -377,7 +295,7 @@ void main() { final clickEvents = []; await tester.pumpWidget(createTestWidget( - onClick: (data) => clickEvents.add(data), + onClick: (data, snapshot) async => clickEvents.add(data), )); // Start first gesture @@ -408,11 +326,12 @@ void main() { await tester.pumpWidget(MaterialApp( home: Scaffold( body: MsrGestureDetector( - onClick: (data) { + onClick: (data, snapshot) async { throw Exception('Test exception'); }, - onLongClick: (data) {}, - onScroll: (data) {}, + onLongClick: (data, snapshot) async {}, + onScroll: (data) async {}, + layoutSnapshotWidgetFilter: {}, child: ElevatedButton( onPressed: () {}, child: const Text('Test Button'), @@ -432,299 +351,212 @@ void main() { // The exception should be handled internally expect(exceptionThrown, isFalse); }); + }); - testWidgets('should handle widgets without render objects', + group('Coordinates calculation', () { + testWidgets( + 'should calculate x, y, endX, endY coordinates correctly for scroll', (WidgetTester tester) async { - final clickEvents = []; + final scrollEvents = []; - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: MsrGestureDetector( - onClick: (data) => clickEvents.add(data), - onLongClick: (data) {}, - onScroll: (data) {}, - child: const SizedBox.shrink(), + // Set up a test environment with known device pixel ratio + await tester.binding.setSurfaceSize(const Size(400, 600)); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MsrGestureDetector( + onClick: (data, snapshot) async {}, + onLongClick: (data, snapshot) async {}, + onScroll: (data) async { + scrollEvents.add(data); + }, + layoutSnapshotWidgetFilter: {}, + child: SizedBox( + height: 300, + child: ListView( + children: List.generate( + 20, + (index) => ListTile(title: Text('Item $index')), + ), + ), + ), + ), ), ), - )); + ); - // Try to tap on the empty widget - await tester.tap(find.byType(SizedBox)); + // Get the device pixel ratio for calculations + final devicePixelRatio = tester.view.devicePixelRatio; + + // Define start and end positions for the scroll gesture + const startPosition = Offset(200, 250); // Start at center of ListView + const endPosition = Offset(200, 150); // End 100 pixels up + const scrollDelta = Offset(0, -100); // Upward scroll + + // Perform the scroll gesture + await tester.dragFrom(startPosition, scrollDelta); await tester.pump(); - expect(clickEvents, isEmpty); + // Verify that scroll event was captured + expect(scrollEvents, hasLength(1)); + + final scrollEvent = scrollEvents.first; + + // Verify target and direction + expect(scrollEvent.target, 'ListView'); + expect(scrollEvent.direction, MsrScrollDirection.up); + + // Verify coordinate calculations + // x and y should be the start position (where touch began) multiplied by device pixel ratio + expect(scrollEvent.x, + equals((startPosition.dx * devicePixelRatio).roundToDouble())); + expect(scrollEvent.y, + equals((startPosition.dy * devicePixelRatio).roundToDouble())); + + // endX and endY should be the end position (where touch ended) multiplied by device pixel ratio + expect(scrollEvent.endX, + equals((endPosition.dx * devicePixelRatio).roundToDouble())); + expect(scrollEvent.endY, + equals((endPosition.dy * devicePixelRatio).roundToDouble())); + + // Verify that end coordinates are different from start coordinates (confirming scroll occurred) + expect( + scrollEvent.endY, + lessThan(scrollEvent + .y)); // End Y should be less than start Y for upward scroll + expect( + scrollEvent.endX, + equals( + scrollEvent.x)); // X should remain the same for vertical scroll }); - }); - group('Label Extraction', () { - testWidgets('should extract labels from different widget types', + testWidgets( + 'should calculate coordinates correctly for horizontal scroll', (WidgetTester tester) async { - final clickEvents = []; + final scrollEvents = []; - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: MsrGestureDetector( - onClick: (data) => clickEvents.add(data), - onLongClick: (data) {}, - onScroll: (data) {}, - child: Column( - children: [ - // Wrap each widget to isolate them properly - SizedBox( - height: 60, - child: const ListTile(title: Text('ListTile Label')), - ), - SizedBox( - height: 60, - child: Tooltip( - message: 'Tooltip Message', - child: IconButton( - onPressed: () {}, - icon: const Icon(Icons.info), + await tester.binding.setSurfaceSize(const Size(400, 600)); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MsrGestureDetector( + onClick: (data, snapshot) async {}, + onLongClick: (data, snapshot) async {}, + onScroll: (data) async { + scrollEvents.add(data); + }, + layoutSnapshotWidgetFilter: {}, + child: SizedBox( + height: 200, + child: ListView( + scrollDirection: Axis.horizontal, + children: List.generate( + 20, + (index) => SizedBox( + width: 100, + child: ListTile(title: Text('Item $index')), ), ), ), - SizedBox( - height: 60, - child: ElevatedButton( - onPressed: () {}, - child: const Text('Button Label'), - ), - ), - // For testing semantic labels, use a clickable widget - SizedBox( - height: 60, - child: InkWell( - key: ValueKey('InkWell'), - onTap: () {}, - child: const Icon(Icons.star), - ), - ), - ], + ), ), ), ), - )); + ); - // Clear any initial events - clickEvents.clear(); + final devicePixelRatio = tester.view.devicePixelRatio; - await tester.tap(find.byType(ListTile)); - await tester.pump(); - expect(clickEvents, hasLength(1)); - expect(clickEvents.last.targetId, 'ListTile Label'); + // Define positions for horizontal scroll + const startPosition = Offset(200, 100); + const endPosition = Offset(100, 100); // Move 100 pixels left + const scrollDelta = Offset(-100, 0); // Leftward scroll - // Test Button with text label - await tester.tap(find.byType(ElevatedButton)); + // Perform horizontal scroll + await tester.dragFrom(startPosition, scrollDelta); await tester.pump(); - expect(clickEvents, hasLength(2)); - expect(clickEvents.last.targetId, 'Button Label'); - // Test InkWell with Icon - await tester.tap(find.byKey(ValueKey("InkWell"))); - await tester.pump(); - expect(clickEvents, hasLength(3)); - expect(clickEvents.last.targetId, null); + expect(scrollEvents, hasLength(1)); + + final scrollEvent = scrollEvents.first; + + // Verify horizontal scroll direction + expect(scrollEvent.direction, MsrScrollDirection.left); + + // Verify coordinate calculations for horizontal scroll + expect(scrollEvent.x, + equals((startPosition.dx * devicePixelRatio).roundToDouble())); + expect(scrollEvent.y, + equals((startPosition.dy * devicePixelRatio).roundToDouble())); + expect(scrollEvent.endX, + equals((endPosition.dx * devicePixelRatio).roundToDouble())); + expect(scrollEvent.endY, + equals((endPosition.dy * devicePixelRatio).roundToDouble())); + + // Verify that end coordinates reflect the horizontal movement + expect( + scrollEvent.endX, + lessThan(scrollEvent + .x)); // End X should be less than start X for leftward scroll + expect( + scrollEvent.endY, + equals(scrollEvent + .y)); // Y should remain the same for horizontal scroll }); - testWidgets('should handle null labels gracefully', + testWidgets( + 'should handle device pixel ratio scaling in coordinate calculations', (WidgetTester tester) async { - final clickEvents = []; - - await tester.pumpWidget(MaterialApp( - home: Scaffold( - body: MsrGestureDetector( - onClick: (data) => clickEvents.add(data), - onLongClick: (data) {}, - onScroll: (data) {}, - child: IconButton( - onPressed: () {}, - icon: const Icon(Icons.add), - ), - ), - ), - )); - - await tester.tap(find.byType(IconButton)); - await tester.pump(); + final scrollEvents = []; - expect(clickEvents, hasLength(1)); - expect(clickEvents.first.targetId, isNull); - }); - }); + // Set a custom device pixel ratio for testing + await tester.binding.setSurfaceSize(const Size(400, 600)); + tester.view.devicePixelRatio = 2.0; - group('Coordinates calculation', () { - testWidgets('should calculate x, y, endX, endY coordinates correctly for scroll', - (WidgetTester tester) async { - final scrollEvents = []; - - // Set up a test environment with known device pixel ratio - await tester.binding.setSurfaceSize(const Size(400, 600)); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: MsrGestureDetector( - onClick: (data) {}, - onLongClick: (data) {}, - onScroll: (data) => scrollEvents.add(data), - child: SizedBox( - height: 300, - child: ListView( - children: List.generate( - 20, - (index) => ListTile(title: Text('Item $index')), - ), - ), - ), - ), - ), - ), - ); - - // Get the device pixel ratio for calculations - final devicePixelRatio = tester.view.devicePixelRatio; - - // Define start and end positions for the scroll gesture - const startPosition = Offset(200, 250); // Start at center of ListView - const endPosition = Offset(200, 150); // End 100 pixels up - const scrollDelta = Offset(0, -100); // Upward scroll - - // Perform the scroll gesture - await tester.dragFrom(startPosition, scrollDelta); - await tester.pump(); - - // Verify that scroll event was captured - expect(scrollEvents, hasLength(1)); - - final scrollEvent = scrollEvents.first; - - // Verify target and direction - expect(scrollEvent.target, 'ListView'); - expect(scrollEvent.direction, MsrScrollDirection.up); - - // Verify coordinate calculations - // x and y should be the start position (where touch began) multiplied by device pixel ratio - expect(scrollEvent.x, equals((startPosition.dx * devicePixelRatio).roundToDouble())); - expect(scrollEvent.y, equals((startPosition.dy * devicePixelRatio).roundToDouble())); - - // endX and endY should be the end position (where touch ended) multiplied by device pixel ratio - expect(scrollEvent.endX, equals((endPosition.dx * devicePixelRatio).roundToDouble())); - expect(scrollEvent.endY, equals((endPosition.dy * devicePixelRatio).roundToDouble())); - - // Verify that end coordinates are different from start coordinates (confirming scroll occurred) - expect(scrollEvent.endY, lessThan(scrollEvent.y)); // End Y should be less than start Y for upward scroll - expect(scrollEvent.endX, equals(scrollEvent.x)); // X should remain the same for vertical scroll - }); - - testWidgets('should calculate coordinates correctly for horizontal scroll', - (WidgetTester tester) async { - final scrollEvents = []; - - await tester.binding.setSurfaceSize(const Size(400, 600)); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: MsrGestureDetector( - onClick: (data) {}, - onLongClick: (data) {}, - onScroll: (data) => scrollEvents.add(data), - child: SizedBox( - height: 200, - child: ListView( - scrollDirection: Axis.horizontal, - children: List.generate( - 20, - (index) => SizedBox( - width: 100, - child: ListTile(title: Text('Item $index')), - ), - ), - ), - ), - ), - ), - ), - ); - - final devicePixelRatio = tester.view.devicePixelRatio; - - // Define positions for horizontal scroll - const startPosition = Offset(200, 100); - const endPosition = Offset(100, 100); // Move 100 pixels left - const scrollDelta = Offset(-100, 0); // Leftward scroll - - // Perform horizontal scroll - await tester.dragFrom(startPosition, scrollDelta); - await tester.pump(); - - expect(scrollEvents, hasLength(1)); - - final scrollEvent = scrollEvents.first; - - // Verify horizontal scroll direction - expect(scrollEvent.direction, MsrScrollDirection.left); - - // Verify coordinate calculations for horizontal scroll - expect(scrollEvent.x, equals((startPosition.dx * devicePixelRatio).roundToDouble())); - expect(scrollEvent.y, equals((startPosition.dy * devicePixelRatio).roundToDouble())); - expect(scrollEvent.endX, equals((endPosition.dx * devicePixelRatio).roundToDouble())); - expect(scrollEvent.endY, equals((endPosition.dy * devicePixelRatio).roundToDouble())); - - // Verify that end coordinates reflect the horizontal movement - expect(scrollEvent.endX, lessThan(scrollEvent.x)); // End X should be less than start X for leftward scroll - expect(scrollEvent.endY, equals(scrollEvent.y)); // Y should remain the same for horizontal scroll - }); - - testWidgets('should handle device pixel ratio scaling in coordinate calculations', - (WidgetTester tester) async { - final scrollEvents = []; - - // Set a custom device pixel ratio for testing - await tester.binding.setSurfaceSize(const Size(400, 600)); - tester.view.devicePixelRatio = 2.0; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: MsrGestureDetector( - onClick: (data) {}, - onLongClick: (data) {}, - onScroll: (data) => scrollEvents.add(data), - child: SizedBox( - height: 300, - child: ListView( - children: List.generate(10, (index) => ListTile(title: Text('Item $index'))), - ), - ), + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MsrGestureDetector( + onClick: (data, snapshot) async {}, + onLongClick: (data, snapshot) async {}, + onScroll: (data) async { + scrollEvents.add(data); + }, + layoutSnapshotWidgetFilter: {}, + child: SizedBox( + height: 300, + child: ListView( + children: List.generate( + 10, (index) => ListTile(title: Text('Item $index'))), ), ), ), - ); + ), + ), + ); - const startPosition = Offset(100, 200); - const scrollDelta = Offset(0, -50); + const startPosition = Offset(100, 200); + const scrollDelta = Offset(0, -50); - await tester.dragFrom(startPosition, scrollDelta); - await tester.pump(); + await tester.dragFrom(startPosition, scrollDelta); + await tester.pump(); - expect(scrollEvents, hasLength(1)); + expect(scrollEvents, hasLength(1)); - final scrollEvent = scrollEvents.first; + final scrollEvent = scrollEvents.first; - // With device pixel ratio of 2.0, coordinates should be doubled - expect(scrollEvent.x, equals(200.0)); // 100 * 2.0 - expect(scrollEvent.y, equals(400.0)); // 200 * 2.0 - expect(scrollEvent.endX, equals(200.0)); // 100 * 2.0 - expect(scrollEvent.endY, equals(300.0)); // 150 * 2.0 + // With device pixel ratio of 2.0, coordinates should be doubled + expect(scrollEvent.x, equals(200.0)); // 100 * 2.0 + expect(scrollEvent.y, equals(400.0)); // 200 * 2.0 + expect(scrollEvent.endX, equals(200.0)); // 100 * 2.0 + expect(scrollEvent.endY, equals(300.0)); // 150 * 2.0 - // Clean up - tester.view.devicePixelRatio = 1; - }); + // Clean up + tester.view.devicePixelRatio = 1; + }); }); }); } -// Helper class to create pointer events with custom timestamps class TestPointerEvent { static PointerDownEvent createDownEvent({ required Offset position, diff --git a/flutter/packages/measure_flutter/test/http/http_collector_test.dart b/flutter/packages/measure_flutter/test/http/http_collector_test.dart index 4190194e5..e24973ee5 100644 --- a/flutter/packages/measure_flutter/test/http/http_collector_test.dart +++ b/flutter/packages/measure_flutter/test/http/http_collector_test.dart @@ -342,4 +342,4 @@ void main() { } }); }); -} \ No newline at end of file +} diff --git a/flutter/packages/measure_flutter/test/isolate/file_processing_isolate_test.dart b/flutter/packages/measure_flutter/test/isolate/file_processing_isolate_test.dart new file mode 100644 index 000000000..0aff2fc1a --- /dev/null +++ b/flutter/packages/measure_flutter/test/isolate/file_processing_isolate_test.dart @@ -0,0 +1,323 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:measure_flutter/src/gestures/snapshot_node.dart'; +import 'package:measure_flutter/src/isolate/file_processor.dart'; +import 'package:measure_flutter/src/isolate/file_processing_isolate.dart'; + +import '../utils/noop_logger.dart'; +import '../utils/test_png.dart'; + +void main() { + group('FileProcessingIsolate', () { + late FileProcessingIsolate isolate; + late NoopLogger logger; + late Directory tempDir; + + setUp(() async { + logger = NoopLogger(); + isolate = FileProcessingIsolate(logger: logger); + tempDir = Directory.systemTemp.createTempSync('file_processing_test'); + }); + + tearDown(() async { + await isolate.dispose(); + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('initializes successfully', () async { + await isolate.init(); + + // Should be able to process requests after initialization + final snapshot = SnapshotNode( + label: 'TestWidget', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + final result = await isolate.processLayoutSnapshotWrite( + snapshot, + 'test.json', + tempDir.path, + ); + + expect(result.error, isNull); + expect(result.filePath, isNotNull); + expect(result.size, greaterThan(0)); + }); + + test('init is idempotent', () async { + await isolate.init(); + + // Second call should be no-op + await isolate.init(); + + final snapshot = SnapshotNode( + label: 'TestWidget', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + final result = await isolate.processLayoutSnapshotWrite( + snapshot, + 'test.json', + tempDir.path, + ); + + expect(result.error, isNull); + }); + + test('returns error when processing without initialization', () async { + final snapshot = SnapshotNode( + label: 'TestWidget', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + final result = await isolate.processLayoutSnapshotWrite( + snapshot, + 'test.json', + tempDir.path, + ); + + expect(result.error, equals('Isolate not initialized')); + expect(result.filePath, isNull); + }); + + test('processes layout snapshot write successfully', () async { + await isolate.init(); + + final snapshot = SnapshotNode( + label: 'Parent', + x: 10, + y: 20, + width: 200, + height: 300, + id: 'test-id', + highlighted: true, + children: [ + SnapshotNode( + label: 'Child', + x: 15, + y: 25, + width: 50, + height: 50, + children: [], + ), + ], + ); + + final result = await isolate.processLayoutSnapshotWrite( + snapshot, + 'snapshot.json', + tempDir.path, + // Disable compression for easier verification + compress: false, + ); + + expect(result.error, isNull); + expect(result.filePath, contains('snapshot.json')); + expect(result.size, greaterThan(0)); + + // Verify file was actually created + final file = File(result.filePath!); + expect(file.existsSync(), isTrue); + + // Verify file content is valid + final content = await file.readAsString(); + expect(content, contains('Parent')); + expect(content, contains('Child')); + expect(content, contains('test-id')); + }); + + test('processes image compression successfully', () async { + await isolate.init(); + + final imageBytes = createTestPngBytes(); + final params = CompressAndSaveParams( + originalBytes: imageBytes, + jpegQuality: 85, + fileName: 'test-image.jpg', + rootPath: tempDir.path, + ); + + final result = await isolate.processImageCompression(params); + + expect(result.error, isNull); + expect(result.filePath, contains('test-image.jpg')); + expect(result.size, greaterThan(0)); + + // Verify file was created + final file = File(result.filePath!); + expect(file.existsSync(), isTrue); + }); + + test('handles multiple concurrent requests', () async { + await isolate.init(); + + final snapshots = List.generate( + 5, + (i) => SnapshotNode( + label: 'Widget$i', + x: i * 10.0, + y: i * 10.0, + width: 100, + height: 100, + children: [], + ), + ); + + // Process multiple requests concurrently + final futures = snapshots + .asMap() + .entries + .map((entry) => isolate.processLayoutSnapshotWrite( + entry.value, + 'snapshot_${entry.key}.json', + tempDir.path, + )) + .toList(); + + final results = await Future.wait(futures); + + // All should succeed + for (var i = 0; i < results.length; i++) { + expect(results[i].error, isNull, reason: 'Request $i failed'); + expect(results[i].filePath, isNotNull); + expect(results[i].size, greaterThan(0)); + } + + // Verify all files were created + for (var i = 0; i < 5; i++) { + final file = File('${tempDir.path}/snapshot_$i.json'); + expect(file.existsSync(), isTrue, reason: 'File $i not created'); + } + }); + + test('disposes cleanly', () async { + await isolate.init(); + + await isolate.dispose(); + + // Should return error after disposal + final snapshot = SnapshotNode( + label: 'TestWidget', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + final result = await isolate.processLayoutSnapshotWrite( + snapshot, + 'test.json', + tempDir.path, + ); + + expect(result.error, equals('Isolate not initialized')); + }); + + test('dispose completes pending requests with errors', () async { + await isolate.init(); + + final snapshot = SnapshotNode( + label: 'TestWidget', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + // Start a request but dispose before it completes + final future = isolate.processLayoutSnapshotWrite( + snapshot, + 'test.json', + tempDir.path, + ); + + // Dispose immediately (in practice, this will likely complete before disposal, + // but the test verifies the disposal logic handles pending requests) + await isolate.dispose(); + + final result = await future; + + // Should either succeed (if it completed before disposal) or return disposal error + expect( + result.error == null || result.error == 'Isolate disposed before request completed', + isTrue, + ); + }); + + test('handles invalid file path gracefully', () async { + await isolate.init(); + + final snapshot = SnapshotNode( + label: 'TestWidget', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + // Use invalid path + final result = await isolate.processLayoutSnapshotWrite( + snapshot, + 'test.json', + '/invalid/nonexistent/path', + ); + + // Should return error + expect(result.error, isNotNull); + }); + + test('can reinitialize after disposal', () async { + await isolate.init(); + + final snapshot = SnapshotNode( + label: 'Test1', + x: 0, + y: 0, + width: 100, + height: 100, + children: [], + ); + + // First request should succeed + var result = await isolate.processLayoutSnapshotWrite( + snapshot, + 'test1.json', + tempDir.path, + ); + expect(result.error, isNull); + + // Dispose + await isolate.dispose(); + + // Reinitialize + await isolate.init(); + + // Second request should also succeed + result = await isolate.processLayoutSnapshotWrite( + snapshot, + 'test2.json', + tempDir.path, + ); + expect(result.error, isNull); + expect(result.filePath, contains('test2.json')); + }); + }); +} diff --git a/flutter/packages/measure_flutter/test/measure_internal_test.dart b/flutter/packages/measure_flutter/test/measure_internal_test.dart new file mode 100644 index 000000000..1b68ecd59 --- /dev/null +++ b/flutter/packages/measure_flutter/test/measure_internal_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:measure_flutter/src/config/measure_config.dart'; +import 'package:measure_flutter/src/measure_initializer.dart'; +import 'package:measure_flutter/src/measure_internal.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MeasureInternal - Isolate Management', () { + late MeasureInternal measureInternal; + late MeasureInitializer initializer; + + setUp(() { + initializer = MeasureInitializer( + const MeasureConfig( + enableLogging: false, + autoStart: false, + ), + ); + + measureInternal = MeasureInternal( + initializer: initializer, + methodChannel: initializer.methodChannel, + ); + }); + + tearDown(() async { + try { + await measureInternal.unregisterCollectors(); + } catch (_) {} + }); + + test('init with autoStart enabled registers collectors', () async { + final autoStartInitializer = MeasureInitializer( + const MeasureConfig( + enableLogging: false, + autoStart: true, + ), + ); + + final autoStartMeasure = MeasureInternal( + initializer: autoStartInitializer, + methodChannel: autoStartInitializer.methodChannel, + ); + + try { + await autoStartMeasure.init(); + + expect( + autoStartMeasure.initializer.exceptionCollector.isEnabled(), true); + expect(autoStartMeasure.initializer.bugReportCollector.isEnabled, true); + } finally { + await autoStartMeasure.unregisterCollectors(); + } + }); + + test('init with autoStart disabled does not register collectors', () async { + await measureInternal.init(); + + expect(measureInternal.initializer.exceptionCollector.isEnabled(), false); + expect(measureInternal.initializer.bugReportCollector.isEnabled, false); + }); + + test('registerCollectors registers all collectors', () async { + expect(measureInternal.initializer.exceptionCollector.isEnabled(), false); + + await measureInternal.registerCollectors(); + + expect(measureInternal.initializer.exceptionCollector.isEnabled(), true); + expect( + measureInternal.initializer.customEventCollector.isEnabled(), true); + expect(measureInternal.initializer.bugReportCollector.isEnabled, true); + }); + + test('unregisterCollectors unregisters all collectors and disposes isolate', + () async { + await measureInternal.registerCollectors(); + expect(measureInternal.initializer.exceptionCollector.isEnabled(), true); + + await measureInternal.unregisterCollectors(); + + expect(measureInternal.initializer.exceptionCollector.isEnabled(), false); + expect( + measureInternal.initializer.customEventCollector.isEnabled(), false); + expect(measureInternal.initializer.bugReportCollector.isEnabled, false); + }); + + test('multiple register/unregister cycles work correctly', () async { + await measureInternal.registerCollectors(); + expect(measureInternal.initializer.exceptionCollector.isEnabled(), true); + + await measureInternal.unregisterCollectors(); + expect(measureInternal.initializer.exceptionCollector.isEnabled(), false); + + await measureInternal.registerCollectors(); + expect(measureInternal.initializer.exceptionCollector.isEnabled(), true); + + await measureInternal.unregisterCollectors(); + expect(measureInternal.initializer.exceptionCollector.isEnabled(), false); + }); + + test('registerCollectors is idempotent', () async { + await measureInternal.registerCollectors(); + expect(measureInternal.initializer.exceptionCollector.isEnabled(), true); + + await measureInternal.registerCollectors(); + expect(measureInternal.initializer.exceptionCollector.isEnabled(), true); + }); + + test('file processing isolate is accessible after registerCollectors', + () async { + await measureInternal.registerCollectors(); + + final isolate = measureInternal.initializer.fileProcessingIsolate; + expect(isolate, isNotNull); + expect(measureInternal.initializer.exceptionCollector.isEnabled(), true); + }); + + test('collectors are unregistered after unregisterCollectors', () async { + await measureInternal.registerCollectors(); + expect(measureInternal.initializer.exceptionCollector.isEnabled(), true); + + await measureInternal.unregisterCollectors(); + expect(measureInternal.initializer.exceptionCollector.isEnabled(), false); + }); + }); +} diff --git a/flutter/packages/measure_flutter/test/measure_test.dart b/flutter/packages/measure_flutter/test/measure_test.dart index 3bfbe0766..0fa35fe91 100644 --- a/flutter/packages/measure_flutter/test/measure_test.dart +++ b/flutter/packages/measure_flutter/test/measure_test.dart @@ -117,7 +117,7 @@ void main() { final measure = Measure.withMethodChannel(methodChannel); await measure.init( - () {}, + () {}, clientInfo: ClientInfo( apiKey: "msrsh-123", apiUrl: "https://example.com", diff --git a/flutter/packages/measure_flutter/test/method_channel/signal_processor_test.dart b/flutter/packages/measure_flutter/test/method_channel/signal_processor_test.dart index 7a8b1caf7..1d84e1cda 100644 --- a/flutter/packages/measure_flutter/test/method_channel/signal_processor_test.dart +++ b/flutter/packages/measure_flutter/test/method_channel/signal_processor_test.dart @@ -20,7 +20,8 @@ void main() { logger = NoopLogger(); channel = TestMethodChannel(); configProvider = FakeConfigProvider(); - signalProcessor = DefaultSignalProcessor(logger: logger, channel: channel, configProvider: configProvider); + signalProcessor = DefaultSignalProcessor( + logger: logger, channel: channel, configProvider: configProvider); }); group('trackEvent', () { diff --git a/flutter/packages/measure_flutter/test/navigation/navigation_collector_test.dart b/flutter/packages/measure_flutter/test/navigation/navigation_collector_test.dart index 445785fda..d2e2277a0 100644 --- a/flutter/packages/measure_flutter/test/navigation/navigation_collector_test.dart +++ b/flutter/packages/measure_flutter/test/navigation/navigation_collector_test.dart @@ -46,7 +46,7 @@ void main() { // Arrange const screenName = 'HomeScreen'; const userTriggered = true; - final attributes = { "key": StringAttr("value") }; + final attributes = {"key": StringAttr("value")}; // Act await navigationCollector.trackScreenViewEvent( diff --git a/flutter/packages/measure_flutter/test/tracing/span_processor_test.dart b/flutter/packages/measure_flutter/test/tracing/span_processor_test.dart index c84ffc351..c8878a2c3 100644 --- a/flutter/packages/measure_flutter/test/tracing/span_processor_test.dart +++ b/flutter/packages/measure_flutter/test/tracing/span_processor_test.dart @@ -104,31 +104,31 @@ void main() { test( "discards checkpoints to keep them within max checkpoints per span limit", - () { - final span = MsrSpan( - logger: logger, - spanProcessor: spanProcessor, - timeProvider: timeProvider, - isSampled: true, - name: "span-name", - spanId: "span-id", - traceId: "trace-id", - parentId: null, - startTime: timeProvider.now() - 1000, - ); - - // Add one more checkpoint than the maximum allowed - for (int i = 0; i <= configProvider.maxCheckpointsPerSpan; i++) { - span.setCheckpoint("checkpoint"); - } - - span.end(); - - expect( - signalProcessor.trackedSpans.first.checkpoints.length, - configProvider.maxCheckpointsPerSpan, - ); - }); + () { + final span = MsrSpan( + logger: logger, + spanProcessor: spanProcessor, + timeProvider: timeProvider, + isSampled: true, + name: "span-name", + spanId: "span-id", + traceId: "trace-id", + parentId: null, + startTime: timeProvider.now() - 1000, + ); + + // Add one more checkpoint than the maximum allowed + for (int i = 0; i <= configProvider.maxCheckpointsPerSpan; i++) { + span.setCheckpoint("checkpoint"); + } + + span.end(); + + expect( + signalProcessor.trackedSpans.first.checkpoints.length, + configProvider.maxCheckpointsPerSpan, + ); + }); test("discards span if duration is negative", () { final span = MsrSpan( diff --git a/flutter/packages/measure_flutter/test/utils/fake_config_provider.dart b/flutter/packages/measure_flutter/test/utils/fake_config_provider.dart index a46179df8..1af6f6e30 100644 --- a/flutter/packages/measure_flutter/test/utils/fake_config_provider.dart +++ b/flutter/packages/measure_flutter/test/utils/fake_config_provider.dart @@ -30,6 +30,7 @@ class FakeConfigProvider implements ConfigProvider { int _maxUserDefinedAttributeValueLength = 100; int _maxUserDefinedAttributeKeyLength = 256; int _maxUserDefinedAttributesPerEvent = 256; + Map _widgetFilter = {}; // Getters @override @@ -110,7 +111,8 @@ class FakeConfigProvider implements ConfigProvider { int get maxDiskUsageInMb => _maxDiskUsageInMb; @override - int get maxUserDefinedAttributeValueLength => _maxUserDefinedAttributeValueLength; + int get maxUserDefinedAttributeValueLength => + _maxUserDefinedAttributeValueLength; @override int get maxUserDefinedAttributeKeyLength => _maxUserDefinedAttributeKeyLength; @@ -118,6 +120,9 @@ class FakeConfigProvider implements ConfigProvider { @override int get maxUserDefinedAttributesPerEvent => _maxUserDefinedAttributesPerEvent; + @override + Map get widgetFilter => _widgetFilter; + // Setters set autoInitializeNativeSDK(bool value) => _autoInitializeNativeSDK = value; @@ -181,11 +186,17 @@ class FakeConfigProvider implements ConfigProvider { set maxDiskUsageInMb(int value) => _maxDiskUsageInMb = value; - set maxUserDefinedAttributeValueLength(int value) => _maxUserDefinedAttributeValueLength = value; + set maxUserDefinedAttributeValueLength(int value) => + _maxUserDefinedAttributeValueLength = value; + + set maxUserDefinedAttributeKeyLength(int value) => + _maxUserDefinedAttributeKeyLength = value; - set maxUserDefinedAttributeKeyLength(int value) => _maxUserDefinedAttributeKeyLength = value; + set maxUserDefinedAttributesPerEvent(int value) => + _maxUserDefinedAttributesPerEvent = value; - set maxUserDefinedAttributesPerEvent(int value) => _maxUserDefinedAttributesPerEvent = value; + set widgetFilter(Map value) => + _widgetFilter = value; // Methods @override diff --git a/flutter/packages/measure_flutter/test/utils/fake_file_processing_isolate.dart b/flutter/packages/measure_flutter/test/utils/fake_file_processing_isolate.dart new file mode 100644 index 000000000..7d03df0a5 --- /dev/null +++ b/flutter/packages/measure_flutter/test/utils/fake_file_processing_isolate.dart @@ -0,0 +1,56 @@ +import 'package:measure_flutter/src/gestures/snapshot_node.dart'; +import 'package:measure_flutter/src/isolate/file_processor.dart'; +import 'package:measure_flutter/src/isolate/file_processing_isolate.dart'; + +/// Fake implementation of FileProcessingIsolate for testing +class FakeFileProcessingIsolate implements FileProcessingIsolate { + bool shouldReturnError = false; + bool shouldThrowException = false; + + @override + Future init() async { + // No-op for testing + } + + @override + Future dispose() async { + // No-op for testing + } + + @override + Future processLayoutSnapshotWrite( + SnapshotNode snapshot, + String fileName, + String rootPath, { + bool compress = true, + }) async { + if (shouldThrowException) { + throw Exception('Test exception'); + } + + if (shouldReturnError) { + return const FileProcessingResult(error: 'Write failed'); + } + + final filePath = '$rootPath/$fileName'; + final jsonString = snapshot.toJson().toString(); + final size = jsonString.length; + + return FileProcessingResult( + filePath: filePath, + size: size, + ); + } + + @override + Future processImageCompression( + CompressAndSaveParams params, + ) async { + // Not used in layout snapshot collector tests + throw UnimplementedError(); + } + + Future shutdown() async { + // No-op for testing + } +} diff --git a/flutter/packages/measure_flutter/test/utils/fake_file_storage.dart b/flutter/packages/measure_flutter/test/utils/fake_file_storage.dart index 67fd319e2..9b25d0e76 100644 --- a/flutter/packages/measure_flutter/test/utils/fake_file_storage.dart +++ b/flutter/packages/measure_flutter/test/utils/fake_file_storage.dart @@ -6,6 +6,7 @@ import 'package:measure_flutter/src/storage/file_storage.dart'; class FakeFileStorage implements FileStorage { final Map _storedFiles = {}; bool shouldFailWrite = false; + bool shouldReturnNullPath = false; int? shouldFailWriteAfterCount; int _writeCount = 0; @@ -40,6 +41,9 @@ class FakeFileStorage implements FileStorage { @override Future getRootPath() async { + if (shouldReturnNullPath) { + return null; + } return _rootPath; } } diff --git a/flutter/packages/measure_flutter/test/utils/fake_id_provider.dart b/flutter/packages/measure_flutter/test/utils/fake_id_provider.dart index e3073fbbd..a8f21955d 100644 --- a/flutter/packages/measure_flutter/test/utils/fake_id_provider.dart +++ b/flutter/packages/measure_flutter/test/utils/fake_id_provider.dart @@ -17,4 +17,4 @@ class FakeIdProvider implements IdProvider { String uuid() { return 'uuid-${++_counter}'; } -} \ No newline at end of file +} diff --git a/flutter/packages/measure_flutter/test/utils/fake_image_picker.dart b/flutter/packages/measure_flutter/test/utils/fake_image_picker.dart index bf58df8ef..33d9d5e03 100644 --- a/flutter/packages/measure_flutter/test/utils/fake_image_picker.dart +++ b/flutter/packages/measure_flutter/test/utils/fake_image_picker.dart @@ -84,4 +84,4 @@ class FakeXFile implements XFile { Future saveTo(String path) { throw UnimplementedError(); } -} \ No newline at end of file +} diff --git a/flutter/packages/measure_flutter/test/utils/fake_measure.dart b/flutter/packages/measure_flutter/test/utils/fake_measure.dart index fb40e2b9f..fb4cb2c91 100644 --- a/flutter/packages/measure_flutter/test/utils/fake_measure.dart +++ b/flutter/packages/measure_flutter/test/utils/fake_measure.dart @@ -141,11 +141,11 @@ class FakeMeasure implements MeasureApi { } @override - void trackBugReport({ + Future trackBugReport({ required String description, required List attachments, required Map attributes, - }) { + }) async { trackedBugReports.add(BugReportCall(description, attachments, attributes)); } @@ -165,19 +165,30 @@ class FakeMeasure implements MeasureApi { } @override - void trackClick(ClickData clickData) { + Future trackClick(ClickData clickData, SnapshotNode? snapshot) async { throw UnimplementedError(); } @override - void trackLongClick(LongClickData longClickData) { + Future trackLongClick( + LongClickData longClickData, SnapshotNode? snapshot) async { throw UnimplementedError(); } @override - void trackScroll(ScrollData scrollData) { + Future trackScroll(ScrollData scrollData) async { throw UnimplementedError(); } + + @override + Map getLayoutSnapshotWidgetFilter() { + throw UnimplementedError(); + } + + @override + Logger? getLogger() { + return null; + } } class ScreenViewCall { diff --git a/flutter/packages/measure_flutter/test/utils/fake_signal_processor.dart b/flutter/packages/measure_flutter/test/utils/fake_signal_processor.dart index 36ed3ad64..78200d436 100644 --- a/flutter/packages/measure_flutter/test/utils/fake_signal_processor.dart +++ b/flutter/packages/measure_flutter/test/utils/fake_signal_processor.dart @@ -54,7 +54,7 @@ class FakeSignalProcessor implements SignalProcessor { threadName: threadName, attachments: attachments, )); - + if (data is ExceptionData) { trackedExceptions.add(data); } diff --git a/ios/Sources/MeasureSDK/Swift/Events/AttachmentType.swift b/ios/Sources/MeasureSDK/Swift/Events/AttachmentType.swift index ef1238e88..794a16d8b 100644 --- a/ios/Sources/MeasureSDK/Swift/Events/AttachmentType.swift +++ b/ios/Sources/MeasureSDK/Swift/Events/AttachmentType.swift @@ -10,4 +10,5 @@ import Foundation public enum AttachmentType: String, Codable { case screenshot case layoutSnapshot = "layout_snapshot" + case layoutSnapshotJson = "layout_snapshot_json" } diff --git a/ios/Sources/MeasureSDK/Swift/Events/InternalSignalCollector.swift b/ios/Sources/MeasureSDK/Swift/Events/InternalSignalCollector.swift index ff680f959..e229681fc 100644 --- a/ios/Sources/MeasureSDK/Swift/Events/InternalSignalCollector.swift +++ b/ios/Sources/MeasureSDK/Swift/Events/InternalSignalCollector.swift @@ -111,7 +111,7 @@ final class BaseInternalSignalCollector: InternalSignalCollector { type: .custom, attributes: evaluatedAttributes, sessionId: sessionId, - attachments: nil, + attachments: attachments, userDefinedAttributes: serializedUserDefinedAttributes, threadName: threadName ) @@ -135,7 +135,7 @@ final class BaseInternalSignalCollector: InternalSignalCollector { type: .exception, attributes: evaluatedAttributes, sessionId: sessionId, - attachments: nil, + attachments: attachments, userDefinedAttributes: serializedUserDefinedAttributes, threadName: threadName ) @@ -148,7 +148,7 @@ final class BaseInternalSignalCollector: InternalSignalCollector { type: .screenView, attributes: evaluatedAttributes, sessionId: sessionId, - attachments: nil, + attachments: attachments, userDefinedAttributes: serializedUserDefinedAttributes, threadName: threadName ) @@ -161,7 +161,7 @@ final class BaseInternalSignalCollector: InternalSignalCollector { type: .http, attributes: evaluatedAttributes, sessionId: sessionId, - attachments: nil, + attachments: attachments, userDefinedAttributes: serializedUserDefinedAttributes, threadName: threadName )