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 extends T> 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