Add a virtual-display (VD) platform view test, and refactor tests a bit. (#161349)

Towards https://github.com/flutter/flutter/issues/161261.

Still need to add a HC (Hybrid Composition) variant, but figured I'd do
this incrementally to make it easier to review.
This commit is contained in:
Matan Lurey 2025-01-09 19:23:40 -08:00 committed by GitHub
parent 99fe90a05e
commit 89b336109f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 250 additions and 16 deletions

View File

@ -12,6 +12,7 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import com.example.android_engine_test.extensions.NativeDriverSupportPlugin import com.example.android_engine_test.extensions.NativeDriverSupportPlugin
import com.example.android_engine_test.fixtures.BlueOrangeGradientPlatformViewFactory import com.example.android_engine_test.fixtures.BlueOrangeGradientPlatformViewFactory
import com.example.android_engine_test.fixtures.BlueOrangeGradientSurfaceViewPlatformViewFactory
import com.example.android_engine_test.fixtures.ChangingColorButtonPlatformViewFactory import com.example.android_engine_test.fixtures.ChangingColorButtonPlatformViewFactory
import com.example.android_engine_test.fixtures.OtherFaceTexturePlugin import com.example.android_engine_test.fixtures.OtherFaceTexturePlugin
import com.example.android_engine_test.fixtures.SmileyFaceTexturePlugin import com.example.android_engine_test.fixtures.SmileyFaceTexturePlugin
@ -35,6 +36,7 @@ class MainActivity : FlutterActivity() {
.registry .registry
.apply { .apply {
registerViewFactory("blue_orange_gradient_platform_view", BlueOrangeGradientPlatformViewFactory()) registerViewFactory("blue_orange_gradient_platform_view", BlueOrangeGradientPlatformViewFactory())
registerViewFactory("blue_orange_gradient_surface_view_platform_view", BlueOrangeGradientSurfaceViewPlatformViewFactory())
registerViewFactory("changing_color_button_platform_view", ChangingColorButtonPlatformViewFactory()) registerViewFactory("changing_color_button_platform_view", ChangingColorButtonPlatformViewFactory())
} }
} }

View File

