diff --git a/dev/devicelab/bin/tasks/ios_content_validation_test.dart b/dev/devicelab/bin/tasks/ios_content_validation_test.dart index ffdaaa18c72a..1f5efa2bfb69 100644 --- a/dev/devicelab/bin/tasks/ios_content_validation_test.dart +++ b/dev/devicelab/bin/tasks/ios_content_validation_test.dart @@ -46,10 +46,13 @@ Future main() async { throw TaskResult.failure('Must validate incorrect app icon image size.'); } - // The project is still using Flutter template icon. + // The project is still using Flutter template icon and launch image. if (!output.contains('Warning: App icon is set to the default placeholder icon. Replace with unique icons.')) { throw TaskResult.failure('Must validate template app icon.'); } + if (!output.contains('Warning: Launch image is set to the default placeholder. Replace with unique launch images.')) { + throw TaskResult.failure('Must validate template launch image.'); + } }); final String archivePath = path.join( diff --git a/packages/flutter_tools/lib/src/commands/build_ios.dart b/packages/flutter_tools/lib/src/commands/build_ios.dart index f8ff34b8a948..7f7c53375485 100644 --- a/packages/flutter_tools/lib/src/commands/build_ios.dart +++ b/packages/flutter_tools/lib/src/commands/build_ios.dart @@ -57,30 +57,32 @@ class BuildIOSCommand extends _BuildIOSSubCommand { Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs.directory(xcodeResultOutput).parent; } -/// The key that uniquely identifies an image file in an app icon asset. -/// It consists of (idiom, size, scale). +/// The key that uniquely identifies an image file in an image asset. +/// It consists of (idiom, scale, size?), where size is present for app icon +/// asset, and null for launch image asset. @immutable -class _AppIconImageFileKey { - const _AppIconImageFileKey(this.idiom, this.size, this.scale); +class _ImageAssetFileKey { + const _ImageAssetFileKey(this.idiom, this.scale, this.size); /// The idiom (iphone or ipad). final String idiom; - /// The logical size in point (e.g. 83.5). - final double size; /// The scale factor (e.g. 2). final int scale; + /// The logical size in point (e.g. 83.5). + /// Size is present for app icon, and null for launch image. + final double? size; @override - int get hashCode => Object.hash(idiom, size, scale); + int get hashCode => Object.hash(idiom, scale, size); @override - bool operator ==(Object other) => other is _AppIconImageFileKey + bool operator ==(Object other) => other is _ImageAssetFileKey && other.idiom == idiom - && other.size == size - && other.scale == scale; + && other.scale == scale + && other.size == size; - /// The pixel size. - int get pixelSize => (size * scale).toInt(); // pixel size must be an int. + /// The pixel size based on logical size and scale. + int? get pixelSize => size == null ? null : (size! * scale).toInt(); // pixel size must be an int. } /// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for @@ -159,11 +161,16 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { return super.validateCommand(); } - // Parses Contents.json into a map, with the key to be _AppIconImageFileKey, and value to be the icon image file name. - Map<_AppIconImageFileKey, String> _parseIconContentsJson(String contentsJsonDirName) { + // A helper function to parse Contents.json of an image asset into a map, + // with the key to be _ImageAssetFileKey, and value to be the image file name. + // Some assets have size (e.g. app icon) and others do not (e.g. launch image). + Map<_ImageAssetFileKey, String> _parseImageAssetContentsJson( + String contentsJsonDirName, + { required bool requiresSize }) + { final Directory contentsJsonDirectory = globals.fs.directory(contentsJsonDirName); if (!contentsJsonDirectory.existsSync()) { - return <_AppIconImageFileKey, String>{}; + return <_ImageAssetFileKey, String>{}; } final File contentsJsonFile = contentsJsonDirectory.childFile('Contents.json'); final Map contents = json.decode(contentsJsonFile.readAsStringSync()) as Map? ?? {}; @@ -171,10 +178,10 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { final Map info = contents['info'] as Map? ?? {}; if ((info['version'] as int?) != 1) { // Skips validation for unknown format. - return <_AppIconImageFileKey, String>{}; + return <_ImageAssetFileKey, String>{}; } - final Map<_AppIconImageFileKey, String> iconInfo = <_AppIconImageFileKey, String>{}; + final Map<_ImageAssetFileKey, String> iconInfo = <_ImageAssetFileKey, String>{}; for (final dynamic image in images) { final Map imageMap = image as Map; final String? idiom = imageMap['idiom'] as String?; @@ -182,85 +189,142 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { final String? scale = imageMap['scale'] as String?; final String? fileName = imageMap['filename'] as String?; - if (size == null || idiom == null || scale == null || fileName == null) { + // requiresSize must match the actual presence of size in json. + if (requiresSize != (size != null) + || idiom == null || scale == null || fileName == null) + { continue; } - // for example, "64x64". Parse the width since it is a square. - final Iterable parsedSizes = size.split('x') + final double? parsedSize; + if (size != null) { + // for example, "64x64". Parse the width since it is a square. + final Iterable parsedSizes = size.split('x') .map((String element) => double.tryParse(element)) .whereType(); - if (parsedSizes.isEmpty) { - continue; + if (parsedSizes.isEmpty) { + continue; + } + parsedSize = parsedSizes.first; + } else { + parsedSize = null; } - final double parsedSize = parsedSizes.first; // for example, "3x". final Iterable parsedScales = scale.split('x') - .map((String element) => int.tryParse(element)) - .whereType(); + .map((String element) => int.tryParse(element)) + .whereType(); if (parsedScales.isEmpty) { continue; } final int parsedScale = parsedScales.first; - - iconInfo[_AppIconImageFileKey(idiom, parsedSize, parsedScale)] = fileName; + iconInfo[_ImageAssetFileKey(idiom, parsedScale, parsedSize)] = fileName; } - return iconInfo; } - Future _validateIconsAfterArchive(StringBuffer messageBuffer) async { - final BuildableIOSApp app = await buildableIOSApp; - final String templateIconImageDirName = await app.templateAppIconDirNameForImages; - - final Map<_AppIconImageFileKey, String> templateIconMap = _parseIconContentsJson(app.templateAppIconDirNameForContentsJson); - final Map<_AppIconImageFileKey, String> projectIconMap = _parseIconContentsJson(app.projectAppIconDirName); - - // validate each of the project icon images. - final List filesWithTemplateIcon = []; - final List filesWithWrongSize = []; - for (final MapEntry<_AppIconImageFileKey, String> entry in projectIconMap.entries) { - final String projectIconFileName = entry.value; - final String? templateIconFileName = templateIconMap[entry.key]; - final File projectIconFile = globals.fs.file(globals.fs.path.join(app.projectAppIconDirName, projectIconFileName)); - if (!projectIconFile.existsSync()) { - continue; - } - final Uint8List projectIconBytes = projectIconFile.readAsBytesSync(); - - // validate conflict with template icon file. - if (templateIconFileName != null) { - final File templateIconFile = globals.fs.file(globals.fs.path.join( - templateIconImageDirName, templateIconFileName)); - if (templateIconFile.existsSync() && md5.convert(projectIconBytes) == - md5.convert(templateIconFile.readAsBytesSync())) { - filesWithTemplateIcon.add(entry.value); - } + // A helper function to check if an image asset is still using template files. + bool _isAssetStillUsingTemplateFiles({ + required Map<_ImageAssetFileKey, String> templateImageInfoMap, + required Map<_ImageAssetFileKey, String> projectImageInfoMap, + required String templateImageDirName, + required String projectImageDirName, + }) { + return projectImageInfoMap.entries.any((MapEntry<_ImageAssetFileKey, String> entry) { + final String projectFileName = entry.value; + final String? templateFileName = templateImageInfoMap[entry.key]; + if (templateFileName == null) { + return false; } + final File projectFile = globals.fs.file( + globals.fs.path.join(projectImageDirName, projectFileName)); + final File templateFile = globals.fs.file( + globals.fs.path.join(templateImageDirName, templateFileName)); + + return projectFile.existsSync() + && templateFile.existsSync() + && md5.convert(projectFile.readAsBytesSync()) == + md5.convert(templateFile.readAsBytesSync()); + }); + } + // A helper function to return a list of image files in an image asset with + // wrong sizes (as specified in its Contents.json file). + List _imageFilesWithWrongSize({ + required Map<_ImageAssetFileKey, String> imageInfoMap, + required String imageDirName, + }) { + return imageInfoMap.entries.where((MapEntry<_ImageAssetFileKey, String> entry) { + final String fileName = entry.value; + final File imageFile = globals.fs.file(globals.fs.path.join(imageDirName, fileName)); + if (!imageFile.existsSync()) { + return false; + } // validate image size is correct. // PNG file's width is at byte [16, 20), and height is at byte [20, 24), in big endian format. // Based on https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_format - final ByteData projectIconData = projectIconBytes.buffer.asByteData(); - if (projectIconData.lengthInBytes < 24) { - continue; - } - final int width = projectIconData.getInt32(16); - final int height = projectIconData.getInt32(20); - if (width != entry.key.pixelSize || height != entry.key.pixelSize) { - filesWithWrongSize.add(entry.value); + final ByteData imageData = imageFile.readAsBytesSync().buffer.asByteData(); + if (imageData.lengthInBytes < 24) { + return false; } - } + final int width = imageData.getInt32(16); + final int height = imageData.getInt32(20); + // The size must not be null. + final int expectedSize = entry.key.pixelSize!; + return width != expectedSize || height != expectedSize; + }) + .map((MapEntry<_ImageAssetFileKey, String> entry) => entry.value) + .toList(); + } - if (filesWithTemplateIcon.isNotEmpty) { + Future _validateIconAssetsAfterArchive(StringBuffer messageBuffer) async { + final BuildableIOSApp app = await buildableIOSApp; + + final Map<_ImageAssetFileKey, String> templateInfoMap = _parseImageAssetContentsJson( + app.templateAppIconDirNameForContentsJson, + requiresSize: true); + final Map<_ImageAssetFileKey, String> projectInfoMap = _parseImageAssetContentsJson( + app.projectAppIconDirName, + requiresSize: true); + + final bool usesTemplate = _isAssetStillUsingTemplateFiles( + templateImageInfoMap: templateInfoMap, + projectImageInfoMap: projectInfoMap, + templateImageDirName: await app.templateAppIconDirNameForImages, + projectImageDirName: app.projectAppIconDirName); + if (usesTemplate) { messageBuffer.writeln('\nWarning: App icon is set to the default placeholder icon. Replace with unique icons.'); } + + final List filesWithWrongSize = _imageFilesWithWrongSize( + imageInfoMap: projectInfoMap, + imageDirName: app.projectAppIconDirName); if (filesWithWrongSize.isNotEmpty) { messageBuffer.writeln('\nWarning: App icon is using the wrong size (e.g. ${filesWithWrongSize.first}).'); } } + Future _validateLaunchImageAssetsAfterArchive(StringBuffer messageBuffer) async { + final BuildableIOSApp app = await buildableIOSApp; + + final Map<_ImageAssetFileKey, String> templateInfoMap = _parseImageAssetContentsJson( + app.templateLaunchImageDirNameForContentsJson, + requiresSize: false); + final Map<_ImageAssetFileKey, String> projectInfoMap = _parseImageAssetContentsJson( + app.projectLaunchImageDirName, + requiresSize: false); + + final bool usesTemplate = _isAssetStillUsingTemplateFiles( + templateImageInfoMap: templateInfoMap, + projectImageInfoMap: projectInfoMap, + templateImageDirName: await app.templateLaunchImageDirNameForImages, + projectImageDirName: app.projectLaunchImageDirName); + + if (usesTemplate) { + messageBuffer.writeln('\nWarning: Launch image is set to the default placeholder. Replace with unique launch images.'); + } + } + Future _validateXcodeBuildSettingsAfterArchive(StringBuffer messageBuffer) async { final BuildableIOSApp app = await buildableIOSApp; @@ -296,7 +360,9 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { final StringBuffer validationMessageBuffer = StringBuffer(); await _validateXcodeBuildSettingsAfterArchive(validationMessageBuffer); - await _validateIconsAfterArchive(validationMessageBuffer); + await _validateIconAssetsAfterArchive(validationMessageBuffer); + await _validateLaunchImageAssetsAfterArchive(validationMessageBuffer); + validationMessageBuffer.write('\nTo update the settings, please refer to https://docs.flutter.dev/deployment/ios'); globals.printBox(validationMessageBuffer.toString(), title: 'App Settings'); diff --git a/packages/flutter_tools/lib/src/ios/application_package.dart b/packages/flutter_tools/lib/src/ios/application_package.dart index 5c2af2458591..80363e0930c5 100644 --- a/packages/flutter_tools/lib/src/ios/application_package.dart +++ b/packages/flutter_tools/lib/src/ios/application_package.dart @@ -153,30 +153,21 @@ class BuildableIOSApp extends IOSApp { _hostAppBundleName == null ? 'Runner.app' : _hostAppBundleName!, 'Info.plist'); - // Both project icon's image assets and Contents.json are in the same directory. - String get projectAppIconDirName => globals.fs.path.join('ios', _appIconDirNameSuffix); - - // template icon's Contents.json is in flutter_tools. - String get templateAppIconDirNameForContentsJson => globals.fs.path.join( - Cache.flutterRoot!, - 'packages', - 'flutter_tools', - 'templates', - 'app_shared', - 'ios.tmpl', - _appIconDirNameSuffix, - ); + String get projectAppIconDirName => _projectImageAssetDirName(_appIconAsset); - // template icon's image assets are in flutter_template_images package. - Future get templateAppIconDirNameForImages async { - final Directory imageTemplate = await templateImageDirectory(null, globals.fs, globals.logger); - return globals.fs.path.join( - imageTemplate.path, - 'app_shared', - 'ios.tmpl', - _appIconDirNameSuffix, - ); - } + String get projectLaunchImageDirName => _projectImageAssetDirName(_launchImageAsset); + + String get templateAppIconDirNameForContentsJson + => _templateImageAssetDirNameForContentsJson(_appIconAsset); + + String get templateLaunchImageDirNameForContentsJson + => _templateImageAssetDirNameForContentsJson(_launchImageAsset); + + Future get templateAppIconDirNameForImages async + => _templateImageAssetDirNameForImages(_appIconAsset); + + Future get templateLaunchImageDirNameForImages async + => _templateImageAssetDirNameForImages(_launchImageAsset); String get ipaOutputPath => globals.fs.path.join(getIosBuildDirectory(), 'ipa'); @@ -185,10 +176,35 @@ class BuildableIOSApp extends IOSApp { return globals.fs.path.join(getIosBuildDirectory(), type, _hostAppBundleName); } - String get _appIconDirNameSuffix => globals.fs.path.join( + String _projectImageAssetDirName(String asset) + => globals.fs.path.join('ios', 'Runner', 'Assets.xcassets', asset); + + // Template asset's Contents.json file is in flutter_tools, but the actual + String _templateImageAssetDirNameForContentsJson(String asset) + => globals.fs.path.join( + Cache.flutterRoot!, + 'packages', + 'flutter_tools', + 'templates', + _templateImageAssetDirNameSuffix(asset), + ); + + // Template asset's images are in flutter_template_images package. + Future _templateImageAssetDirNameForImages(String asset) async { + final Directory imageTemplate = await templateImageDirectory(null, globals.fs, globals.logger); + return globals.fs.path.join(imageTemplate.path, _templateImageAssetDirNameSuffix(asset)); + } + + String _templateImageAssetDirNameSuffix(String asset) => globals.fs.path.join( + 'app_shared', + 'ios.tmpl', 'Runner', 'Assets.xcassets', - 'AppIcon.appiconset'); + asset, + ); + + String get _appIconAsset => 'AppIcon.appiconset'; + String get _launchImageAsset => 'LaunchImage.imageset'; } class PrebuiltIOSApp extends IOSApp implements PrebuiltApplicationPackage { diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart index 195d605cad2a..1043411e2d3a 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_ipa_test.dart @@ -1476,6 +1476,162 @@ void main() { Platform: () => macosPlatform, XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), }); + + testUsingContext('Validate template launch images with conflicts', () async { + const String projectLaunchImageContentsJsonPath = 'ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json'; + const String projectLaunchImagePath = 'ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png'; + final String templateLaunchImageContentsJsonPath = '${Cache.flutterRoot!}/packages/flutter_tools/templates/app_shared/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json'; + const String templateLaunchImagePath = '/flutter_template_images/templates/app_shared/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png'; + + fakeProcessManager.addCommands([ + xattrCommand, + setUpFakeXcodeBuildHandler(onRun: () { + fileSystem.file(templateLaunchImageContentsJsonPath) + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "images": [ + { + "idiom": "iphone", + "filename": "LaunchImage@2x.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} +'''); + fileSystem.file(templateLaunchImagePath) + ..createSync(recursive: true) + ..writeAsBytes([1, 2, 3]); + + fileSystem.file(projectLaunchImageContentsJsonPath) + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "images": [ + { + "idiom": "iphone", + "filename": "LaunchImage@2x.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} +'''); + fileSystem.file(projectLaunchImagePath) + ..createSync(recursive: true) + ..writeAsBytes([1, 2, 3]); + }), + exportArchiveCommand(exportOptionsPlist: _exportOptionsPlist), + ]); + + createMinimalMockProjectFiles(); + + final BuildCommand command = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: MemoryFileSystem.test(), + logger: BufferLogger.test(), + osUtils: FakeOperatingSystemUtils(), + ); + await createTestCommandRunner(command).run( + ['build', 'ipa', '--no-pub']); + + expect( + testLogger.statusText, + contains('Warning: Launch image is set to the default placeholder. Replace with unique launch images.'), + ); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => fakeProcessManager, + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + + + testUsingContext('Validate template launch images without conflicts', () async { + const String projectLaunchImageContentsJsonPath = 'ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json'; + const String projectLaunchImagePath = 'ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png'; + final String templateLaunchImageContentsJsonPath = '${Cache.flutterRoot!}/packages/flutter_tools/templates/app_shared/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json'; + const String templateLaunchImagePath = '/flutter_template_images/templates/app_shared/ios.tmpl/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png'; + + fakeProcessManager.addCommands([ + xattrCommand, + setUpFakeXcodeBuildHandler(onRun: () { + fileSystem.file(templateLaunchImageContentsJsonPath) + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "images": [ + { + "idiom": "iphone", + "filename": "LaunchImage@2x.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} +'''); + fileSystem.file(templateLaunchImagePath) + ..createSync(recursive: true) + ..writeAsBytes([1, 2, 3]); + + fileSystem.file(projectLaunchImageContentsJsonPath) + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "images": [ + { + "idiom": "iphone", + "filename": "LaunchImage@2x.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} +'''); + fileSystem.file(projectLaunchImagePath) + ..createSync(recursive: true) + ..writeAsBytes([4, 5, 6]); + }), + exportArchiveCommand(exportOptionsPlist: _exportOptionsPlist), + ]); + + createMinimalMockProjectFiles(); + + final BuildCommand command = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: MemoryFileSystem.test(), + logger: BufferLogger.test(), + osUtils: FakeOperatingSystemUtils(), + ); + await createTestCommandRunner(command).run( + ['build', 'ipa', '--no-pub']); + + expect( + testLogger.statusText, + isNot(contains('Warning: Launch image is set to the default placeholder. Replace with unique launch images.')), + ); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => fakeProcessManager, + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + } diff --git a/packages/flutter_tools/test/general.shard/application_package_test.dart b/packages/flutter_tools/test/general.shard/application_package_test.dart index 9fecc6838146..1fa60d37c687 100644 --- a/packages/flutter_tools/test/general.shard/application_package_test.dart +++ b/packages/flutter_tools/test/general.shard/application_package_test.dart @@ -476,6 +476,92 @@ void main() { ), ); }, overrides: overrides); + + testUsingContext('returns project launch image dirname', () async { + final BuildableIOSApp iosApp = BuildableIOSApp( + IosProject.fromFlutter(FlutterProject.fromDirectory(globals.fs.currentDirectory)), + 'com.foo.bar', + 'Runner', + ); + final String launchImageDirSuffix = globals.fs.path.join( + 'Runner', + 'Assets.xcassets', + 'LaunchImage.imageset', + ); + expect(iosApp.projectLaunchImageDirName, globals.fs.path.join('ios', launchImageDirSuffix)); + }, overrides: overrides); + + testUsingContext('returns template launch image dirname for Contents.json', () async { + final BuildableIOSApp iosApp = BuildableIOSApp( + IosProject.fromFlutter(FlutterProject.fromDirectory(globals.fs.currentDirectory)), + 'com.foo.bar', + 'Runner', + ); + final String launchImageDirSuffix = globals.fs.path.join( + 'Runner', + 'Assets.xcassets', + 'LaunchImage.imageset', + ); + expect( + iosApp.templateLaunchImageDirNameForContentsJson, + globals.fs.path.join( + Cache.flutterRoot!, + 'packages', + 'flutter_tools', + 'templates', + 'app_shared', + 'ios.tmpl', + launchImageDirSuffix, + ), + ); + }, overrides: overrides); + + testUsingContext('returns template launch image dirname for images', () async { + final String toolsDir = globals.fs.path.join( + Cache.flutterRoot!, + 'packages', + 'flutter_tools', + ); + final String packageConfigPath = globals.fs.path.join( + toolsDir, + '.dart_tool', + 'package_config.json' + ); + globals.fs.file(packageConfigPath) + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "configVersion": 2, + "packages": [ + { + "name": "flutter_template_images", + "rootUri": "/flutter_template_images", + "packageUri": "lib/", + "languageVersion": "2.12" + } + ] +} +'''); + final BuildableIOSApp iosApp = BuildableIOSApp( + IosProject.fromFlutter(FlutterProject.fromDirectory(globals.fs.currentDirectory)), + 'com.foo.bar', + 'Runner'); + final String launchImageDirSuffix = globals.fs.path.join( + 'Runner', + 'Assets.xcassets', + 'LaunchImage.imageset', + ); + expect( + await iosApp.templateLaunchImageDirNameForImages, + globals.fs.path.absolute( + 'flutter_template_images', + 'templates', + 'app_shared', + 'ios.tmpl', + launchImageDirSuffix, + ), + ); + }, overrides: overrides); }); group('FuchsiaApp', () {