implements AsyncAssemblyExecutor {
- private AssemblyStatusUpdateTask statusUpdateTask;
- private Exception exception;
-
- public AsyncAssemblyExecutorImpl(AssemblyStatusUpdateTask statusUpdateTask) {
- this.statusUpdateTask = statusUpdateTask;
- }
-
- @Override
- protected void onPostExecute(Void v) {
- getListener().onUploadFinished();
- statusUpdateTask.execute();
- }
-
- @Override
- protected void onCancelled() {
- if (exception != null) {
- getListener().onUploadFailed(exception);
- }
- }
-
- @Override
- protected Void doInBackground(Void... params) {
- try {
- uploadTusFiles();
- } catch (IOException | ProtocolException e) {
- setError(e);
- stop();
- }
- return null;
- }
-
- @Override
- public void execute() {
- super.execute();
- }
-
- @Override
- public void stop() {
- cancel(false);
- }
-
- @Override
- public void hardStop() {
- cancel(true);
- }
-
- void setError(Exception exception) {
- this.exception = exception;
- }
- }
-}
diff --git a/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidTransloadit.java b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidTransloadit.java
index 09ec443..4c72d46 100644
--- a/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidTransloadit.java
+++ b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidTransloadit.java
@@ -1,19 +1,28 @@
package com.transloadit.android.sdk;
-
import android.content.Context;
import androidx.annotation.Nullable;
-import com.transloadit.sdk.async.AssemblyProgressListener;
-
+import com.transloadit.sdk.SignatureProvider;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
-
+/**
+ * Android-friendly extension of the core {@link com.transloadit.sdk.Transloadit} client.
+ */
public class AndroidTransloadit extends com.transloadit.sdk.Transloadit {
+
+ /**
+ * Creates a client using inline credentials and a custom host.
+ *
+ * @param key Transloadit API key
+ * @param secret Transloadit API secret
+ * @param duration signature validity duration in seconds
+ * @param hostUrl Transloadit API host
+ */
public AndroidTransloadit(String key, @Nullable String secret, long duration, String hostUrl) {
super(key, secret, duration, hostUrl);
}
@@ -22,8 +31,8 @@ public AndroidTransloadit(String key, @Nullable String secret, long duration, St
* A new instance to transloadit client
*
* @param key User's transloadit key
- * @param secret User's transloadit secret.
- * @param hostUrl the host url to the transloadit service.
+ * @param secret User's transloadit secret
+ * @param hostUrl the host url to the transloadit service
*/
public AndroidTransloadit(String key, String secret, String hostUrl) {
this(key, secret, 5 * 60, hostUrl);
@@ -33,20 +42,93 @@ public AndroidTransloadit(String key, String secret, String hostUrl) {
* A new instance to transloadit client
*
* @param key User's transloadit key
- * @param secret User's transloadit secret.
+ * @param secret User's transloadit secret
*/
public AndroidTransloadit(String key, String secret) {
this(key, secret, 5 * 60, DEFAULT_HOST_URL);
}
- public AndroidAsyncAssembly newAssembly(AssemblyProgressListener listener, Context context) {
- return new AndroidAsyncAssembly(this, listener, context);
+ /**
+ * A new instance to transloadit client without a secret, using external signature generation.
+ *
+ * This constructor should be used when you want to generate signatures on your backend
+ * server instead of including the secret key in your Android application. This approach
+ * significantly improves security by preventing the secret from being extracted from the APK.
+ *
+ * @param key User's transloadit key
+ * @param signatureProvider Provider for generating signatures externally
+ * @param duration for how long (in seconds) the request should be valid
+ * @param hostUrl the host url to the transloadit service
+ */
+ public AndroidTransloadit(String key, SignatureProvider signatureProvider, long duration, String hostUrl) {
+ super(key, signatureProvider, duration, hostUrl);
+ }
+
+ /**
+ * A new instance to transloadit client without a secret, using external signature generation.
+ *
+ * This constructor should be used when you want to generate signatures on your backend
+ * server instead of including the secret key in your Android application.
+ *
+ * @param key User's transloadit key
+ * @param signatureProvider Provider for generating signatures externally
+ * @param hostUrl the host url to the transloadit service
+ */
+ public AndroidTransloadit(String key, SignatureProvider signatureProvider, String hostUrl) {
+ this(key, signatureProvider, 5 * 60, hostUrl);
+ }
+
+ /**
+ * A new instance to transloadit client without a secret, using external signature generation.
+ *
+ * This constructor should be used when you want to generate signatures on your backend
+ * server instead of including the secret key in your Android application.
+ *
+ * @param key User's transloadit key
+ * @param signatureProvider Provider for generating signatures externally
+ */
+ public AndroidTransloadit(String key, SignatureProvider signatureProvider) {
+ this(key, signatureProvider, 5 * 60, DEFAULT_HOST_URL);
+ }
+
+ /**
+ * Creates a new {@link AndroidAssembly} that dispatches callbacks on Android threads.
+ *
+ * @param listener lifecycle listener that receives callbacks
+ * @param context Android context used for configuration
+ * @return configured {@link AndroidAssembly}
+ */
+ public AndroidAssembly newAssembly(AndroidAssemblyListener listener, Context context) {
+ return new AndroidAssembly(this, listener, context);
+ }
+
+ /**
+ * Internal helper used by tests to inspect the configured API key.
+ */
+ String getKeyForTesting() {
+ return getKeyInternal();
+ }
+
+ /**
+ * Internal helper used by tests to inspect the configured secret.
+ */
+ @Nullable
+ String getSecretForTesting() {
+ return getSecretInternal();
+ }
+
+ /**
+ * Internal helper used by tests to check whether signing is enabled.
+ */
+ boolean isSigningEnabledForTesting() {
+ return isSigningEnabledInternal();
}
/**
* Determines the current version number of the SDK. This method is called within the constructor
* of the parent class.
- * @return Version Number as String
+ *
+ * @return version number as string
*/
@Override
protected String loadVersionInfo() {
diff --git a/transloadit-android/src/main/java/com/transloadit/android/sdk/AssemblyStatusUpdateTask.java b/transloadit-android/src/main/java/com/transloadit/android/sdk/AssemblyStatusUpdateTask.java
deleted file mode 100644
index 255abab..0000000
--- a/transloadit-android/src/main/java/com/transloadit/android/sdk/AssemblyStatusUpdateTask.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package com.transloadit.android.sdk;
-
-import android.os.AsyncTask;
-
-import com.transloadit.sdk.response.AssemblyResponse;
-
-import java.util.concurrent.Callable;
-
-/**
- * This class helps us run a watch on an assembly status in an async manner.
- */
-class AssemblyStatusUpdateTask extends AsyncTask {
- private AndroidAsyncAssembly assembly;
- private Exception exception;
- private Callable callable;
-
- public AssemblyStatusUpdateTask(AndroidAsyncAssembly assembly, Callable callable) {
- this.assembly = assembly;
- this.callable = callable;
- }
-
- @Override
- protected void onPostExecute(AssemblyResponse response) {
- assembly.getListener().onAssemblyFinished(response);
- }
-
- @Override
- protected void onCancelled() {
- if (exception != null) {
- assembly.getListener().onAssemblyStatusUpdateFailed(exception);
- }
- }
-
- @Override
- protected AssemblyResponse doInBackground(Void... params) {
- try {
- return callable.call();
- } catch (Exception e) {
- setError(e);
- cancel(false);
- }
-
- return null;
- }
-
- void setError(Exception exception) {
- this.exception = exception;
- }
-}
\ No newline at end of file
diff --git a/transloadit-android/src/main/resources/android-sdk-version/version.properties b/transloadit-android/src/main/resources/android-sdk-version/version.properties
index c30884e..cc390f4 100644
--- a/transloadit-android/src/main/resources/android-sdk-version/version.properties
+++ b/transloadit-android/src/main/resources/android-sdk-version/version.properties
@@ -1,3 +1,3 @@
-version="0.0.10"
+version="0.2.0"
description="An Android Integration of the Transloadit's(https://transloadit.com) file uploading and encoding service."
group='com.transloadit.android.sdk'
diff --git a/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyConcurrencyTest.java b/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyConcurrencyTest.java
new file mode 100644
index 0000000..5f0522c
--- /dev/null
+++ b/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyConcurrencyTest.java
@@ -0,0 +1,42 @@
+package com.transloadit.android.sdk;
+
+import static org.junit.Assert.assertSame;
+
+import android.content.Context;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.lang.reflect.Field;
+import java.util.concurrent.ExecutorService;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 28)
+public class AndroidAssemblyConcurrencyTest {
+
+ private static final AndroidAssemblyListener NOOP_LISTENER = new AndroidAssemblyListener() {};
+
+ @Test
+ public void assembliesShareExecutorInstance() throws Exception {
+ Context context = ApplicationProvider.getApplicationContext();
+ AndroidTransloadit transloadit = new AndroidTransloadit("key", "secret");
+
+ AndroidAssembly first = new AndroidAssembly(transloadit, NOOP_LISTENER, context);
+ AndroidAssembly second = new AndroidAssembly(transloadit, NOOP_LISTENER, context);
+
+ ExecutorService firstExecutor = executorOf(first);
+ ExecutorService secondExecutor = executorOf(second);
+
+ assertSame(firstExecutor, secondExecutor);
+ }
+
+ private static ExecutorService executorOf(AndroidAssembly assembly) throws Exception {
+ Field field = AndroidAssembly.class.getDeclaredField("executor");
+ field.setAccessible(true);
+ return (ExecutorService) field.get(assembly);
+ }
+}
diff --git a/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyDispatchTest.java b/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyDispatchTest.java
new file mode 100644
index 0000000..a51e643
--- /dev/null
+++ b/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyDispatchTest.java
@@ -0,0 +1,116 @@
+package com.transloadit.android.sdk;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.os.Looper;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import com.transloadit.sdk.AssemblyListener;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowLooper;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 28)
+public class AndroidAssemblyDispatchTest {
+
+ private AndroidTransloadit transloadit;
+ private Context context;
+
+ @Before
+ public void setUp() {
+ context = ApplicationProvider.getApplicationContext();
+ transloadit = new AndroidTransloadit("key", "secret");
+ }
+
+ @Test
+ public void callbacksDefaultToMainThread() throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference ranOnMain = new AtomicReference<>(false);
+
+ AndroidAssemblyListener listener = new AndroidAssemblyListener() {
+ @Override
+ public void onUploadFinished() {
+ ranOnMain.set(Looper.myLooper() == Looper.getMainLooper());
+ latch.countDown();
+ }
+ };
+
+ AndroidAssembly assembly = new AndroidAssembly(transloadit, listener, context);
+ AssemblyListener adapter = assembly.createListenerAdapterForTesting();
+
+ ExecutorService background = Executors.newSingleThreadExecutor();
+ Future> dispatched = background.submit(() -> {
+ adapter.onAssemblyUploadFinished();
+ return null;
+ });
+
+ dispatched.get(5, TimeUnit.SECONDS);
+ ShadowLooper.runUiThreadTasksIncludingDelayedTasks();
+
+ assertTrue("Callback not invoked", latch.await(5, TimeUnit.SECONDS));
+ assertTrue("Callback should run on main thread", Boolean.TRUE.equals(ranOnMain.get()));
+
+ background.shutdownNow();
+ assembly.close();
+ }
+
+ @Test
+ public void callbacksCanOptOutOfMainThread() throws Exception {
+ CountDownLatch latch = new CountDownLatch(1);
+ AtomicReference callbackThread = new AtomicReference<>();
+
+ AndroidAssemblyListener listener = new AndroidAssemblyListener() {
+ @Override
+ public void onUploadFinished() {
+ callbackThread.set(Thread.currentThread());
+ latch.countDown();
+ }
+ };
+
+ AndroidAssembly assembly = new AndroidAssembly(transloadit, listener, context);
+ assembly.useDirectCallbacks();
+ AssemblyListener adapter = assembly.createListenerAdapterForTesting();
+
+ ExecutorService background = Executors.newSingleThreadExecutor();
+ Future taskThread = background.submit(() -> {
+ Thread dispatchThread = Thread.currentThread();
+ adapter.onAssemblyUploadFinished();
+ return dispatchThread;
+ });
+
+ assertTrue("Callback not invoked", latch.await(5, TimeUnit.SECONDS));
+ Thread dispatchThread = taskThread.get(5, TimeUnit.SECONDS);
+ assertSame("Callback should execute on dispatch thread", dispatchThread, callbackThread.get());
+
+ background.shutdownNow();
+ assembly.close();
+ }
+
+ @Test
+ public void pauseAndResumeHelpersSucceedWithoutUploads() throws Exception {
+ AndroidAssemblyListener listener = new AndroidAssemblyListener() { };
+ AndroidAssembly assembly = new AndroidAssembly(transloadit, listener, context);
+ try {
+ assertTrue(assembly.pauseUploadsSafely());
+ assertTrue(assembly.resumeUploadsSafely());
+ } finally {
+ assembly.close();
+ }
+ }
+}
diff --git a/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyUploadWorkerTest.java b/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyUploadWorkerTest.java
new file mode 100644
index 0000000..c119909
--- /dev/null
+++ b/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyUploadWorkerTest.java
@@ -0,0 +1,264 @@
+package com.transloadit.android.sdk;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import android.content.Context;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.work.ListenableWorker;
+import androidx.work.testing.TestListenableWorkerBuilder;
+
+import com.transloadit.sdk.exceptions.RequestException;
+import com.transloadit.sdk.response.AssemblyResponse;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.net.URLConnection;
+import java.net.URLStreamHandler;
+import java.net.URLStreamHandlerFactory;
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import okhttp3.MediaType;
+import okhttp3.Protocol;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 28)
+public class AndroidAssemblyUploadWorkerTest {
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void missingFileFailsFast() throws Exception {
+ File missing = new File(temporaryFolder.getRoot(), "does_not_exist.bin");
+ AndroidAssemblyWorkConfig config = AndroidAssemblyWorkConfig.newBuilder("key", "secret")
+ .paramsJson("{\"steps\":{\"noop\":{\"robot\":\"/video/encode\"}}}")
+ .addFile(missing, "file")
+ .build();
+
+ Context context = ApplicationProvider.getApplicationContext();
+ AndroidAssemblyUploadWorker worker = TestListenableWorkerBuilder
+ .from(context, AndroidAssemblyUploadWorker.class)
+ .setInputData(config.toInputData())
+ .build();
+
+ ListenableWorker.Result result = worker.startWork().get();
+ assertTrue(result instanceof ListenableWorker.Result.Failure);
+ }
+
+ @Test
+ public void signatureProviderWithoutErrorStreamReturnsDeterministicFailure() throws Exception {
+ ensureNullHttpHandlerRegistered();
+ AndroidAssemblyUploadWorker worker = TestListenableWorkerBuilder
+ .from(ApplicationProvider.getApplicationContext(), AndroidAssemblyUploadWorker.class)
+ .build();
+
+ Method method = AndroidAssemblyUploadWorker.class.getDeclaredMethod(
+ "buildSignatureProvider", String.class, String.class, java.util.Map.class);
+ method.setAccessible(true);
+
+ Object providerObj = method.invoke(worker, "nullhttp://signature.test", "POST", Collections.emptyMap());
+ com.transloadit.sdk.SignatureProvider provider = (com.transloadit.sdk.SignatureProvider) providerObj;
+
+ try {
+ provider.generateSignature("{}");
+ fail("Expected RequestException");
+ } catch (RequestException ex) {
+ assertTrue(ex.getMessage().contains("500"));
+ }
+ }
+
+ @Test
+ public void workerDoesNotLeakExecutorThreads() throws Exception {
+ File missing = new File(temporaryFolder.getRoot(), "does_not_exist.bin");
+ AndroidAssemblyWorkConfig config = AndroidAssemblyWorkConfig.newBuilder("key", "secret")
+ .paramsJson("{\"steps\":{\"noop\":{\"robot\":\"/video/encode\"}}}")
+ .addFile(missing, "file")
+ .build();
+
+ Set before = currentPoolThreadNames();
+
+ AndroidAssemblyUploadWorker worker = TestListenableWorkerBuilder
+ .from(ApplicationProvider.getApplicationContext(), AndroidAssemblyUploadWorker.class)
+ .setInputData(config.toInputData())
+ .build();
+
+ ListenableWorker.Result result = worker.startWork().get();
+ assertTrue(result instanceof ListenableWorker.Result.Failure);
+
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2);
+ while (System.nanoTime() < deadline) {
+ Set leaked = currentPoolThreadNames();
+ leaked.removeAll(before);
+ if (leaked.isEmpty()) {
+ return;
+ }
+ Thread.sleep(50);
+ }
+ Set leaked = currentPoolThreadNames();
+ leaked.removeAll(before);
+ fail("Detected leaked executor threads: " + leaked);
+ }
+
+ @Test
+ public void statusUpdateFailureUnblocksLatch() throws Exception {
+ File temp = temporaryFolder.newFile("input.txt");
+ AndroidAssemblyWorkConfig config = AndroidAssemblyWorkConfig.newBuilder("key", "secret")
+ .addFile(temp, "file")
+ .paramsJson("{\"steps\":{\"noop\":{\"robot\":\"/video/encode\"}}}")
+ .completionTimeoutMillis(200)
+ .build();
+
+ StatusFailureWorker worker = TestListenableWorkerBuilder
+ .from(ApplicationProvider.getApplicationContext(), StatusFailureWorker.class)
+ .setInputData(config.toInputData())
+ .build();
+
+ long start = System.nanoTime();
+ ListenableWorker.Result result = worker.startWork().get();
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+
+ assertTrue(result instanceof ListenableWorker.Result.Failure);
+ assertTrue("Expected latch to unblock quickly, took " + elapsedMs + "ms", elapsedMs < 500);
+ }
+
+ private static Set currentPoolThreadNames() {
+ return Thread.getAllStackTraces().keySet().stream()
+ .map(Thread::getName)
+ .filter(name -> name.startsWith("pool-") || name.startsWith("android-assembly-"))
+ .collect(Collectors.toSet());
+ }
+
+ private static volatile boolean handlerRegistered = false;
+
+ private static void ensureNullHttpHandlerRegistered() {
+ if (handlerRegistered) {
+ return;
+ }
+ synchronized (AndroidAssemblyUploadWorkerTest.class) {
+ if (handlerRegistered) {
+ return;
+ }
+ try {
+ URL.setURLStreamHandlerFactory(new NullHttpHandlerFactory());
+ } catch (Error ignored) {
+ // Factory already registered elsewhere; nothing to do.
+ }
+ handlerRegistered = true;
+ }
+ }
+
+ private static class NullHttpHandlerFactory implements URLStreamHandlerFactory {
+ @Override
+ public URLStreamHandler createURLStreamHandler(String protocol) {
+ if ("nullhttp".equals(protocol)) {
+ return new URLStreamHandler() {
+ @Override
+ protected URLConnection openConnection(URL u) {
+ return new NullHttpURLConnection(u);
+ }
+ };
+ }
+ return null;
+ }
+ }
+
+ private static class NullHttpURLConnection extends HttpURLConnection {
+ protected NullHttpURLConnection(URL url) {
+ super(url);
+ }
+
+ @Override
+ public void disconnect() { }
+
+ @Override
+ public boolean usingProxy() {
+ return false;
+ }
+
+ @Override
+ public void connect() { }
+
+ @Override
+ public int getResponseCode() {
+ return 500;
+ }
+
+ @Override
+ public InputStream getInputStream() {
+ return null;
+ }
+
+ @Override
+ public InputStream getErrorStream() {
+ return null;
+ }
+
+ @Override
+ public OutputStream getOutputStream() {
+ return new ByteArrayOutputStream();
+ }
+ }
+
+ public static class StatusFailureWorker extends AndroidAssemblyUploadWorker {
+ public StatusFailureWorker(Context context, androidx.work.WorkerParameters params) {
+ super(context, params);
+ }
+
+ @Override
+ protected AndroidAssembly createAssembly(AndroidTransloadit transloadit, AndroidAssemblyListener listener) {
+ return new StatusFailureAssembly(transloadit, listener, getApplicationContext());
+ }
+ }
+
+ private static class StatusFailureAssembly extends AndroidAssembly {
+ private final AndroidAssemblyListener listener;
+
+ StatusFailureAssembly(AndroidTransloadit transloadit, AndroidAssemblyListener listener, Context context) {
+ super(transloadit, listener, context);
+ this.listener = listener;
+ }
+
+ @Override
+ public Future saveAsync(boolean isResumable) {
+ Request request = new Request.Builder().url("https://example.com/assembly").build();
+ Response response = new Response.Builder()
+ .request(request)
+ .protocol(Protocol.HTTP_1_1)
+ .code(200)
+ .message("OK")
+ .body(ResponseBody.create("{}", MediaType.get("application/json")))
+ .build();
+ AssemblyResponse assemblyResponse;
+ try {
+ assemblyResponse = new AssemblyResponse(response);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ listener.onAssemblyStatusUpdateFailed(new RequestException("status failure"));
+ return CompletableFuture.completedFuture(assemblyResponse);
+ }
+ }
+}
diff --git a/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyWorkConfigTest.java b/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyWorkConfigTest.java
new file mode 100644
index 0000000..87d2052
--- /dev/null
+++ b/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAssemblyWorkConfigTest.java
@@ -0,0 +1,98 @@
+package com.transloadit.android.sdk;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import androidx.work.Constraints;
+import androidx.work.NetworkType;
+import androidx.work.OneTimeWorkRequest;
+
+import org.json.JSONObject;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+
+public class AndroidAssemblyWorkConfigTest {
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void roundTripSerializationWorks() throws Exception {
+ File file = temporaryFolder.newFile("upload.bin");
+ AndroidAssemblyWorkConfig config = AndroidAssemblyWorkConfig.newBuilder("key", "secret")
+ .hostUrl("https://api2.transloadit.com")
+ .paramsJson("{\"steps\":{\"resize\":{\"robot\":\"/image/resize\",\"width\":32}}}")
+ .addFile(file, "image")
+ .preferenceName("custom_store")
+ .completionTimeoutMillis(90_000)
+ .uploadTimeoutMillis(120_000)
+ .waitForCompletion(true)
+ .resumable(true)
+ .build();
+
+ JSONObject json = config.toJson();
+ AndroidAssemblyWorkConfig restored = AndroidAssemblyWorkConfig.fromJson(json);
+
+ assertEquals("key", restored.getAuthKey());
+ assertEquals("secret", restored.getAuthSecret());
+ assertEquals("https://api2.transloadit.com", restored.getHostUrl());
+ assertEquals("custom_store", restored.getPreferenceName());
+ assertTrue(restored.shouldWaitForCompletion());
+ assertTrue(restored.isResumable());
+ assertEquals(90_000, restored.getCompletionTimeoutMillis());
+ assertEquals(120_000, restored.getUploadTimeoutMillis());
+ assertEquals(1, restored.getFiles().size());
+ assertEquals(file.getAbsolutePath(), restored.getFiles().get(0).getPath());
+ assertNotNull(restored.getParams());
+ }
+
+ @Test
+ public void workRequestUsesNetworkConstraint() throws Exception {
+ File file = temporaryFolder.newFile("upload.bin");
+ AndroidAssemblyWorkConfig config = AndroidAssemblyWorkConfig.newBuilder("key", "secret")
+ .addFile(file, "file")
+ .build();
+
+ OneTimeWorkRequest request = config.toWorkRequest();
+ Constraints constraints = request.getWorkSpec().constraints;
+ assertEquals(NetworkType.CONNECTED, constraints.getRequiredNetworkType());
+ AndroidAssemblyWorkConfig reread = AndroidAssemblyWorkConfig.fromInputData(request.getWorkSpec().input);
+ assertEquals("key", reread.getAuthKey());
+ }
+
+ @Test
+ public void signatureProviderRoundTrip() throws Exception {
+ File file = temporaryFolder.newFile("upload.bin");
+ AndroidAssemblyWorkConfig config = AndroidAssemblyWorkConfig.newBuilder("key")
+ .signatureProvider("https://example.com/sign")
+ .signatureProviderMethod("post")
+ .addSignatureProviderHeader("Authorization", "Bearer token")
+ .paramsJson("{\"steps\":{\"resize\":{\"robot\":\"/image/resize\"}}}")
+ .addFile(file, "file")
+ .build();
+
+ JSONObject json = config.toJson();
+ AndroidAssemblyWorkConfig restored = AndroidAssemblyWorkConfig.fromJson(json);
+
+ assertEquals("key", restored.getAuthKey());
+ assertEquals("https://example.com/sign", restored.getSignatureProviderUrl());
+ assertEquals("POST", restored.getSignatureProviderMethod());
+ assertEquals("Bearer token", restored.getSignatureProviderHeaders().get("Authorization"));
+ assertEquals(1, restored.getFiles().size());
+ assertEquals(file.getAbsolutePath(), restored.getFiles().get(0).getPath());
+ }
+
+ @Test
+ public void allowsRemoteOnlyAssemblies() throws Exception {
+ AndroidAssemblyWorkConfig config = AndroidAssemblyWorkConfig.newBuilder("key", "secret")
+ .paramsJson("{\"steps\":{\"import\":{\"robot\":\"/http/import\",\"url\":\"https://example.com/file.jpg\"}}}")
+ .build();
+
+ assertTrue(config.getFiles().isEmpty());
+ assertNotNull(config.getParams());
+ }
+}
diff --git a/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAsyncAssemblyTest.java b/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAsyncAssemblyTest.java
deleted file mode 100644
index 6863c35..0000000
--- a/transloadit-android/src/test/java/com/transloadit/android/sdk/AndroidAsyncAssemblyTest.java
+++ /dev/null
@@ -1,110 +0,0 @@
-package com.transloadit.android.sdk;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNull;
-import static org.junit.Assert.assertTrue;
-
-
-import android.content.Context;
-
-import com.transloadit.sdk.async.AssemblyProgressListener;
-import com.transloadit.sdk.response.AssemblyResponse;
-
-import org.junit.Rule;
-import org.junit.Test;
-import org.mockito.Mockito;
-import org.mockserver.client.MockServerClient;
-import org.mockserver.junit.MockServerRule;
-import org.mockserver.model.HttpRequest;
-import org.mockserver.model.HttpResponse;
-
-import java.io.BufferedReader;
-import java.io.File;
-import java.io.FileReader;
-import java.io.IOException;
-
-import io.tus.java.client.TusURLMemoryStore;
-
-public class AndroidAsyncAssemblyTest {
- public final int PORT = 9040;
- @Rule
- public MockServerRule mockServerRule = new MockServerRule(this, true, PORT);
-
- private MockServerClient mockServerClient;
- private boolean uploadFinished;
- private boolean assemblyFinished;
- private long totalUploaded;
- private Exception statusUpdateError;
- private Exception uploadError;
-
-
- @Test
- public void testSave() throws Exception {
- // for assembly creation
- mockServerClient.when(HttpRequest.request()
- .withPath("/assemblies/76fe5df1c93a0a530f3e583805cf98b4")
- .withMethod("POST"))
- .respond(HttpResponse.response().withBody(getJson("assembly.json")));
-
- // for assembly status check
- mockServerClient.when(HttpRequest.request()
- .withPath("/assemblies/76fe5df1c93a0a530f3e583805cf98b4").withMethod("GET"))
- .respond(HttpResponse.response().withBody(getJson("assembly.json")));
-
- AndroidTransloadit transloadit = new AndroidTransloadit("KEY", "SECRET", "http://localhost:" + PORT);
- AssemblyProgressListener listener = new Listener();
- AndroidAsyncAssembly assembly = new MockAsyncAssembly(transloadit, listener, Mockito.mock(Context.class));
- assembly.setAssemblyId("76fe5df1c93a0a530f3e583805cf98b4");
- assembly.setTusURLStore(new TusURLMemoryStore());
- assembly.addFile(new File(getClass().getClassLoader().getResource("assembly.json").getFile()), "file_name");
- AssemblyResponse resumableAssembly = assembly.save();
- assertEquals(resumableAssembly.json().get("assembly_id"), "76fe5df1c93a0a530f3e583805cf98b4");
- assertTrue(uploadFinished);
- assertTrue(assemblyFinished);
- assertEquals(1077, totalUploaded);
- assertNull(statusUpdateError);
- assertNull(uploadError);
- }
-
- class Listener implements AssemblyProgressListener {
- @Override
- public void onUploadFinished() {
- uploadFinished = true;
- }
-
- @Override
- public void onUploadProgress(long uploadedBytes, long totalBytes) {
- totalUploaded = uploadedBytes;
- }
-
- @Override
- public void onAssemblyFinished(AssemblyResponse response) {
- assemblyFinished = true;
- }
-
- @Override
- public void onUploadFailed(Exception exception) {
- uploadError = exception;
- }
-
- @Override
- public void onAssemblyStatusUpdateFailed(Exception exception) {
- statusUpdateError = exception;
- }
- }
-
- private String getJson (String name) throws IOException {
- String filePath = getClass().getClassLoader().getResource(name).getFile();
-
- BufferedReader br = new BufferedReader(new FileReader(filePath));
- StringBuilder sb = new StringBuilder();
- String line = br.readLine();
-
- while (line != null) {
- sb.append(line).append("\n");
- line = br.readLine();
- }
-
- return sb.toString();
- }
-}
\ No newline at end of file
diff --git a/transloadit-android/src/test/java/com/transloadit/android/sdk/AssemblyIntegrationTest.java b/transloadit-android/src/test/java/com/transloadit/android/sdk/AssemblyIntegrationTest.java
new file mode 100644
index 0000000..46b9264
--- /dev/null
+++ b/transloadit-android/src/test/java/com/transloadit/android/sdk/AssemblyIntegrationTest.java
@@ -0,0 +1,56 @@
+package com.transloadit.android.sdk;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.transloadit.sdk.Assembly;
+import com.transloadit.sdk.exceptions.LocalOperationException;
+import com.transloadit.sdk.exceptions.RequestException;
+import com.transloadit.sdk.response.AssemblyResponse;
+
+import org.json.JSONArray;
+import org.junit.Assume;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+public class AssemblyIntegrationTest {
+
+ @Test
+ public void createAssemblyAndWaitForCompletion() throws Exception {
+ String key = System.getenv("TRANSLOADIT_KEY");
+ String secret = System.getenv("TRANSLOADIT_SECRET");
+ Assume.assumeTrue("TRANSLOADIT_KEY env var required", key != null && !key.isEmpty());
+ Assume.assumeTrue("TRANSLOADIT_SECRET env var required", secret != null && !secret.isEmpty());
+
+ AndroidTransloadit transloadit = new AndroidTransloadit(key, secret);
+ Assembly assembly = transloadit.newAssembly();
+
+ Map importStep = new HashMap<>();
+ importStep.put("url", "https://demos.transloadit.com/inputs/chameleon.jpg");
+ assembly.addStep("import", "/http/import", importStep);
+
+ Map resizeStep = new HashMap<>();
+ resizeStep.put("use", "import");
+ resizeStep.put("width", 64);
+ resizeStep.put("height", 64);
+ assembly.addStep("resize", "/image/resize", resizeStep);
+
+ AssemblyResponse response = assembly.save(false);
+ String assemblyId = response.getId();
+
+ long deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5);
+ while (!response.isFinished() && System.currentTimeMillis() < deadline) {
+ Thread.sleep(5000);
+ response = transloadit.getAssembly(assemblyId);
+ }
+
+ assertTrue("Assembly did not finish in time", response.isFinished());
+ assertEquals("ASSEMBLY_COMPLETED", response.json().optString("ok"));
+
+ JSONArray resizeResult = response.getStepResult("resize");
+ assertTrue("Expected resize step results", resizeResult != null && resizeResult.length() > 0);
+ }
+}
diff --git a/transloadit-android/src/test/java/com/transloadit/android/sdk/MockAsyncAssembly.java b/transloadit-android/src/test/java/com/transloadit/android/sdk/MockAsyncAssembly.java
deleted file mode 100644
index 59f5c21..0000000
--- a/transloadit-android/src/test/java/com/transloadit/android/sdk/MockAsyncAssembly.java
+++ /dev/null
@@ -1,59 +0,0 @@
-package com.transloadit.android.sdk;
-
-import android.content.Context;
-
-import com.transloadit.sdk.async.AssemblyProgressListener;
-import com.transloadit.sdk.response.AssemblyResponse;
-
-import org.jetbrains.annotations.NotNull;
-import org.mockito.Mockito;
-import org.mockito.invocation.InvocationOnMock;
-import org.mockito.stubbing.Answer;
-
-import java.io.IOException;
-
-import io.tus.java.client.ProtocolException;
-import io.tus.java.client.TusClient;
-import io.tus.java.client.TusUpload;
-import io.tus.java.client.TusUploader;
-
-public class MockAsyncAssembly extends AndroidAsyncAssembly {
- public MockAsyncAssembly(AndroidTransloadit transloadit, AssemblyProgressListener listener, Context context) {
- super(transloadit, listener, context);
- tusClient = new MockTusClient();
- }
-
- static class MockTusClient extends TusClient {
- @Override
- public TusUploader resumeOrCreateUpload(@NotNull TusUpload upload) throws ProtocolException, IOException {
- TusUploader uploader = Mockito.mock(TusUploader.class);
- // 1077 / 3 = 359 i.e size of the LICENSE file
- Mockito.when(uploader.uploadChunk()).thenReturn(359,359, 359, 0, -1);
- return uploader;
- }
- }
-
- @Override
- protected void startExecutor() {
- AssemblyStatusUpdateTask statusUpdateTask = Mockito.mock(AssemblyStatusUpdateTask.class);
- Mockito.when(statusUpdateTask.execute()).thenAnswer(new Answer() {
- public Void answer(InvocationOnMock invocation) {
- getListener().onAssemblyFinished(Mockito.mock(AssemblyResponse.class));
- return null;
- }
- });
- executor = new MockExecutor(statusUpdateTask);
- executor.execute();
- }
-
- class MockExecutor extends AndroidAsyncAssembly.AsyncAssemblyExecutorImpl {
- MockExecutor(AssemblyStatusUpdateTask statusUpdateTask) {
- super(statusUpdateTask);
- }
-
- @Override
- public void execute() {
- onPostExecute(doInBackground());
- }
- }
-}
diff --git a/transloadit-android/src/test/java/com/transloadit/android/sdk/SignatureProviderE2ETest.java b/transloadit-android/src/test/java/com/transloadit/android/sdk/SignatureProviderE2ETest.java
new file mode 100644
index 0000000..7bf6ea6
--- /dev/null
+++ b/transloadit-android/src/test/java/com/transloadit/android/sdk/SignatureProviderE2ETest.java
@@ -0,0 +1,560 @@
+package com.transloadit.android.sdk;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import android.content.Context;
+import android.os.Looper;
+
+import androidx.test.core.app.ApplicationProvider;
+
+import com.transloadit.sdk.SignatureProvider;
+import com.transloadit.sdk.exceptions.LocalOperationException;
+import com.transloadit.sdk.exceptions.RequestException;
+import com.transloadit.sdk.response.AssemblyResponse;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Assume;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+import org.robolectric.Shadows;
+import org.robolectric.shadows.ShadowLooper;
+
+import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Iterator;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import io.tus.java.client.ProtocolException;
+
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+
+/**
+ * Host-side end-to-end verification for the signature provider flow.
+ *
+ * The test executes only when ANDROID_SDK_E2E=true and Transloadit credentials are
+ * provided via environment variables. Otherwise it is skipped so PR runs remain fast.
+ */
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = {28})
+public class SignatureProviderE2ETest {
+ private static final String ENV_E2E_FLAG = "ANDROID_SDK_E2E";
+ private static final String ENV_KEY = "TRANSLOADIT_KEY";
+ private static final String ENV_SECRET = "TRANSLOADIT_SECRET";
+ private static final String SIGNATURE_ENDPOINT = "/sign";
+
+ private static boolean e2eEnabled;
+ private static String transloaditKey;
+ private static String transloaditSecret;
+
+ @BeforeClass
+ public static void loadEnv() {
+ e2eEnabled = parseBoolean(System.getenv(ENV_E2E_FLAG));
+ transloaditKey = firstNonEmpty(System.getenv(ENV_KEY));
+ transloaditSecret = firstNonEmpty(System.getenv(ENV_SECRET));
+ }
+
+ @Test
+ public void uploadCompletesViaExternalSignatureProviderWithPauseResume() throws Exception {
+ Assume.assumeTrue("E2E signature-provider test disabled", e2eEnabled);
+ Assume.assumeTrue("TRANSLOADIT_KEY missing", !isNullOrEmpty(transloaditKey));
+ Assume.assumeTrue("TRANSLOADIT_SECRET missing", !isNullOrEmpty(transloaditSecret));
+
+ Context context = ApplicationProvider.getApplicationContext();
+ File upload = createTempUpload(context, 32 * 1024 * 1024); // 32 MiB to ensure pause window
+
+ AtomicBoolean progressObserved = new AtomicBoolean(false);
+ AtomicBoolean uploadFinished = new AtomicBoolean(false);
+ AtomicBoolean sseObserved = new AtomicBoolean(false);
+ AtomicBoolean pauseInvoked = new AtomicBoolean(false);
+ AtomicBoolean resumeInvoked = new AtomicBoolean(false);
+ AtomicReference lastProgressFraction = new AtomicReference<>(0.0d);
+ AtomicReference unexpectedStatusUpdateFailure = new AtomicReference<>(null);
+ AtomicReference resizeResults = new AtomicReference<>(null);
+ CountDownLatch resultLatch = new CountDownLatch(1);
+
+ List timeline = Collections.synchronizedList(new ArrayList<>());
+ long startMillis = System.currentTimeMillis();
+ Consumer log = message -> {
+ long delta = System.currentTimeMillis() - startMillis;
+ String entry = String.format(Locale.US, "[+%6dms] %s", delta, message);
+ timeline.add(entry);
+ System.out.println("[SignatureProviderE2ETest] " + entry);
+ };
+
+ CountDownLatch progressLatch = new CountDownLatch(1);
+ CountDownLatch sseLatch = new CountDownLatch(1);
+
+ log.accept("E2E flag=" + e2eEnabled + " key present=" + !isNullOrEmpty(transloaditKey));
+ log.accept("Temp upload path=" + upload.getAbsolutePath() + " size=" + upload.length());
+
+ try (MockWebServer signingServer = startSigningServer(transloaditSecret)) {
+ log.accept("Signing server url=" + signingServer.url(SIGNATURE_ENDPOINT));
+ SignatureProvider provider = paramsJson ->
+ requestSignature(signingServer.url(SIGNATURE_ENDPOINT).url(), paramsJson);
+
+ AndroidAssemblyListener listener = new AndroidAssemblyListener() {
+ @Override
+ public void onUploadFinished() {
+ uploadFinished.set(true);
+ log.accept("Upload finished callback");
+ }
+
+ @Override
+ public void onUploadProgress(long uploadedBytes, long totalBytes) {
+ if (totalBytes > 0L && uploadedBytes > 0L) {
+ double fraction = (double) uploadedBytes / (double) totalBytes;
+ lastProgressFraction.set(fraction);
+ progressObserved.set(true);
+ progressLatch.countDown();
+ log.accept(String.format(Locale.US, "Upload progress %.2f%%", 100.0 * fraction));
+ }
+ }
+
+ @Override
+ public void onUploadFailed(Exception exception) {
+ throw new AssertionError("Upload failed", exception);
+ }
+
+ @Override
+ public void onAssemblyStatusUpdateFailed(Exception exception) {
+ log.accept("Assembly status update failed: " + exception);
+ if (exception instanceof ProtocolException) {
+ String msg = exception.getMessage();
+ if (msg != null && (msg.contains("unexpected status code (404)")
+ || msg.contains("Server rejected operation"))) {
+ return;
+ }
+ }
+ if (exception instanceof Exception) {
+ unexpectedStatusUpdateFailure.compareAndSet(null, (Exception) exception);
+ } else {
+ unexpectedStatusUpdateFailure.compareAndSet(null, new Exception(String.valueOf(exception)));
+ }
+ }
+
+ @Override
+ public void onAssemblyFinished(AssemblyResponse response) {
+ sseObserved.set(true);
+ sseLatch.countDown();
+ log.accept("Assembly finished SSE payload ok=" + response.json().optString("ok"));
+ }
+
+ @Override
+ public void onAssemblyProgress(JSONObject progressPerOriginalFile) {
+ sseObserved.set(true);
+ sseLatch.countDown();
+ log.accept("Assembly progress SSE: " + progressPerOriginalFile);
+ }
+
+ @Override
+ public void onAssemblyResultFinished(JSONArray result) {
+ sseObserved.set(true);
+ sseLatch.countDown();
+ log.accept("Assembly result SSE payload=" + result);
+ JSONArray extracted = extractStepResultFromSse("resize", result);
+ if (extracted != null && extracted.length() > 0) {
+ resizeResults.compareAndSet(null, extracted);
+ resultLatch.countDown();
+ }
+ }
+ };
+
+ AndroidTransloadit transloadit = new AndroidTransloadit(transloaditKey, provider);
+
+ try (AndroidAssembly assembly = transloadit.newAssembly(listener, context)) {
+ assembly.addFile(upload, "image");
+
+ Map resize = new HashMap<>();
+ resize.put("width", 32);
+ resize.put("height", 32);
+ resize.put("resize_strategy", "fit");
+ resize.put("format", "jpg");
+ resize.put("result", true);
+ assembly.addStep("resize", "/image/resize", resize);
+
+ Future future = assembly.saveAsync(true);
+
+ // Wait until some bytes are uploaded before pausing
+ boolean progressSeen = awaitLatch(progressLatch, 2, TimeUnit.MINUTES);
+ if (!progressSeen) {
+ log.accept("Timed out waiting for upload progress");
+ failWithTimeline("Upload progress not observed", timeline);
+ }
+
+ boolean shouldPause = lastProgressFraction.get() < 0.99d;
+ if (!shouldPause) {
+ log.accept("Skipping pause/resume because upload already completed");
+ pauseInvoked.set(true);
+ resumeInvoked.set(true);
+ } else {
+ assembly.pauseUploads();
+ pauseInvoked.set(true);
+ log.accept("Uploads paused");
+
+ Thread.sleep(TimeUnit.SECONDS.toMillis(2));
+
+ assembly.resumeUploads();
+ resumeInvoked.set(true);
+ log.accept("Uploads resumed");
+ }
+
+ AssemblyResponse initial = await(future, 5, TimeUnit.MINUTES);
+ assertNotNull("Initial assembly response missing", initial);
+ assertNotNull("Assembly ID missing", initial.getId());
+ log.accept("Initial assembly id=" + initial.getId());
+
+ AssemblyResponse completed = waitForCompletion(transloadit, initial.getId());
+ assertNotNull("Final assembly response missing", completed);
+ JSONObject completedJson = completed.json();
+ log.accept("Completed assembly ok=" + completedJson.optString("ok"));
+ JSONObject resultsObject = completedJson.optJSONObject("results");
+ if (resultsObject != null) {
+ log.accept("Completed assembly result keys=" + jsonObjectKeys(resultsObject));
+ JSONArray resizeFromCompletion = resultsObject.optJSONArray("resize");
+ if (resizeFromCompletion != null) {
+ log.accept("Completed assembly resize results count=" + resizeFromCompletion.length());
+ } else {
+ log.accept("Completed assembly resize results missing");
+ }
+ } else {
+ log.accept("Completed assembly results missing");
+ }
+
+ boolean sseSeen = awaitLatch(sseLatch, 2, TimeUnit.MINUTES);
+ if (!sseSeen) {
+ log.accept("Timed out waiting for SSE events");
+ log.accept("Final assembly payload=" + completedJson);
+ failWithTimeline("SSE progress not observed", timeline);
+ }
+
+ assertTrue("Assembly not completed",
+ completedJson.optString("ok", "").toUpperCase().contains("ASSEMBLY_COMPLETED"));
+
+ boolean resultSeen = awaitLatch(resultLatch, 2, TimeUnit.MINUTES);
+ if (!resultSeen) {
+ try {
+ AssemblyResponse latest = transloadit.getAssemblyByUrl(completed.getSslUrl());
+ JSONObject latestJson = latest.json();
+ log.accept("Latest assembly ok=" + latestJson.optString("ok"));
+ JSONObject latestResults = latestJson.optJSONObject("results");
+ if (latestResults != null) {
+ log.accept("Latest assembly result keys=" + jsonObjectKeys(latestResults));
+ JSONArray latestResize = latestResults.optJSONArray("resize");
+ if (latestResize != null) {
+ log.accept("Latest assembly resize results count=" + latestResize.length());
+ } else {
+ log.accept("Latest assembly resize results missing");
+ }
+ } else {
+ log.accept("Latest assembly results missing");
+ }
+ } catch (Exception pollErr) {
+ log.accept("Failed to poll latest assembly: " + pollErr);
+ }
+ }
+ assertTrue("Timed out waiting for resize SSE results", resultSeen);
+ JSONArray results = resizeResults.get();
+ assertNotNull("Resize SSE payload missing", results);
+ assertTrue("Resize step missing", results.length() > 0);
+ }
+ } finally {
+ if (upload.exists()) {
+ //noinspection ResultOfMethodCallIgnored
+ upload.delete();
+ }
+ }
+
+ assertTrue("Progress callback not observed", progressObserved.get());
+ assertTrue("Upload finished callback not observed", uploadFinished.get());
+ assertTrue("Pause not invoked", pauseInvoked.get());
+ assertTrue("Resume not invoked", resumeInvoked.get());
+ Exception statusFailure = unexpectedStatusUpdateFailure.get();
+ if (statusFailure != null) {
+ failWithTimeline("Unexpected assembly status failure: " + statusFailure, timeline);
+ }
+ if (!sseObserved.get()) {
+ failWithTimeline("SSE events not observed", timeline);
+ }
+ if (resultLatch.getCount() > 0) {
+ failWithTimeline("SSE results not observed", timeline);
+ }
+ }
+
+ private static JSONArray cloneJsonArray(JSONArray array) {
+ if (array == null) {
+ return null;
+ }
+ try {
+ return new JSONArray(array.toString());
+ } catch (JSONException e) {
+ throw new RuntimeException("Failed to clone JSON array", e);
+ }
+ }
+
+ private static JSONArray extractStepResultFromSse(String expectedStep, JSONArray payload) {
+ if (payload == null || payload.length() < 2) {
+ return null;
+ }
+ String stepName = payload.optString(0, null);
+ if (!expectedStep.equals(stepName)) {
+ return null;
+ }
+ Object raw = payload.opt(1);
+ if (raw instanceof JSONObject) {
+ JSONArray array = new JSONArray();
+ array.put(raw);
+ return cloneJsonArray(array);
+ }
+ if (raw instanceof JSONArray) {
+ return cloneJsonArray((JSONArray) raw);
+ }
+ return null;
+ }
+
+ private static MockWebServer startSigningServer(String secret) throws IOException {
+ MockWebServer server = new MockWebServer();
+ server.setDispatcher(new Dispatcher() {
+ @Override
+ public MockResponse dispatch(RecordedRequest request) {
+ if (SIGNATURE_ENDPOINT.equals(request.getPath())
+ && "POST".equals(request.getMethod())) {
+ try {
+ String paramsJson = request.getBody().readUtf8();
+ String signature = computeSignature(paramsJson, secret);
+ JSONObject payload = new JSONObject();
+ payload.put("signature", signature);
+ return new MockResponse()
+ .setResponseCode(200)
+ .setHeader("Content-Type", "application/json")
+ .setBody(payload.toString());
+ } catch (JSONException e) {
+ return new MockResponse().setResponseCode(500)
+ .setBody("{\"error\":\"json construction failed\"}");
+ } catch (Exception e) {
+ return new MockResponse().setResponseCode(500)
+ .setBody("{\"error\":\"" + e.getMessage() + "\"}");
+ }
+ }
+ return new MockResponse().setResponseCode(404);
+ }
+ });
+ server.start();
+ return server;
+ }
+
+ private static String requestSignature(URL endpoint, String paramsJson) throws IOException {
+ HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
+ connection.setRequestMethod("POST");
+ connection.setDoOutput(true);
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setConnectTimeout(10_000);
+ connection.setReadTimeout(10_000);
+ try (OutputStream os = new BufferedOutputStream(connection.getOutputStream())) {
+ os.write(paramsJson.getBytes(StandardCharsets.UTF_8));
+ }
+
+ int responseCode = connection.getResponseCode();
+ if (responseCode != HttpURLConnection.HTTP_OK) {
+ throw new IOException("Signing server returned " + responseCode);
+ }
+
+ StringBuilder response = new StringBuilder();
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ response.append(line);
+ }
+ } finally {
+ connection.disconnect();
+ }
+
+ try {
+ JSONObject json = new JSONObject(response.toString());
+ return json.getString("signature");
+ } catch (JSONException e) {
+ throw new IOException("Malformed signing response", e);
+ }
+ }
+
+ private static AssemblyResponse waitForCompletion(AndroidTransloadit transloadit, String id)
+ throws InterruptedException, LocalOperationException, RequestException {
+ long deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5);
+ AssemblyResponse response = null;
+ while (System.currentTimeMillis() < deadline) {
+ response = transloadit.getAssembly(id);
+ JSONObject json = response.json();
+ String status = json.optString("ok", "");
+ if (!isNullOrEmpty(status)
+ && status.toUpperCase().contains("ASSEMBLY_COMPLETED")) {
+ return response;
+ }
+ Thread.sleep(TimeUnit.SECONDS.toMillis(5));
+ }
+ return response;
+ }
+
+ private static T await(Future future, long timeout, TimeUnit unit)
+ throws Exception {
+ try {
+ return future.get(timeout, unit);
+ } catch (TimeoutException timeoutException) {
+ future.cancel(true);
+ throw timeoutException;
+ }
+ }
+
+ private static String computeSignature(String paramsJson, String secret) throws Exception {
+ Mac mac = Mac.getInstance("HmacSHA384");
+ mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA384"));
+ byte[] digest = mac.doFinal(paramsJson.getBytes(StandardCharsets.UTF_8));
+ return "sha384:" + toHex(digest);
+ }
+
+ private static String toHex(byte[] bytes) {
+ char[] hexArray = "0123456789abcdef".toCharArray();
+ char[] hexChars = new char[bytes.length * 2];
+ for (int j = 0; j < bytes.length; j++) {
+ int v = bytes[j] & 0xFF;
+ hexChars[j * 2] = hexArray[v >>> 4];
+ hexChars[j * 2 + 1] = hexArray[v & 0x0F];
+ }
+ return new String(hexChars);
+ }
+
+ private static File createTempUpload(Context context, int sizeBytes) throws IOException {
+ File file = File.createTempFile("transloadit-e2e", ".jpg", context.getCacheDir());
+ byte[] fixtureBytes;
+ try (InputStream in = SignatureProviderE2ETest.class.getResourceAsStream("/chameleon.jpg")) {
+ if (in == null) {
+ throw new IOException("Embedded chameleon.jpg fixture missing");
+ }
+ fixtureBytes = readFully(in);
+ }
+ if (fixtureBytes.length == 0) {
+ throw new IOException("Embedded chameleon.jpg fixture is empty");
+ }
+
+ try (FileOutputStream fos = new FileOutputStream(file);
+ OutputStream os = new BufferedOutputStream(fos)) {
+ os.write(fixtureBytes);
+ long current = fixtureBytes.length;
+ while (current < sizeBytes) {
+ int toWrite = (int) Math.min(fixtureBytes.length, sizeBytes - current);
+ os.write(fixtureBytes, 0, toWrite);
+ current += toWrite;
+ }
+ os.flush();
+ }
+
+ return file;
+ }
+
+ private static byte[] readFully(InputStream inputStream) throws IOException {
+ byte[] buffer = new byte[8192];
+ int read;
+ int offset = 0;
+ byte[] data = new byte[buffer.length];
+ while ((read = inputStream.read(buffer)) != -1) {
+ if (offset + read > data.length) {
+ byte[] newData = new byte[Math.max(data.length * 2, offset + read)];
+ System.arraycopy(data, 0, newData, 0, offset);
+ data = newData;
+ }
+ System.arraycopy(buffer, 0, data, offset, read);
+ offset += read;
+ }
+ byte[] result = new byte[offset];
+ System.arraycopy(data, 0, result, 0, offset);
+ return result;
+ }
+
+ private static boolean awaitLatch(CountDownLatch latch, long timeout, TimeUnit unit) throws InterruptedException {
+ long deadline = System.nanoTime() + unit.toNanos(timeout);
+ ShadowLooper mainLooper = Shadows.shadowOf(Looper.getMainLooper());
+ while (latch.getCount() > 0) {
+ mainLooper.idle();
+ long remaining = deadline - System.nanoTime();
+ if (remaining <= 0) {
+ break;
+ }
+ long waitNanos = Math.min(TimeUnit.MILLISECONDS.toNanos(250), Math.max(1, remaining));
+ if (latch.await(waitNanos, TimeUnit.NANOSECONDS)) {
+ mainLooper.idle();
+ return true;
+ }
+ }
+ mainLooper.idle();
+ return latch.getCount() == 0;
+ }
+
+ private static boolean parseBoolean(String value) {
+ if (value == null) {
+ return false;
+ }
+ return "1".equals(value) || "true".equalsIgnoreCase(value);
+ }
+
+ private static boolean isNullOrEmpty(String value) {
+ return value == null || value.isEmpty();
+ }
+
+ private static String firstNonEmpty(String value) {
+ return isNullOrEmpty(value) ? null : value;
+ }
+
+ private static void failWithTimeline(String message, List timeline) {
+ StringBuilder sb = new StringBuilder(message);
+ if (timeline != null && !timeline.isEmpty()) {
+ sb.append("\nTimeline:");
+ for (String entry : timeline) {
+ sb.append("\n ").append(entry);
+ }
+ }
+ throw new AssertionError(sb.toString());
+ }
+
+ private static String jsonObjectKeys(JSONObject object) {
+ if (object == null) {
+ return "[]";
+ }
+ List keys = new ArrayList<>();
+ for (Iterator iterator = object.keys(); iterator.hasNext();) {
+ keys.add(iterator.next());
+ }
+ return keys.toString();
+ }
+}
diff --git a/transloadit-android/src/test/java/com/transloadit/android/sdk/SignatureProviderTest.java b/transloadit-android/src/test/java/com/transloadit/android/sdk/SignatureProviderTest.java
new file mode 100644
index 0000000..0d91839
--- /dev/null
+++ b/transloadit-android/src/test/java/com/transloadit/android/sdk/SignatureProviderTest.java
@@ -0,0 +1,138 @@
+package com.transloadit.android.sdk;
+
+import static org.junit.Assert.*;
+
+import com.transloadit.sdk.SignatureProvider;
+import com.transloadit.sdk.exceptions.LocalOperationException;
+
+import org.junit.Test;
+
+/**
+ * Test cases for Android SDK SignatureProvider functionality
+ */
+public class SignatureProviderTest {
+
+ private static class TestSignatureProvider implements SignatureProvider {
+ private String signatureToReturn;
+ private String lastParamsReceived;
+ private boolean shouldThrowException;
+
+ public TestSignatureProvider(String signatureToReturn) {
+ this.signatureToReturn = signatureToReturn;
+ this.shouldThrowException = false;
+ }
+
+ public void setShouldThrowException(boolean shouldThrow) {
+ this.shouldThrowException = shouldThrow;
+ }
+
+ @Override
+ public String generateSignature(String paramsJson) throws Exception {
+ if (shouldThrowException) {
+ throw new Exception("Test exception from signature provider");
+ }
+ this.lastParamsReceived = paramsJson;
+ return signatureToReturn;
+ }
+
+ public String getLastParamsReceived() {
+ return lastParamsReceived;
+ }
+ }
+
+ /**
+ * Test AndroidTransloadit instance creation with SignatureProvider
+ */
+ @Test
+ public void testAndroidTransloaditWithSignatureProvider() {
+ TestSignatureProvider provider = new TestSignatureProvider("sha384:test-signature");
+
+ // Test all constructor variants
+ AndroidTransloadit t1 = new AndroidTransloadit("test_key", provider);
+ assertNotNull(t1);
+ assertEquals("test_key", t1.getKeyForTesting());
+ assertNull(t1.getSecretForTesting());
+ assertEquals(provider, t1.getSignatureProvider());
+ assertTrue(t1.isSigningEnabledForTesting());
+
+ AndroidTransloadit t2 = new AndroidTransloadit("test_key", provider, "https://api.example.com");
+ assertNotNull(t2);
+ assertEquals(provider, t2.getSignatureProvider());
+
+ AndroidTransloadit t3 = new AndroidTransloadit("test_key", provider, 600, "https://api.example.com");
+ assertNotNull(t3);
+ assertEquals(provider, t3.getSignatureProvider());
+ }
+
+ /**
+ * Test that AndroidTransloadit still works with traditional secret-based auth
+ */
+ @Test
+ public void testAndroidTransloaditWithSecret() {
+ AndroidTransloadit transloadit = new AndroidTransloadit("test_key", "test_secret");
+
+ assertNotNull(transloadit);
+ assertEquals("test_key", transloadit.getKeyForTesting());
+ assertEquals("test_secret", transloadit.getSecretForTesting());
+ assertNull(transloadit.getSignatureProvider());
+ assertTrue(transloadit.isSigningEnabledForTesting());
+ }
+
+ /**
+ * Test setting and getting signature provider
+ */
+ @Test
+ public void testSetSignatureProvider() {
+ AndroidTransloadit transloadit = new AndroidTransloadit("test_key", "test_secret");
+
+ // Initially no provider
+ assertNull(transloadit.getSignatureProvider());
+
+ // Set a provider
+ TestSignatureProvider provider = new TestSignatureProvider("sha384:new-signature");
+ transloadit.setSignatureProvider(provider);
+
+ assertEquals(provider, transloadit.getSignatureProvider());
+ assertTrue(transloadit.isSigningEnabledForTesting());
+
+ // Remove provider
+ transloadit.setSignatureProvider(null);
+ assertNull(transloadit.getSignatureProvider());
+ assertTrue("Secret-based clients should continue signing", transloadit.isSigningEnabledForTesting());
+ }
+
+ /**
+ * Test that version info includes both Android and Java SDK versions
+ */
+ @Test
+ public void testVersionInfo() {
+ AndroidTransloadit transloadit = new AndroidTransloadit("test_key", "test_secret");
+ String versionInfo = transloadit.loadVersionInfo();
+
+ assertNotNull(versionInfo);
+ assertTrue(versionInfo.contains("android-sdk:"));
+ assertTrue(versionInfo.contains("java-sdk:"));
+ }
+
+ /**
+ * Test creating AndroidTransloadit without secret (for unsigned requests)
+ */
+ @Test
+ public void testAndroidTransloaditWithoutSecret() {
+ AndroidTransloadit transloadit = new AndroidTransloadit("test_key", (String) null, 300, "https://api.example.com");
+
+ assertNotNull(transloadit);
+ assertEquals("test_key", transloadit.getKeyForTesting());
+ assertNull(transloadit.getSecretForTesting());
+ assertNull(transloadit.getSignatureProvider());
+ assertFalse(transloadit.isSigningEnabledForTesting());
+
+ // Enabling signing without provider or secret should fail
+ try {
+ transloadit.setRequestSigning(true);
+ fail("Expected LocalOperationException when enabling signing without secret or provider");
+ } catch (LocalOperationException expected) {
+ // expected
+ }
+ }
+}
diff --git a/transloadit-android/src/test/resources/chameleon.jpg b/transloadit-android/src/test/resources/chameleon.jpg
new file mode 100644
index 0000000..ea5dcc0
Binary files /dev/null and b/transloadit-android/src/test/resources/chameleon.jpg differ