@ -7,6 +7,7 @@
package com.example.android_engine_test.extensions package com.example.android_engine_test.extensions
import android.app.Activity import android.app.Activity
import android.os.Build
import android.os.SystemClock import android.os.SystemClock
import android.view.MotionEvent import android.view.MotionEvent
import io.flutter.Log import io.flutter.Log
@ -44,6 +45,10 @@ class NativeDriverSupportPlugin :
return return
} }
when (call.method) { when (call.method) {
"sdk_version" -> {
val versionMap = mapOf("version" to Build.VERSION.SDK_INT)
result.success(versionMap)
}
"ping" -> { "ping" -> {
result.success(null) result.success(null)
} }

View File

@ -0,0 +1,98 @@
// 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.
@file:Suppress("PackageName")
package com.example.android_engine_test.fixtures
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.view.ViewGroup
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class BlueOrangeGradientSurfaceViewPlatformViewFactory : PlatformViewFactory(null) {
override fun create(
context: Context,
viewId: Int,
args: Any?
): PlatformView = GradientSurfaceViewPlatformView(context)
}
private class GradientSurfaceViewPlatformView(
context: Context
) : SurfaceView(context),
PlatformView,
SurfaceHolder.Callback {
val paint = Paint()
init {
layoutParams =
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
holder.addCallback(this)
}
override fun getView(): View = this
override fun dispose() {}
override fun surfaceCreated(holder: SurfaceHolder) {
val canvas = holder.lockCanvas()
if (canvas != null) {
drawGradient(canvas)
holder.unlockCanvasAndPost(canvas)
}
}
override fun surfaceChanged(
holder: SurfaceHolder,
format: Int,
width: Int,
height: Int
) {
val canvas = holder.lockCanvas()
if (canvas != null) {
drawGradient(canvas)
holder.unlockCanvasAndPost(canvas)
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {}
private fun drawGradient(canvas: Canvas) {
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
}
override fun onSizeChanged(
w: Int,
h: Int,
oldw: Int,
oldh: Int
) {
paint.shader =
LinearGradient(
0f,
0f,
w.toFloat(),
h.toFloat(),
intArrayOf(
Color.rgb(0x41, 0x69, 0xE1),
Color.rgb(0xFF, 0xA5, 0x00)
),
null,
Shader.TileMode.CLAMP
)
super.onSizeChanged(w, h, oldw, oldh)
}
}

View File

@ -21,7 +21,7 @@ Future<int> _fetchTexture(int width, int height) async {
} }
void main() async { void main() async {
ensureAndroidOrIosDevice(); ensureAndroidDevice();
enableFlutterDriverExtension(commands: <CommandExtension>[nativeDriverCommands]); enableFlutterDriverExtension(commands: <CommandExtension>[nativeDriverCommands]);
// Run on full screen. // Run on full screen.

View File

@ -10,7 +10,7 @@ import 'package:flutter_driver/driver_extension.dart';
import 'src/allow_list_devices.dart'; import 'src/allow_list_devices.dart';
void main() { void main() {
ensureAndroidOrIosDevice(); ensureAndroidDevice();
enableFlutterDriverExtension(commands: <CommandExtension>[nativeDriverCommands]); enableFlutterDriverExtension(commands: <CommandExtension>[nativeDriverCommands]);
// Run on full screen. // Run on full screen.

View File

@ -7,10 +7,10 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_driver/driver_extension.dart'; import 'package:flutter_driver/driver_extension.dart';
import 'src/allow_list_devices.dart'; import '../src/allow_list_devices.dart';
void main() async { void main() async {
ensureAndroidOrIosDevice(); ensureAndroidDevice();
enableFlutterDriverExtension(commands: <CommandExtension>[nativeDriverCommands]); enableFlutterDriverExtension(commands: <CommandExtension>[nativeDriverCommands]);
// Run on full screen. // Run on full screen.
@ -25,6 +25,11 @@ final class MainApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const MaterialApp( return const MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
// It is assumed:
// - The Android SDK version is >= 23 (the test driver checks)
// - This view does NOT use a SurfaceView
//
// See https://github.com/flutter/flutter/blob/main/docs/platforms/android/Android-Platform-Views.md.
home: AndroidView(viewType: 'blue_orange_gradient_platform_view'), home: AndroidView(viewType: 'blue_orange_gradient_platform_view'),
); );
} }

View File

@ -0,0 +1,36 @@
// 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:android_driver_extensions/extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_driver/driver_extension.dart';
import '../src/allow_list_devices.dart';
void main() async {
ensureAndroidDevice();
enableFlutterDriverExtension(commands: <CommandExtension>[nativeDriverCommands]);
// Run on full screen.
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
runApp(const MainApp());
}
final class MainApp extends StatelessWidget {
const MainApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
// It is assumed:
// - The Android SDK version is >= 23 (the test driver checks)
// - This view DOES use a SurfaceView
//
// See https://github.com/flutter/flutter/blob/main/docs/platforms/android/Android-Platform-Views.md.
home: AndroidView(viewType: 'blue_orange_gradient_surface_view_platform_view'),
);
}
}

View File

@ -10,7 +10,7 @@ import 'package:flutter_driver/driver_extension.dart';
import 'src/allow_list_devices.dart'; import 'src/allow_list_devices.dart';
void main() async { void main() async {
ensureAndroidOrIosDevice(); ensureAndroidDevice();
enableFlutterDriverExtension(commands: <CommandExtension>[nativeDriverCommands]); enableFlutterDriverExtension(commands: <CommandExtension>[nativeDriverCommands]);
// Run on full screen. // Run on full screen.

View File

@ -6,11 +6,11 @@ import 'dart:io' as io;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// Throws an [UnsupportedError] if the current platform is not Android or iOS. /// Throws an [UnsupportedError] if the current platform is not Android.
void ensureAndroidOrIosDevice() { void ensureAndroidDevice() {
if (kIsWeb || (!io.Platform.isAndroid && !io.Platform.isIOS)) { if (kIsWeb || !io.Platform.isAndroid) {
throw UnsupportedError( throw UnsupportedError(
'This app should only run on Android or iOS devices. It uses native ' 'This app should only run on Android devices. It uses native Android '
'plugins that are not developed for other platforms, and would need to ' 'plugins that are not developed for other platforms, and would need to '
'be adapted to run on other platforms. See the README.md for details.', 'be adapted to run on other platforms. See the README.md for details.',
); );

View File

@ -0,0 +1,64 @@
// 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:android_driver_extensions/native_driver.dart';
import 'package:android_driver_extensions/skia_gold.dart';
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
import '../_luci_skia_gold_prelude.dart';
void main() async {
// To test the golden file generation locally, comment out the following line.
// autoUpdateGoldenFiles = true;
const String goldenPrefix = 'texture_layer_hybrid_composition_platform_view';
late final FlutterDriver flutterDriver;
late final NativeDriver nativeDriver;
setUpAll(() async {
if (isLuci) {
await enableSkiaGoldComparator();
}
flutterDriver = await FlutterDriver.connect();
nativeDriver = await AndroidNativeDriver.connect(flutterDriver);
await nativeDriver.configureForScreenshotTesting();
await flutterDriver.waitUntilFirstFrameRasterized();
// Double check that we are really probably testing using TLHC.
// See https://github.com/flutter/flutter/blob/main/docs/platforms/android/Android-Platform-Views.md.
if (await nativeDriver.sdkVersion case final int version when version < 23) {
fail('Requires SDK >= 23, got $version');
}
});
tearDownAll(() async {
await nativeDriver.close();
await flutterDriver.close();
});
test('should screenshot and match a blue -> orange gradient', () async {
await flutterDriver.waitFor(find.byType('AndroidView'));
await expectLater(
nativeDriver.screenshot(),
matchesGoldenFile('$goldenPrefix.blue_orange_gradient_portrait.android.png'),
);
}, timeout: Timeout.none);
test('should rotate landscape and screenshot the gradient', () async {
await flutterDriver.waitFor(find.byType('AndroidView'));
await nativeDriver.rotateToLandscape();
await expectLater(
nativeDriver.screenshot(),
matchesGoldenFile('$goldenPrefix.blue_orange_gradient_landscape_rotated.android.png'),
);
await nativeDriver.rotateResetDefault();
await expectLater(
nativeDriver.screenshot(),
matchesGoldenFile('$goldenPrefix.blue_orange_gradient_portait_rotated_back.android.png'),
);
}, timeout: Timeout.none);
}

View File

@ -7,12 +7,14 @@ import 'package:android_driver_extensions/skia_gold.dart';
import 'package:flutter_driver/flutter_driver.dart'; import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import '_luci_skia_gold_prelude.dart'; import '../_luci_skia_gold_prelude.dart';
void main() async { void main() async {
// To test the golden file generation locally, comment out the following line. // To test the golden file generation locally, comment out the following line.
// autoUpdateGoldenFiles = true; // autoUpdateGoldenFiles = true;
const String goldenPrefix = 'virtual_display_platform_view';
late final FlutterDriver flutterDriver; late final FlutterDriver flutterDriver;
late final NativeDriver nativeDriver; late final NativeDriver nativeDriver;
@ -24,6 +26,12 @@ void main() async {
nativeDriver = await AndroidNativeDriver.connect(flutterDriver); nativeDriver = await AndroidNativeDriver.connect(flutterDriver);
await nativeDriver.configureForScreenshotTesting(); await nativeDriver.configureForScreenshotTesting();
await flutterDriver.waitUntilFirstFrameRasterized(); await flutterDriver.waitUntilFirstFrameRasterized();
// Double check that we are really probably testing using Virtual Display.
// See https://github.com/flutter/flutter/blob/main/docs/platforms/android/Android-Platform-Views.md.
if (await nativeDriver.sdkVersion case final int version when version < 23) {
fail('Requires SDK >= 23, got $version');
}
}); });
tearDownAll(() async { tearDownAll(() async {
@ -35,7 +43,7 @@ void main() async {
await flutterDriver.waitFor(find.byType('AndroidView')); await flutterDriver.waitFor(find.byType('AndroidView'));
await expectLater( await expectLater(
nativeDriver.screenshot(), nativeDriver.screenshot(),
matchesGoldenFile('platform_view_blue_orange_gradient_portrait.android.png'), matchesGoldenFile('$goldenPrefix.blue_orange_gradient_portrait.android.png'),
); );
}, timeout: Timeout.none); }, timeout: Timeout.none);
@ -44,13 +52,13 @@ void main() async {
await nativeDriver.rotateToLandscape(); await nativeDriver.rotateToLandscape();
await expectLater( await expectLater(
nativeDriver.screenshot(), nativeDriver.screenshot(),
matchesGoldenFile('platform_view_blue_orange_gradient_landscape.android.png'), matchesGoldenFile('$goldenPrefix.blue_orange_gradient_landscape_rotated.android.png'),
); );
await nativeDriver.rotateResetDefault(); await nativeDriver.rotateResetDefault();
await expectLater( await expectLater(
nativeDriver.screenshot(), nativeDriver.screenshot(),
matchesGoldenFile('platform_view_blue_orange_gradient_portrait_post_rotation.android.png'), matchesGoldenFile('$goldenPrefix.blue_orange_gradient_portait_rotated_back.android.png'),
); );
}, timeout: Timeout.none); }, timeout: Timeout.none);
} }

View File

@ -49,10 +49,14 @@ final class NativeDriverCommandExtension implements CommandExtension {
if (result == null) { if (result == null) {
return const _MethodChannelResult(<String, Object?>{}); return const _MethodChannelResult(<String, Object?>{});
} }
if (result is! Map<String, Object?>) { if (result is! Map<Object?, Object?>) {
throw ArgumentError.value(result, 'result', 'Expected a Map<String, Object?>'); throw ArgumentError.value(
result,
'result',
'Expected a Map<String, Object?>, got ${result.runtimeType}',
);
} }
return _MethodChannelResult(result); return _MethodChannelResult(result.cast());
} }
// While these could have been implemented in native code, they are already // While these could have been implemented in native code, they are already

View File

@ -71,6 +71,12 @@ final class AndroidNativeDriver implements NativeDriver {
await _driver.sendCommand(NativeCommand.tap(finder)); await _driver.sendCommand(NativeCommand.tap(finder));
} }
@override
Future<int> get sdkVersion async {
final Map<String, Object?> result = await _driver.sendCommand(NativeCommand.getSdkVersion);
return result['version']! as int;
}
/// Waits for 2 seconds before completing. /// Waits for 2 seconds before completing.
/// ///
/// There is no perfect way, outside of polling, to know when the device is /// There is no perfect way, outside of polling, to know when the device is

View File

@ -27,6 +27,9 @@ final class NativeCommand extends Command {
/// Pings the device to ensure it is responsive. /// Pings the device to ensure it is responsive.
static const NativeCommand ping = NativeCommand('ping'); static const NativeCommand ping = NativeCommand('ping');
/// Gets the SDK version code.
static const NativeCommand getSdkVersion = NativeCommand('sdk_version');
/// The method to call on the plugin. /// The method to call on the plugin.
final String method; final String method;

View File

@ -45,6 +45,9 @@ abstract interface class NativeDriver {
/// ``` /// ```
Future<Duration> ping(); Future<Duration> ping();
/// Returns the SDK version.
Future<int> get sdkVersion;
/// Take a screenshot using a platform-specific mechanism. /// Take a screenshot using a platform-specific mechanism.
/// ///
/// The image is returned as an opaque handle that can be used to retrieve /// The image is returned as an opaque handle that can be used to retrieve