[flutter_driver] show refresh rate status in timeline summary (#95699)

diff --git a/packages/flutter_driver/lib/src/driver/refresh_rate_summarizer.dart b/packages/flutter_driver/lib/src/driver/refresh_rate_summarizer.dart
new file mode 100644
index 0000000..cef0d7a
--- /dev/null
+++ b/packages/flutter_driver/lib/src/driver/refresh_rate_summarizer.dart
@@ -0,0 +1,130 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'timeline.dart';
+
+/// Event name for refresh rate related timeline events.
+const String kUIThreadVsyncProcessEvent = 'VsyncProcessCallback';
+
+/// A summary of [TimelineEvents]s corresponding to `kUIThreadVsyncProcessEvent` events.
+///
+/// `RefreshRate` is the time between the start of a vsync pulse and the target time of that vsync.
+class RefreshRateSummary {
+
+  /// Creates a [RefreshRateSummary] given the timeline events.
+  factory RefreshRateSummary({required List<TimelineEvent> vsyncEvents}) {
+    return RefreshRateSummary._(refreshRates: _computeRefreshRates(vsyncEvents));
+  }
+
+  RefreshRateSummary._({required List<double> refreshRates}) {
+    _numberOfTotalFrames = refreshRates.length;
+    for (final double refreshRate in refreshRates) {
+      if ((refreshRate - 30).abs() < _kErrorMargin) {
+        _numberOf30HzFrames++;
+        continue;
+      }
+      if ((refreshRate - 60).abs() < _kErrorMargin) {
+        _numberOf60HzFrames++;
+        continue;
+      }
+      if ((refreshRate - 90).abs() < _kErrorMargin) {
+        _numberOf90HzFrames++;
+        continue;
+      }
+      if ((refreshRate - 120).abs() < _kErrorMargin) {
+        _numberOf120HzFrames++;
+        continue;
+      }
+      _framesWithIllegalRefreshRate.add(refreshRate);
+    }
+    assert(_numberOfTotalFrames ==
+        _numberOf30HzFrames +
+            _numberOf60HzFrames +
+            _numberOf90HzFrames +
+            _numberOf120HzFrames +
+            _framesWithIllegalRefreshRate.length);
+  }
+
+  static const double _kErrorMargin = 6.0;
+
+  /// Number of frames with 30hz refresh rate
+  int get numberOf30HzFrames => _numberOf30HzFrames;
+
+  /// Number of frames with 60hz refresh rate
+  int get numberOf60HzFrames => _numberOf60HzFrames;
+
+  /// Number of frames with 90hz refresh rate
+  int get numberOf90HzFrames => _numberOf90HzFrames;
+
+  /// Number of frames with 120hz refresh rate
+  int get numberOf120HzFrames => _numberOf120HzFrames;
+
+  /// The percentage of 30hz frames.
+  ///
+  /// For example, if this value is 20, it means there are 20 percent of total
+  /// frames are 30hz. 0 means no frames are 30hz, 100 means all frames are 30hz.
+  double get percentageOf30HzFrames => _numberOfTotalFrames > 0
+      ? _numberOf30HzFrames / _numberOfTotalFrames * 100
+      : 0;
+
+  /// The percentage of 60hz frames.
+  ///
+  /// For example, if this value is 20, it means there are 20 percent of total
+  /// frames are 60hz. 0 means no frames are 60hz, 100 means all frames are 60hz.
+  double get percentageOf60HzFrames => _numberOfTotalFrames > 0
+      ? _numberOf60HzFrames / _numberOfTotalFrames * 100
+      : 0;
+
+  /// The percentage of 90hz frames.
+  ///
+  /// For example, if this value is 20, it means there are 20 percent of total
+  /// frames are 90hz. 0 means no frames are 90hz, 100 means all frames are 90hz.
+  double get percentageOf90HzFrames => _numberOfTotalFrames > 0
+      ? _numberOf90HzFrames / _numberOfTotalFrames * 100
+      : 0;
+
+  /// The percentage of 90hz frames.
+  ///
+  /// For example, if this value is 20, it means there are 20 percent of total
+  /// frames are 120hz. 0 means no frames are 120hz, 100 means all frames are 120hz.
+  double get percentageOf120HzFrames => _numberOfTotalFrames > 0
+      ? _numberOf120HzFrames / _numberOfTotalFrames * 100
+      : 0;
+
+  /// A list of all the frames with Illegal refresh rate.
+  ///
+  /// A refresh rate is consider illegal if it does not belong to anyone below:
+  /// 30hz, 60hz, 90hz or 120hz.
+  List<double> get framesWithIllegalRefreshRate =>
+      _framesWithIllegalRefreshRate;
+
+  int _numberOf30HzFrames = 0;
+  int _numberOf60HzFrames = 0;
+  int _numberOf90HzFrames = 0;
+  int _numberOf120HzFrames = 0;
+  int _numberOfTotalFrames = 0;
+
+  final List<double> _framesWithIllegalRefreshRate = <double>[];
+
+  static List<double> _computeRefreshRates(List<TimelineEvent> vsyncEvents) {
+    final List<double> result = <double>[];
+    for (int i = 0; i < vsyncEvents.length; i++) {
+      final TimelineEvent event = vsyncEvents[i];
+      if (event.phase != 'B') {
+        continue;
+      }
+      assert(event.name == kUIThreadVsyncProcessEvent);
+      assert(event.arguments != null);
+      final Map<String, dynamic> arguments = event.arguments!;
+      const double nanosecondsPerSecond = 1e+9;
+      final int startTimeInNanoseconds = int.parse(arguments['StartTime'] as String);
+      final int targetTimeInNanoseconds = int.parse(arguments['TargetTime'] as String);
+      final int frameDurationInNanoseconds = targetTimeInNanoseconds - startTimeInNanoseconds;
+      final double refreshRate = nanosecondsPerSecond /
+          frameDurationInNanoseconds;
+      result.add(refreshRate);
+    }
+    return result;
+  }
+}
diff --git a/packages/flutter_driver/lib/src/driver/timeline_summary.dart b/packages/flutter_driver/lib/src/driver/timeline_summary.dart
index 73af4ae..c1cf5c9 100644
--- a/packages/flutter_driver/lib/src/driver/timeline_summary.dart
+++ b/packages/flutter_driver/lib/src/driver/timeline_summary.dart
@@ -13,6 +13,7 @@
 import 'percentile_utils.dart';
 import 'profiling_summarizer.dart';
 import 'raster_cache_summarizer.dart';
+import 'refresh_rate_summarizer.dart';
 import 'scene_display_lag_summarizer.dart';
 import 'timeline.dart';
 import 'vsync_frame_lag_summarizer.dart';
@@ -220,6 +221,7 @@
     final Map<String, dynamic> profilingSummary = _profilingSummarizer().summarize();
     final RasterCacheSummarizer rasterCacheSummarizer = _rasterCacheSummarizer();
     final GCSummarizer gcSummarizer = _gcSummarizer();
+    final RefreshRateSummary refreshRateSummary = RefreshRateSummary(vsyncEvents: _extractNamedEvents(kUIThreadVsyncProcessEvent));
 
     final Map<String, dynamic> timelineSummary = <String, dynamic>{
       'average_frame_build_time_millis': computeAverageFrameBuildTimeMillis(),
@@ -271,6 +273,11 @@
       '99th_percentile_picture_cache_memory': rasterCacheSummarizer.computePercentilePictureMemory(99.0),
       'worst_picture_cache_memory': rasterCacheSummarizer.computeWorstPictureMemory(),
       'total_ui_gc_time': gcSummarizer.totalGCTimeMillis,
+      '30hz_frame_percentage': refreshRateSummary.percentageOf30HzFrames,
+      '60hz_frame_percentage': refreshRateSummary.percentageOf60HzFrames,
+      '90hz_frame_percentage': refreshRateSummary.percentageOf90HzFrames,
+      '120hz_frame_percentage': refreshRateSummary.percentageOf120HzFrames,
+      'illegal_refresh_rate_frame_count': refreshRateSummary.framesWithIllegalRefreshRate.length,
     };
 
     timelineSummary.addAll(profilingSummary);
diff --git a/packages/flutter_driver/test/src/real_tests/timeline_summary_test.dart b/packages/flutter_driver/test/src/real_tests/timeline_summary_test.dart
index 38131e7..d93bd3e 100644
--- a/packages/flutter_driver/test/src/real_tests/timeline_summary_test.dart
+++ b/packages/flutter_driver/test/src/real_tests/timeline_summary_test.dart
@@ -3,10 +3,12 @@
 // found in the LICENSE file.
 
 import 'dart:convert' show json;
+import 'dart:math';
 
 import 'package:file/file.dart';
 import 'package:flutter_driver/flutter_driver.dart';
 import 'package:flutter_driver/src/driver/profiling_summarizer.dart';
+import 'package:flutter_driver/src/driver/refresh_rate_summarizer.dart';
 import 'package:flutter_driver/src/driver/scene_display_lag_summarizer.dart';
 import 'package:flutter_driver/src/driver/vsync_frame_lag_summarizer.dart';
 import 'package:path/path.dart' as path;
@@ -89,10 +91,14 @@
       'ts': timeStamp,
     };
 
