Skip to content

Commit be81e9e

Browse files
authored
[tools]build ipa validate launch image using template files (#116242)
* [tools]build ipa validate launch image using template files * reuse more code by sharing the same file key * fix space
1 parent 0234b18 commit be81e9e

File tree

5 files changed

+419
-92
lines changed

5 files changed

+419
-92
lines changed

dev/devicelab/bin/tasks/ios_content_validation_test.dart

+4-1
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,13 @@ Future<void> main() async {
4646
throw TaskResult.failure('Must validate incorrect app icon image size.');
4747
}
4848

49-
// The project is still using Flutter template icon.
49+
// The project is still using Flutter template icon and launch image.
5050
if (!output.contains('Warning: App icon is set to the default placeholder icon. Replace with unique icons.')) {
5151
throw TaskResult.failure('Must validate template app icon.');
5252
}
53+
if (!output.contains('Warning: Launch image is set to the default placeholder. Replace with unique launch images.')) {
54+
throw TaskResult.failure('Must validate template launch image.');
55+
}
5356
});
5457

5558
final String archivePath = path.join(

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

+132-66
Original file line numberDiff line numberDiff line change
@@ -57,30 +57,32 @@ class BuildIOSCommand extends _BuildIOSSubCommand {
5757
Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs.directory(xcodeResultOutput).parent;
5858
}
5959

60-
/// The key that uniquely identifies an image file in an app icon asset.
61-
/// It consists of (idiom, size, scale).
60+
/// The key that uniquely identifies an image file in an image asset.
61+
/// It consists of (idiom, scale, size?), where size is present for app icon
62+
/// asset, and null for launch image asset.
6263
@immutable
63-
class _AppIconImageFileKey {
64-
const _AppIconImageFileKey(this.idiom, this.size, this.scale);
64+
class _ImageAssetFileKey {
65+
const _ImageAssetFileKey(this.idiom, this.scale, this.size);
6566

6667
/// The idiom (iphone or ipad).
6768
final String idiom;
68-
/// The logical size in point (e.g. 83.5).
69-
final double size;
7069
/// The scale factor (e.g. 2).
7170
final int scale;
71+
/// The logical size in point (e.g. 83.5).
72+
/// Size is present for app icon, and null for launch image.
73+
final double? size;
7274

7375
@override
74-
int get hashCode => Object.hash(idiom, size, scale);
76+
int get hashCode => Object.hash(idiom, scale, size);
7577

7678
@override
77-
bool operator ==(Object other) => other is _AppIconImageFileKey
79+
bool operator ==(Object other) => other is _ImageAssetFileKey
7880
&& other.idiom == idiom
79-
&& other.size == size
80-
&& other.scale == scale;
81+
&& other.scale == scale
82+
&& other.size == size;
8183

82-
/// The pixel size.
83-
int get pixelSize => (size * scale).toInt(); // pixel size must be an int.
84+
/// The pixel size based on logical size and scale.
85+
int? get pixelSize => size == null ? null : (size! * scale).toInt(); // pixel size must be an int.
8486
}
8587

8688
/// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for
@@ -159,108 +161,170 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
159161
return super.validateCommand();
160162
}
161163

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) {
164+
// A helper function to parse Contents.json of an image asset into a map,
165+
// with the key to be _ImageAssetFileKey, and value to be the image file name.
166+
// Some assets have size (e.g. app icon) and others do not (e.g. launch image).
167+
Map<_ImageAssetFileKey, String> _parseImageAssetContentsJson(
168+
String contentsJsonDirName,
169+
{ required bool requiresSize })
170+
{
164171
final Directory contentsJsonDirectory = globals.fs.directory(contentsJsonDirName);
165172
if (!contentsJsonDirectory.existsSync()) {
166-
return <_AppIconImageFileKey, String>{};
173+
return <_ImageAssetFileKey, String>{};
167174
}
168175
final File contentsJsonFile = contentsJsonDirectory.childFile('Contents.json');
169176
final Map<String, dynamic> contents = json.decode(contentsJsonFile.readAsStringSync()) as Map<String, dynamic>? ?? <String, dynamic>{};
170177
final List<dynamic> images = contents['images'] as List<dynamic>? ?? <dynamic>[];
171178
final Map<String, dynamic> info = contents['info'] as Map<String, dynamic>? ?? <String, dynamic>{};
172179
if ((info['version'] as int?) != 1) {
173180
// Skips validation for unknown format.
174-
return <_AppIconImageFileKey, String>{};
181+
return <_ImageAssetFileKey, String>{};
175182
}
176183

177-
final Map<_AppIconImageFileKey, String> iconInfo = <_AppIconImageFileKey, String>{};
184+
final Map<_ImageAssetFileKey, String> iconInfo = <_ImageAssetFileKey, String>{};
178185
for (final dynamic image in images) {
179186
final Map<String, dynamic> imageMap = image as Map<String, dynamic>;
180187
final String? idiom = imageMap['idiom'] as String?;
181188
final String? size = imageMap['size'] as String?;
182189
final String? scale = imageMap['scale'] as String?;
183190
final String? fileName = imageMap['filename'] as String?;
184191

185-
if (size == null || idiom == null || scale == null || fileName == null) {
192+
// requiresSize must match the actual presence of size in json.
193+
if (requiresSize != (size != null)
194+
|| idiom == null || scale == null || fileName == null)
195+
{
186196
continue;
187197
}
188198

189-
// for example, "64x64". Parse the width since it is a square.
190-
final Iterable<double> parsedSizes = size.split('x')
199+
final double? parsedSize;
200+
if (size != null) {
201+
// for example, "64x64". Parse the width since it is a square.
202+
final Iterable<double> parsedSizes = size.split('x')
191203
.map((String element) => double.tryParse(element))
192204
.whereType<double>();
193-
if (parsedSizes.isEmpty) {
194-
continue;
205+
if (parsedSizes.isEmpty) {
206+
continue;
207+
}
208+
parsedSize = parsedSizes.first;
209+
} else {
210+
parsedSize = null;
195211
}
196-
final double parsedSize = parsedSizes.first;
197212

198213
// for example, "3x".
199214
final Iterable<int> parsedScales = scale.split('x')
200-
.map((String element) => int.tryParse(element))
201-
.whereType<int>();
215+
.map((String element) => int.tryParse(element))
216+
.whereType<int>();
202217
if (parsedScales.isEmpty) {
203218
continue;
204219
}
205220
final int parsedScale = parsedScales.first;
206-
207-
iconInfo[_AppIconImageFileKey(idiom, parsedSize, parsedScale)] = fileName;
221+
iconInfo[_ImageAssetFileKey(idiom, parsedScale, parsedSize)] = fileName;
208222
}
209-
210223
return iconInfo;
211224
}
212225

213-
Future<void> _validateIconsAfterArchive(StringBuffer messageBuffer) async {
214-
final BuildableIOSApp app = await buildableIOSApp;
215-
final String templateIconImageDirName = await app.templateAppIconDirNameForImages;
216-
217-
final Map<_AppIconImageFileKey, String> templateIconMap = _parseIconContentsJson(app.templateAppIconDirNameForContentsJson);
218-
final Map<_AppIconImageFileKey, String> projectIconMap = _parseIconContentsJson(app.projectAppIconDirName);
219-
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) {
224-
final String projectIconFileName = entry.value;
225-
final String? templateIconFileName = templateIconMap[entry.key];
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-
}
226+
// A helper function to check if an image asset is still using template files.
227+
bool _isAssetStillUsingTemplateFiles({
228+
required Map<_ImageAssetFileKey, String> templateImageInfoMap,
229+
required Map<_ImageAssetFileKey, String> projectImageInfoMap,
230+
required String templateImageDirName,
231+
required String projectImageDirName,
232+
}) {
233+
return projectImageInfoMap.entries.any((MapEntry<_ImageAssetFileKey, String> entry) {
234+
final String projectFileName = entry.value;
235+
final String? templateFileName = templateImageInfoMap[entry.key];
236+
if (templateFileName == null) {
237+
return false;
240238
}
239+
final File projectFile = globals.fs.file(
240+
globals.fs.path.join(projectImageDirName, projectFileName));
241+
final File templateFile = globals.fs.file(
242+
globals.fs.path.join(templateImageDirName, templateFileName));
243+
244+
return projectFile.existsSync()
245+
&& templateFile.existsSync()
246+
&& md5.convert(projectFile.readAsBytesSync()) ==
247+
md5.convert(templateFile.readAsBytesSync());
248+
});
249+
}
241250

251+
// A helper function to return a list of image files in an image asset with
252+
// wrong sizes (as specified in its Contents.json file).
253+
List<String> _imageFilesWithWrongSize({
254+
required Map<_ImageAssetFileKey, String> imageInfoMap,
255+
required String imageDirName,
256+
}) {
257+
return imageInfoMap.entries.where((MapEntry<_ImageAssetFileKey, String> entry) {
258+
final String fileName = entry.value;
259+
final File imageFile = globals.fs.file(globals.fs.path.join(imageDirName, fileName));
260+
if (!imageFile.existsSync()) {
261+
return false;
262+
}
242263
// validate image size is correct.
243264
// PNG file's width is at byte [16, 20), and height is at byte [20, 24), in big endian format.
244265
// 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);
266+
final ByteData imageData = imageFile.readAsBytesSync().buffer.asByteData();
267+
if (imageData.lengthInBytes < 24) {
268+
return false;
253269
}
254-
}
270+
final int width = imageData.getInt32(16);
271+
final int height = imageData.getInt32(20);
272+
// The size must not be null.
273+
final int expectedSize = entry.key.pixelSize!;
274+
return width != expectedSize || height != expectedSize;
275+
})
276+
.map((MapEntry<_ImageAssetFileKey, String> entry) => entry.value)
277+
.toList();
278+
}
255279

256-
if (filesWithTemplateIcon.isNotEmpty) {
280+
Future<void> _validateIconAssetsAfterArchive(StringBuffer messageBuffer) async {
281+
final BuildableIOSApp app = await buildableIOSApp;
282+
283+
final Map<_ImageAssetFileKey, String> templateInfoMap = _parseImageAssetContentsJson(
284+
app.templateAppIconDirNameForContentsJson,
285+
requiresSize: true);
286+
final Map<_ImageAssetFileKey, String> projectInfoMap = _parseImageAssetContentsJson(
287+
app.projectAppIconDirName,
288+
requiresSize: true);
289+
290+
final bool usesTemplate = _isAssetStillUsingTemplateFiles(
291+
templateImageInfoMap: templateInfoMap,
292+
projectImageInfoMap: projectInfoMap,
293+
templateImageDirName: await app.templateAppIconDirNameForImages,
294+
projectImageDirName: app.projectAppIconDirName);
295+
if (usesTemplate) {
257296
messageBuffer.writeln('\nWarning: App icon is set to the default placeholder icon. Replace with unique icons.');
258297
}
298+
299+
final List<String> filesWithWrongSize = _imageFilesWithWrongSize(
300+
imageInfoMap: projectInfoMap,
301+
imageDirName: app.projectAppIconDirName);
259302
if (filesWithWrongSize.isNotEmpty) {
260303
messageBuffer.writeln('\nWarning: App icon is using the wrong size (e.g. ${filesWithWrongSize.first}).');
261304
}
262305
}
263306

307+
Future<void> _validateLaunchImageAssetsAfterArchive(StringBuffer messageBuffer) async {
308+
final BuildableIOSApp app = await buildableIOSApp;
309+
310+
final Map<_ImageAssetFileKey, String> templateInfoMap = _parseImageAssetContentsJson(
311+
app.templateLaunchImageDirNameForContentsJson,
312+
requiresSize: false);
313+
final Map<_ImageAssetFileKey, String> projectInfoMap = _parseImageAssetContentsJson(
314+
app.projectLaunchImageDirName,
315+
requiresSize: false);
316+
317+
final bool usesTemplate = _isAssetStillUsingTemplateFiles(
318+
templateImageInfoMap: templateInfoMap,
319+
projectImageInfoMap: projectInfoMap,
320+
templateImageDirName: await app.templateLaunchImageDirNameForImages,
321+
projectImageDirName: app.projectLaunchImageDirName);
322+
323+
if (usesTemplate) {
324+
messageBuffer.writeln('\nWarning: Launch image is set to the default placeholder. Replace with unique launch images.');
325+
}
326+
}
327+
264328
Future<void> _validateXcodeBuildSettingsAfterArchive(StringBuffer messageBuffer) async {
265329
final BuildableIOSApp app = await buildableIOSApp;
266330

@@ -296,7 +360,9 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand {
296360

297361
final StringBuffer validationMessageBuffer = StringBuffer();
298362
await _validateXcodeBuildSettingsAfterArchive(validationMessageBuffer);
299-
await _validateIconsAfterArchive(validationMessageBuffer);
363+
await _validateIconAssetsAfterArchive(validationMessageBuffer);
364+
await _validateLaunchImageAssetsAfterArchive(validationMessageBuffer);
365+
300366
validationMessageBuffer.write('\nTo update the settings, please refer to https://docs.flutter.dev/deployment/ios');
301367
globals.printBox(validationMessageBuffer.toString(), title: 'App Settings');
302368

packages/flutter_tools/lib/src/ios/application_package.dart

+41-25
Original file line numberDiff line numberDiff line change
@@ -153,30 +153,21 @@ class BuildableIOSApp extends IOSApp {
153153
_hostAppBundleName == null ? 'Runner.app' : _hostAppBundleName!,
154154
'Info.plist');
155155

156-
// Both project icon's image assets and Contents.json are in the same directory.
157-
String get projectAppIconDirName => globals.fs.path.join('ios', _appIconDirNameSuffix);
158-
159-
// template icon's Contents.json is in flutter_tools.
160-
String get templateAppIconDirNameForContentsJson => globals.fs.path.join(
161-
Cache.flutterRoot!,
162-
'packages',
163-
'flutter_tools',
164-
'templates',
165-
'app_shared',
166-
'ios.tmpl',
167-
_appIconDirNameSuffix,
168-
);
156+
String get projectAppIconDirName => _projectImageAssetDirName(_appIconAsset);
169157

170-
// template icon's image assets are in flutter_template_images package.
171-
Future<String> get templateAppIconDirNameForImages async {
172-
final Directory imageTemplate = await templateImageDirectory(null, globals.fs, globals.logger);
173-
return globals.fs.path.join(
174-
imageTemplate.path,
175-
'app_shared',
176-
'ios.tmpl',
177-
_appIconDirNameSuffix,
178-
);
179-
}
158+
String get projectLaunchImageDirName => _projectImageAssetDirName(_launchImageAsset);
159+
160+
String get templateAppIconDirNameForContentsJson
161+
=> _templateImageAssetDirNameForContentsJson(_appIconAsset);
162+
163+
String get templateLaunchImageDirNameForContentsJson
164+
=> _templateImageAssetDirNameForContentsJson(_launchImageAsset);
165+
166+
Future<String> get templateAppIconDirNameForImages async
167+
=> _templateImageAssetDirNameForImages(_appIconAsset);
168+
169+
Future<String> get templateLaunchImageDirNameForImages async
170+
=> _templateImageAssetDirNameForImages(_launchImageAsset);
180171

181172
String get ipaOutputPath =>
182173
globals.fs.path.join(getIosBuildDirectory(), 'ipa');
@@ -185,10 +176,35 @@ class BuildableIOSApp extends IOSApp {
185176
return globals.fs.path.join(getIosBuildDirectory(), type, _hostAppBundleName);
186177
}
187178

188-
String get _appIconDirNameSuffix => globals.fs.path.join(
179+
String _projectImageAssetDirName(String asset)
180+
=> globals.fs.path.join('ios', 'Runner', 'Assets.xcassets', asset);
181+
182+
// Template asset's Contents.json file is in flutter_tools, but the actual
183+
String _templateImageAssetDirNameForContentsJson(String asset)
184+
=> globals.fs.path.join(
185+
Cache.flutterRoot!,
186+
'packages',
187+
'flutter_tools',
188+
'templates',
189+
_templateImageAssetDirNameSuffix(asset),
190+
);
191+
192+
// Template asset's images are in flutter_template_images package.
193+
Future<String> _templateImageAssetDirNameForImages(String asset) async {
194+
final Directory imageTemplate = await templateImageDirectory(null, globals.fs, globals.logger);
195+
return globals.fs.path.join(imageTemplate.path, _templateImageAssetDirNameSuffix(asset));
196+
}
197+
198+
String _templateImageAssetDirNameSuffix(String asset) => globals.fs.path.join(
199+
'app_shared',
200+
'ios.tmpl',
189201
'Runner',
190202
'Assets.xcassets',
191-
'AppIcon.appiconset');
203+
asset,
204+
);
205+
206+
String get _appIconAsset => 'AppIcon.appiconset';
207+
String get _launchImageAsset => 'LaunchImage.imageset';
192208
}
193209

194210
class PrebuiltIOSApp extends IOSApp implements PrebuiltApplicationPackage {

0 commit comments

Comments
 (0)