Unverified Commit 8186fb74 authored by Lau Ching Jun's avatar Lau Ching Jun Committed by GitHub

Use XML format in PlistParser (#94863)

* Switch to use xml in PlistParser

* Review feedbacks: Change dynamic to Object, remove getValueFromFile
parent 56916711
...@@ -500,7 +500,7 @@ class IntelliJValidatorOnMac extends IntelliJValidator { ...@@ -500,7 +500,7 @@ class IntelliJValidatorOnMac extends IntelliJValidator {
@override @override
String get version { String get version {
return _version ??= _plistParser.getValueFromFile( return _version ??= _plistParser.getStringValueFromFile(
plistFile, plistFile,
PlistParser.kCFBundleShortVersionStringKey, PlistParser.kCFBundleShortVersionStringKey,
) ?? 'unknown'; ) ?? 'unknown';
...@@ -514,7 +514,7 @@ class IntelliJValidatorOnMac extends IntelliJValidator { ...@@ -514,7 +514,7 @@ class IntelliJValidatorOnMac extends IntelliJValidator {
} }
final String? altLocation = _plistParser final String? altLocation = _plistParser
.getValueFromFile(plistFile, 'JetBrainsToolboxApp'); .getStringValueFromFile(plistFile, 'JetBrainsToolboxApp');
if (altLocation != null) { if (altLocation != null) {
_pluginsPath = '$altLocation.plugins'; _pluginsPath = '$altLocation.plugins';
......
...@@ -56,7 +56,7 @@ abstract class IOSApp extends ApplicationPackage { ...@@ -56,7 +56,7 @@ abstract class IOSApp extends ApplicationPackage {
globals.printError('Invalid prebuilt iOS app. Does not contain Info.plist.'); globals.printError('Invalid prebuilt iOS app. Does not contain Info.plist.');
return null; return null;
} }
final String? id = globals.plistParser.getValueFromFile( final String? id = globals.plistParser.getStringValueFromFile(
plistPath, plistPath,
PlistParser.kCFBundleIdentifierKey, PlistParser.kCFBundleIdentifierKey,
); );
......
...@@ -27,7 +27,7 @@ Future<void> validateBitcode(BuildMode buildMode, TargetPlatform targetPlatform, ...@@ -27,7 +27,7 @@ Future<void> validateBitcode(BuildMode buildMode, TargetPlatform targetPlatform,
final String? clangVersion = clangResult?.stdout.split('\n').first; final String? clangVersion = clangResult?.stdout.split('\n').first;
final String? engineClangVersion = flutterFrameworkPath == null final String? engineClangVersion = flutterFrameworkPath == null
? null ? null
: globals.plistParser.getValueFromFile( : globals.plistParser.getStringValueFromFile(
globals.fs.path.join(flutterFrameworkPath, 'Info.plist'), globals.fs.path.join(flutterFrameworkPath, 'Info.plist'),
'ClangVersion', 'ClangVersion',
); );
......
...@@ -3,12 +3,12 @@ ...@@ -3,12 +3,12 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:process/process.dart'; import 'package:process/process.dart';
import 'package:xml/xml.dart';
import '../base/file_system.dart'; import '../base/file_system.dart';
import '../base/io.dart'; import '../base/io.dart';
import '../base/logger.dart'; import '../base/logger.dart';
import '../base/process.dart'; import '../base/process.dart';
import '../base/utils.dart';
import '../convert.dart'; import '../convert.dart';
class PlistParser { class PlistParser {
...@@ -28,42 +28,109 @@ class PlistParser { ...@@ -28,42 +28,109 @@ class PlistParser {
static const String kCFBundleShortVersionStringKey = 'CFBundleShortVersionString'; static const String kCFBundleShortVersionStringKey = 'CFBundleShortVersionString';
static const String kCFBundleExecutable = 'CFBundleExecutable'; static const String kCFBundleExecutable = 'CFBundleExecutable';
/// Parses the plist file located at [plistFilePath] and returns the /// Returns the content, converted to XML, of the plist file located at
/// associated map of key/value property list pairs. /// [plistFilePath].
/// ///
/// If [plistFilePath] points to a non-existent file or a file that's not a /// If [plistFilePath] points to a non-existent file or a file that's not a
/// valid property list file, this will return an empty map. /// valid property list file, this will return null.
/// ///
/// The [plistFilePath] argument must not be null. /// The [plistFilePath] argument must not be null.
Map<String, dynamic> parseFile(String plistFilePath) { String? plistXmlContent(String plistFilePath) {
assert(plistFilePath != null);
const String executable = '/usr/bin/plutil'; const String executable = '/usr/bin/plutil';
if (!_fileSystem.isFileSync(executable)) { if (!_fileSystem.isFileSync(executable)) {
throw const FileNotFoundException(executable); throw const FileNotFoundException(executable);
} }
if (!_fileSystem.isFileSync(plistFilePath)) { final List<String> args = <String>[
return const <String, dynamic>{}; executable, '-convert', 'xml1', '-o', '-', plistFilePath,
} ];
final String normalizedPlistPath = _fileSystem.path.absolute(plistFilePath);
try { try {
final List<String> args = <String>[ final String xmlContent = _processUtils.runSync(
executable, '-convert', 'json', '-o', '-', normalizedPlistPath,
];
final String jsonContent = _processUtils.runSync(
args, args,
throwOnError: true, throwOnError: true,
).stdout.trim(); ).stdout.trim();
return castStringKeyedMap(json.decode(jsonContent)) ?? const <String, dynamic>{}; return xmlContent;
} on ProcessException catch (error) { } on ProcessException catch (error) {
_logger.printTrace('$error'); _logger.printTrace('$error');
return const <String, dynamic>{}; return null;
}
}
/// Parses the plist file located at [plistFilePath] and returns the
/// associated map of key/value property list pairs.
///
/// If [plistFilePath] points to a non-existent file or a file that's not a
/// valid property list file, this will return an empty map.
///
/// The [plistFilePath] argument must not be null.
Map<String, Object> parseFile(String plistFilePath) {
assert(plistFilePath != null);
if (!_fileSystem.isFileSync(plistFilePath)) {
return const <String, Object>{};
}
final String normalizedPlistPath = _fileSystem.path.absolute(plistFilePath);
final String? xmlContent = plistXmlContent(normalizedPlistPath);
if (xmlContent == null) {
return const <String, Object>{};
}
return _parseXml(xmlContent);
}
Map<String, Object> _parseXml(String xmlContent) {
final XmlDocument document = XmlDocument.parse(xmlContent);
// First element child is <plist>. The first element child of plist is <dict>.
final XmlElement dictObject = document.firstElementChild!.firstElementChild!;
return _parseXmlDict(dictObject);
}
Map<String, Object> _parseXmlDict(XmlElement node) {
String? lastKey;
final Map<String, Object> result = <String, Object>{};
for (final XmlNode child in node.children) {
if (child is XmlElement) {
if (child.name.local == 'key') {
lastKey = child.text;
} else {
assert(lastKey != null);
result[lastKey!] = _parseXmlNode(child)!;
lastKey = null;
}
}
}
return result;
}
static final RegExp _nonBase64Pattern = RegExp('[^a-zA-Z0-9+/=]+');
Object? _parseXmlNode(XmlElement node) {
switch (node.name.local){
case 'string':
return node.text;
case 'real':
return double.parse(node.text);
case 'integer':
return int.parse(node.text);
case 'true':
return true;
case 'false':
return false;
case 'date':
return DateTime.parse(node.text);
case 'data':
return base64.decode(node.text.replaceAll(_nonBase64Pattern, ''));
case 'array':
return node.children.whereType<XmlElement>().map<Object?>(_parseXmlNode).whereType<Object>().toList();
case 'dict':
return _parseXmlDict(node);
} }
return null;
} }
/// Parses the Plist file located at [plistFilePath] and returns the value /// Parses the Plist file located at [plistFilePath] and returns the string
/// that's associated with the specified [key] within the property list. /// value that's associated with the specified [key] within the property list.
/// ///
/// If [plistFilePath] points to a non-existent file or a file that's not a /// If [plistFilePath] points to a non-existent file or a file that's not a
/// valid property list file, this will return null. /// valid property list file, this will return null.
...@@ -71,9 +138,8 @@ class PlistParser { ...@@ -71,9 +138,8 @@ class PlistParser {
/// If [key] is not found in the property list, this will return null. /// If [key] is not found in the property list, this will return null.
/// ///
/// The [plistFilePath] and [key] arguments must not be null. /// The [plistFilePath] and [key] arguments must not be null.
String? getValueFromFile(String plistFilePath, String key) { String? getStringValueFromFile(String plistFilePath, String key) {
assert(key != null);
final Map<String, dynamic> parsed = parseFile(plistFilePath); final Map<String, dynamic> parsed = parseFile(plistFilePath);
return parsed[key] as String; return parsed[key] as String?;
} }
} }
...@@ -495,7 +495,7 @@ class IOSSimulator extends Device { ...@@ -495,7 +495,7 @@ class IOSSimulator extends Device {
// parsing the xcodeproj or configuration files. // parsing the xcodeproj or configuration files.
// See https://github.com/flutter/flutter/issues/31037 for more information. // See https://github.com/flutter/flutter/issues/31037 for more information.
final String plistPath = globals.fs.path.join(package.simulatorBundlePath, 'Info.plist'); final String plistPath = globals.fs.path.join(package.simulatorBundlePath, 'Info.plist');
final String? bundleIdentifier = globals.plistParser.getValueFromFile(plistPath, PlistParser.kCFBundleIdentifierKey); final String? bundleIdentifier = globals.plistParser.getStringValueFromFile(plistPath, PlistParser.kCFBundleIdentifierKey);
if (bundleIdentifier == null) { if (bundleIdentifier == null) {
globals.printError('Invalid prebuilt iOS app. Info.plist does not contain bundle identifier'); globals.printError('Invalid prebuilt iOS app. Info.plist does not contain bundle identifier');
return LaunchResult.failed(); return LaunchResult.failed();
......
...@@ -180,7 +180,7 @@ class IosProject extends XcodeBasedProject { ...@@ -180,7 +180,7 @@ class IosProject extends XcodeBasedProject {
// Try parsing the default, first. // Try parsing the default, first.
if (defaultInfoPlist.existsSync()) { if (defaultInfoPlist.existsSync()) {
try { try {
fromPlist = globals.plistParser.getValueFromFile( fromPlist = globals.plistParser.getStringValueFromFile(
defaultHostInfoPlist.path, defaultHostInfoPlist.path,
PlistParser.kCFBundleIdentifierKey, PlistParser.kCFBundleIdentifierKey,
); );
...@@ -332,7 +332,7 @@ class IosProject extends XcodeBasedProject { ...@@ -332,7 +332,7 @@ class IosProject extends XcodeBasedProject {
// The Info.plist file of a target contains the key WKCompanionAppBundleIdentifier, // The Info.plist file of a target contains the key WKCompanionAppBundleIdentifier,
// if it is a watchOS companion app. // if it is a watchOS companion app.
if (infoFile.existsSync()) { if (infoFile.existsSync()) {
final String? fromPlist = globals.plistParser.getValueFromFile(infoFile.path, 'WKCompanionAppBundleIdentifier'); final String? fromPlist = globals.plistParser.getStringValueFromFile(infoFile.path, 'WKCompanionAppBundleIdentifier');
if (bundleIdentifier == fromPlist) { if (bundleIdentifier == fromPlist) {
return true; return true;
} }
......
...@@ -11,10 +11,10 @@ import 'package:flutter_tools/src/convert.dart'; ...@@ -11,10 +11,10 @@ import 'package:flutter_tools/src/convert.dart';
import 'package:flutter_tools/src/doctor_validator.dart'; import 'package:flutter_tools/src/doctor_validator.dart';
import 'package:flutter_tools/src/intellij/intellij_validator.dart'; import 'package:flutter_tools/src/intellij/intellij_validator.dart';
import 'package:flutter_tools/src/ios/plist_parser.dart'; import 'package:flutter_tools/src/ios/plist_parser.dart';
import 'package:test/fake.dart';
import '../../src/common.dart'; import '../../src/common.dart';
import '../../src/fake_process_manager.dart'; import '../../src/fake_process_manager.dart';
import '../../src/fakes.dart';
final Platform macPlatform = FakePlatform( final Platform macPlatform = FakePlatform(
operatingSystem: 'macos', operatingSystem: 'macos',
...@@ -373,17 +373,6 @@ void main() { ...@@ -373,17 +373,6 @@ void main() {
}); });
} }
class FakePlistParser extends Fake implements PlistParser {
FakePlistParser(this.values);
final Map<String, String> values;
@override
String? getValueFromFile(String plistFilePath, String key) {
return values[key];
}
}
class IntelliJValidatorTestTarget extends IntelliJValidator { class IntelliJValidatorTestTarget extends IntelliJValidator {
IntelliJValidatorTestTarget(String title, String installPath, FileSystem fileSystem) IntelliJValidatorTestTarget(String title, String installPath, FileSystem fileSystem)
: super(title, installPath, fileSystem: fileSystem, userMessages: UserMessages()); : super(title, installPath, fileSystem: fileSystem, userMessages: UserMessages());
......
...@@ -33,6 +33,20 @@ const String base64PlistJson = ...@@ -33,6 +33,20 @@ const String base64PlistJson =
'eyJDRkJ1bmRsZUV4ZWN1dGFibGUiOiJBcHAiLCJDRkJ1bmRsZUlkZW50aWZpZXIiOiJpby5mb' 'eyJDRkJ1bmRsZUV4ZWN1dGFibGUiOiJBcHAiLCJDRkJ1bmRsZUlkZW50aWZpZXIiOiJpby5mb'
'HV0dGVyLmZsdXR0ZXIuYXBwIn0='; 'HV0dGVyLmZsdXR0ZXIuYXBwIn0=';
const String base64PlistXmlWithComplexDatatypes =
'PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHBsaXN0I'
'FBVQkxJQyAiLS8vQXBwbGUvL0RURCBQTElTVCAxLjAvL0VOIiAiaHR0cDovL3d3dy5hcHBsZS'
'5jb20vRFREcy9Qcm9wZXJ0eUxpc3QtMS4wLmR0ZCI+CjxwbGlzdCB2ZXJzaW9uPSIxLjAiPgo'
'8ZGljdD4KICA8a2V5PkNGQnVuZGxlRXhlY3V0YWJsZTwva2V5PgogIDxzdHJpbmc+QXBwPC9z'
'dHJpbmc+CiAgPGtleT5DRkJ1bmRsZUlkZW50aWZpZXI8L2tleT4KICA8c3RyaW5nPmlvLmZsd'
'XR0ZXIuZmx1dHRlci5hcHA8L3N0cmluZz4KICA8a2V5PmludFZhbHVlPC9rZXk+CiAgPGludG'
'VnZXI+MjwvaW50ZWdlcj4KICA8a2V5PmRvdWJsZVZhbHVlPC9rZXk+CiAgPHJlYWw+MS41PC9'
'yZWFsPgogIDxrZXk+YmluYXJ5VmFsdWU8L2tleT4KICA8ZGF0YT5ZV0pqWkE9PTwvZGF0YT4K'
'ICA8a2V5PmFycmF5VmFsdWU8L2tleT4KICA8YXJyYXk+CiAgICA8dHJ1ZSAvPgogICAgPGZhb'
'HNlIC8+CiAgICA8aW50ZWdlcj4zPC9pbnRlZ2VyPgogIDwvYXJyYXk+CiAgPGtleT5kYXRlVm'
'FsdWU8L2tleT4KICA8ZGF0ZT4yMDIxLTEyLTAxVDEyOjM0OjU2WjwvZGF0ZT4KPC9kaWN0Pgo'
'8L3BsaXN0Pg==';
void main() { void main() {
// The tests herein explicitly don't use `MemoryFileSystem` or a mocked // The tests herein explicitly don't use `MemoryFileSystem` or a mocked
// `ProcessManager` because doing so wouldn't actually test what we want to // `ProcessManager` because doing so wouldn't actually test what we want to
...@@ -62,62 +76,79 @@ void main() { ...@@ -62,62 +76,79 @@ void main() {
file.deleteSync(); file.deleteSync();
}); });
testWithoutContext('PlistParser.getValueFromFile works with xml file', () { testWithoutContext('PlistParser.getStringValueFromFile works with xml file', () {
file.writeAsBytesSync(base64.decode(base64PlistXml)); file.writeAsBytesSync(base64.decode(base64PlistXml));
expect(parser.getValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app'); expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
expect(parser.getValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app'); expect(parser.getStringValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
expect(logger.statusText, isEmpty); expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty); expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain. }, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getValueFromFile works with binary file', () { testWithoutContext('PlistParser.getStringValueFromFile works with binary file', () {
file.writeAsBytesSync(base64.decode(base64PlistBinary)); file.writeAsBytesSync(base64.decode(base64PlistBinary));
expect(parser.getValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app'); expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
expect(parser.getValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app'); expect(parser.getStringValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
expect(logger.statusText, isEmpty); expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty); expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain. }, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getValueFromFile works with json file', () { testWithoutContext('PlistParser.getStringValueFromFile works with json file', () {
file.writeAsBytesSync(base64.decode(base64PlistJson)); file.writeAsBytesSync(base64.decode(base64PlistJson));
expect(parser.getValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app'); expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
expect(parser.getValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app'); expect(parser.getStringValueFromFile(file.absolute.path, 'CFBundleIdentifier'), 'io.flutter.flutter.app');
expect(logger.statusText, isEmpty); expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty); expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain. }, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getValueFromFile returns null for non-existent plist file', () { testWithoutContext('PlistParser.getStringValueFromFile returns null for non-existent plist file', () {
expect(parser.getValueFromFile('missing.plist', 'CFBundleIdentifier'), null); expect(parser.getStringValueFromFile('missing.plist', 'CFBundleIdentifier'), null);
expect(logger.statusText, isEmpty); expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty); expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain. }, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getValueFromFile returns null for non-existent key within plist', () { testWithoutContext('PlistParser.getStringValueFromFile returns null for non-existent key within plist', () {
file.writeAsBytesSync(base64.decode(base64PlistXml)); file.writeAsBytesSync(base64.decode(base64PlistXml));
expect(parser.getValueFromFile(file.path, 'BadKey'), null); expect(parser.getStringValueFromFile(file.path, 'BadKey'), null);
expect(parser.getValueFromFile(file.absolute.path, 'BadKey'), null); expect(parser.getStringValueFromFile(file.absolute.path, 'BadKey'), null);
expect(logger.statusText, isEmpty); expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty); expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain. }, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getValueFromFile returns null for malformed plist file', () { testWithoutContext('PlistParser.getStringValueFromFile returns null for malformed plist file', () {
file.writeAsBytesSync(const <int>[1, 2, 3, 4, 5, 6]); file.writeAsBytesSync(const <int>[1, 2, 3, 4, 5, 6]);
expect(parser.getValueFromFile(file.path, 'CFBundleIdentifier'), null); expect(parser.getStringValueFromFile(file.path, 'CFBundleIdentifier'), null);
expect(logger.statusText, isNotEmpty); expect(logger.statusText, isNotEmpty);
expect(logger.errorText, isEmpty); expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain. }, skip: !platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.getValueFromFile throws when /usr/bin/plutil is not found', () async { testWithoutContext('PlistParser.getStringValueFromFile throws when /usr/bin/plutil is not found', () async {
file.writeAsBytesSync(base64.decode(base64PlistXml));
expect( expect(
() => parser.getValueFromFile('irrelevant.plist', 'ununsed'), () => parser.getStringValueFromFile(file.path, 'unused'),
throwsA(isA<FileNotFoundException>()), throwsA(isA<FileNotFoundException>()),
); );
expect(logger.statusText, isEmpty); expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty); expect(logger.errorText, isEmpty);
}, skip: platform.isMacOS); // [intended] requires macos tool chain. }, skip: platform.isMacOS); // [intended] requires macos tool chain.
testWithoutContext('PlistParser.parseFile can handle different datatypes', () async {
file.writeAsBytesSync(base64.decode(base64PlistXmlWithComplexDatatypes));
final Map<String, Object> values = parser.parseFile(file.path);
expect(values['CFBundleIdentifier'], 'io.flutter.flutter.app');
expect(values['CFBundleIdentifier'], 'io.flutter.flutter.app');
expect(values['intValue'], 2);
expect(values['doubleValue'], 1.5);
expect(values['binaryValue'], base64.decode('YWJjZA=='));
expect(values['arrayValue'], <dynamic>[true, false, 3]);
expect(values['dateValue'], DateTime.utc(2021, 12, 1, 12, 34, 56));
expect(logger.statusText, isEmpty);
expect(logger.errorText, isEmpty);
}, skip: !platform.isMacOS); // [intended] requires macos tool chain.
} }
...@@ -289,20 +289,26 @@ class FakeStdio extends Stdio { ...@@ -289,20 +289,26 @@ class FakeStdio extends Stdio {
} }
class FakePlistParser implements PlistParser { class FakePlistParser implements PlistParser {
final Map<String, dynamic> _underlyingValues = <String, String>{}; FakePlistParser([Map<String, Object>? underlyingValues]):
_underlyingValues = underlyingValues ?? <String, Object>{};
void setProperty(String key, dynamic value) { final Map<String, Object> _underlyingValues;
void setProperty(String key, Object value) {
_underlyingValues[key] = value; _underlyingValues[key] = value;
} }
@override @override
Map<String, dynamic> parseFile(String plistFilePath) { String? plistXmlContent(String plistFilePath) => throw UnimplementedError();
@override
Map<String, Object> parseFile(String plistFilePath) {
return _underlyingValues; return _underlyingValues;
} }
@override @override
String getValueFromFile(String plistFilePath, String key) { String? getStringValueFromFile(String plistFilePath, String key) {
return _underlyingValues[key] as String; return _underlyingValues[key] as String?;
} }
} }
......
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