diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 7056c82..de2d1a2 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -1,5 +1,6 @@ + - + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 25d06d3..f8266d2 100644 --- a/pom.xml +++ b/pom.xml @@ -44,6 +44,18 @@ 23.0.0 provided + + org.junit.jupiter + junit-jupiter + 5.8.2 + test + + + org.mockito + mockito-core + 4.3.1 + test + diff --git a/src/main/java/org/cryptomator/integrations/common/CheckAvailability.java b/src/main/java/org/cryptomator/integrations/common/CheckAvailability.java new file mode 100644 index 0000000..0b29c4c --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/common/CheckAvailability.java @@ -0,0 +1,48 @@ +package org.cryptomator.integrations.common; + +import org.jetbrains.annotations.ApiStatus; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Identifies 0..n public methods to check preconditions for the integration to work. These are the rules: + * + *
    + *
  • Both the type and the method(s) must be annotated with {@code @CheckAvailability}
  • + *
  • Only public no-arg boolean methods are considered
  • + *
  • Methods may be {@code static}, in which case they get invoked before instantiating the service
  • + *
  • Should the method throw an exception, it has the same effect as returning {@code false}
  • + *
  • No specific execution order is guaranteed in case of multiple annotated methods
  • + *
  • Annotations must be present on classes or ancestor classes, not on interfaces
  • + *
+ * + * Example: + *
+ * {@code
+ * @CheckAvailability
+ * public class Foo {
+ *	@CheckAvailability
+ *	public static boolean isSupported() {
+ *		return "enabled".equals(System.getProperty("plugin.status"));
+ *	}
+ * }
+ * }
+ * 
+ *

