| // 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 'dart:async'; |
| import 'dart:io'; |
| |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:flutter_driver/driver_extension.dart'; |
| import 'package:path_provider/path_provider.dart'; |
| |
| import 'motion_event_diff.dart'; |
| import 'page.dart'; |
| |
| MethodChannel channel = const MethodChannel('android_views_integration'); |
| |
| const String kEventsFileName = 'touchEvents'; |
| |
| class MotionEventsPage extends PageWidget { |
| const MotionEventsPage({Key? key}) |
| : super('Motion Event Tests', const ValueKey<String>('MotionEventsListTile'), key: key); |
| |
| @override |
| Widget build(BuildContext context) { |
| return const MotionEventsBody(); |
| } |
| } |
| |
| /// Wraps a flutter driver [DataHandler] with one that waits until a delegate is set. |
| /// |
| /// This allows the driver test to call [FlutterDriver.requestData] before the handler was |
| /// set by the app in which case the requestData call will only complete once the app is ready |
| /// for it. |
| class FutureDataHandler { |
| final Completer<DataHandler> handlerCompleter = Completer<DataHandler>(); |
| |
| Future<String> handleMessage(String? message) async { |
| final DataHandler handler = await handlerCompleter.future; |
| return handler(message); |
| } |
| } |
| |
| FutureDataHandler driverDataHandler = FutureDataHandler(); |
| |
| class MotionEventsBody extends StatefulWidget { |
| const MotionEventsBody({super.key}); |
| |
| @override |
| State createState() => MotionEventsBodyState(); |
| } |
| |
| class MotionEventsBodyState extends State<MotionEventsBody> { |
| static const int kEventsBufferSize = 1000; |
| |
| MethodChannel? viewChannel; |
| |
| /// The list of motion events that were passed to the FlutterView. |
| List<Map<String, dynamic>> flutterViewEvents = <Map<String, dynamic>>[]; |
| |
| /// The list of motion events that were passed to the embedded view. |
| List<Map<String, dynamic>> embeddedViewEvents = <Map<String, dynamic>>[]; |
| |
| @override |
| Widget build(BuildContext context) { |
| return Column( |
| children: <Widget>[ |
| SizedBox( |
| height: 300.0, |
| child: AndroidView( |
| key: const ValueKey<String>('PlatformView'), |
| viewType: 'simple_view', |
| onPlatformViewCreated: onPlatformViewCreated), |
| ), |
| Expanded( |
| child: ListView.builder( |
| itemBuilder: buildEventTile, |
| itemCount: flutterViewEvents.length, |
| ), |
| ), |
| Row( |
| children: <Widget>[ |
| Expanded( |
| child: ElevatedButton( |
| onPressed: listenToFlutterViewEvents, |
| child: const Text('RECORD'), |
| ), |
| ), |
| Expanded( |
| child: ElevatedButton( |
| child: const Text('CLEAR'), |
| onPressed: () { |
| setState(() { |
| flutterViewEvents.clear(); |
| embeddedViewEvents.clear(); |
| }); |
| }, |
| ), |
| ), |
| Expanded( |
| child: ElevatedButton( |
| child: const Text('SAVE'), |
| onPressed: () { |
| const StandardMessageCodec codec = StandardMessageCodec(); |
| saveRecordedEvents( |
| codec.encodeMessage(flutterViewEvents)!, context); |
| }, |
| ), |
| ), |
| Expanded( |
| child: ElevatedButton( |
| key: const ValueKey<String>('play'), |
| child: const Text('PLAY FILE'), |
| onPressed: () { playEventsFile(); }, |
| ), |
| ), |
| Expanded( |
| child: ElevatedButton( |
| key: const ValueKey<String>('back'), |
| child: const Text('BACK'), |
| onPressed: () { Navigator.pop(context); }, |
| ), |
| ), |
| ], |
| ), |
| ], |
| ); |
| } |
| |
| Future<String> playEventsFile() async { |
| const StandardMessageCodec codec = StandardMessageCodec(); |
| try { |
| final ByteData data = await rootBundle.load('packages/assets_for_android_views/assets/touchEvents'); |
| final List<dynamic> unTypedRecordedEvents = codec.decodeMessage(data) as List<dynamic>; |
| final List<Map<String, dynamic>> recordedEvents = unTypedRecordedEvents |
| .cast<Map<dynamic, dynamic>>() |
| .map<Map<String, dynamic>>((Map<dynamic, dynamic> e) =>e.cast<String, dynamic>()) |
| .toList(); |
| await channel.invokeMethod<void>('pipeFlutterViewEvents'); |
| await viewChannel?.invokeMethod<void>('pipeTouchEvents'); |
| print('replaying ${recordedEvents.length} motion events'); |
| for (final Map<String, dynamic> event in recordedEvents.reversed) { |
| await channel.invokeMethod<void>('synthesizeEvent', event); |
| } |
| |
| await channel.invokeMethod<void>('stopFlutterViewEvents'); |
| await viewChannel?.invokeMethod<void>('stopTouchEvents'); |
| |
| if (flutterViewEvents.length != embeddedViewEvents.length) { |
| return 'Synthesized ${flutterViewEvents.length} events but the embedded view received ${embeddedViewEvents.length} events'; |
| } |
| |
| final StringBuffer diff = StringBuffer(); |
| for (int i = 0; i < flutterViewEvents.length; ++i) { |
| final String currentDiff = diffMotionEvents(flutterViewEvents[i], embeddedViewEvents[i]); |
| if (currentDiff.isEmpty) { |
| continue; |
| } |
| if (diff.isNotEmpty) { |
| diff.write(', '); |
| } |
| diff.write(currentDiff); |
| } |
| return diff.toString(); |
| } catch(e) { |
| return e.toString(); |
| } |
| } |
| |
| @override |
| void initState() { |
| super.initState(); |
| channel.setMethodCallHandler(onMethodChannelCall); |
| } |
| |
| Future<void> saveRecordedEvents(ByteData data, BuildContext context) async { |
| if (await channel.invokeMethod<bool>('getStoragePermission') ?? false) { |
| if (mounted) { |
| showMessage(context, 'External storage permissions are required to save events'); |
| } |
| return; |
| } |
| try { |
| final Directory? outDir = await getExternalStorageDirectory(); |
| // This test only runs on Android so we can assume path separator is '/'. |
| final File file = File('${outDir?.path}/$kEventsFileName'); |
| await file.writeAsBytes(data.buffer.asUint8List(0, data.lengthInBytes), flush: true); |
| if (!mounted) { |
| return; |
| } |
| showMessage(context, 'Saved original events to ${file.path}'); |
| } catch (e) { |
| if (!mounted) { |
| return; |
| } |
| showMessage(context, 'Failed saving $e'); |
| } |
| } |
| |
| void showMessage(BuildContext context, String message) { |
| ScaffoldMessenger.of(context).showSnackBar(SnackBar( |
| content: Text(message), |
| duration: const Duration(seconds: 3), |
| )); |
| } |
| |
| void onPlatformViewCreated(int id) { |
| viewChannel = MethodChannel('simple_view/$id'); |
| viewChannel?.setMethodCallHandler(onViewMethodChannelCall); |
| driverDataHandler.handlerCompleter.complete(handleDriverMessage); |
| } |
| |
| void listenToFlutterViewEvents() { |
| channel.invokeMethod<void>('pipeFlutterViewEvents'); |
| viewChannel?.invokeMethod<void>('pipeTouchEvents'); |
| Timer(const Duration(seconds: 3), () { |
| channel.invokeMethod<void>('stopFlutterViewEvents'); |
| viewChannel?.invokeMethod<void>('stopTouchEvents'); |
| }); |
| } |
| |
| Future<String> handleDriverMessage(String? message) async { |
| switch (message) { |
| case 'run test': |
| return playEventsFile(); |
| } |
| return 'unknown message: "$message"'; |
| } |
| |
| Future<dynamic> onMethodChannelCall(MethodCall call) { |
| switch (call.method) { |
| case 'onTouch': |
| final Map<dynamic, dynamic> map = call.arguments as Map<dynamic, dynamic>; |
| flutterViewEvents.insert(0, map.cast<String, dynamic>()); |
| if (flutterViewEvents.length > kEventsBufferSize) { |
| flutterViewEvents.removeLast(); |
| } |
| setState(() {}); |
| break; |
| } |
| return Future<dynamic>.value(); |
| } |
| |
| Future<dynamic> onViewMethodChannelCall(MethodCall call) { |
| switch (call.method) { |
| case 'onTouch': |
| final Map<dynamic, dynamic> map = call.arguments as Map<dynamic, dynamic>; |
| embeddedViewEvents.insert(0, map.cast<String, dynamic>()); |
| if (embeddedViewEvents.length > kEventsBufferSize) { |
| embeddedViewEvents.removeLast(); |
| } |
| setState(() {}); |
| break; |
| } |
| return Future<dynamic>.value(); |
| } |
| |
| Widget buildEventTile(BuildContext context, int index) { |
| if (embeddedViewEvents.length > index) { |
| return TouchEventDiff( |
| flutterViewEvents[index], embeddedViewEvents[index]); |
| } |
| return Text( |
| 'Unmatched event, action: ${flutterViewEvents[index]['action']}'); |
| } |
| } |
| |
| class TouchEventDiff extends StatelessWidget { |
| const TouchEventDiff(this.originalEvent, this.synthesizedEvent, {super.key}); |
| |
| final Map<String, dynamic> originalEvent; |
| final Map<String, dynamic> synthesizedEvent; |
| |
| @override |
| Widget build(BuildContext context) { |
| |
| Color color; |
| final String diff = diffMotionEvents(originalEvent, synthesizedEvent); |
| String msg; |
| final int action = synthesizedEvent['action'] as int; |
| final String actionName = getActionName(getActionMasked(action), action); |
| if (diff.isEmpty) { |
| color = Colors.green; |
| msg = 'Matched event (action $actionName)'; |
| } else { |
| color = Colors.red; |
| msg = '[$actionName] $diff'; |
| } |
| return GestureDetector( |
| onLongPress: () { |
| print('expected:'); |
| prettyPrintEvent(originalEvent); |
| print('\nactual:'); |
| prettyPrintEvent(synthesizedEvent); |
| }, |
| child: Container( |
| color: color, |
| margin: const EdgeInsets.only(bottom: 2.0), |
| child: Text(msg), |
| ), |
| ); |
| } |
| |
| void prettyPrintEvent(Map<String, dynamic> event) { |
| final StringBuffer buffer = StringBuffer(); |
| final int action = event['action'] as int; |
| final int maskedAction = getActionMasked(action); |
| final String actionName = getActionName(maskedAction, action); |
| |
| buffer.write('$actionName '); |
| if (maskedAction == 5 || maskedAction == 6) { |
| buffer.write('pointer: ${getPointerIdx(action)} '); |
| } |
| |
| final List<Map<dynamic, dynamic>> coords = (event['pointerCoords'] as List<dynamic>).cast<Map<dynamic, dynamic>>(); |
| for (int i = 0; i < coords.length; i++) { |
| buffer.write('p$i x: ${coords[i]['x']} y: ${coords[i]['y']}, pressure: ${coords[i]['pressure']} '); |
| } |
| print(buffer); |
| } |
| } |