motion_events_page.dart 10.3 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// 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';

20
class MotionEventsPage extends PageWidget {
21
  const MotionEventsPage({Key? key})
22
      : super('Motion Event Tests', const ValueKey<String>('MotionEventsListTile'), key: key);
23 24 25

  @override
  Widget build(BuildContext context) {
26
    return const MotionEventsBody();
27 28 29 30 31 32 33 34 35 36 37
  }
}

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

38
  Future<String> handleMessage(String? message) async {
39 40 41 42 43 44 45 46
    final DataHandler handler = await handlerCompleter.future;
    return handler(message);
  }
}

FutureDataHandler driverDataHandler = FutureDataHandler();

class MotionEventsBody extends StatefulWidget {
47
  const MotionEventsBody({super.key});
48

49 50 51 52 53 54 55
  @override
  State createState() => MotionEventsBodyState();
}

class MotionEventsBodyState extends State<MotionEventsBody> {
  static const int kEventsBufferSize = 1000;

56
  MethodChannel? viewChannel;
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82

  /// 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>[
Chris Yang's avatar
Chris Yang committed
83
            Expanded(
84
              child: ElevatedButton(
Chris Yang's avatar
Chris Yang committed
85
                onPressed: listenToFlutterViewEvents,
86
                child: const Text('RECORD'),
Chris Yang's avatar
Chris Yang committed
87
              ),
88
            ),
Chris Yang's avatar
Chris Yang committed
89
            Expanded(
90
              child: ElevatedButton(
Chris Yang's avatar
Chris Yang committed
91 92 93 94 95 96 97 98
                child: const Text('CLEAR'),
                onPressed: () {
                  setState(() {
                    flutterViewEvents.clear();
                    embeddedViewEvents.clear();
                  });
                },
              ),
99
            ),
Chris Yang's avatar
Chris Yang committed
100
            Expanded(
101
              child: ElevatedButton(
Chris Yang's avatar
Chris Yang committed
102 103 104 105
                child: const Text('SAVE'),
                onPressed: () {
                  const StandardMessageCodec codec = StandardMessageCodec();
                  saveRecordedEvents(
106
                    codec.encodeMessage(flutterViewEvents)!, context);
Chris Yang's avatar
Chris Yang committed
107 108
                },
              ),
109
            ),
Chris Yang's avatar
Chris Yang committed
110
            Expanded(
111
              child: ElevatedButton(
Chris Yang's avatar
Chris Yang committed
112 113 114 115 116 117
                key: const ValueKey<String>('play'),
                child: const Text('PLAY FILE'),
                onPressed: () { playEventsFile(); },
              ),
            ),
            Expanded(
118
              child: ElevatedButton(
Chris Yang's avatar
Chris Yang committed
119 120 121 122
                key: const ValueKey<String>('back'),
                child: const Text('BACK'),
                onPressed: () { Navigator.pop(context); },
              ),
123 124 125 126 127 128 129 130 131 132 133
            ),
          ],
        ),
      ],
    );
  }

  Future<String> playEventsFile() async {
    const StandardMessageCodec codec = StandardMessageCodec();
    try {
      final ByteData data = await rootBundle.load('packages/assets_for_android_views/assets/touchEvents');
134
      final List<dynamic> unTypedRecordedEvents = codec.decodeMessage(data) as List<dynamic>;
135 136 137 138 139
      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');
140
      await viewChannel?.invokeMethod<void>('pipeTouchEvents');
141
      print('replaying ${recordedEvents.length} motion events');
142
      for (final Map<String, dynamic> event in recordedEvents.reversed) {
143 144 145 146
        await channel.invokeMethod<void>('synthesizeEvent', event);
      }

      await channel.invokeMethod<void>('stopFlutterViewEvents');
147
      await viewChannel?.invokeMethod<void>('stopTouchEvents');
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173

      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 {
174
    if (await channel.invokeMethod<bool>('getStoragePermission') ?? false) {
175 176 177
      if (mounted) {
        showMessage(context, 'External storage permissions are required to save events');
      }
178 179 180
      return;
    }
    try {
181
      final Directory? outDir = await getExternalStorageDirectory();
182
      // This test only runs on Android so we can assume path separator is '/'.
183
      final File file = File('${outDir?.path}/$kEventsFileName');
184
      await file.writeAsBytes(data.buffer.asUint8List(0, data.lengthInBytes), flush: true);
185 186 187
      if (!mounted) {
        return;
      }
188 189
      showMessage(context, 'Saved original events to ${file.path}');
    } catch (e) {
190 191 192
      if (!mounted) {
        return;
      }
193
      showMessage(context, 'Failed saving $e');
194 195 196 197
    }
  }

  void showMessage(BuildContext context, String message) {
198
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(
199 200 201 202 203 204 205
      content: Text(message),
      duration: const Duration(seconds: 3),
    ));
  }

  void onPlatformViewCreated(int id) {
    viewChannel = MethodChannel('simple_view/$id');
206
    viewChannel?.setMethodCallHandler(onViewMethodChannelCall);
207 208 209 210 211
    driverDataHandler.handlerCompleter.complete(handleDriverMessage);
  }

  void listenToFlutterViewEvents() {
    channel.invokeMethod<void>('pipeFlutterViewEvents');
212
    viewChannel?.invokeMethod<void>('pipeTouchEvents');
213 214
    Timer(const Duration(seconds: 3), () {
      channel.invokeMethod<void>('stopFlutterViewEvents');
215
      viewChannel?.invokeMethod<void>('stopTouchEvents');
216 217 218
    });
  }

219
  Future<String> handleDriverMessage(String? message) async {
220 221 222 223 224 225 226 227 228 229
    switch (message) {
      case 'run test':
        return playEventsFile();
    }
    return 'unknown message: "$message"';
  }

  Future<dynamic> onMethodChannelCall(MethodCall call) {
    switch (call.method) {
      case 'onTouch':
230
        final Map<dynamic, dynamic> map = call.arguments as Map<dynamic, dynamic>;
231 232 233 234 235 236
        flutterViewEvents.insert(0, map.cast<String, dynamic>());
        if (flutterViewEvents.length > kEventsBufferSize)
          flutterViewEvents.removeLast();
        setState(() {});
        break;
    }
237
    return Future<dynamic>.value();
238 239 240 241 242
  }

  Future<dynamic> onViewMethodChannelCall(MethodCall call) {
    switch (call.method) {
      case 'onTouch':
243
        final Map<dynamic, dynamic> map = call.arguments as Map<dynamic, dynamic>;
244 245 246 247 248 249
        embeddedViewEvents.insert(0, map.cast<String, dynamic>());
        if (embeddedViewEvents.length > kEventsBufferSize)
          embeddedViewEvents.removeLast();
        setState(() {});
        break;
    }
250
    return Future<dynamic>.value();
251 252 253 254 255 256 257 258 259 260 261 262
  }

  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 {
263
  const TouchEventDiff(this.originalEvent, this.synthesizedEvent, {super.key});
264 265 266 267 268 269 270 271 272 273

  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;
274
    final int action = synthesizedEvent['action'] as int;
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299
    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();
300
    final int action = event['action'] as int;
301 302 303 304 305 306 307 308
    final int maskedAction = getActionMasked(action);
    final String actionName = getActionName(maskedAction, action);

    buffer.write('$actionName ');
    if (maskedAction == 5 || maskedAction == 6) {
     buffer.write('pointer: ${getPointerIdx(action)} ');
    }

309
    final List<Map<dynamic, dynamic>> coords = (event['pointerCoords'] as List<dynamic>).cast<Map<dynamic, dynamic>>();
310 311 312
    for (int i = 0; i < coords.length; i++) {
      buffer.write('p$i x: ${coords[i]['x']} y: ${coords[i]['y']}, pressure: ${coords[i]['pressure']} ');
    }
313
    print(buffer);
314 315
  }
}