提交 131c1fbb 编写于 作者: H Hitish Chappidi

Prepare for release 0.10.3.

上级 2e9ac4e6
......@@ -26,4 +26,4 @@ https://developer.android.com/studio/command-line/bundletool
## Releases
Latest release: [0.10.2](https://github.com/google/bundletool/releases)
Latest release: [0.10.3](https://github.com/google/bundletool/releases)
......@@ -32,13 +32,13 @@ configurations {
// The repackaging rules are defined in the "shadowJar" task below.
dependencies {
compile "com.android.tools:r8:1.0.37"
compile "com.android.tools:r8:1.5.68"
compile "com.android.tools.build:apkzlib:3.4.0-beta01"
compile "com.android.tools.ddms:ddmlib:26.2.0"
shadow "com.android.tools.build:aapt2-proto:0.4.0"
shadow "com.google.auto.value:auto-value:1.5.2"
annotationProcessor "com.google.auto.value:auto-value:1.5.2"
shadow "com.google.auto.value:auto-value-annotations:1.6.2"
annotationProcessor "com.google.auto.value:auto-value:1.6.2"
shadow "com.google.errorprone:error_prone_annotations:2.3.1"
shadow "com.google.guava:guava:27.0.1-jre"
shadow "com.google.protobuf:protobuf-java:3.4.0"
......@@ -49,8 +49,8 @@ dependencies {
compileLinux "com.android.tools.build:aapt2:3.5.0-alpha03-5252756:linux"
testCompile "com.android.tools.build:aapt2-proto:0.4.0"
testCompile "com.google.auto.value:auto-value-annotations:1.5.2"
testAnnotationProcessor "com.google.auto.value:auto-value:1.5.2"
testCompile "com.google.auto.value:auto-value-annotations:1.6.2"
testAnnotationProcessor "com.google.auto.value:auto-value:1.6.2"
testCompile "com.google.errorprone:error_prone_annotations:2.3.1"
testCompile "com.google.guava:guava:27.0.1-jre"
testCompile "com.google.truth.extensions:truth-java8-extension:0.45"
......
release_version = 0.10.2
release_version = 0.10.3
distributionUrl=https\://services.gradle.org/distributions/gradle-4.6-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-5.0-bin.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
......
......@@ -23,7 +23,10 @@ import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import com.android.bundle.Commands.AssetModuleMetadata;
import com.android.bundle.Commands.AssetSliceSet;
import com.android.bundle.Commands.BuildApksResult;
import com.android.bundle.Commands.DeliveryType;
import com.android.bundle.Devices.DeviceSpec;
import com.android.tools.build.bundletool.commands.CommandHelp.CommandDescription;
import com.android.tools.build.bundletool.commands.CommandHelp.FlagDescription;
......@@ -52,6 +55,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.logging.Logger;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
......@@ -169,11 +173,22 @@ public abstract class ExtractApksCommand {
.map(
modules ->
modules.contains(ALL_MODULES_SHORTCUT)
? toc.getVariantList().stream()
.flatMap(variant -> variant.getApkSetList().stream())
.map(apkSet -> apkSet.getModuleMetadata().getName())
? Stream.concat(
toc.getVariantList().stream()
.flatMap(variant -> variant.getApkSetList().stream())
.map(apkSet -> apkSet.getModuleMetadata().getName()),
toc.getAssetSliceSetList().stream()
.filter(
sliceSet ->
sliceSet
.getAssetModuleMetadata()
.getDeliveryType()
.equals(DeliveryType.INSTALL_TIME))
.map(AssetSliceSet::getAssetModuleMetadata)
.map(AssetModuleMetadata::getName))
.collect(toImmutableSet())
: modules);
validateAssetModules(toc, requestedModuleNames);
ApkMatcher apkMatcher = new ApkMatcher(getDeviceSpec(), requestedModuleNames, getInstant());
ImmutableList<ZipPath> matchedApks = apkMatcher.getMatchingApks(toc);
......@@ -208,6 +223,33 @@ public abstract class ExtractApksCommand {
}
}
/** Check that none of the requested modules is an asset module that is not install-time. */
private static void validateAssetModules(
BuildApksResult toc, Optional<ImmutableSet<String>> requestedModuleNames) {
if (requestedModuleNames.isPresent()) {
ImmutableList<String> requestedNonInstallTimeAssetModules =
toc.getAssetSliceSetList().stream()
.filter(
sliceSet ->
!sliceSet
.getAssetModuleMetadata()
.getDeliveryType()
.equals(DeliveryType.INSTALL_TIME))
.map(AssetSliceSet::getAssetModuleMetadata)
.map(AssetModuleMetadata::getName)
.filter(requestedModuleNames.get()::contains)
.collect(toImmutableList());
if (!requestedNonInstallTimeAssetModules.isEmpty()) {
throw ValidationException.builder()
.withMessage(
String.format(
"The following requested asset packs do not have install time delivery: %s.",
requestedNonInstallTimeAssetModules))
.build();
}
}
}
private ImmutableList<Path> extractMatchedApksFromApksArchive(
ImmutableList<ZipPath> matchedApkPaths) {
Path outputDirectoryPath =
......@@ -295,10 +337,10 @@ public abstract class ExtractApksCommand {
.setExampleValue("base,module1,module2")
.setOptional(true)
.setDescription(
"List of modules to be extracted, or \"%s\" for all modules. Defaults to "
+ "modules installed during the first install, i.e. not on-demand. Note "
+ "that the dependent modules will also be extracted. The value of this "
+ "flag is ignored if the device receives a standalone APK.",
"List of modules to be extracted, or \"%s\" for all modules. "
+ "Defaults to modules installed during the first install, i.e. not "
+ "on-demand. Note that the dependent modules will also be extracted. The "
+ "value of this flag is ignored if the device receives a standalone APK.",
ALL_MODULES_SHORTCUT)
.build())
.addFlag(
......
......@@ -233,10 +233,10 @@ public abstract class InstallApksCommand {
.setExampleValue("base,module1,module2")
.setOptional(true)
.setDescription(
"List of modules to be installed, or \"%s\" for all modules. Defaults to "
+ "modules installed during first install, i.e. not on-demand. Note that "
+ "the dependent modules will also be installed. The value of this flag is "
+ "ignored if the device receives a standalone APK.",
"List of modules to be installed, or \"%s\" for all modules. "
+ "Defaults to modules installed during the first install, i.e. not "
+ "on-demand. Note that the dependent modules will also be extracted. The "
+ "value of this flag is ignored if the device receives a standalone APK.",
ExtractApksCommand.ALL_MODULES_SHORTCUT)
.build())
.build();
......
......@@ -102,10 +102,10 @@ public abstract class ValidateBundleCommand {
printModuleSummary(moduleEntry.getValue());
}
if (!appBundle.getAssetModules().isEmpty()) {
System.out.printf("Remote asset modules:\n");
System.out.printf("Asset packs:\n");
for (Entry<BundleModuleName, BundleModule> moduleEntry :
appBundle.getAssetModules().entrySet()) {
System.out.printf("\tRemote asset module: %s\n", moduleEntry.getKey());
System.out.printf("\tAsset pack: %s\n", moduleEntry.getKey());
printModuleSummary(moduleEntry.getValue());
}
}
......
......@@ -23,6 +23,8 @@ import static com.google.common.collect.ImmutableSet.toImmutableSet;
import com.android.bundle.Commands.ApkDescription;
import com.android.bundle.Commands.ApkSet;
import com.android.bundle.Commands.AssetModuleMetadata;
import com.android.bundle.Commands.AssetSliceSet;
import com.android.bundle.Commands.BuildApksResult;
import com.android.bundle.Commands.DeliveryType;
import com.android.bundle.Commands.ModuleMetadata;
......@@ -96,10 +98,19 @@ public class ApkMatcher {
public ImmutableList<ZipPath> getMatchingApks(BuildApksResult buildApksResult) {
Optional<Variant> matchingVariant = variantMatcher.getMatchingVariant(buildApksResult);
return matchingVariant.isPresent()
? getMatchingApksFromVariant(
matchingVariant.get(), Version.of(buildApksResult.getBundletool().getVersion()))
: ImmutableList.of();
if (matchingVariant.isPresent()) {
validateVariant(matchingVariant.get(), buildApksResult);
}
ImmutableList<ZipPath> variantApks =
matchingVariant.isPresent()
? getMatchingApksFromVariant(
matchingVariant.get(), Version.of(buildApksResult.getBundletool().getVersion()))
: ImmutableList.of();
ImmutableList<ZipPath> assetModuleApks = getMatchingApksFromAssetModules(buildApksResult);
return ImmutableList.<ZipPath>builder().addAll(variantApks).addAll(assetModuleApks).build();
}
public ImmutableList<ZipPath> getMatchingApksFromVariant(Variant variant, Version bundleVersion) {
......@@ -128,8 +139,6 @@ public class ApkMatcher {
private Predicate<String> getModuleNameMatcher(Variant variant, Version bundleVersion) {
if (requestedModuleNames.isPresent()) {
validateVariant(variant);
ImmutableMultimap<String, String> moduleDependenciesMap = buildAdjacencyMap(variant);
HashSet<String> dependencyModules = new HashSet<>(requestedModuleNames.get());
......@@ -155,15 +164,19 @@ public class ApkMatcher {
}
}
private void validateVariant(Variant variant) {
private void validateVariant(Variant variant, BuildApksResult buildApksResult) {
if (requestedModuleNames.isPresent()) {
Set<String> unknownModules =
Sets.difference(
requestedModuleNames.get(),
Set<String> availableModules =
Sets.union(
variant.getApkSetList().stream()
.map(ApkSet::getModuleMetadata)
.map(ModuleMetadata::getName)
.collect(toImmutableSet()),
buildApksResult.getAssetSliceSetList().stream()
.map(AssetSliceSet::getAssetModuleMetadata)
.map(AssetModuleMetadata::getName)
.collect(toImmutableSet()));
Set<String> unknownModules = Sets.difference(requestedModuleNames.get(), availableModules);
if (!unknownModules.isEmpty()) {
throw ValidationException.builder()
.withMessage(
......@@ -251,4 +264,42 @@ public class ApkMatcher {
TargetingDimensionMatcher<T> matcher, ApkTargeting apkTargeting) {
matcher.checkDeviceCompatible(matcher.getTargetingValue(apkTargeting));
}
private ImmutableList<ZipPath> getMatchingApksFromAssetModules(BuildApksResult buildApksResult) {
ImmutableList.Builder<ZipPath> matchedApksBuilder = ImmutableList.builder();
Predicate<String> assetModuleNameMatcher =
getInstallTimeAssetModuleNameMatcher(buildApksResult);
for (AssetSliceSet sliceSet : buildApksResult.getAssetSliceSetList()) {
String moduleName = sliceSet.getAssetModuleMetadata().getName();
for (ApkDescription apkDescription : sliceSet.getApkDescriptionList()) {
ApkTargeting apkTargeting = apkDescription.getTargeting();
checkCompatibleWithApkTargeting(apkTargeting);
if (matchesApk(apkTargeting, /*isSplit=*/ true, moduleName, assetModuleNameMatcher)) {
matchedApksBuilder.add(ZipPath.create(apkDescription.getPath()));
}
}
}
return matchedApksBuilder.build();
}
private Predicate<String> getInstallTimeAssetModuleNameMatcher(BuildApksResult buildApksResult) {
ImmutableSet<String> upfrontAssetModuleNames =
buildApksResult.getAssetSliceSetList().stream()
.filter(
sliceSet ->
sliceSet
.getAssetModuleMetadata()
.getDeliveryType()
.equals(DeliveryType.INSTALL_TIME))
.map(sliceSet -> sliceSet.getAssetModuleMetadata().getName())
.collect(toImmutableSet());
return requestedModuleNames.isPresent()
? Sets.intersection(upfrontAssetModuleNames, requestedModuleNames.get())::contains
: upfrontAssetModuleNames::contains;
}
}
......@@ -77,6 +77,9 @@ public abstract class BundleModule {
/** The file of an App Bundle module that contains the APEX manifest. */
public static final ZipPath APEX_MANIFEST_PATH = ZipPath.create("root/apex_manifest.json");
/** The NOTICE file of an APEX Bundle module. */
public static final ZipPath APEX_NOTICE_PATH = ZipPath.create("assets/NOTICE.html.gz");
/** Used to parse file names in the apex/ directory, for multi-Abi targeting. */
public static final Splitter ABI_SPLITTER = Splitter.on(".").omitEmptyStrings();
......
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.tools.build.bundletool.model;
import com.google.errorprone.annotations.Immutable;
import com.google.errorprone.annotations.MustBeClosed;
import java.io.InputStream;
/**
* Represents a delegate for a ModuleEntry in a an App Bundle's module.
*
* <p>Useful for selectively overriding certain method(s) while leaving the rest of the
* functionality unchanged.
*/
@Immutable
public class DelegatingModuleEntry implements ModuleEntry {
private final ModuleEntry delegate;
public DelegatingModuleEntry(ModuleEntry delegate) {
this.delegate = delegate;
}
@MustBeClosed
@Override
public InputStream getContent() {
return delegate.getContent();
}
@Override
public ZipPath getPath() {
return delegate.getPath();
}
@Override
public boolean isDirectory() {
return delegate.isDirectory();
}
@Override
public boolean shouldCompress() {
return delegate.shouldCompress();
}
@Override
public ModuleEntry setCompression(boolean shouldCompress) {
return delegate.setCompression(shouldCompress);
}
}
......@@ -315,15 +315,6 @@ public abstract class ManifestDeliveryElement {
return fromManifestElement(manifestElement, "delivery", isFastFollowAllowed);
}
/**
* Returns the instance of the delivery element for instant delivery if Android Manifest contains
* the <dist:instant-delivery> element.
*/
public static Optional<ManifestDeliveryElement> instantFromManifestElement(
XmlProtoElement manifestElement, boolean isFastFollowAllowed) {
return fromManifestElement(manifestElement, "instant-delivery", isFastFollowAllowed);
}
private static Optional<ManifestDeliveryElement> fromManifestElement(
XmlProtoElement manifestElement, String deliveryTag, boolean isFastFollowAllowed) {
return manifestElement
......@@ -336,6 +327,15 @@ public abstract class ManifestDeliveryElement {
});
}
/**
* Returns the instance of the delivery element for instant delivery if Android Manifest contains
* the <dist:instant-delivery> element.
*/
public static Optional<ManifestDeliveryElement> instantFromManifestElement(
XmlProtoElement manifestElement, boolean isFastFollowAllowed) {
return fromManifestElement(manifestElement, "instant-delivery", isFastFollowAllowed);
}
@VisibleForTesting
static Optional<ManifestDeliveryElement> fromManifestRootNode(
XmlNode xmlNode, boolean isFastFollowAllowed) {
......
......@@ -42,6 +42,8 @@ import static com.android.tools.build.bundletool.model.AndroidManifest.SERVICE_E
import static com.android.tools.build.bundletool.model.AndroidManifest.SPLIT_NAME_RESOURCE_ID;
import static com.android.tools.build.bundletool.model.AndroidManifest.SUPPORTS_GL_TEXTURE_ELEMENT_NAME;
import static com.android.tools.build.bundletool.model.AndroidManifest.TARGET_SANDBOX_VERSION_RESOURCE_ID;
import static com.android.tools.build.bundletool.model.AndroidManifest.TARGET_SDK_VERSION_ATTRIBUTE_NAME;
import static com.android.tools.build.bundletool.model.AndroidManifest.TARGET_SDK_VERSION_RESOURCE_ID;
import static com.android.tools.build.bundletool.model.AndroidManifest.USES_FEATURE_ELEMENT_NAME;
import static com.android.tools.build.bundletool.model.AndroidManifest.USES_SDK_ELEMENT_NAME;
import static com.android.tools.build.bundletool.model.AndroidManifest.VALUE_RESOURCE_ID;
......@@ -95,6 +97,12 @@ public class ManifestEditor {
MAX_SDK_VERSION_ATTRIBUTE_NAME, MAX_SDK_VERSION_RESOURCE_ID, maxSdkVersion);
}
/** Sets the targetSdkVersion attribute. */
public ManifestEditor setTargetSdkVersion(int targetSdkVersion) {
return setUsesSdkAttribute(
TARGET_SDK_VERSION_ATTRIBUTE_NAME, TARGET_SDK_VERSION_RESOURCE_ID, targetSdkVersion);
}
/** Sets split id and related manifest entries for feature/master split. */
public ManifestEditor setSplitIdForFeatureSplit(String splitId) {
if (isBaseSplit(splitId)) {
......
......@@ -59,6 +59,18 @@ public interface ModuleEntry {
*/
ModuleEntry setCompression(boolean shouldCompress);
/**
* Creates and returns a new ModuleEntry, identical with the old one, but with a different path.
*/
default ModuleEntry setPath(ZipPath newPath) {
return new DelegatingModuleEntry(this) {
@Override
public ZipPath getPath() {
return newPath;
}
};
}
/** Checks whether the given entries are identical. */
static boolean equal(ModuleEntry entry1, ModuleEntry entry2) {
if (!entry1.getPath().equals(entry2.getPath())) {
......
......@@ -453,7 +453,7 @@ public abstract class ModuleSplit {
public static ModuleSplit fromAssetBundleModule(BundleModule bundleModule) {
checkArgument(
bundleModule.getModuleType().equals(ModuleType.ASSET_MODULE),
"Expected an Asset Module, got %s",
"Expected an asset pack, got %s",
bundleModule.getModuleType());
ModuleSplit.Builder splitBuilder =
ModuleSplit.builder()
......
......@@ -26,7 +26,7 @@ import com.google.common.base.Strings;
*/
public final class BundleToolVersion {
private static final String CURRENT_VERSION = "0.10.2";
private static final String CURRENT_VERSION = "0.10.3";
/** Returns the version of BundleTool being run. */
public static Version getCurrentVersion() {
......
......@@ -43,22 +43,27 @@ import com.android.tools.build.bundletool.model.exceptions.manifest.ManifestSdkT
import com.android.tools.build.bundletool.model.exceptions.manifest.ManifestSdkTargetingException.MinSdkInvalidException;
import com.android.tools.build.bundletool.model.exceptions.manifest.ManifestVersionCodeConflictException;
import com.android.tools.build.bundletool.model.utils.xmlproto.XmlProtoAttribute;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableCollection;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import java.util.Optional;
/** Validates {@code AndroidManifest.xml} file of each module. */
public class AndroidManifestValidator extends SubValidator {
private static final int UPFRONT_ASSET_PACK_MIN_SDK_VERSION = 21;
private static final Joiner COMMA_JOINER = Joiner.on(',');
@Override
public void validateAllModules(ImmutableList<BundleModule> modules) {
validateSameVersionCode(modules);
validateInstant(modules);
validateNoVersionCodeInAssetModules(modules);
validateTargetSandboxVersion(modules);
validateMinSdk(modules);
}
public void validateSameVersionCode(ImmutableList<BundleModule> modules) {
......@@ -76,7 +81,7 @@ public class AndroidManifestValidator extends SubValidator {
}
}
private void validateNoVersionCodeInAssetModules(ImmutableList<BundleModule> modules) {
private static void validateNoVersionCodeInAssetModules(ImmutableList<BundleModule> modules) {
Optional<BundleModule> assetModuleWithVersionCode =
modules.stream()
.filter(
......@@ -98,6 +103,56 @@ public class AndroidManifestValidator extends SubValidator {
}
}
void validateTargetSandboxVersion(ImmutableList<BundleModule> modules) {
ImmutableList<Integer> targetSandboxVersion =
modules.stream()
.map(BundleModule::getAndroidManifest)
.filter(manifest -> !manifest.getModuleType().equals(ModuleType.ASSET_MODULE))
.map(AndroidManifest::getTargetSandboxVersion)
.filter(Optional::isPresent)
.map(Optional::get)
.distinct()
.sorted()
.collect(toImmutableList());
if (targetSandboxVersion.size() > 1) {
throw ValidationException.builder()
.withMessage(
"The attribute 'targetSandboxVersion' should have the same value across modules, but "
+ "found [%s]",
COMMA_JOINER.join(targetSandboxVersion))
.build();
} else if (targetSandboxVersion.size() == 1
&& Iterables.getOnlyElement(targetSandboxVersion) > 2) {
throw ValidationException.builder()
.withMessage(
"The attribute 'targetSandboxVersion' cannot have a value greater than 2, but found "
+ "%d",
Iterables.getOnlyElement(targetSandboxVersion))
.build();
}
}
private static void validateMinSdk(ImmutableList<BundleModule> modules) {
int baseMinSdk =
modules.stream()
.filter(BundleModule::isBaseModule)
.map(BundleModule::getAndroidManifest)
.mapToInt(AndroidManifest::getEffectiveMinSdkVersion)
.findFirst()
.orElseThrow(() -> new ValidationException("No base module found."));
if (modules.stream()
.filter(m -> m.getAndroidManifest().getMinSdkVersion().isPresent())
.anyMatch(m -> m.getAndroidManifest().getEffectiveMinSdkVersion() < baseMinSdk)) {
throw ValidationException.builder()
.withMessage(
"Modules cannot have a minSdkVersion attribute with a value lower than "
+ "the one from the base module.")
.build();
}
}
@Override
public void validateModule(BundleModule module) {
validateInstant(module);
......@@ -106,7 +161,6 @@ public class AndroidManifestValidator extends SubValidator {
validateFusingConfig(module);
validateMinMaxSdk(module);
validateNumberOfDistinctSplitIds(module);
validateOnDemandIsInstantMutualExclusion(module);
validateAssetModuleManifest(module);
validateMinSdkCondition(module);
validateNoConditionalTargetingInAssetModules(module);
......@@ -127,7 +181,7 @@ public class AndroidManifestValidator extends SubValidator {
}
}
private void validateInstant(ImmutableList<BundleModule> modules) {
private static void validateInstant(ImmutableList<BundleModule> modules) {
// If any module is 'instant' validate that 'base' is instant too.
BundleModule baseModule =
modules.stream()
......@@ -145,7 +199,7 @@ public class AndroidManifestValidator extends SubValidator {
}
}
private void validateInstant(BundleModule module) {
private static void validateInstant(BundleModule module) {
AndroidManifest manifest = module.getAndroidManifest();
Optional<Boolean> isInstantModule = manifest.isInstantModule();
if (isInstantModule.orElse(false)) {
......@@ -158,7 +212,7 @@ public class AndroidManifestValidator extends SubValidator {
}
}
private void validateDeliverySettings(BundleModule module) {
private static void validateDeliverySettings(BundleModule module) {
boolean deliveryTypeDeclared = module.getAndroidManifest().isDeliveryTypeDeclared();
ModuleDeliveryType deliveryType = module.getDeliveryType();
......@@ -193,7 +247,7 @@ public class AndroidManifestValidator extends SubValidator {
}
}
private void validateInstantDeliverySettings(BundleModule module) {
private static void validateInstantDeliverySettings(BundleModule module) {
if (module.getAndroidManifest().getInstantManifestDeliveryElement().isPresent()
&& module.getAndroidManifest().getInstantAttribute().isPresent()) {
throw ValidationException.builder()
......@@ -205,28 +259,7 @@ public class AndroidManifestValidator extends SubValidator {
}
}
private void validateOnDemandIsInstantMutualExclusion(BundleModule module) {
boolean isInstant = module.getAndroidManifest().isInstantModule().orElse(false);
if (module.getDeliveryType().equals(NO_INITIAL_INSTALL)
&& isInstant
&& module.getModuleType().equals(ModuleType.FEATURE_MODULE)) {
throw ValidationException.builder()
.withMessage(
"Feature module cannot be on-demand and 'instant' at the same time (module '%s').",