-    Map<String, dynamic> vsyncCallback(int timeStamp) => <String, dynamic>{
+    Map<String, dynamic> vsyncCallback(int timeStamp, {String phase = 'B', String startTime = '2750850055428', String endTime = '2750866722095'}) => <String, dynamic>{
       'name': 'VsyncProcessCallback',
-      'ph': 'B',
+      'ph': phase,
       'ts': timeStamp,
+      'args': <String, dynamic>{
+        'StartTime': startTime,
+        'TargetTime': endTime,
+      }
     };
 
     List<Map<String, dynamic>> _genGC(String name, int count, int startTime, int timeDiff) {
@@ -467,6 +473,11 @@
             '99th_percentile_picture_cache_memory': 0.0,
             'worst_picture_cache_memory': 0.0,
             'total_ui_gc_time': 0.4,
+            '30hz_frame_percentage': 0,
+            '60hz_frame_percentage': 0,
+            '90hz_frame_percentage': 0,
+            '120hz_frame_percentage': 0,
+            'illegal_refresh_rate_frame_count': 0,
           },
         );
       });
@@ -582,6 +593,11 @@
           '99th_percentile_picture_cache_memory': 0.0,
           'worst_picture_cache_memory': 0.0,
           'total_ui_gc_time': 0.4,
