feat: Rework getting plugin implementation candidates and plugin resolution (#145258)

Part of #137040 and #80374

- Extracted getting plugin implementation candidates and the default implementation to its own method
- Extracted resolving the plugin implementation to its own method
- Simplify candidate selection algorithm
- Support overriding inline dart implementation for an app-facing plugin
- Throw error, if a federated plugin implements an app-facing plugin, but also references a default implementation
- Throw error, if a plugin provides an inline implementation, but also references a default implementation
This commit is contained in:
August 2024-05-07 22:03:27 +02:00 committed by GitHub
parent b8dc9da9b6
commit be3e916443
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 409 additions and 105 deletions

View File

@ -97,7 +97,7 @@ Future<List<Plugin>> findPlugins(FlutterProject project, { bool throwOnError = t
package.name,
packageRoot,
project.manifest.dependencies,
fileSystem: fs
fileSystem: fs,
);
if (plugin != null) {
plugins.add(plugin);
@ -186,9 +186,9 @@ bool _writeFlutterPluginsList(
pluginsMap[platformKey] = _createPluginMapOfPlatform(plugins, platformKey);
}
final Map<String, Object> result = <String, Object> {};
final Map<String, Object> result = <String, Object>{};
result['info'] = 'This is a generated file; do not edit or check into version control.';
result['info'] = 'This is a generated file; do not edit or check into version control.';
result[_kFlutterPluginsPluginListKey] = pluginsMap;
/// The dependencyGraph object is kept for backwards compatibility, but
/// should be removed once migration is complete.
@ -1145,7 +1145,7 @@ bool hasPlugins(FlutterProject project) {
// TODO(stuartmorgan): Expand implementation to apply to all implementations,
// not just Dart-only, per the federated plugin spec.
List<PluginInterfaceResolution> resolvePlatformImplementation(
List<Plugin> plugins
List<Plugin> plugins,
) {
const Iterable<String> platformKeys = <String>[
AndroidPlugin.kConfigKey,
@ -1154,127 +1154,247 @@ List<PluginInterfaceResolution> resolvePlatformImplementation(
MacOSPlugin.kConfigKey,
WindowsPlugin.kConfigKey,
];
final List<PluginInterfaceResolution> finalResolution = <PluginInterfaceResolution>[];
final List<PluginInterfaceResolution> pluginResolutions = <PluginInterfaceResolution>[];
bool hasResolutionError = false;
bool hasPluginPubspecError = false;
for (final String platformKey in platformKeys) {
// Key: the plugin name
final Map<String, List<Plugin>> possibleResolutions = <String, List<Plugin>>{};
// Key: the plugin name, value: the list of plugin candidates for the implementation of [platformKey].
final Map<String, List<Plugin>> pluginImplCandidates = <String, List<Plugin>>{};
// Key: the plugin name, value: the plugin name of the default implementation of [platformKey].
final Map<String, String> defaultImplementations = <String, String>{};
for (final Plugin plugin in plugins) {
final String? defaultImplementation = plugin.defaultPackagePlatforms[platformKey];
if (plugin.platforms[platformKey] == null && defaultImplementation == null) {
// The plugin doesn't implement this platform.
continue;
}
String? implementsPackage = plugin.implementsPackage;
if (implementsPackage == null || implementsPackage.isEmpty) {
final bool hasInlineDartImplementation =
plugin.pluginDartClassPlatforms[platformKey] != null;
if (defaultImplementation == null && !hasInlineDartImplementation) {
// Skip native inline PluginPlatform implementation
continue;
}
if (defaultImplementation != null) {
defaultImplementations[plugin.name] = defaultImplementation;
continue;
} else {
// An app-facing package (i.e., one with no 'implements') with an
// inline implementation should be its own default implementation.
// Desktop platforms originally did not work that way, and enabling
// it unconditionally would break existing published plugins, so
// only treat it as such if either:
// - the platform is not desktop, or
// - the plugin requires at least Flutter 2.11 (when this opt-in logic
// was added), so that existing plugins continue to work.
// See https://github.com/flutter/flutter/issues/87862 for details.
final bool isDesktop = platformKey == 'linux' || platformKey == 'macos' || platformKey == 'windows';
final semver.VersionConstraint? flutterConstraint = plugin.flutterConstraint;
final semver.Version? minFlutterVersion = flutterConstraint != null &&
flutterConstraint is semver.VersionRange ? flutterConstraint.min : null;
final bool hasMinVersionForImplementsRequirement = minFlutterVersion != null &&
minFlutterVersion.compareTo(semver.Version(2, 11, 0)) >= 0;
if (!isDesktop || hasMinVersionForImplementsRequirement) {
implementsPackage = plugin.name;
defaultImplementations[plugin.name] = plugin.name;
} else {
// If it doesn't meet any of the conditions, it isn't eligible for
// auto-registration.
continue;
}
}
}
// If there's no Dart implementation, there's nothing to register.
if (plugin.pluginDartClassPlatforms[platformKey] == null ||
plugin.pluginDartClassPlatforms[platformKey] == 'none') {
final String? error = _validatePlugin(plugin, platformKey);
if (error != null) {
globals.printError(error);
hasPluginPubspecError = true;
continue;
}
final String? implementsPluginName = _getImplementedPlugin(plugin, platformKey);
final String? defaultImplPluginName = _getDefaultImplPlugin(plugin, platformKey);
// If it hasn't been skipped, it's a candidate for auto-registration, so
// add it as a possible resolution.
possibleResolutions.putIfAbsent(implementsPackage, () => <Plugin>[]);
possibleResolutions[implementsPackage]!.add(plugin);
if (defaultImplPluginName != null) {
// Each plugin can only have one default implementation for this [platformKey].
defaultImplementations[plugin.name] = defaultImplPluginName;
}
if (implementsPluginName != null) {
pluginImplCandidates.putIfAbsent(implementsPluginName, () => <Plugin>[]);
pluginImplCandidates[implementsPluginName]!.add(plugin);
}
}
final List<Plugin> pluginResolution = <Plugin>[];
final Map<String, Plugin> pluginResolution = <String, Plugin>{};
// Resolve all the possible resolutions to a single option for each plugin, or throw if that's not possible.
for (final MapEntry<String, List<Plugin>> entry in possibleResolutions.entries) {
final List<Plugin> candidates = entry.value;
// If there's only one candidate, use it.
if (candidates.length == 1) {
pluginResolution.add(candidates.first);
continue;
}
// Next, try direct dependencies of the resolving application.
final Iterable<Plugin> directDependencies = candidates.where((Plugin plugin) {
return plugin.isDirectDependency;
});
if (directDependencies.isNotEmpty) {
if (directDependencies.length > 1) {
globals.printError(
'Plugin ${entry.key}:$platformKey has conflicting direct dependency implementations:\n'
'${directDependencies.map((Plugin plugin) => ' ${plugin.name}\n').join()}'
'To fix this issue, remove all but one of these dependencies from pubspec.yaml.\n'
);
hasResolutionError = true;
} else {
pluginResolution.add(directDependencies.first);
}
continue;
}
// Next, defer to the default implementation if there is one.
final String? defaultPackageName = defaultImplementations[entry.key];
if (defaultPackageName != null) {
final int defaultIndex = candidates
.indexWhere((Plugin plugin) => plugin.name == defaultPackageName);
if (defaultIndex != -1) {
pluginResolution.add(candidates[defaultIndex]);
continue;
}
}
// Otherwise, require an explicit choice.
if (candidates.length > 1) {
globals.printError(
'Plugin ${entry.key}:$platformKey has multiple possible implementations:\n'
'${candidates.map((Plugin plugin) => ' ${plugin.name}\n').join()}'
'To fix this issue, add one of these dependencies to pubspec.yaml.\n'
);
// Now resolve all the possible resolutions to a single option for each
// plugin, or throw if that's not possible.
for (final MapEntry<String, List<Plugin>> implCandidatesEntry in pluginImplCandidates.entries) {
final (Plugin? resolution, String? error) = _resolveImplementationOfPlugin(
platformKey: platformKey,
pluginName: implCandidatesEntry.key,
candidates: implCandidatesEntry.value,
defaultPackageName: defaultImplementations[implCandidatesEntry.key],
);
if (error != null) {
globals.printError(error);
hasResolutionError = true;
continue;
} else if (resolution != null) {
pluginResolution[implCandidatesEntry.key] = resolution;
}
}
finalResolution.addAll(
pluginResolution.map((Plugin plugin) =>
PluginInterfaceResolution(plugin: plugin, platform: platformKey)),
pluginResolutions.addAll(
pluginResolution.values.map((Plugin plugin) {
return PluginInterfaceResolution(plugin: plugin, platform: platformKey);
}),
);
}
if (hasPluginPubspecError) {
throwToolExit('Please resolve the plugin pubspec errors');
}
if (hasResolutionError) {
throwToolExit('Please resolve the plugin implementation selection errors');
}
return finalResolution;
return pluginResolutions;
}
/// Validates conflicting plugin parameters in pubspec, such as
/// `dartPluginClass`, `default_package` and `implements`.
///
/// Returns an error, if failing.
String? _validatePlugin(Plugin plugin, String platformKey) {
final String? implementsPackage = plugin.implementsPackage;
final String? defaultImplPluginName = plugin.defaultPackagePlatforms[platformKey];
if (plugin.name == implementsPackage &&
plugin.name == defaultImplPluginName) {
// Allow self implementing and self as platform default.
return null;
}
if (defaultImplPluginName != null) {
if (implementsPackage != null && implementsPackage.isNotEmpty) {
return 'Plugin ${plugin.name}:$platformKey provides an implementation for $implementsPackage '
'and also references a default implementation for $defaultImplPluginName, which is currently not supported. '
'Ask the maintainers of ${plugin.name} to either remove the implementation via `implements: $implementsPackage` '
'or avoid referencing a default implementation via `platforms: $platformKey: default_package: $defaultImplPluginName`.\n';
}
if (_hasPluginInlineDartImpl(plugin, platformKey)) {
return 'Plugin ${plugin.name}:$platformKey which provides an inline implementation '
'cannot also reference a default implementation for $defaultImplPluginName. '
'Ask the maintainers of ${plugin.name} to either remove the implementation via `platforms: $platformKey: dartPluginClass` '
'or avoid referencing a default implementation via `platforms: $platformKey: default_package: $defaultImplPluginName`.\n';
}
}
return null;
}
/// Determine if this [plugin] serves as implementation for an app-facing
/// package for the given platform [platformKey].
///
/// If so, return the package name, which the [plugin] implements.
///
/// Options:
/// * The [plugin] (e.g. 'url_launcher_linux') serves as implementation for
/// an app-facing package (e.g. 'url_launcher').
/// * The [plugin] (e.g. 'url_launcher') implements itself and then also
/// serves as its own default implementation.
/// * The [plugin] does not provide an implementation.
String? _getImplementedPlugin(Plugin plugin, String platformKey) {
final bool hasInlineDartImpl = _hasPluginInlineDartImpl(plugin, platformKey);
if (hasInlineDartImpl) {
final String? implementsPackage = plugin.implementsPackage;
// Only can serve, if the plugin has a dart inline implementation.
if (implementsPackage != null && implementsPackage.isNotEmpty) {
return implementsPackage;
}
if (_isEligibleDartSelfImpl(plugin, platformKey)) {
// The inline Dart plugin implements itself.
return plugin.name;
}
}
return null;
}
/// Determine if this [plugin] (or package) references a default plugin with an
/// implementation for the given platform [platformKey].
///
/// If so, return the plugin name, which provides the default implementation.
///
/// Options:
/// * The [plugin] (e.g. 'url_launcher') references a default implementation
/// (e.g. 'url_launcher_linux').
/// * The [plugin] (e.g. 'url_launcher') implements itself and then also
/// serves as its own default implementation.
/// * The [plugin] does not reference a default implementation.
String? _getDefaultImplPlugin(Plugin plugin, String platformKey) {
final String? defaultImplPluginName =
plugin.defaultPackagePlatforms[platformKey];
if (defaultImplPluginName != null) {
return defaultImplPluginName;
}
if (_hasPluginInlineDartImpl(plugin, platformKey) &&
_isEligibleDartSelfImpl(plugin, platformKey)) {
// The inline Dart plugin serves as its own default implementation.
return plugin.name;
}
return null;
}
/// Determine if the [plugin]'s inline dart implementation for the
/// [platformKey] is eligible to serve as its own default.
///
/// An app-facing package (i.e., one with no 'implements') with an
/// inline implementation should be its own default implementation.
/// Desktop platforms originally did not work that way, and enabling
/// it unconditionally would break existing published plugins, so
/// only treat it as such if either:
/// - the platform is not desktop, or
/// - the plugin requires at least Flutter 2.11 (when this opt-in logic
/// was added), so that existing plugins continue to work.
/// See https://github.com/flutter/flutter/issues/87862 for details.
bool _isEligibleDartSelfImpl(Plugin plugin, String platformKey) {
final bool isDesktop = platformKey == 'linux' || platformKey == 'macos' || platformKey == 'windows';
final semver.VersionConstraint? flutterConstraint = plugin.flutterConstraint;
final semver.Version? minFlutterVersion = flutterConstraint != null &&
flutterConstraint is semver.VersionRange ? flutterConstraint.min : null;
final bool hasMinVersionForImplementsRequirement = minFlutterVersion != null &&
minFlutterVersion.compareTo(semver.Version(2, 11, 0)) >= 0;
return !isDesktop || hasMinVersionForImplementsRequirement;
}
/// Determine if the plugin provides an inline dart implementation.
bool _hasPluginInlineDartImpl(Plugin plugin, String platformKey) {
return plugin.pluginDartClassPlatforms[platformKey] != null &&
plugin.pluginDartClassPlatforms[platformKey] != 'none';
}
/// Get the resolved plugin [resolution] from the [candidates] serving as implementation for
/// [pluginName].
///
/// Returns an [error] string, if failing.
(Plugin? resolution, String? error) _resolveImplementationOfPlugin({
required String platformKey,
required String pluginName,
required List<Plugin> candidates,
String? defaultPackageName,
}) {
// If there's only one candidate, use it.
if (candidates.length == 1) {
return (candidates.first, null);
}
// Next, try direct dependencies of the resolving application.
final Iterable<Plugin> directDependencies = candidates.where((Plugin plugin) {
return plugin.isDirectDependency;
});
if (directDependencies.isNotEmpty) {
if (directDependencies.length > 1) {
// Allow overriding an app-facing package with an inline implementation (which is a direct dependency)
// with another direct dependency which implements the app-facing package.
final Iterable<Plugin> implementingPackage = directDependencies.where((Plugin plugin) => plugin.implementsPackage != null && plugin.implementsPackage!.isNotEmpty);
final Set<Plugin> appFacingPackage = directDependencies.toSet()..removeAll(implementingPackage);
if (implementingPackage.length == 1 && appFacingPackage.length == 1) {
return (implementingPackage.first, null);
}
return (
null,
'Plugin $pluginName:$platformKey has conflicting direct dependency implementations:\n'
'${directDependencies.map((Plugin plugin) => ' ${plugin.name}\n').join()}'
'To fix this issue, remove all but one of these dependencies from pubspec.yaml.\n',
);
} else {
return (directDependencies.first, null);
}
}
// Next, defer to the default implementation if there is one.
if (defaultPackageName != null) {
final int defaultIndex = candidates
.indexWhere((Plugin plugin) => plugin.name == defaultPackageName);
if (defaultIndex != -1) {
return (candidates[defaultIndex], null);
}
}
// Otherwise, require an explicit choice.
if (candidates.length > 1) {
return (
null,
'Plugin $pluginName:$platformKey has multiple possible implementations:\n'
'${candidates.map((Plugin plugin) => ' ${plugin.name}\n').join()}'
'To fix this issue, add one of these dependencies to pubspec.yaml.\n',
);
}
// No implementation provided
return (null, null);
}
/// Generates the Dart plugin registrant, which allows to bind a platform
@ -1321,7 +1441,7 @@ Future<void> generateMainDartWithPluginRegistrant(
} on FileSystemException catch (error) {
globals.printWarning(
'Unable to remove ${newMainDart.path}, received error: $error.\n'
'You might need to run flutter clean.'
'You might need to run flutter clean.',
);
rethrow;
}

View File

@ -551,6 +551,190 @@ void main() {
);
});
testWithoutContext('selects user selected implementation despite inline implementation', () async {
final Set<String> directDependencies = <String>{
'user_selected_url_launcher_implementation',
'url_launcher',
};
final List<PluginInterfaceResolution> resolutions = resolvePlatformImplementation(<Plugin>[
Plugin.fromYaml(
'url_launcher',
'',
YamlMap.wrap(<String, dynamic>{
'platforms': <String, dynamic>{
'android': <String, dynamic>{
'dartPluginClass': 'UrlLauncherAndroid',
},
'ios': <String, dynamic>{
'dartPluginClass': 'UrlLauncherIos',
},
},
}),
null,
<String>[],
fileSystem: fs,
appDependencies: directDependencies,
),
Plugin.fromYaml(
'user_selected_url_launcher_implementation',
'',
YamlMap.wrap(<String, dynamic>{
'implements': 'url_launcher',
'platforms': <String, dynamic>{
'android': <String, dynamic>{
'dartPluginClass': 'UrlLauncherAndroid',
},
},
}),
null,
<String>[],
fileSystem: fs,
appDependencies: directDependencies,
),
]);
expect(resolutions.length, equals(2));
expect(resolutions[0].toMap(), equals(
<String, String>{
'pluginName': 'user_selected_url_launcher_implementation',
'dartClass': 'UrlLauncherAndroid',
'platform': 'android',
})
);
expect(resolutions[1].toMap(), equals(
<String, String>{
'pluginName': 'url_launcher',
'dartClass': 'UrlLauncherIos',
'platform': 'ios',
})
);
});
testUsingContext(
'provides error when a plugin has a default implementation and implements another plugin',
() async {
final Set<String> directDependencies = <String>{
'url_launcher',
};
expect(() {
resolvePlatformImplementation(<Plugin>[
Plugin.fromYaml(
'url_launcher',
'',
YamlMap.wrap(<String, dynamic>{
'platforms': <String, dynamic>{
'linux': <String, dynamic>{
'default_package': 'url_launcher_linux_1',
},
},
}),
null,
<String>[],
fileSystem: fs,
appDependencies: directDependencies,
),
Plugin.fromYaml(
'url_launcher_linux_1',
'',
YamlMap.wrap(<String, dynamic>{
'implements': 'url_launcher',
'platforms': <String, dynamic>{
'linux': <String, dynamic>{
'default_package': 'url_launcher_linux_2',
},
},
}),
null,
<String>[],
fileSystem: fs,
appDependencies: directDependencies,
),
Plugin.fromYaml(
'url_launcher_linux_2',
'',
YamlMap.wrap(<String, dynamic>{
'implements': 'url_launcher',
'platforms': <String, dynamic>{
'linux': <String, dynamic>{
'dartPluginClass': 'UrlLauncherPluginLinux',
},
},
}),
null,
<String>[],
fileSystem: fs,
appDependencies: directDependencies,
),
]);
},
throwsToolExit(
message: 'Please resolve the plugin pubspec errors',
));
expect(
testLogger.errorText,
'Plugin url_launcher_linux_1:linux provides an implementation for url_launcher '
'and also references a default implementation for url_launcher_linux_2, which is currently not supported. '
'Ask the maintainers of url_launcher_linux_1 to either remove the implementation via `implements: url_launcher` '
'or avoid referencing a default implementation via `platforms: linux: default_package: url_launcher_linux_2`.'
'\n\n');
});
testUsingContext(
'provides error when a plugin has a default implementation and an inline implementation',
() async {
final Set<String> directDependencies = <String>{
'url_launcher',
};
expect(() {
resolvePlatformImplementation(<Plugin>[
Plugin.fromYaml(
'url_launcher',
'',
YamlMap.wrap(<String, dynamic>{
'platforms': <String, dynamic>{
'linux': <String, dynamic>{
'default_package': 'url_launcher_linux',
'dartPluginClass': 'UrlLauncherPluginLinux',
},
},
}),
null,
<String>[],
fileSystem: fs,
appDependencies: directDependencies,
),
Plugin.fromYaml(
'url_launcher_linux',
'',
YamlMap.wrap(<String, dynamic>{
'implements': 'url_launcher',
'platforms': <String, dynamic>{
'linux': <String, dynamic>{
'dartPluginClass': 'UrlLauncherPluginLinux',
},
},
}),
null,
<String>[],
fileSystem: fs,
appDependencies: directDependencies,
),
]);
},
throwsToolExit(
message: 'Please resolve the plugin pubspec errors',
));
expect(
testLogger.errorText,
'Plugin url_launcher:linux which provides an inline implementation '
'cannot also reference a default implementation for url_launcher_linux. '
'Ask the maintainers of url_launcher to either remove the implementation via `platforms: linux: dartPluginClass` '
'or avoid referencing a default implementation via `platforms: linux: default_package: url_launcher_linux`.'
'\n\n');
});
testUsingContext('provides error when user selected multiple implementations', () async {
final Set<String> directDependencies = <String>{
'url_launcher_linux_1',