2
2
// Use of this source code is governed by a BSD-style license that can be
3
3
// found in the LICENSE file.
4
4
5
+ import 'dart:typed_data' ;
6
+
5
7
import 'package:crypto/crypto.dart' ;
6
8
import 'package:file/file.dart' ;
7
9
import 'package:meta/meta.dart' ;
@@ -55,6 +57,32 @@ class BuildIOSCommand extends _BuildIOSSubCommand {
55
57
Directory _outputAppDirectory (String xcodeResultOutput) => globals.fs.directory (xcodeResultOutput).parent;
56
58
}
57
59
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
+
58
86
/// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for
59
87
/// App Store submission.
60
88
///
@@ -131,28 +159,52 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
131
159
return super .validateCommand ();
132
160
}
133
161
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) {
136
164
final Directory contentsJsonDirectory = globals.fs.directory (contentsJsonDirName);
137
165
if (! contentsJsonDirectory.existsSync ()) {
138
- return < String , String > {};
166
+ return < _AppIconImageFileKey , String > {};
139
167
}
140
168
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
+ }
145
176
177
+ final Map <_AppIconImageFileKey , String > iconInfo = < _AppIconImageFileKey , String > {};
146
178
for (final dynamic image in images) {
147
179
final Map <String , dynamic > imageMap = image as Map <String , dynamic >;
148
180
final String ? idiom = imageMap['idiom' ] as String ? ;
149
181
final String ? size = imageMap['size' ] as String ? ;
150
182
final String ? scale = imageMap['scale' ] as String ? ;
151
183
final String ? fileName = imageMap['filename' ] as String ? ;
152
184
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 ;
155
187
}
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;
156
208
}
157
209
158
210
return iconInfo;
@@ -162,29 +214,51 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
162
214
final BuildableIOSApp app = await buildableIOSApp;
163
215
final String templateIconImageDirName = await app.templateAppIconDirNameForImages;
164
216
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);
167
219
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) {
171
224
final String projectIconFileName = entry.value;
172
225
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
+ }
175
240
}
176
241
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) {
186
257
messageBuffer.writeln ('\n Warning: App icon is set to the default placeholder icon. Replace with unique icons.' );
187
258
}
259
+ if (filesWithWrongSize.isNotEmpty) {
260
+ messageBuffer.writeln ('\n Warning: App icon is using the wrong size (e.g. ${filesWithWrongSize .first }).' );
261
+ }
188
262
}
189
263
190
264
Future <void > _validateXcodeBuildSettingsAfterArchive (StringBuffer messageBuffer) async {
0 commit comments