+          '30hz_frame_percentage': 0,
+          '60hz_frame_percentage': 100,
+          '90hz_frame_percentage': 0,
+          '120hz_frame_percentage': 0,
+          'illegal_refresh_rate_frame_count': 0,
         });
       });
     });
@@ -734,5 +750,173 @@
         expect(summarizer.computePercentileVsyncFrameLag(99), 990);
       });
     });
+
+    group('RefreshRateSummarizer tests', () {
+
+      const double kCompareDelta = 0.01;
+      RefreshRateSummary _summarize(List<Map<String, dynamic>> traceEvents) {
+        final Timeline timeline = Timeline.fromJson(<String, dynamic>{
+          'traceEvents': traceEvents,
+        });
+        return RefreshRateSummary(vsyncEvents: timeline.events!);
+      }
+
+      List<Map<String, dynamic>> _populateEvents({required int numberOfEvents, required  int startTime, required int interval, required int margin}) {
+        final List<Map<String, dynamic>> events = <Map<String, dynamic>>[];
+        int startTimeInNanoseconds = startTime;
+        for (int i = 0; i < numberOfEvents; i ++) {
+          final int randomMargin = margin >= 1 ? (-margin + Random().nextInt(margin*2)) : 0;
+          final int endTime = startTimeInNanoseconds + interval + randomMargin;
+          events.add(vsyncCallback(0, startTime: startTimeInNanoseconds.toString(), endTime: endTime.toString()));
+          startTimeInNanoseconds = endTime;
+        }
+        return events;
+      }
+
+      test('Recognize 30 hz frames.', () async {
+        const int startTimeInNanoseconds = 2750850055430;
+        const int intervalInNanoseconds = 33333333;
+        // allow some margins
+        const int margin = 3000000;
+        final List<Map<String, dynamic>> events = _populateEvents(numberOfEvents: 100,
+                                                                  startTime: startTimeInNanoseconds,
+                                                                  interval: intervalInNanoseconds,
+                                                                  margin: margin,
+                                                                 );
+        final RefreshRateSummary summary = _summarize(events);
+        expect(summary.percentageOf30HzFrames, closeTo(100, kCompareDelta));
+        expect(summary.percentageOf60HzFrames, 0);
+        expect(summary.percentageOf90HzFrames, 0);
+        expect(summary.percentageOf120HzFrames, 0);
+        expect(summary.framesWithIllegalRefreshRate, isEmpty);
+      });
+
+      test('Recognize 60 hz frames.', () async {
+        const int startTimeInNanoseconds = 2750850055430;
+        const int intervalInNanoseconds = 16666666;
+        // allow some margins
+        const int margin = 1200000;
+        final List<Map<String, dynamic>> events = _populateEvents(numberOfEvents: 100,
+                                                                  startTime: startTimeInNanoseconds,
+                                                                  interval: intervalInNanoseconds,
+                                                                  margin: margin,
+                                                                 );
+
+        final RefreshRateSummary summary = _summarize(events);
+        expect(summary.percentageOf30HzFrames, 0);
+        expect(summary.percentageOf60HzFrames, closeTo(100, kCompareDelta));
+        expect(summary.percentageOf90HzFrames, 0);
+        expect(summary.percentageOf120HzFrames, 0);
+        expect(summary.framesWithIllegalRefreshRate, isEmpty);
+      });
+
+      test('Recognize 90 hz frames.', () async {
+        const int startTimeInNanoseconds = 2750850055430;
+        const int intervalInNanoseconds = 11111111;
+        // allow some margins
+        const int margin = 500000;
+        final List<Map<String, dynamic>> events = _populateEvents(numberOfEvents: 100,
+                                                                  startTime: startTimeInNanoseconds,
+                                                                  interval: intervalInNanoseconds,
+                                                                  margin: margin,
+                                                                 );
+
+        final RefreshRateSummary summary = _summarize(events);
+        expect(summary.percentageOf30HzFrames, 0);
+        expect(summary.percentageOf60HzFrames, 0);
+        expect(summary.percentageOf90HzFrames, closeTo(100, kCompareDelta));
+        expect(summary.percentageOf120HzFrames, 0);
+        expect(summary.framesWithIllegalRefreshRate, isEmpty);
+      });
+
+      test('Recognize 120 hz frames.', () async {
+        const int startTimeInNanoseconds = 2750850055430;
+        const int intervalInNanoseconds = 8333333;
+        // allow some margins
+        const int margin = 300000;
+        final List<Map<String, dynamic>> events = _populateEvents(numberOfEvents: 100,
+                                                                  startTime: startTimeInNanoseconds,
+                                                                  interval: intervalInNanoseconds,
+                                                                  margin: margin,
+                                                                 );
+        final RefreshRateSummary summary = _summarize(events);
+        expect(summary.percentageOf30HzFrames, 0);
+        expect(summary.percentageOf60HzFrames, 0);
+        expect(summary.percentageOf90HzFrames, 0);
+        expect(summary.percentageOf120HzFrames, closeTo(100, kCompareDelta));
+        expect(summary.framesWithIllegalRefreshRate, isEmpty);
+      });
+
+      test('Identify illegal refresh rates.', () async {
+        const int startTimeInNanoseconds = 2750850055430;
+        const int intervalInNanoseconds = 10000000;
+        final List<Map<String, dynamic>> events = _populateEvents(numberOfEvents: 1,
+                                                                  startTime: startTimeInNanoseconds,
+                                                                  interval: intervalInNanoseconds,
+                                                                  margin: 0,
+                                                                 );
+        final RefreshRateSummary summary = _summarize(events);
+        expect(summary.percentageOf30HzFrames, 0);
+        expect(summary.percentageOf60HzFrames, 0);
+        expect(summary.percentageOf90HzFrames, 0);
+        expect(summary.percentageOf120HzFrames, 0);
+        expect(summary.framesWithIllegalRefreshRate, isNotEmpty);
+        expect(summary.framesWithIllegalRefreshRate.first, closeTo(100, kCompareDelta));
+      });
+
+      test('Mixed refresh rates.', () async {
+
+        final List<Map<String, dynamic>> events = <Map<String, dynamic>>[];
+        const int num30Hz = 10;
+        const int num60Hz = 20;
+        const int num90Hz = 20;
+        const int num120Hz = 40;
+        const int numIllegal = 10;
+
+        // Add 30hz frames
+        events.addAll(_populateEvents(numberOfEvents: num30Hz,
+                                      startTime: 0,
+                                      interval: 32000000,
+                                      margin: 0,
+                                      ));
+
+        // Add 60hz frames
+        events.addAll(_populateEvents(numberOfEvents: num60Hz,
+                                      startTime: 0,
+                                      interval: 16000000,
+                                      margin: 0,
+                                      ));
+
+
+        // Add 90hz frames
+        events.addAll(_populateEvents(numberOfEvents: num90Hz,
+                                      startTime: 0,
+                                      interval: 11000000,
+                                      margin: 0,
+                                      ));
+
+        // Add 120hz frames
+        events.addAll(_populateEvents(numberOfEvents: num120Hz,
+                                      startTime: 0,
+                                      interval: 8000000,
+                                      margin: 0,
+                                      ));
+
+        // Add illegal refresh rate frames
+        events.addAll(_populateEvents(numberOfEvents: numIllegal,
+                                      startTime: 0,
+                                      interval: 60000,
+                                      margin: 0,
+                                      ));
+
+        final RefreshRateSummary summary  = _summarize(events);
+        expect(summary.percentageOf30HzFrames, closeTo(num30Hz, kCompareDelta));
+        expect(summary.percentageOf60HzFrames, closeTo(num60Hz, kCompareDelta));
+        expect(summary.percentageOf90HzFrames, closeTo(num90Hz, kCompareDelta));
+        expect(summary.percentageOf120HzFrames, closeTo(num120Hz, kCompareDelta));
+        expect(summary.framesWithIllegalRefreshRate, isNotEmpty);
+        expect(summary.framesWithIllegalRefreshRate.length, 10);
+      });
+    });
   });
 }