Skip to content

Commit e438a12

Browse files
authored
[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
1 parent 900b395 commit e438a12

File tree

3 files changed

+474
-29
lines changed

3 files changed

+474
-29
lines changed

dev/devicelab/bin/tasks/ios_content_validation_test.dart

+23
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ Future<void> main() async {
1717
section('Archive');
1818

1919
await inDirectory(flutterProject.rootPath, () async {
20+
final File appIconFile = File(path.join(
21+
flutterProject.rootPath,
22+
'ios',
23+
'Runner',
24+
'Assets.xcassets',
25+
'AppIcon.appiconset',
26+
27+
));
28+
// Resizes app icon to 123x456 (it is supposed to be 20x20).
29+
appIconFile.writeAsBytesSync(appIconFile.readAsBytesSync()
30+
..buffer.asByteData().setInt32(16, 123)
31+
..buffer.asByteData().setInt32(20, 456)
32+
);
33+
2034
final String output = await evalFlutter('build', options: <String>[
2135
'xcarchive',
2236
'-v',
@@ -27,6 +41,15 @@ Future<void> main() async {
2741
if (!output.contains('Sending archive event if usage enabled')) {
2842
throw TaskResult.failure('Usage archive event not sent');
2943
}
44+
45+
if (!output.contains('Warning: App icon is using the wrong size (e.g. [email protected]).')) {
46+
throw TaskResult.failure('Must validate incorrect app icon image size.');
47+
}
48+
49+
// The project is still using Flutter template icon.
50+
if (!output.contains('Warning: App icon is set to the default placeholder icon. Replace with unique icons.')) {
51+
throw TaskResult.failure('Must validate template app icon.');
52+
}
3053
});
3154

3255
final String archivePath = path.join(

packages/flutter_tools/lib/src/commands/build_ios.dart

+99-25
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
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+
57
import 'package:crypto/crypto.dart';
68
import 'package:file/file.dart';
79
import '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('\nWarning: App icon is set to the default placeholder icon. Replace with unique icons.');
187258
}
259+
if (filesWithWrongSize.isNotEmpty) {
260+
messageBuffer.writeln('\nWarning: App icon is using the wrong size (e.g. ${filesWithWrongSize.first}).');
261+
}
188262
}
189263

190264
Future<void> _validateXcodeBuildSettingsAfterArchive(StringBuffer messageBuffer) async {

0 commit comments

Comments
 (0)