// Copyright 2018 The Chromium 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 'dart:typed_data'; 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 Page { const MotionEventsPage() : super('Motion Event Tests', const ValueKey<String>('MotionEventsListTile')); @override Widget build(BuildContext context) { return 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 { @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>[ RaisedButton( child: const Text('RECORD'), onPressed: listenToFlutterViewEvents, ), RaisedButton( child: const Text('CLEAR'), onPressed: () { setState(() { flutterViewEvents.clear(); embeddedViewEvents.clear(); }); }, ), RaisedButton( child: const Text('SAVE'), onPressed: () { const StandardMessageCodec codec = StandardMessageCodec(); saveRecordedEvents( codec.encodeMessage(flutterViewEvents), context); }, ), RaisedButton( key: const ValueKey<String>('play'), child: const Text('PLAY FILE'), onPressed: () { playEventsFile(); }, ), ], ), ], ); } 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); 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 (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')) { 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); showMessage(context, 'Saved original events to ${file.path}'); } catch (e) { showMessage(context, 'Failed saving ${e.toString()}'); } } void showMessage(BuildContext context, String message) { Scaffold.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; flutterViewEvents.insert(0, map.cast<String, dynamic>()); if (flutterViewEvents.length > kEventsBufferSize) flutterViewEvents.removeLast(); setState(() {}); break; } return Future<dynamic>.sync(null); } Future<dynamic> onViewMethodChannelCall(MethodCall call) { switch (call.method) { case 'onTouch': final Map<dynamic, dynamic> map = call.arguments; embeddedViewEvents.insert(0, map.cast<String, dynamic>()); if (embeddedViewEvents.length > kEventsBufferSize) embeddedViewEvents.removeLast(); setState(() {}); break; } return Future<dynamic>.sync(null); } 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); 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']; 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']; 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'].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.toString()); } }