22// Use of this source code is governed by a BSD-style license that can be
33// found in the LICENSE file.
44
5+ import 'dart:typed_data' ;
6+
57import 'package:crypto/crypto.dart' ;
68import 'package:file/file.dart' ;
79import 'package:meta/meta.dart' ;
@@ -55,6 +57,32 @@ class BuildIOSCommand extends _BuildIOSSubCommand {
5557 Directory _outputAppDirectory (String xcodeResultOutput) => globals.fs.directory (xcodeResultOutput).parent;
5658}
5759
60+ /// The key that uniquely identifies an image file in an app icon asset.
61+ /// It consists of (idiom, size, scale).
62+ @immutable
63+ class _AppIconImageFileKey {
64+ const _AppIconImageFileKey (this .idiom, this .size, this .scale);
65+
66+ /// The idiom (iphone or ipad).
67+ final String idiom;
68+ /// The logical size in point (e.g. 83.5).
69+ final double size;
70+ /// The scale factor (e.g. 2).
71+ final int scale;
72+
73+ @override
74+ int get hashCode => Object .hash (idiom, size, scale);
75+
76+ @override
77+ bool operator == (Object other) => other is _AppIconImageFileKey
78+ && other.idiom == idiom
79+ && other.size == size
80+ && other.scale == scale;
81+
82+ /// The pixel size.
83+ int get pixelSize => (size * scale).toInt (); // pixel size must be an int.
84+ }
85+
5886/// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for
5987/// App Store submission.
6088///
@@ -131,28 +159,52 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
131159 return super .validateCommand ();
132160 }
133161
134- // 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.
135- Map <String , String > _parseIconContentsJson (String contentsJsonDirName) {
162+ // Parses Contents.json into a map, with the key to be _AppIconImageFileKey , and value to be the icon image file name.
163+ Map <_AppIconImageFileKey , String > _parseIconContentsJson (String contentsJsonDirName) {
136164 final Directory contentsJsonDirectory = globals.fs.directory (contentsJsonDirName);
137165 if (! contentsJsonDirectory.existsSync ()) {
138- return < String , String > {};
166+ return < _AppIconImageFileKey , String > {};
139167 }
140168 final File contentsJsonFile = contentsJsonDirectory.childFile ('Contents.json' );
141- final Map <String , dynamic > content = json.decode (contentsJsonFile.readAsStringSync ()) as Map <String , dynamic >;
142- final List <dynamic > images = content['images' ] as List <dynamic >? ?? < dynamic > [];
143-
144- final Map <String , String > iconInfo = < String , String > {};
169+ final Map <String , dynamic > contents = json.decode (contentsJsonFile.readAsStringSync ()) as Map <String , dynamic >? ?? < String , dynamic > {};
170+ final List <dynamic > images = contents['images' ] as List <dynamic >? ?? < dynamic > [];
171+ final Map <String , dynamic > info = contents['info' ] as Map <String , dynamic >? ?? < String , dynamic > {};
172+ if ((info['version' ] as int ? ) != 1 ) {
173+ // Skips validation for unknown format.
174+ return < _AppIconImageFileKey , String > {};
175+ }
145176
177+ final Map <_AppIconImageFileKey , String > iconInfo = < _AppIconImageFileKey , String > {};
146178 for (final dynamic image in images) {
147179 final Map <String , dynamic > imageMap = image as Map <String , dynamic >;
148180 final String ? idiom = imageMap['idiom' ] as String ? ;
149181 final String ? size = imageMap['size' ] as String ? ;
150182 final String ? scale = imageMap['scale' ] as String ? ;
151183 final String ? fileName = imageMap['filename' ] as String ? ;
152184
153- if (size != null && idiom != null && scale != null && fileName ! = null ) {
154- iconInfo[ '$ idiom $ size $ scale ' ] = fileName ;
185+ if (size == null || idiom == null || scale == null || fileName = = null ) {
186+ continue ;
155187 }
188+
189+ // for example, "64x64". Parse the width since it is a square.
190+ final Iterable <double > parsedSizes = size.split ('x' )
191+ .map ((String element) => double .tryParse (element))
192+ .whereType <double >();
193+ if (parsedSizes.isEmpty) {
194+ continue ;
195+ }
196+ final double parsedSize = parsedSizes.first;
197+
198+ // for example, "3x".
199+ final Iterable <int > parsedScales = scale.split ('x' )
200+ .map ((String element) => int .tryParse (element))
201+ .whereType <int >();
202+ if (parsedScales.isEmpty) {
203+ continue ;
204+ }
205+ final int parsedScale = parsedScales.first;
206+
207+ iconInfo[_AppIconImageFileKey (idiom, parsedSize, parsedScale)] = fileName;
156208 }
157209
158210 return iconInfo;
@@ -162,29 +214,51 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
162214 final BuildableIOSApp app = await buildableIOSApp;
163215 final String templateIconImageDirName = await app.templateAppIconDirNameForImages;
164216
165- final Map <String , String > templateIconMap = _parseIconContentsJson (app.templateAppIconDirNameForContentsJson);
166- final Map <String , String > projectIconMap = _parseIconContentsJson (app.projectAppIconDirName);
217+ final Map <_AppIconImageFileKey , String > templateIconMap = _parseIconContentsJson (app.templateAppIconDirNameForContentsJson);
218+ final Map <_AppIconImageFileKey , String > projectIconMap = _parseIconContentsJson (app.projectAppIconDirName);
167219
168- // find if any of the project icons conflict with template icons
169- final bool hasConflict = projectIconMap.entries
170- .where ((MapEntry <String , String > entry) {
220+ // validate each of the project icon images.
221+ final List <String > filesWithTemplateIcon = < String > [];
222+ final List <String > filesWithWrongSize = < String > [];
223+ for (final MapEntry <_AppIconImageFileKey , String > entry in projectIconMap.entries) {
171224 final String projectIconFileName = entry.value;
172225 final String ? templateIconFileName = templateIconMap[entry.key];
173- if (templateIconFileName == null ) {
174- return false ;
226+ final File projectIconFile = globals.fs.file (globals.fs.path.join (app.projectAppIconDirName, projectIconFileName));
227+ if (! projectIconFile.existsSync ()) {
228+ continue ;
229+ }
230+ final Uint8List projectIconBytes = projectIconFile.readAsBytesSync ();
231+
232+ // validate conflict with template icon file.
233+ if (templateIconFileName != null ) {
234+ final File templateIconFile = globals.fs.file (globals.fs.path.join (
235+ templateIconImageDirName, templateIconFileName));
236+ if (templateIconFile.existsSync () && md5.convert (projectIconBytes) ==
237+ md5.convert (templateIconFile.readAsBytesSync ())) {
238+ filesWithTemplateIcon.add (entry.value);
239+ }
175240 }
176241
177- final File projectIconFile = globals.fs.file (globals.fs.path.join (app.projectAppIconDirName, projectIconFileName));
178- final File templateIconFile = globals.fs.file (globals.fs.path.join (templateIconImageDirName, templateIconFileName));
179- return projectIconFile.existsSync ()
180- && templateIconFile.existsSync ()
181- && md5.convert (projectIconFile.readAsBytesSync ()) == md5.convert (templateIconFile.readAsBytesSync ());
182- })
183- .isNotEmpty;
184-
185- if (hasConflict) {
242+ // validate image size is correct.
243+ // PNG file's width is at byte [16, 20), and height is at byte [20, 24), in big endian format.
244+ // Based on https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_format
245+ final ByteData projectIconData = projectIconBytes.buffer.asByteData ();
246+ if (projectIconData.lengthInBytes < 24 ) {
247+ continue ;
248+ }
249+ final int width = projectIconData.getInt32 (16 );
250+ final int height = projectIconData.getInt32 (20 );
251+ if (width != entry.key.pixelSize || height != entry.key.pixelSize) {
252+ filesWithWrongSize.add (entry.value);
253+ }
254+ }
255+
256+ if (filesWithTemplateIcon.isNotEmpty) {
186257 messageBuffer.writeln ('\n Warning: App icon is set to the default placeholder icon. Replace with unique icons.' );
187258 }
259+ if (filesWithWrongSize.isNotEmpty) {
260+ messageBuffer.writeln ('\n Warning: App icon is using the wrong size (e.g. ${filesWithWrongSize .first }).' );
261+ }
188262 }
189263
190264 Future <void > _validateXcodeBuildSettingsAfterArchive (StringBuffer messageBuffer) async {
0 commit comments