// 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); } }