diff --git a/dev/devicelab/bin/tasks/ios_content_validation_test.dart b/dev/devicelab/bin/tasks/ios_content_validation_test.dart index c37f011c95cc..ffdaaa18c72a 100644 --- a/dev/devicelab/bin/tasks/ios_content_validation_test.dart +++ b/dev/devicelab/bin/tasks/ios_content_validation_test.dart @@ -17,6 +17,20 @@ Future main() async { section('Archive'); await inDirectory(flutterProject.rootPath, () async { + final File appIconFile = File(path.join( + flutterProject.rootPath, + 'ios', + 'Runner', + 'Assets.xcassets', + 'AppIcon.appiconset', + 'Icon-App-20x20@1x.png', + )); + // Resizes app icon to 123x456 (it is supposed to be 20x20). + appIconFile.writeAsBytesSync(appIconFile.readAsBytesSync() + ..buffer.asByteData().setInt32(16, 123) + ..buffer.asByteData().setInt32(20, 456) + ); + final String output = await evalFlutter('build', options: [ 'xcarchive', '-v', @@ -27,6 +41,15 @@ Future main() async { if (!output.contains('Sending archive event if usage enabled')) { throw TaskResult.failure('Usage archive event not sent'); } + + if (!output.contains('Warning: App icon is using the wrong size (e.g. Icon-App-20x20@1x.png).')) { + throw TaskResult.failure('Must validate incorrect app icon image size.'); + } + + // The project is still using Flutter template icon. + 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.'); + } }); 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 347540c9e1dd..f8ff34b8a948 100644 --- a/packages/flutter_tools/lib/src/commands/build_ios.dart +++ b/packages/flutter_tools/lib/src/commands/build_ios.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:typed_data'; + import 'package:crypto/crypto.dart'; import 'package:file/file.dart'; import 'package:meta/meta.dart'; @@ -55,6 +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). +@immutable +class _AppIconImageFileKey { + const _AppIconImageFileKey(this.idiom, this.size, this.scale); + + /// 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; + + @override + int get hashCode => Object.hash(idiom, size, scale); + + @override + bool operator ==(Object other) => other is _AppIconImageFileKey + && other.idiom == idiom + && other.size == size + && other.scale == scale; + + /// The pixel size. + int get pixelSize => (size * scale).toInt(); // pixel size must be an int. +} + /// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for /// App Store submission. /// @@ -131,18 +159,22 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { return super.validateCommand(); } - // Parses Contents.json into a map, with the key to be the combination of (idiom, size, scale), and value to be the icon image file name. - Map _parseIconContentsJson(String contentsJsonDirName) { + // 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) { final Directory contentsJsonDirectory = globals.fs.directory(contentsJsonDirName); if (!contentsJsonDirectory.existsSync()) { - return {}; + return <_AppIconImageFileKey, String>{}; } final File contentsJsonFile = contentsJsonDirectory.childFile('Contents.json'); - final Map content = json.decode(contentsJsonFile.readAsStringSync()) as Map; - final List images = content['images'] as List? ?? []; - - final Map iconInfo = {}; + final Map contents = json.decode(contentsJsonFile.readAsStringSync()) as Map? ?? {}; + final List images = contents['images'] as List? ?? []; + final Map info = contents['info'] as Map? ?? {}; + if ((info['version'] as int?) != 1) { + // Skips validation for unknown format. + return <_AppIconImageFileKey, String>{}; + } + final Map<_AppIconImageFileKey, String> iconInfo = <_AppIconImageFileKey, String>{}; for (final dynamic image in images) { final Map imageMap = image as Map; final String? idiom = imageMap['idiom'] as String?; @@ -150,9 +182,29 @@ 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) { - iconInfo['$idiom $size $scale'] = fileName; + if (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') + .map((String element) => double.tryParse(element)) + .whereType(); + if (parsedSizes.isEmpty) { + continue; + } + final double parsedSize = parsedSizes.first; + + // for example, "3x". + final Iterable parsedScales = scale.split('x') + .map((String element) => int.tryParse(element)) + .whereType(); + if (parsedScales.isEmpty) { + continue; + } + final int parsedScale = parsedScales.first; + + iconInfo[_AppIconImageFileKey(idiom, parsedSize, parsedScale)] = fileName; } return iconInfo; @@ -162,29 +214,51 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { final BuildableIOSApp app = await buildableIOSApp; final String templateIconImageDirName = await app.templateAppIconDirNameForImages; - final Map templateIconMap = _parseIconContentsJson(app.templateAppIconDirNameForContentsJson); - final Map projectIconMap = _parseIconContentsJson(app.projectAppIconDirName); + final Map<_AppIconImageFileKey, String> templateIconMap = _parseIconContentsJson(app.templateAppIconDirNameForContentsJson); + final Map<_AppIconImageFileKey, String> projectIconMap = _parseIconContentsJson(app.projectAppIconDirName); - // find if any of the project icons conflict with template icons - final bool hasConflict = projectIconMap.entries - .where((MapEntry entry) { + // 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]; - if (templateIconFileName == null) { - return false; + 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); + } } - final File projectIconFile = globals.fs.file(globals.fs.path.join(app.projectAppIconDirName, projectIconFileName)); - final File templateIconFile = globals.fs.file(globals.fs.path.join(templateIconImageDirName, templateIconFileName)); - return projectIconFile.existsSync() - && templateIconFile.existsSync() - && md5.convert(projectIconFile.readAsBytesSync()) == md5.convert(templateIconFile.readAsBytesSync()); - }) - .isNotEmpty; - - if (hasConflict) { + // 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); + } + } + + if (filesWithTemplateIcon.isNotEmpty) { messageBuffer.writeln('\nWarning: App icon is set to the default placeholder icon. Replace with unique icons.'); } + if (filesWithWrongSize.isNotEmpty) { + messageBuffer.writeln('\nWarning: App icon is using the wrong size (e.g. ${filesWithWrongSize.first}).'); + } } Future _validateXcodeBuildSettingsAfterArchive(StringBuffer messageBuffer) async { 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 9e81ab0cdd50..195d605cad2a 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 @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:typed_data'; + import 'package:args/command_runner.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/file_system.dart'; @@ -1010,7 +1012,11 @@ void main() { "filename": "Icon-App-20x20@2x.png", "scale": "2x" } - ] + ], + "info": { + "version": 1, + "author": "xcode" + } } '''); fileSystem.file(templateIconImagePath) @@ -1028,7 +1034,11 @@ void main() { "filename": "Icon-App-20x20@2x.png", "scale": "2x" } - ] + ], + "info": { + "version": 1, + "author": "xcode" + } } '''); fileSystem.file(projectIconImagePath) @@ -1081,7 +1091,11 @@ void main() { "filename": "Icon-App-20x20@2x.png", "scale": "2x" } - ] + ], + "info": { + "version": 1, + "author": "xcode" + } } '''); fileSystem.file(templateIconImagePath) @@ -1099,7 +1113,11 @@ void main() { "filename": "Icon-App-20x20@2x.png", "scale": "2x" } - ] + ], + "info": { + "version": 1, + "author": "xcode" + } } '''); fileSystem.file(projectIconImagePath) @@ -1128,6 +1146,336 @@ void main() { Platform: () => macosPlatform, XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), }); + + testUsingContext('Validate app icon using the wrong width', () async { + const String projectIconContentsJsonPath = 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json'; + const String projectIconImagePath = 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png'; + + fakeProcessManager.addCommands([ + xattrCommand, + setUpFakeXcodeBuildHandler(onRun: () { + fileSystem.file(projectIconContentsJsonPath) + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "images": [ + { + "size": "20x20", + "idiom": "iphone", + "filename": "Icon-App-20x20@2x.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} +'''); + fileSystem.file(projectIconImagePath) + ..createSync(recursive: true) + ..writeAsBytes(Uint8List(16)) + // set width to 1 pixel + ..writeAsBytes(Uint8List(4)..buffer.asByteData().setInt32(0, 1), mode: FileMode.append) + // set height to 40 pixels + ..writeAsBytes(Uint8List(4)..buffer.asByteData().setInt32(0, 40), mode: FileMode.append); + }), + 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: App icon is using the wrong size (e.g. Icon-App-20x20@2x.png).')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => fakeProcessManager, + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + + testUsingContext('Validate app icon using the wrong height', () async { + const String projectIconContentsJsonPath = 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json'; + const String projectIconImagePath = 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png'; + + fakeProcessManager.addCommands([ + xattrCommand, + setUpFakeXcodeBuildHandler(onRun: () { + fileSystem.file(projectIconContentsJsonPath) + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "images": [ + { + "size": "20x20", + "idiom": "iphone", + "filename": "Icon-App-20x20@2x.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} +'''); + fileSystem.file(projectIconImagePath) + ..createSync(recursive: true) + ..writeAsBytes(Uint8List(16)) + // set width to 40 pixels + ..writeAsBytes(Uint8List(4)..buffer.asByteData().setInt32(0, 40), mode: FileMode.append) + // set height to 1 pixel + ..writeAsBytes(Uint8List(4)..buffer.asByteData().setInt32(0, 1), mode: FileMode.append); + }), + 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: App icon is using the wrong size (e.g. Icon-App-20x20@2x.png).')); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => fakeProcessManager, + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + + testUsingContext('Validate app icon using the correct width and height', () async { + const String projectIconContentsJsonPath = 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json'; + const String projectIconImagePath = 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png'; + + fakeProcessManager.addCommands([ + xattrCommand, + setUpFakeXcodeBuildHandler(onRun: () { + fileSystem.file(projectIconContentsJsonPath) + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "images": [ + { + "size": "20x20", + "idiom": "iphone", + "filename": "Icon-App-20x20@2x.png", + "scale": "2x" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} +'''); + fileSystem.file(projectIconImagePath) + ..createSync(recursive: true) + ..writeAsBytes(Uint8List(16)) + // set width to 40 pixels + ..writeAsBytes(Uint8List(4)..buffer.asByteData().setInt32(0, 40), mode: FileMode.append) + // set height to 40 pixel + ..writeAsBytes(Uint8List(4)..buffer.asByteData().setInt32(0, 40), mode: FileMode.append); + }), + 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: App icon is using the wrong size (e.g. Icon-App-20x20@2x.png).'))); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => fakeProcessManager, + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + + testUsingContext('Validate app icon should skip validation for unknown format version', () async { + const String projectIconContentsJsonPath = 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json'; + const String projectIconImagePath = 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png'; + + fakeProcessManager.addCommands([ + xattrCommand, + setUpFakeXcodeBuildHandler(onRun: () { + // Uses unknown format version 123. + fileSystem.file(projectIconContentsJsonPath) + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "images": [ + { + "size": "20x20", + "idiom": "iphone", + "filename": "Icon-App-20x20@2x.png", + "scale": "2x" + } + ], + "info": { + "version": 123, + "author": "xcode" + } +} +'''); + fileSystem.file(projectIconImagePath) + ..createSync(recursive: true) + ..writeAsBytes(Uint8List(16)) + // set width to 1 pixel + ..writeAsBytes(Uint8List(4)..buffer.asByteData().setInt32(0, 1), mode: FileMode.append) + // set height to 1 pixel + ..writeAsBytes(Uint8List(4)..buffer.asByteData().setInt32(0, 1), mode: FileMode.append); + }), + 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']); + + // The validation should be skipped, even when the icon size is incorrect. + expect(testLogger.statusText, isNot(contains('Warning: App icon is using the wrong size (e.g. Icon-App-20x20@2x.png).'))); + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => fakeProcessManager, + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); + + testUsingContext('Validate app icon should skip validation of an icon image if invalid format', () async { + const String projectIconContentsJsonPath = 'ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json'; + final List imageFileNames = [ + 'Icon-App-20x20@1x.png', + 'Icon-App-20x20@2x.png', + 'Icon-App-20x20@3x.png', + 'Icon-App-29x29@1x.png', + 'Icon-App-29x29@2x.png', + 'Icon-App-29x29@3x.png', + ]; + + fakeProcessManager.addCommands([ + xattrCommand, + setUpFakeXcodeBuildHandler(onRun: () { + // The following json contains examples of: + // - invalid size + // - invalid scale + // - missing size + // - missing idiom + // - missing filename + // - missing scale + fileSystem.file(projectIconContentsJsonPath) + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "images": [ + { + "size": "20*20", + "idiom": "iphone", + "filename": "Icon-App-20x20@2x.png", + "scale": "1x" + }, + { + "size": "20x20", + "idiom": "iphone", + "filename": "Icon-App-20x20@2x.png", + "scale": "2@" + }, + { + "idiom": "iphone", + "filename": "Icon-App-20x20@3x.png", + "scale": "3x" + }, + { + "size": "29x29", + "filename": "Icon-App-29x29@1x.png", + "scale": "1x" + }, + { + "size": "29x29", + "idiom": "iphone", + "scale": "2x" + }, + { + "size": "29x29", + "idiom": "iphone", + "filename": "Icon-App-20x20@3x.png" + } + ], + "info": { + "version": 1, + "author": "xcode" + } +} +'''); + + // Resize all related images to 1x1. + for (final String imageFileName in imageFileNames) { + fileSystem.file('ios/Runner/Assets.xcassets/AppIcon.appiconset/$imageFileName') + ..createSync(recursive: true) + ..writeAsBytes(Uint8List(16)) + // set width to 1 pixel + ..writeAsBytes(Uint8List(4)..buffer.asByteData().setInt32(0, 1), mode: FileMode.append) + // set height to 1 pixel + ..writeAsBytes(Uint8List(4)..buffer.asByteData().setInt32(0, 1), mode: FileMode.append); + } + }), + 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']); + + // The validation should be skipped, even when the image size is incorrect. + for (final String imageFileName in imageFileNames) { + expect(testLogger.statusText, isNot(contains( + 'Warning: App icon is using the wrong size (e.g. $imageFileName).'))); + } + }, overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => fakeProcessManager, + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + }); }