Browse Source
This commit revisit the build configuration to enforce the following: * A single Java toolchain is used consistently with a recent Java version (here, Java 23) and language level * the main source is compiled with the Java 17 "-release" target * Multi-Release classes are compiled with their respective "-release" target. For now, only "spring-core" ships Java 21 variants. Closes gh-34507pull/34511/head
13 changed files with 419 additions and 126 deletions
@ -1,2 +1,4 @@ |
|||||||
org.gradle.caching=true |
org.gradle.caching=true |
||||||
javaFormatVersion=0.0.42 |
javaFormatVersion=0.0.42 |
||||||
|
junitJupiterVersion=5.11.4 |
||||||
|
assertjVersion=3.27.3 |
||||||
@ -0,0 +1,139 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2002-2025 the original author or authors. |
||||||
|
* |
||||||
|
* 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 |
||||||
|
* |
||||||
|
* https://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 org.springframework.build.multirelease; |
||||||
|
|
||||||
|
import javax.inject.Inject; |
||||||
|
|
||||||
|
import org.gradle.api.artifacts.Configuration; |
||||||
|
import org.gradle.api.artifacts.ConfigurationContainer; |
||||||
|
import org.gradle.api.artifacts.dsl.DependencyHandler; |
||||||
|
import org.gradle.api.attributes.LibraryElements; |
||||||
|
import org.gradle.api.file.ConfigurableFileCollection; |
||||||
|
import org.gradle.api.file.FileCollection; |
||||||
|
import org.gradle.api.java.archives.Attributes; |
||||||
|
import org.gradle.api.model.ObjectFactory; |
||||||
|
import org.gradle.api.tasks.SourceSet; |
||||||
|
import org.gradle.api.tasks.SourceSetContainer; |
||||||
|
import org.gradle.api.tasks.TaskContainer; |
||||||
|
import org.gradle.api.tasks.TaskProvider; |
||||||
|
import org.gradle.api.tasks.bundling.Jar; |
||||||
|
import org.gradle.api.tasks.compile.JavaCompile; |
||||||
|
import org.gradle.api.tasks.testing.Test; |
||||||
|
import org.gradle.language.base.plugins.LifecycleBasePlugin; |
||||||
|
|
||||||
|
/** |
||||||
|
* @author Cedric Champeau |
||||||
|
* @author Brian Clozel |
||||||
|
*/ |
||||||
|
public abstract class MultiReleaseExtension { |
||||||
|
private final TaskContainer tasks; |
||||||
|
private final SourceSetContainer sourceSets; |
||||||
|
private final DependencyHandler dependencies; |
||||||
|
private final ObjectFactory objects; |
||||||
|
private final ConfigurationContainer configurations; |
||||||
|
|
||||||
|
@Inject |
||||||
|
public MultiReleaseExtension(SourceSetContainer sourceSets, |
||||||
|
ConfigurationContainer configurations, |
||||||
|
TaskContainer tasks, |
||||||
|
DependencyHandler dependencies, |
||||||
|
ObjectFactory objectFactory) { |
||||||
|
this.sourceSets = sourceSets; |
||||||
|
this.configurations = configurations; |
||||||
|
this.tasks = tasks; |
||||||
|
this.dependencies = dependencies; |
||||||
|
this.objects = objectFactory; |
||||||
|
} |
||||||
|
|
||||||
|
public void releaseVersions(int... javaVersions) { |
||||||
|
releaseVersions("src/main/", "src/test/", javaVersions); |
||||||
|
} |
||||||
|
|
||||||
|
private void releaseVersions(String mainSourceDirectory, String testSourceDirectory, int... javaVersions) { |
||||||
|
for (int javaVersion : javaVersions) { |
||||||
|
addLanguageVersion(javaVersion, mainSourceDirectory, testSourceDirectory); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void addLanguageVersion(int javaVersion, String mainSourceDirectory, String testSourceDirectory) { |
||||||
|
String javaN = "java" + javaVersion; |
||||||
|
|
||||||
|
SourceSet langSourceSet = sourceSets.create(javaN, srcSet -> srcSet.getJava().srcDir(mainSourceDirectory + javaN)); |
||||||
|
SourceSet testSourceSet = sourceSets.create(javaN + "Test", srcSet -> srcSet.getJava().srcDir(testSourceDirectory + javaN)); |
||||||
|
SourceSet sharedSourceSet = sourceSets.findByName(SourceSet.MAIN_SOURCE_SET_NAME); |
||||||
|
SourceSet sharedTestSourceSet = sourceSets.findByName(SourceSet.TEST_SOURCE_SET_NAME); |
||||||
|
|
||||||
|
FileCollection mainClasses = objects.fileCollection().from(sourceSets.getByName(SourceSet.MAIN_SOURCE_SET_NAME).getOutput().getClassesDirs()); |
||||||
|
dependencies.add(javaN + "Implementation", mainClasses); |
||||||
|
|
||||||
|
tasks.named(langSourceSet.getCompileJavaTaskName(), JavaCompile.class, task -> |
||||||
|
task.getOptions().getRelease().set(javaVersion) |
||||||
|
); |
||||||
|
tasks.named(testSourceSet.getCompileJavaTaskName(), JavaCompile.class, task -> |
||||||
|
task.getOptions().getRelease().set(javaVersion) |
||||||
|
); |
||||||
|
|
||||||
|
TaskProvider<Test> testTask = createTestTask(javaVersion, testSourceSet, sharedTestSourceSet, langSourceSet, sharedSourceSet); |
||||||
|
tasks.named("check", task -> task.dependsOn(testTask)); |
||||||
|
|
||||||
|
configureMultiReleaseJar(javaVersion, langSourceSet); |
||||||
|
} |
||||||
|
|
||||||
|
private TaskProvider<Test> createTestTask(int javaVersion, SourceSet testSourceSet, SourceSet sharedTestSourceSet, SourceSet langSourceSet, SourceSet sharedSourceSet) { |
||||||
|
Configuration testImplementation = configurations.getByName(testSourceSet.getImplementationConfigurationName()); |
||||||
|
testImplementation.extendsFrom(configurations.getByName(sharedTestSourceSet.getImplementationConfigurationName())); |
||||||
|
Configuration testCompileOnly = configurations.getByName(testSourceSet.getCompileOnlyConfigurationName()); |
||||||
|
testCompileOnly.extendsFrom(configurations.getByName(sharedTestSourceSet.getCompileOnlyConfigurationName())); |
||||||
|
testCompileOnly.getDependencies().add(dependencies.create(langSourceSet.getOutput().getClassesDirs())); |
||||||
|
testCompileOnly.getDependencies().add(dependencies.create(sharedSourceSet.getOutput().getClassesDirs())); |
||||||
|
|
||||||
|
Configuration testRuntimeClasspath = configurations.getByName(testSourceSet.getRuntimeClasspathConfigurationName()); |
||||||
|
// so here's the deal. MRjars are JARs! Which means that to execute tests, we need
|
||||||
|
// the JAR on classpath, not just classes + resources as Gradle usually does
|
||||||
|
testRuntimeClasspath.getAttributes() |
||||||
|
.attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.class, LibraryElements.JAR)); |
||||||
|
|
||||||
|
TaskProvider<Test> testTask = tasks.register("java" + javaVersion + "Test", Test.class, test -> { |
||||||
|
test.setGroup(LifecycleBasePlugin.VERIFICATION_GROUP); |
||||||
|
|
||||||
|
ConfigurableFileCollection testClassesDirs = objects.fileCollection(); |
||||||
|
testClassesDirs.from(testSourceSet.getOutput()); |
||||||
|
testClassesDirs.from(sharedTestSourceSet.getOutput()); |
||||||
|
test.setTestClassesDirs(testClassesDirs); |
||||||
|
ConfigurableFileCollection classpath = objects.fileCollection(); |
||||||
|
// must put the MRJar first on classpath
|
||||||
|
classpath.from(tasks.named("jar")); |
||||||
|
// then we put the specific test sourceset tests, so that we can override
|
||||||
|
// the shared versions
|
||||||
|
classpath.from(testSourceSet.getOutput()); |
||||||
|
|
||||||
|
// then we add the shared tests
|
||||||
|
classpath.from(sharedTestSourceSet.getRuntimeClasspath()); |
||||||
|
test.setClasspath(classpath); |
||||||
|
}); |
||||||
|
return testTask; |
||||||
|
} |
||||||
|
|
||||||
|
private void configureMultiReleaseJar(int version, SourceSet languageSourceSet) { |
||||||
|
tasks.named("jar", Jar.class, jar -> { |
||||||
|
jar.into("META-INF/versions/" + version, s -> s.from(languageSourceSet.getOutput())); |
||||||
|
Attributes attributes = jar.getManifest().getAttributes(); |
||||||
|
attributes.put("Multi-Release", "true"); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,61 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2002-2025 the original author or authors. |
||||||
|
* |
||||||
|
* 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 |
||||||
|
* |
||||||
|
* https://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 org.springframework.build.multirelease; |
||||||
|
|
||||||
|
import javax.inject.Inject; |
||||||
|
|
||||||
|
import org.gradle.api.Plugin; |
||||||
|
import org.gradle.api.Project; |
||||||
|
import org.gradle.api.artifacts.ConfigurationContainer; |
||||||
|
import org.gradle.api.artifacts.dsl.DependencyHandler; |
||||||
|
import org.gradle.api.model.ObjectFactory; |
||||||
|
import org.gradle.api.plugins.ExtensionContainer; |
||||||
|
import org.gradle.api.plugins.JavaPlugin; |
||||||
|
import org.gradle.api.plugins.JavaPluginExtension; |
||||||
|
import org.gradle.api.tasks.TaskContainer; |
||||||
|
import org.gradle.jvm.toolchain.JavaToolchainService; |
||||||
|
|
||||||
|
/** |
||||||
|
* A plugin which adds support for building multi-release jars |
||||||
|
* with Gradle. |
||||||
|
* @author Cedric Champeau |
||||||
|
* @author Brian Clozel |
||||||
|
* @see <a href="https://github.com/melix/mrjar-gradle-plugin">original project</a> |
||||||
|
*/ |
||||||
|
public class MultiReleaseJarPlugin implements Plugin<Project> { |
||||||
|
|
||||||
|
@Inject |
||||||
|
protected JavaToolchainService getToolchains() { |
||||||
|
throw new UnsupportedOperationException(); |
||||||
|
} |
||||||
|
|
||||||
|
public void apply(Project project) { |
||||||
|
project.getPlugins().apply(JavaPlugin.class); |
||||||
|
ExtensionContainer extensions = project.getExtensions(); |
||||||
|
JavaPluginExtension javaPluginExtension = extensions.getByType(JavaPluginExtension.class); |
||||||
|
ConfigurationContainer configurations = project.getConfigurations(); |
||||||
|
TaskContainer tasks = project.getTasks(); |
||||||
|
DependencyHandler dependencies = project.getDependencies(); |
||||||
|
ObjectFactory objects = project.getObjects(); |
||||||
|
extensions.create("multiRelease", MultiReleaseExtension.class, |
||||||
|
javaPluginExtension.getSourceSets(), |
||||||
|
configurations, |
||||||
|
tasks, |
||||||
|
dependencies, |
||||||
|
objects); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,137 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2002-2025 the original author or authors. |
||||||
|
* |
||||||
|
* 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 |
||||||
|
* |
||||||
|
* https://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 org.springframework.build.multirelease; |
||||||
|
|
||||||
|
import java.io.File; |
||||||
|
import java.io.FileWriter; |
||||||
|
import java.io.IOException; |
||||||
|
import java.io.PrintWriter; |
||||||
|
import java.nio.file.Files; |
||||||
|
import java.nio.file.Path; |
||||||
|
import java.util.jar.Attributes; |
||||||
|
import java.util.jar.JarFile; |
||||||
|
import org.gradle.testkit.runner.BuildResult; |
||||||
|
import org.gradle.testkit.runner.GradleRunner; |
||||||
|
import org.junit.jupiter.api.BeforeEach; |
||||||
|
import org.junit.jupiter.api.Test; |
||||||
|
import org.junit.jupiter.api.io.TempDir; |
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||||
|
|
||||||
|
/** |
||||||
|
* Tests for {@link MultiReleaseJarPlugin} |
||||||
|
*/ |
||||||
|
public class MultiReleaseJarPluginTests { |
||||||
|
|
||||||
|
private File projectDir; |
||||||
|
|
||||||
|
private File buildFile; |
||||||
|
|
||||||
|
@BeforeEach |
||||||
|
void setup(@TempDir File projectDir) { |
||||||
|
this.projectDir = projectDir; |
||||||
|
this.buildFile = new File(this.projectDir, "build.gradle"); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void configureSourceSets() throws IOException { |
||||||
|
writeBuildFile(""" |
||||||
|
plugins { |
||||||
|
id 'java' |
||||||
|
id 'org.springframework.build.multiReleaseJar' |
||||||
|
} |
||||||
|
multiRelease { releaseVersions 21, 24 } |
||||||
|
task printSourceSets { |
||||||
|
doLast { |
||||||
|
sourceSets.all { println it.name } |
||||||
|
} |
||||||
|
} |
||||||
|
"""); |
||||||
|
BuildResult buildResult = runGradle("printSourceSets"); |
||||||
|
assertThat(buildResult.getOutput()).contains("main", "test", "java21", "java21Test", "java24", "java24Test"); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void configureToolchainReleaseVersion() throws IOException { |
||||||
|
writeBuildFile(""" |
||||||
|
plugins { |
||||||
|
id 'java' |
||||||
|
id 'org.springframework.build.multiReleaseJar' |
||||||
|
} |
||||||
|
multiRelease { releaseVersions 21 } |
||||||
|
task printReleaseVersion { |
||||||
|
doLast { |
||||||
|
tasks.all { println it.name } |
||||||
|
tasks.named("compileJava21Java") { |
||||||
|
println "compileJava21Java releaseVersion: ${it.options.release.get()}" |
||||||
|
} |
||||||
|
tasks.named("compileJava21TestJava") { |
||||||
|
println "compileJava21TestJava releaseVersion: ${it.options.release.get()}" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
"""); |
||||||
|
|
||||||
|
BuildResult buildResult = runGradle("printReleaseVersion"); |
||||||
|
assertThat(buildResult.getOutput()).contains("compileJava21Java releaseVersion: 21") |
||||||
|
.contains("compileJava21TestJava releaseVersion: 21"); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
void packageInJar() throws IOException { |
||||||
|
writeBuildFile(""" |
||||||
|
plugins { |
||||||
|
id 'java' |
||||||
|
id 'org.springframework.build.multiReleaseJar' |
||||||
|
} |
||||||
|
version = '1.2.3' |
||||||
|
multiRelease { releaseVersions 17 } |
||||||
|
"""); |
||||||
|
writeClass("src/main/java17", "Main.java", """ |
||||||
|
public class Main {} |
||||||
|
"""); |
||||||
|
BuildResult buildResult = runGradle("assemble"); |
||||||
|
File file = new File(this.projectDir, "/build/libs/" + this.projectDir.getName() + "-1.2.3.jar"); |
||||||
|
assertThat(file).exists(); |
||||||
|
try (JarFile jar = new JarFile(file)) { |
||||||
|
Attributes mainAttributes = jar.getManifest().getMainAttributes(); |
||||||
|
assertThat(mainAttributes.getValue("Multi-Release")).isEqualTo("true"); |
||||||
|
|
||||||
|
assertThat(jar.entries().asIterator()).toIterable() |
||||||
|
.anyMatch(entry -> entry.getName().equals("META-INF/versions/17/Main.class")); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void writeBuildFile(String buildContent) throws IOException { |
||||||
|
try (PrintWriter out = new PrintWriter(new FileWriter(this.buildFile))) { |
||||||
|
out.print(buildContent); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private void writeClass(String path, String fileName, String fileContent) throws IOException { |
||||||
|
Path folder = this.projectDir.toPath().resolve(path); |
||||||
|
Files.createDirectories(folder); |
||||||
|
Path filePath = folder.resolve(fileName); |
||||||
|
Files.createFile(filePath); |
||||||
|
Files.writeString(filePath, fileContent); |
||||||
|
} |
||||||
|
|
||||||
|
private BuildResult runGradle(String... args) { |
||||||
|
return GradleRunner.create().withProjectDir(this.projectDir).withArguments(args).withPluginClasspath().build(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -1,99 +0,0 @@ |
|||||||
/** |
|
||||||
* Apply the JVM Toolchain conventions |
|
||||||
* See https://docs.gradle.org/current/userguide/toolchains.html |
|
||||||
* |
|
||||||
* One can choose the toolchain to use for compiling and running the TEST sources. |
|
||||||
* These options apply to Java, Kotlin and Groovy test sources when available. |
|
||||||
* {@code "./gradlew check -PtestToolchain=22"} will use a JDK22 |
|
||||||
* toolchain for compiling and running the test SourceSet. |
|
||||||
* |
|
||||||
* By default, the main build will fall back to using the a JDK 17 |
|
||||||
* toolchain (and 17 language level) for all main sourceSets. |
|
||||||
* See {@link org.springframework.build.JavaConventions}. |
|
||||||
* |
|
||||||
* Gradle will automatically detect JDK distributions in well-known locations. |
|
||||||
* The following command will list the detected JDKs on the host. |
|
||||||
* {@code |
|
||||||
* $ ./gradlew -q javaToolchains |
|
||||||
* } |
|
||||||
* |
|
||||||
* We can also configure ENV variables and let Gradle know about them: |
|
||||||
* {@code |
|
||||||
* $ echo JDK17 |
|
||||||
* /opt/openjdk/java17 |
|
||||||
* $ echo JDK22 |
|
||||||
* /opt/openjdk/java22 |
|
||||||
* $ ./gradlew -Porg.gradle.java.installations.fromEnv=JDK17,JDK22 check |
|
||||||
* } |
|
||||||
* |
|
||||||
* @author Brian Clozel |
|
||||||
* @author Sam Brannen |
|
||||||
*/ |
|
||||||
|
|
||||||
def testToolchainConfigured() { |
|
||||||
return project.hasProperty('testToolchain') && project.testToolchain |
|
||||||
} |
|
||||||
|
|
||||||
def testToolchainLanguageVersion() { |
|
||||||
if (testToolchainConfigured()) { |
|
||||||
return JavaLanguageVersion.of(project.testToolchain.toString()) |
|
||||||
} |
|
||||||
return JavaLanguageVersion.of(17) |
|
||||||
} |
|
||||||
|
|
||||||
plugins.withType(JavaPlugin).configureEach { |
|
||||||
// Configure a specific Java Toolchain for compiling and running tests if the 'testToolchain' property is defined |
|
||||||
if (testToolchainConfigured()) { |
|
||||||
def testLanguageVersion = testToolchainLanguageVersion() |
|
||||||
tasks.withType(JavaCompile).matching { it.name.contains("Test") }.configureEach { |
|
||||||
javaCompiler = javaToolchains.compilerFor { |
|
||||||
languageVersion = testLanguageVersion |
|
||||||
} |
|
||||||
} |
|
||||||
tasks.withType(Test).configureEach{ |
|
||||||
javaLauncher = javaToolchains.launcherFor { |
|
||||||
languageVersion = testLanguageVersion |
|
||||||
} |
|
||||||
// Enable Java experimental support in Bytebuddy |
|
||||||
// Bytebuddy 1.15.4 supports JDK <= 24 |
|
||||||
// see https://github.com/raphw/byte-buddy/blob/master/release-notes.md |
|
||||||
if (testLanguageVersion.compareTo(JavaLanguageVersion.of(24)) > 0 ) { |
|
||||||
jvmArgs("-Dnet.bytebuddy.experimental=true") |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Configure the JMH plugin to use the toolchain for generating and running JMH bytecode |
|
||||||
pluginManager.withPlugin("me.champeau.jmh") { |
|
||||||
if (testToolchainConfigured()) { |
|
||||||
tasks.matching { it.name.contains('jmh') && it.hasProperty('javaLauncher') }.configureEach { |
|
||||||
javaLauncher.set(javaToolchains.launcherFor { |
|
||||||
languageVersion.set(testToolchainLanguageVersion()) |
|
||||||
}) |
|
||||||
} |
|
||||||
tasks.withType(JavaCompile).matching { it.name.contains("Jmh") }.configureEach { |
|
||||||
javaCompiler = javaToolchains.compilerFor { |
|
||||||
languageVersion = testToolchainLanguageVersion() |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Store resolved Toolchain JVM information as custom values in the build scan. |
|
||||||
rootProject.ext { |
|
||||||
resolvedMainToolchain = false |
|
||||||
resolvedTestToolchain = false |
|
||||||
} |
|
||||||
gradle.taskGraph.afterTask { Task task, TaskState state -> |
|
||||||
if (!resolvedMainToolchain && task instanceof JavaCompile && task.javaCompiler.isPresent()) { |
|
||||||
def metadata = task.javaCompiler.get().metadata |
|
||||||
task.project.develocity.buildScan.value('Main toolchain', "$metadata.vendor $metadata.languageVersion ($metadata.installationPath)") |
|
||||||
resolvedMainToolchain = true |
|
||||||
} |
|
||||||
if (testToolchainConfigured() && !resolvedTestToolchain && task instanceof Test && task.javaLauncher.isPresent()) { |
|
||||||
def metadata = task.javaLauncher.get().metadata |
|
||||||
task.project.develocity.buildScan.value('Test toolchain', "$metadata.vendor $metadata.languageVersion ($metadata.installationPath)") |
|
||||||
resolvedTestToolchain = true |
|
||||||
} |
|
||||||
} |
|
||||||
Loading…
Reference in new issue