diff --git a/.ci.yaml b/.ci.yaml index a35767d524..48bdfb687c 100755 --- a/.ci.yaml +++ b/.ci.yaml @@ -2388,6 +2388,17 @@ targets: task_name: android_choreographer_do_frame_test scheduler: luci + - name: Linux_android android_lifecycles_test + bringup: true + recipe: devicelab/devicelab_drone + presubmit: false + timeout: 60 + properties: + tags: > + ["devicelab","android","linux"] + task_name: android_lifecycles_test + scheduler: luci + - name: Mac build_aar_module_test recipe: devicelab/devicelab_drone timeout: 60 diff --git a/TESTOWNERS b/TESTOWNERS index 28bf447a58..9c5d7293a0 100644 --- a/TESTOWNERS +++ b/TESTOWNERS @@ -10,7 +10,9 @@ ## Linux Android DeviceLab tests /dev/devicelab/bin/tasks/analyzer_benchmark.dart @zanderso @flutter/tool +/dev/devicelab/bin/tasks/android_choreographer_do_frame_test.dart @blasten @flutter/engine /dev/devicelab/bin/tasks/android_defines_test.dart @zanderso @flutter/tool +/dev/devicelab/bin/tasks/android_lifecycles_test.dart @blasten @flutter/engine /dev/devicelab/bin/tasks/android_obfuscate_test.dart @zanderso @flutter/tool /dev/devicelab/bin/tasks/android_picture_cache_complexity_scoring_perf__timeline_summary.dart @flar @flutter/engine /dev/devicelab/bin/tasks/android_stack_size_test.dart @zanderso @flutter/tool @@ -71,7 +73,6 @@ /dev/devicelab/bin/tasks/opacity_peephole_fade_transition_text_perf__e2e_summary.dart @flar @flutter/engine /dev/devicelab/bin/tasks/opacity_peephole_col_of_alpha_savelayer_rows_perf__e2e_summary.dart @flar @flutter/engine /dev/devicelab/bin/tasks/opacity_peephole_grid_of_alpha_savelayers_perf__e2e_summary.dart @flar @flutter/engine -/dev/devicelab/bin/tasks/android_choreographer_do_frame_test.dart @blasten @flutter/engine ## Windows Android DeviceLab tests /dev/devicelab/bin/tasks/basic_material_app_win__compile.dart @zanderso @flutter/tool diff --git a/dev/devicelab/bin/tasks/android_lifecycles_test.dart b/dev/devicelab/bin/tasks/android_lifecycles_test.dart new file mode 100644 index 0000000000..d4264b537a --- /dev/null +++ b/dev/devicelab/bin/tasks/android_lifecycles_test.dart @@ -0,0 +1,10 @@ +// 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 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/tasks/android_lifecycles_test.dart'; + +Future main() async { + await task(androidLifecyclesTest()); +} diff --git a/dev/devicelab/lib/framework/devices.dart b/dev/devicelab/lib/framework/devices.dart index 5faa636b55..dd30e9a02f 100644 --- a/dev/devicelab/lib/framework/devices.dart +++ b/dev/devicelab/lib/framework/devices.dart @@ -545,7 +545,7 @@ class AndroidDevice extends Device { } } - /// Executes [command] on `adb shell` and returns its exit code. + /// Executes [command] on `adb shell`. Future shellExec(String command, List arguments, { Map? environment, bool silent = false }) async { await adb(['shell', command, ...arguments], environment: environment, silent: silent); } @@ -637,7 +637,7 @@ class AndroidDevice extends Device { late final StreamController stream; stream = StreamController( onListen: () async { - await adb(['logcat', '--clear']); + await adb(['logcat', '-c']); final Process process = await startProcess( adbPath, // Make logcat less chatty by filtering down to just ActivityManager diff --git a/dev/devicelab/lib/tasks/android_choreographer_do_frame_test.dart b/dev/devicelab/lib/tasks/android_choreographer_do_frame_test.dart index 8226c0a920..d55b11e292 100644 --- a/dev/devicelab/lib/tasks/android_choreographer_do_frame_test.dart +++ b/dev/devicelab/lib/tasks/android_choreographer_do_frame_test.dart @@ -18,12 +18,11 @@ const List kSentinelStr = [ '==== sentinel #3 ====', ]; -// Regression test for https://github.com/flutter/flutter/issues/98973 -// This test ensures that Choreographer#doFrame finishes during application startup. -// This test fails if the application hangs during this period. -// https://ui.perfetto.dev/#!/?s=da6628c3a92456ae8fa3f345d0186e781da77e90fc8a64d073e9fee11d1e65 +/// Tests that Choreographer#doFrame finishes during application startup. +/// This test fails if the application hangs during this period. +/// https://ui.perfetto.dev/#!/?s=da6628c3a92456ae8fa3f345d0186e781da77e90fc8a64d073e9fee11d1e65 +/// Regression test for https://github.com/flutter/flutter/issues/98973 TaskFunction androidChoreographerDoFrameTest({ - String? deviceIdOverride, Map? environment, }) { final Directory tempDir = Directory.systemTemp @@ -87,18 +86,19 @@ Future main() async { } section('Flutter run (mode: $mode)'); + late Process run; await inDirectory(path.join(tempDir.path, 'app'), () async { - final Process run = await startProcess( + run = await startProcess( path.join(flutterDirectory.path, 'bin', 'flutter'), flutterCommandArgs('run', ['--$mode', '--verbose']), ); + }); - int currSentinelIdx = 0; - final StreamSubscription stdout = run.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((String line) { - + int currSentinelIdx = 0; + final StreamSubscription stdout = run.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { if (currSentinelIdx < sentinelCompleters.keys.length && line.contains(sentinelCompleters.keys.elementAt(currSentinelIdx))) { sentinelCompleters.values.elementAt(currSentinelIdx).complete(); @@ -107,61 +107,59 @@ Future main() async { } else { print('stdout: $line'); } - }); - final StreamSubscription stderr = run.stderr - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((String line) { - print('stderr: $line'); - }); + final StreamSubscription stderr = run.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + print('stderr: $line'); + }); - final Completer exitCompleter = Completer(); + final Completer exitCompleter = Completer(); - unawaited(run.exitCode.then((int exitCode) { - exitCompleter.complete(); - })); + unawaited(run.exitCode.then((int exitCode) { + exitCompleter.complete(); + })); - section('Wait for sentinels (mode: $mode)'); - for (final Completer completer in sentinelCompleters.values) { - if (nextCompleterIdx == 0) { - // Don't time out because we don't know how long it would take to get the first log. + section('Wait for sentinels (mode: $mode)'); + for (final Completer completer in sentinelCompleters.values) { + if (nextCompleterIdx == 0) { + // Don't time out because we don't know how long it would take to get the first log. + await Future.any( + >[ + completer.future, + exitCompleter.future, + ], + ); + } else { + try { + // Time out since this should not take 1s after the first log was received. await Future.any( >[ - completer.future, + completer.future.timeout(const Duration(seconds: 1)), exitCompleter.future, ], ); - } else { - try { - // Time out since this should not take 1s after the first log was received. - await Future.any( - >[ - completer.future.timeout(const Duration(seconds: 1)), - exitCompleter.future, - ], - ); - } on TimeoutException { - break; - } - } - if (exitCompleter.isCompleted) { - // The process exited. + } on TimeoutException { break; } - nextCompleterIdx++; } + if (exitCompleter.isCompleted) { + // The process exited. + break; + } + nextCompleterIdx++; + } - section('Quit app (mode: $mode)'); - run.stdin.write('q'); - await exitCompleter.future; + section('Quit app (mode: $mode)'); + run.stdin.write('q'); + await exitCompleter.future; - section('Stop listening to stdout and stderr (mode: $mode)'); - await stdout.cancel(); - await stderr.cancel(); - run.kill(); - }); + section('Stop listening to stdout and stderr (mode: $mode)'); + await stdout.cancel(); + await stderr.cancel(); + run.kill(); if (nextCompleterIdx == sentinelCompleters.values.length) { return TaskResult.success(null); diff --git a/dev/devicelab/lib/tasks/android_lifecycles_test.dart b/dev/devicelab/lib/tasks/android_lifecycles_test.dart new file mode 100644 index 0000000000..91ba64a5c6 --- /dev/null +++ b/dev/devicelab/lib/tasks/android_lifecycles_test.dart @@ -0,0 +1,212 @@ +// 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:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; + +import '../framework/devices.dart'; +import '../framework/framework.dart'; +import '../framework/task_result.dart'; +import '../framework/utils.dart'; + +const String _kOrgName = 'com.example.activitydestroy'; + +final RegExp _lifecycleSentinelRegExp = RegExp(r'==== lifecycle\: (.+) ===='); + +/// Tests the following Android lifecycles: Activity#onStop(), Activity#onResume(), Activity#onPause() +/// from Dart perspective in debug, profile, and release modes. +TaskFunction androidLifecyclesTest({ + Map? environment, +}) { + final Directory tempDir = Directory.systemTemp + .createTempSync('flutter_devicelab_activity_destroy.'); + return () async { + try { + section('Create app'); + await inDirectory(tempDir, () async { + await flutter( + 'create', + options: [ + '--platforms', + 'android', + '--org', + _kOrgName, + 'app', + ], + environment: environment, + ); + }); + + final File mainDart = File(path.join( + tempDir.absolute.path, + 'app', + 'lib', + 'main.dart', + )); + if (!mainDart.existsSync()) { + return TaskResult.failure('${mainDart.path} does not exist'); + } + + section('Patch lib/main.dart'); + await mainDart.writeAsString(r''' + import 'package:flutter/widgets.dart'; + +class LifecycleObserver extends WidgetsBindingObserver { + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + print('==== lifecycle: $state ===='); + } +} + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + WidgetsBinding.instance.addObserver(LifecycleObserver()); + runApp(Container()); +} +''', flush: true); + + final List androidLifecycles = []; + + Future runTestFor(String mode) async { + section('Flutter run (mode: $mode)'); + + late Process run; + await inDirectory(path.join(tempDir.path, 'app'), () async { + run = await startProcess( + path.join(flutterDirectory.path, 'bin', 'flutter'), + flutterCommandArgs('run', ['--$mode']), + ); + }); + + final AndroidDevice device = await devices.workingDevice as AndroidDevice; + await device.unlock(); + + final StreamController lifecyles = StreamController(); + + final StreamSubscription stdout = run.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String log) { + final RegExpMatch? match = _lifecycleSentinelRegExp.firstMatch(log); + print('stdout: $log'); + if (match == null) { + return; + } + final String lifecycle = match[1]!; + androidLifecycles.add(lifecycle); + + print('stdout: Found app lifecycle: $lifecycle'); + lifecyles.add(lifecycle); + }); + + final StreamSubscription stderr = run.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String log) { + print('stderr: $log'); + }); + + final StreamIterator lifecycleItr = StreamIterator(lifecyles.stream); + + { + const String expected = 'AppLifecycleState.resumed'; + await lifecycleItr.moveNext(); + final String got = lifecycleItr.current; + if (expected != got) { + return TaskResult.failure('expected lifecycles: `$expected`, but got` $got`'); + } + } + + section('Toggling app switch (mode: $mode)'); + await device.shellExec('input', ['keyevent', 'KEYCODE_APP_SWITCH']); + + { + const String expected = 'AppLifecycleState.inactive'; + await lifecycleItr.moveNext(); + final String got = lifecycleItr.current; + if (expected != got) { + return TaskResult.failure('expected lifecycles: `$expected`, but got` $got`'); + } + } + + section('Bring activity to foreground (mode: $mode)'); + await device.shellExec('am', ['start', '--activity-single-top', '$_kOrgName.app/.MainActivity']); + + { + const String expected = 'AppLifecycleState.resumed'; + await lifecycleItr.moveNext(); + final String got = lifecycleItr.current; + if (expected != got) { + return TaskResult.failure('expected lifecycles: `$expected`, but got` $got`'); + } + } + + section('Launch Settings app (mode: $mode)'); + await device.shellExec('am', ['start', '-a', 'android.settings.SETTINGS']); + + { + const String expected = 'AppLifecycleState.inactive'; + await lifecycleItr.moveNext(); + final String got = lifecycleItr.current; + if (expected != got) { + return TaskResult.failure('expected lifecycles: `$expected`, but got` $got`'); + } + } + + { + const String expected = 'AppLifecycleState.paused'; + await lifecycleItr.moveNext(); + final String got = lifecycleItr.current; + if (expected != got) { + return TaskResult.failure('expected lifecycles: `$expected`, but got` $got`'); + } + } + + section('Bring activity to foreground (mode: $mode)'); + await device.shellExec('am', ['start', '--activity-single-top', '$_kOrgName.app/.MainActivity']); + + { + const String expected = 'AppLifecycleState.resumed'; + await lifecycleItr.moveNext(); + final String got = lifecycleItr.current; + if (expected != got) { + return TaskResult.failure('expected lifecycles: `$expected`, but got` $got`'); + } + } + + run.kill(); + + section('Stop subscriptions (mode: $mode)'); + + await lifecycleItr.cancel(); + await lifecyles.close(); + await stdout.cancel(); + await stderr.cancel(); + return TaskResult.success(null); + } + + final TaskResult debugResult = await runTestFor('debug'); + if (debugResult.failed) { + return debugResult; + } + + final TaskResult profileResult = await runTestFor('profile'); + if (profileResult.failed) { + return profileResult; + } + + final TaskResult releaseResult = await runTestFor('release'); + if (releaseResult.failed) { + return releaseResult; + } + + return TaskResult.success(null); + } finally { + rmTree(tempDir); + } + }; +}