+ * Annotations are discovered at runtime using reflection, so make sure to make relevant classes accessible to this + * module ({@code opens X to org.cryptomator.integrations.api}). + * + * @since 1.1.0 + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +@Inherited +@ApiStatus.Experimental +public @interface CheckAvailability { +} diff --git a/src/main/java/org/cryptomator/integrations/common/ClassLoaderFactory.java b/src/main/java/org/cryptomator/integrations/common/ClassLoaderFactory.java new file mode 100644 index 0000000..89dab2d --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/common/ClassLoaderFactory.java @@ -0,0 +1,66 @@ +package org.cryptomator.integrations.common; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.VisibleForTesting; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; + +class ClassLoaderFactory { + + private static final String USER_HOME = System.getProperty("user.home"); + private static final String PLUGIN_DIR_KEY = "cryptomator.pluginDir"; + private static final String JAR_SUFFIX = ".jar"; + + /** + * Attempts to find {@code .jar} files in the path specified in {@value #PLUGIN_DIR_KEY} system property. + * A new class loader instance is returned that loads classes from the given classes. + * + * @return A new URLClassLoader that is aware of all {@code .jar} files in the plugin dir + */ + @Contract(value = "-> new", pure = true) + public static URLClassLoader forPluginDir() { + String val = System.getProperty(PLUGIN_DIR_KEY, ""); + final Path p; + if (val.startsWith("~/")) { + p = Path.of(USER_HOME).resolve(val.substring(2)); + } else { + p = Path.of(val); + } + return forPluginDirWithPath(p); + } + + @VisibleForTesting + @Contract(value = "_ -> new", pure = true) + static URLClassLoader forPluginDirWithPath(Path path) throws UncheckedIOException { + return URLClassLoader.newInstance(findJars(path)); + } + + @VisibleForTesting + static URL[] findJars(Path path) { + try (var stream = Files.walk(path)) { + return stream.filter(ClassLoaderFactory::isJarFile).map(ClassLoaderFactory::toUrl).toArray(URL[]::new); + } catch (IOException | UncheckedIOException e) { + // unable to locate any jars // TODO: log a warning? + return new URL[0]; + } + } + + private static URL toUrl(Path path) throws UncheckedIOException { + try { + return path.toUri().toURL(); + } catch (MalformedURLException e) { + throw new UncheckedIOException(e); + } + } + + private static boolean isJarFile(Path path) { + return Files.isRegularFile(path) && path.getFileName().toString().toLowerCase().endsWith(JAR_SUFFIX); + } + +} diff --git a/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java b/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java index f184d8c..2fef2a3 100644 --- a/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java +++ b/src/main/java/org/cryptomator/integrations/common/IntegrationsLoader.java @@ -1,5 +1,10 @@ package org.cryptomator.integrations.common; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.Arrays; import java.util.Comparator; import java.util.Optional; @@ -8,6 +13,9 @@ public class IntegrationsLoader { + private IntegrationsLoader() { + } + /** * Loads the best suited service, i.e. the one with the highest priority that is supported. *

@@ -29,11 +37,13 @@ public static Optional load(Class clazz) { * @return An ordered stream of all suited service candidates */ public static Stream loadAll(Class clazz) { - return ServiceLoader.load(clazz) + return ServiceLoader.load(clazz, ClassLoaderFactory.forPluginDir()) .stream() .filter(IntegrationsLoader::isSupportedOperatingSystem) + .filter(IntegrationsLoader::passesStaticAvailabilityCheck) .sorted(Comparator.comparingInt(IntegrationsLoader::getPriority).reversed()) - .map(ServiceLoader.Provider::get); + .map(ServiceLoader.Provider::get) + .filter(IntegrationsLoader::passesInstanceAvailabilityCheck); } private static int getPriority(ServiceLoader.Provider provider) { @@ -46,4 +56,43 @@ private static boolean isSupportedOperatingSystem(ServiceLoader.Provider prov return annotations.length == 0 || Arrays.stream(annotations).anyMatch(OperatingSystem.Value::isCurrent); } + private static boolean passesStaticAvailabilityCheck(ServiceLoader.Provider provider) { + return passesStaticAvailabilityCheck(provider.type()); + } + + @VisibleForTesting + static boolean passesStaticAvailabilityCheck(Class type) { + return passesAvailabilityCheck(type, null); + } + + @VisibleForTesting + static boolean passesInstanceAvailabilityCheck(Object instance) { + return passesAvailabilityCheck(instance.getClass(), instance); + } + + private static boolean passesAvailabilityCheck(Class type, @Nullable T instance) { + if (!type.isAnnotationPresent(CheckAvailability.class)) { + return true; // if type is not annotated, skip tests + } + return Arrays.stream(type.getMethods()) + .filter(m -> isAvailabilityCheck(m, instance == null)) + .allMatch(m -> passesAvailabilityCheck(m, instance)); + } + + private static boolean passesAvailabilityCheck(Method m, @Nullable Object instance) { + assert Boolean.TYPE.equals(m.getReturnType()); + try { + return (boolean) m.invoke(instance); + } catch (ReflectiveOperationException e) { + return false; + } + } + + private static boolean isAvailabilityCheck(Method m, boolean isStatic) { + return m.isAnnotationPresent(CheckAvailability.class) + && Boolean.TYPE.equals(m.getReturnType()) + && m.getParameterCount() == 0 + && Modifier.isStatic(m.getModifiers()) == isStatic; + } + } diff --git a/src/main/java/org/cryptomator/integrations/tray/ActionItem.java b/src/main/java/org/cryptomator/integrations/tray/ActionItem.java index 383618b..b11a7c5 100644 --- a/src/main/java/org/cryptomator/integrations/tray/ActionItem.java +++ b/src/main/java/org/cryptomator/integrations/tray/ActionItem.java @@ -1,4 +1,8 @@ package org.cryptomator.integrations.tray; -public record ActionItem(String title, Runnable action) implements TrayMenuItem { +public record ActionItem(String title, Runnable action, boolean enabled) implements TrayMenuItem { + + public ActionItem(String title, Runnable action) { + this(title, action, true); + } } diff --git a/src/main/java/org/cryptomator/integrations/tray/TrayMenuController.java b/src/main/java/org/cryptomator/integrations/tray/TrayMenuController.java index fadb980..2d8b864 100644 --- a/src/main/java/org/cryptomator/integrations/tray/TrayMenuController.java +++ b/src/main/java/org/cryptomator/integrations/tray/TrayMenuController.java @@ -23,12 +23,12 @@ static Optional get() { /** * Displays an icon on the system tray. * - * @param rawImageData What image to show + * @param imageData What image to show * @param defaultAction Action to perform when interacting with the icon directly instead of its menu * @param tooltip Text shown when hovering - * @throws IOException thrown when interacting with the given rawImageData + * @throws TrayMenuException thrown when adding the tray icon failed */ - void showTrayIcon(InputStream rawImageData, Runnable defaultAction, String tooltip) throws IOException; + void showTrayIcon(byte[] imageData, Runnable defaultAction, String tooltip) throws TrayMenuException; /** * Show the given options in the tray menu. @@ -36,7 +36,8 @@ static Optional get() { * This method may be called multiple times, e.g. when the vault list changes. * * @param items Menu items + * @throws TrayMenuException thrown when updating the tray menu failed */ - void updateTrayMenu(List items); + void updateTrayMenu(List items) throws TrayMenuException; } diff --git a/src/main/java/org/cryptomator/integrations/tray/TrayMenuException.java b/src/main/java/org/cryptomator/integrations/tray/TrayMenuException.java new file mode 100644 index 0000000..e9b61da --- /dev/null +++ b/src/main/java/org/cryptomator/integrations/tray/TrayMenuException.java @@ -0,0 +1,13 @@ +package org.cryptomator.integrations.tray; + +public class TrayMenuException extends Exception { + + public TrayMenuException(String message) { + super(message); + } + + public TrayMenuException(String message, Throwable cause) { + super(message, cause); + } + +} \ No newline at end of file diff --git a/src/test/java/org/cryptomator/integrations/common/ClassLoaderFactoryTest.java b/src/test/java/org/cryptomator/integrations/common/ClassLoaderFactoryTest.java new file mode 100644 index 0000000..a5a8db5 --- /dev/null +++ b/src/test/java/org/cryptomator/integrations/common/ClassLoaderFactoryTest.java @@ -0,0 +1,135 @@ +package org.cryptomator.integrations.common; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mockito; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Comparator; + +public class ClassLoaderFactoryTest { + + @Nested + @DisplayName("When two .jars exist in the plugin dir") + public class WithJars { + + private static final byte[] FOO_CONTENTS = "foo = 42".getBytes(); + private static final byte[] BAR_CONTENTS = "bar = 23".getBytes(); + private Path pluginDir; + + @BeforeEach + public void setup(@TempDir Path tmpDir) throws IOException { + Files.createDirectory(tmpDir.resolve("plugin1")); + try (var out = Files.newOutputStream(tmpDir.resolve("plugin1/foo.jar")); + var jar = JarBuilder.withTarget(out)) { + jar.addFile("foo.properties", new ByteArrayInputStream(FOO_CONTENTS)); + } + + Files.createDirectory(tmpDir.resolve("plugin2")); + try (var out = Files.newOutputStream(tmpDir.resolve("plugin2/bar.jar")); + var jar = JarBuilder.withTarget(out)) { + jar.addFile("bar.properties", new ByteArrayInputStream(BAR_CONTENTS)); + } + + this.pluginDir = tmpDir; + } + + @Test + @DisplayName("can load resources from both jars") + public void testForPluginDirWithPath() throws IOException { + var cl = ClassLoaderFactory.forPluginDirWithPath(pluginDir); + var fooContents = cl.getResourceAsStream("foo.properties").readAllBytes(); + var barContents = cl.getResourceAsStream("bar.properties").readAllBytes(); + + Assertions.assertArrayEquals(FOO_CONTENTS, fooContents); + Assertions.assertArrayEquals(BAR_CONTENTS, barContents); + } + + @Test + @DisplayName("can load resources when path is set in cryptomator.pluginDir") + public void testForPluginDirFromSysProp() throws IOException { + System.setProperty("cryptomator.pluginDir", pluginDir.toString()); + + var cl = ClassLoaderFactory.forPluginDir(); + var fooContents = cl.getResourceAsStream("foo.properties").readAllBytes(); + var barContents = cl.getResourceAsStream("bar.properties").readAllBytes(); + + Assertions.assertArrayEquals(FOO_CONTENTS, fooContents); + Assertions.assertArrayEquals(BAR_CONTENTS, barContents); + } + } + + @Test + @DisplayName("read path from cryptomator.pluginDir") + public void testReadPluginDirFromSysProp() { + var ucl = Mockito.mock(URLClassLoader.class, "ucl"); + var absPath = "/there/will/be/plugins"; + try (var mockedClass = Mockito.mockStatic(ClassLoaderFactory.class)) { + mockedClass.when(() -> ClassLoaderFactory.forPluginDir()).thenCallRealMethod(); + mockedClass.when(() -> ClassLoaderFactory.forPluginDirWithPath(Path.of(absPath))).thenReturn(ucl); + + System.setProperty("cryptomator.pluginDir", absPath); + var result = ClassLoaderFactory.forPluginDir(); + + Assertions.assertSame(ucl, result); + } + } + + @Test + @DisplayName("read path from cryptomator.pluginDir and replace ~/ with user.home") + public void testReadPluginDirFromSysPropAndReplaceHome() { + var ucl = Mockito.mock(URLClassLoader.class, "ucl"); + var relPath = "~/there/will/be/plugins"; + var absPath = Path.of(System.getProperty("user.home")).resolve("there/will/be/plugins"); + try (var mockedClass = Mockito.mockStatic(ClassLoaderFactory.class)) { + mockedClass.when(() -> ClassLoaderFactory.forPluginDir()).thenCallRealMethod(); + mockedClass.when(() -> ClassLoaderFactory.forPluginDirWithPath(absPath)).thenReturn(ucl); + + System.setProperty("cryptomator.pluginDir", relPath); + var result = ClassLoaderFactory.forPluginDir(); + + Assertions.assertSame(ucl, result); + } + } + + @Test + @DisplayName("findJars returns empty list if not containing jars") + public void testFindJars1(@TempDir Path tmpDir) throws IOException { + Files.createDirectories(tmpDir.resolve("dir1")); + Files.createFile(tmpDir.resolve("file1")); + + var urls = ClassLoaderFactory.findJars(tmpDir); + + Assertions.assertArrayEquals(new URL[0], urls); + } + + @Test + @DisplayName("findJars returns urls of found jars") + public void testFindJars2(@TempDir Path tmpDir) throws IOException { + Files.createDirectories(tmpDir.resolve("dir1")); + Files.createDirectories(tmpDir.resolve("dir2")); + Files.createDirectories(tmpDir.resolve("dir1").resolve("dir2")); + Files.createFile(tmpDir.resolve("a.jar")); + Files.createFile(tmpDir.resolve("a.txt")); + Files.createFile(tmpDir.resolve("dir2").resolve("b.jar")); + + var urls = ClassLoaderFactory.findJars(tmpDir); + + Arrays.sort(urls, Comparator.comparing(URL::toString)); + Assertions.assertArrayEquals(new URL[]{ + new URL(tmpDir.toUri() + "a.jar"), + new URL(tmpDir.toUri() + "dir2/b.jar") + }, urls); + } + +} \ No newline at end of file diff --git a/src/test/java/org/cryptomator/integrations/common/IntegrationsLoaderTest.java b/src/test/java/org/cryptomator/integrations/common/IntegrationsLoaderTest.java new file mode 100644 index 0000000..5c7c41a --- /dev/null +++ b/src/test/java/org/cryptomator/integrations/common/IntegrationsLoaderTest.java @@ -0,0 +1,195 @@ +package org.cryptomator.integrations.common; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +public class IntegrationsLoaderTest { + + @Nested + @DisplayName("@CheckAvailability on static methods") + public class StaticAvailabilityChecks { + + @CheckAvailability + private static class StaticTrue { + @CheckAvailability + public static boolean test() { + return true; + } + } + + @CheckAvailability + private static class StaticFalse { + @CheckAvailability + public static boolean test() { + return false; + } + } + + @Test + @DisplayName("no @CheckAvailability will always pass") + public void testPassesAvailabilityCheck0() { + // @formatter:off + class C1 {} + @CheckAvailability class C2 {} + class C3 { + @CheckAvailability public static boolean test() { return false; } + } + // @formatter:on + + Assertions.assertTrue(IntegrationsLoader.passesStaticAvailabilityCheck(C1.class)); + Assertions.assertTrue(IntegrationsLoader.passesStaticAvailabilityCheck(C2.class)); + Assertions.assertTrue(IntegrationsLoader.passesStaticAvailabilityCheck(C3.class)); + } + + @Test + @DisplayName("@CheckAvailability on non-conforming methods will be skipped") + public void testPassesAvailabilityCheck1() { + // @formatter:off + @CheckAvailability class C1 { + @CheckAvailability private static boolean test1() { return false; } + @CheckAvailability public static boolean test2(String foo) { return false; } + @CheckAvailability public static String test3() { return "false"; } + } + // @formatter:on + + Assertions.assertTrue(IntegrationsLoader.passesStaticAvailabilityCheck(C1.class)); + } + + @Test + @DisplayName("@CheckAvailability on static method") + public void testPassesAvailabilityCheck2() { + Assertions.assertTrue(IntegrationsLoader.passesStaticAvailabilityCheck(StaticTrue.class)); + Assertions.assertFalse(IntegrationsLoader.passesStaticAvailabilityCheck(StaticFalse.class)); + } + + @Test + @DisplayName("@CheckAvailability on inherited static method") + public void testPassesAvailabilityCheck3() { + // @formatter:off + class C1 extends StaticTrue {} + class C2 extends StaticFalse {} + // @formatter:on + + Assertions.assertTrue(IntegrationsLoader.passesStaticAvailabilityCheck(C1.class)); + Assertions.assertFalse(IntegrationsLoader.passesStaticAvailabilityCheck(C2.class)); + } + + @Test + @DisplayName("multiple @CheckAvailability methods") + public void testPassesAvailabilityCheck4() { + // @formatter:off + class C1 extends StaticTrue { + @CheckAvailability public static boolean test1() { return false; } + } + class C2 extends StaticFalse { + @CheckAvailability public static boolean test1() { return true; } + } + @CheckAvailability class C3 { + @CheckAvailability public static boolean test1() { return true; } + @CheckAvailability public static boolean test2() { return false; } + } + // @formatter:on + + Assertions.assertFalse(IntegrationsLoader.passesStaticAvailabilityCheck(C1.class)); + Assertions.assertFalse(IntegrationsLoader.passesStaticAvailabilityCheck(C2.class)); + Assertions.assertFalse(IntegrationsLoader.passesStaticAvailabilityCheck(C3.class)); + } + + + } + + @Nested + @DisplayName("@CheckAvailability on instance methods") + public class InstanceAvailabilityChecks { + + @CheckAvailability + private static class InstanceTrue { + @CheckAvailability + public boolean test() { + return true; + } + } + + @CheckAvailability + private static class InstanceFalse { + @CheckAvailability + public boolean test() { + return false; + } + } + + @Test + @DisplayName("no @CheckAvailability will always pass") + public void testPassesAvailabilityCheck0() { + // @formatter:off + class C1 {} + @CheckAvailability class C2 {} + class C3 { + @CheckAvailability public boolean test() { return false; } + } + // @formatter:on + + Assertions.assertTrue(IntegrationsLoader.passesInstanceAvailabilityCheck(new C1())); + Assertions.assertTrue(IntegrationsLoader.passesInstanceAvailabilityCheck(new C2())); + Assertions.assertTrue(IntegrationsLoader.passesInstanceAvailabilityCheck(new C3())); + } + + @Test + @DisplayName("@CheckAvailability on non-conforming instance methods will be skipped") + public void testPassesAvailabilityCheck1() { + // @formatter:off + @CheckAvailability class C1 { + @CheckAvailability private boolean test1() { return false; } + @CheckAvailability public boolean test2(String foo) { return false; } + @CheckAvailability public String test3() { return "false"; } + } + // @formatter:on + + Assertions.assertTrue(IntegrationsLoader.passesInstanceAvailabilityCheck(C1.class)); + } + + @Test + @DisplayName("@CheckAvailability on instance method") + public void testPassesAvailabilityCheck2() { + Assertions.assertTrue(IntegrationsLoader.passesInstanceAvailabilityCheck(new InstanceTrue())); + Assertions.assertFalse(IntegrationsLoader.passesInstanceAvailabilityCheck(new InstanceFalse())); + } + + @Test + @DisplayName("@CheckAvailability on inherited instance method") + public void testPassesAvailabilityCheck3() { + // @formatter:off + class C1 extends InstanceTrue {} + class C2 extends InstanceFalse {} + // @formatter:on + + Assertions.assertTrue(IntegrationsLoader.passesInstanceAvailabilityCheck(new C1())); + Assertions.assertFalse(IntegrationsLoader.passesInstanceAvailabilityCheck(new C2())); + } + + @Test + @DisplayName("multiple @CheckAvailability methods") + public void testPassesAvailabilityCheck4() { + // @formatter:off + class C1 extends InstanceTrue { + @CheckAvailability public boolean test1() { return false; } + } + class C2 extends InstanceFalse { + @CheckAvailability public boolean test1() { return true; } + } + @CheckAvailability class C3 { + @CheckAvailability public boolean test1() { return true; } + @CheckAvailability public boolean test2() { return false; } + } + // @formatter:on + + Assertions.assertFalse(IntegrationsLoader.passesInstanceAvailabilityCheck(new C1())); + Assertions.assertFalse(IntegrationsLoader.passesInstanceAvailabilityCheck(new C2())); + Assertions.assertFalse(IntegrationsLoader.passesInstanceAvailabilityCheck(new C3())); + } + + } + +} \ No newline at end of file diff --git a/src/test/java/org/cryptomator/integrations/common/JarBuilder.java b/src/test/java/org/cryptomator/integrations/common/JarBuilder.java new file mode 100644 index 0000000..b6bf276 --- /dev/null +++ b/src/test/java/org/cryptomator/integrations/common/JarBuilder.java @@ -0,0 +1,39 @@ +package org.cryptomator.integrations.common; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +public class JarBuilder implements AutoCloseable { + + private final Manifest manifest = new Manifest(); + private final JarOutputStream jos; + + public JarBuilder(JarOutputStream jos) { + this.jos = jos; + manifest.getMainAttributes().put(Attributes.Name.MANIFEST_VERSION, "1.0"); + } + + public static JarBuilder withTarget(OutputStream out) throws IOException { + return new JarBuilder(new JarOutputStream(out)); + } + + public void addFile(String path, InputStream content) throws IOException { + jos.putNextEntry(new JarEntry(path)); + content.transferTo(jos); + jos.closeEntry(); + } + + @Override + public void close() throws IOException { + jos.putNextEntry(new JarEntry(JarFile.MANIFEST_NAME)); + manifest.write(jos); + jos.closeEntry(); + jos.close(); + } +} diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..ca6ee9c --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-inline \ No newline at end of file