Unverified Commit e438a120 authored by hellohuanlin's avatar hellohuanlin Committed by GitHub

[tools]build ipa validate app icon size (#115594)

* [tools]build ipa validate icon size

* add more checks in case apple change the format, and also add device lab tests

* do not depend on collection package
parent 900b3954
...@@ -17,6 +17,20 @@ Future<void> main() async { ...@@ -17,6 +17,20 @@ Future<void> main() async {
section('Archive'); section('Archive');
await inDirectory(flutterProject.rootPath, () async { 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: <String>[ final String output = await evalFlutter('build', options: <String>[
'xcarchive', 'xcarchive',
'-v', '-v',
...@@ -27,6 +41,15 @@ Future<void> main() async { ...@@ -27,6 +41,15 @@ Future<void> main() async {
if (!output.contains('Sending archive event if usage enabled')) { if (!output.contains('Sending archive event if usage enabled')) {
throw TaskResult.failure('Usage archive event not sent'); 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( final String archivePath = path.join(
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be // Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file. // found in the LICENSE file.
import 'dart:typed_data';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
...@@ -55,6 +57,32 @@ class BuildIOSCommand extends _BuildIOSSubCommand { ...@@ -55,6 +57,32 @@ class BuildIOSCommand extends _BuildIOSSubCommand {
Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs.directory(xcodeResultOutput).parent; 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 /// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for
/// App Store submission. /// App Store submission.
/// ///
...@@ -131,18 +159,22 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { ...@@ -131,18 +159,22 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
return super.validateCommand(); 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. // Parses Contents.json into a map, with the key to be _AppIconImageFileKey, and value to be the icon image file name.
Map<String, String> _parseIconContentsJson(String contentsJsonDirName) { Map<_AppIconImageFileKey, String> _parseIconContentsJson(String contentsJsonDirName) {
final Directory contentsJsonDirectory = globals.fs.directory(contentsJsonDirName); final Directory contentsJsonDirectory = globals.fs.directory(contentsJsonDirName);
if (!contentsJsonDirectory.existsSync()) { if (!contentsJsonDirectory.existsSync()) {
return <String, String>{}; return <_AppIconImageFileKey, String>{};
} }
final File contentsJsonFile = contentsJsonDirectory.childFile('Contents.json'); final File contentsJsonFile = contentsJsonDirectory.childFile('Contents.json');
final Map<String, dynamic> content = json.decode(contentsJsonFile.readAsStringSync()) as Map<String, dynamic>; final Map<String, dynamic> contents = json.decode(contentsJsonFile.readAsStringSync()) as Map<String, dynamic>? ?? <String, dynamic>{};
final List<dynamic> images = content['images'] as List<dynamic>? ?? <dynamic>[]; final List<dynamic> images = contents['images'] as List<dynamic>? ?? <dynamic>[];
final Map<String, dynamic> info = contents['info'] as Map<String, dynamic>? ?? <String, dynamic>{};
final Map<String, String> iconInfo = <String, String>{}; 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) { for (final dynamic image in images) {
final Map<String, dynamic> imageMap = image as Map<String, dynamic>; final Map<String, dynamic> imageMap = image as Map<String, dynamic>;
final String? idiom = imageMap['idiom'] as String?; final String? idiom = imageMap['idiom'] as String?;
...@@ -150,9 +182,29 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { ...@@ -150,9 +182,29 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
final String? scale = imageMap['scale'] as String?; final String? scale = imageMap['scale'] as String?;
final String? fileName = imageMap['filename'] as String?; final String? fileName = imageMap['filename'] as String?;
if (size != null && idiom != null && scale != null && fileName != null) { if (size == null || idiom == null || scale == null || fileName == null) {
iconInfo['$idiom $size $scale'] = fileName; continue;
}
// for example, "64x64". Parse the width since it is a square.
final Iterable<double> parsedSizes = size.split('x')
.map((String element) => double.tryParse(element))
.whereType<double>();
if (parsedSizes.isEmpty) {
continue;
} }
final double parsedSize = parsedSizes.first;
// for example, "3x".
final Iterable<int> parsedScales = scale.split('x')
.map((String element) => int.tryParse(element))
.whereType<int>();
if (parsedScales.isEmpty) {
continue;
}
final int parsedScale = parsedScales.first;
iconInfo[_AppIconImageFileKey(idiom, parsedSize, parsedScale)] = fileName;
} }
return iconInfo; return iconInfo;
...@@ -162,29 +214,51 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { ...@@ -162,29 +214,51 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
final BuildableIOSApp app = await buildableIOSApp; final BuildableIOSApp app = await buildableIOSApp;
final String templateIconImageDirName = await app.templateAppIconDirNameForImages; final String templateIconImageDirName = await app.templateAppIconDirNameForImages;
final Map<String, String> templateIconMap = _parseIconContentsJson(app.templateAppIconDirNameForContentsJson); final Map<_AppIconImageFileKey, String> templateIconMap = _parseIconContentsJson(app.templateAppIconDirNameForContentsJson);
final Map<String, String> projectIconMap = _parseIconContentsJson(app.projectAppIconDirName); final Map<_AppIconImageFileKey, String> projectIconMap = _parseIconContentsJson(app.projectAppIconDirName);
// find if any of the project icons conflict with template icons // validate each of the project icon images.
final bool hasConflict = projectIconMap.entries final List<String> filesWithTemplateIcon = <String>[];
.where((MapEntry<String, String> entry) { final List<String> filesWithWrongSize = <String>[];
for (final MapEntry<_AppIconImageFileKey, String> entry in projectIconMap.entries) {
final String projectIconFileName = entry.value; final String projectIconFileName = entry.value;
final String? templateIconFileName = templateIconMap[entry.key]; final String? templateIconFileName = templateIconMap[entry.key];
if (templateIconFileName == null) { final File projectIconFile = globals.fs.file(globals.fs.path.join(app.projectAppIconDirName, projectIconFileName));
return false; if (!projectIconFile.existsSync()) {
continue;
} }
final Uint8List projectIconBytes = projectIconFile.readAsBytesSync();
final File projectIconFile = globals.fs.file(globals.fs.path.join(app.projectAppIconDirName, projectIconFileName)); // validate conflict with template icon file.
final File templateIconFile = globals.fs.file(globals.fs.path.join(templateIconImageDirName, templateIconFileName)); if (templateIconFileName != null) {
return projectIconFile.existsSync() final File templateIconFile = globals.fs.file(globals.fs.path.join(
&& templateIconFile.existsSync() templateIconImageDirName, templateIconFileName));
&& md5.convert(projectIconFile.readAsBytesSync()) == md5.convert(templateIconFile.readAsBytesSync()); if (templateIconFile.existsSync() && md5.convert(projectIconBytes) ==
}) md5.convert(templateIconFile.readAsBytesSync())) {
.isNotEmpty; filesWithTemplateIcon.add(entry.value);
}
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.'); 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<void> _validateXcodeBuildSettingsAfterArchive(StringBuffer messageBuffer) async { Future<void> _validateXcodeBuildSettingsAfterArchive(StringBuffer messageBuffer) async {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment