diff --git a/CHANGELOG.md b/CHANGELOG.md index e0bfa8c..05da7f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.2.1 / 2026-10-02 + +- Fixed the published POM, which declared no dependencies. Apps no longer have to add the Java SDK, the tus clients, and WorkManager by hand ([#23](https://github.com/transloadit/android-sdk/issues/23)). +- Assembly status polling now reads the returned `assembly_ssl_url` directly instead of asking the `SignatureProvider` to sign it, so a backend that only signs Assembly creation works with `waitForCompletion(true)`. +- WorkManager uploads succeed right away when the Assembly has already completed, and fail right away for aborted, canceled, or errored Assemblies instead of retrying. Failed work keeps the available Assembly identifiers in its output. +- The README's WorkManager example now uses a signing endpoint instead of the Auth Secret, and signing is documented as available since Java SDK 2.1.0. + ## 0.2.0 / 2025-10-28 Below 1.0 SemVer allows us to make breaking changes, and we have shipped a number of them in this release, please review carefully. diff --git a/README.md b/README.md index 7dbac6f..58b2a6c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ implementation 'com.transloadit.android.sdk:transloadit-android:0.2.0' ``` -> ℹ️ Signature-based authentication requires `com.transloadit.sdk:transloadit` version **2.2.3** or newer. When developing locally alongside the Java SDK, place both repositories next to each other (`../java-sdk`) and the Gradle build will automatically use the local java-sdk project via dependency substitution. +> ℹ️ Signature-based authentication was introduced in `com.transloadit.sdk:transloadit` version **2.1.0** ([java-sdk#95](https://github.com/transloadit/java-sdk/pull/95)). Android SDK 0.2 uses **2.2.4** for its updated SSE handling. When developing locally alongside the Java SDK, place both repositories next to each other (`../java-sdk`) and set `ANDROID_SDK_USE_LOCAL_JAVA_SDK=1` to use the local java-sdk project via dependency substitution. ## Usage @@ -67,14 +67,20 @@ SignatureProvider signatureProvider = new SignatureProvider() { HttpURLConnection conn = (HttpURLConnection) new URL("https://your-backend.com/sign").openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json"); conn.getOutputStream().write(paramsJson.getBytes()); - // Read the signature from your backend's response - BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream())); - String signature = reader.readLine(); - reader.close(); - - return signature; // Should return something like "sha384:..." + // Read the complete JSON response from your backend + StringBuilder responseJson = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { + char[] buffer = new char[1024]; + int length; + while ((length = reader.read(buffer)) != -1) { + responseJson.append(buffer, 0, length); + } + } + + return new JSONObject(responseJson.toString()).getString("signature"); // Returns "sha384:..." } }; @@ -88,7 +94,7 @@ Your backend should implement an endpoint that: 2. Signs the parameters using your Transloadit secret 3. Returns the signature -Example backend implementation (Node.js): +Example backend implementation (Node.js), with `req.body` containing the unchanged UTF-8 request text supplied by your text/raw-body middleware: ```javascript const crypto = require('crypto') @@ -100,7 +106,7 @@ app.post('/sign', authenticate, (req, res) => { .update(Buffer.from(paramsJson, 'utf-8')) .digest('hex') - res.send(`sha384:${signature}`) + res.json({ signature: `sha384:${signature}` }) }) ``` @@ -214,15 +220,20 @@ assembly.useDirectCallbacks(); // run callbacks on the calling thread ```java AndroidAssemblyWorkConfig config = AndroidAssemblyWorkConfig - .newBuilder("TRANSLOADIT_KEY", "TRANSLOADIT_SECRET") + .newBuilder("TRANSLOADIT_KEY") + .signatureProvider("https://your-backend.com/sign") + .addSignatureProviderHeader("Authorization", "Bearer " + sessionToken) .paramsJson(paramsJsonString) .preferenceName("my_transloadit_store") .addFile(new File(context.getCacheDir(), "photo.jpg"), "image") + .waitForCompletion(true) .build(); WorkManager.getInstance(context).enqueue(config.toWorkRequest()); ``` +The WorkManager signing endpoint must return a JSON object with a `signature` field, as in the backend example above. The session token must be valid when the worker requests the signature. + `AndroidAssemblyUploadWorker` waits for uploads (and, optionally, SSE completion) on a background thread so your app can survive process death or move long-running jobs out of the foreground. ## Example diff --git a/transloadit-android/build.gradle b/transloadit-android/build.gradle index 2e80bad..f8ad258 100644 --- a/transloadit-android/build.gradle +++ b/transloadit-android/build.gradle @@ -1,4 +1,5 @@ plugins { + id 'maven-publish' id 'signing' } @@ -40,16 +41,20 @@ android { targetCompatibility JavaVersion.VERSION_11 } namespace 'com.transloadit.sdk' + publishing { + singleVariant('release') + } } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.transloadit.sdk:transloadit:2.2.4' + api 'com.transloadit.sdk:transloadit:2.2.4' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'io.tus.android.client:tus-android-client:0.1.10' implementation 'io.tus.java.client:tus-java-client:0.5.1' implementation 'org.jetbrains:annotations:23.0.0' - implementation 'androidx.work:work-runtime:2.9.0' + api 'androidx.work:work-runtime:2.9.0' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:4.8.0' testImplementation 'org.mock-server:mockserver-junit-rule:5.15.0' @@ -119,16 +124,13 @@ publishing { publications { androidRelease(MavenPublication) { afterEvaluate { - from components.findByName('release') + from components.release groupId project.getGroup() artifactId 'transloadit-android' description project.getDescription() version project.getVersion() artifact sourcesJar artifact javadocJar - artifact (project.layout.buildDirectory.dir('outputs/aar/transloadit-android-release.aar')) { - builtBy assemble - } pom.withXml { def root = asNode() root.appendNode('description', project.getDescription()) @@ -146,3 +148,41 @@ signing { useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) sign publishing.publications.androidRelease } + +afterEvaluate { + def generatePom = tasks.named('generatePomFileForAndroidReleasePublication') + def verifyPublishedPom = tasks.register('verifyPublishedPom') { + group = 'verification' + description = 'Checks the generated release POM includes all runtime dependencies.' + dependsOn generatePom + doLast { + def pom = new XmlSlurper().parse(generatePom.get().destination) + def actual = pom.dependencies.dependency.collect { + [it.groupId.text(), it.artifactId.text(), it.version.text(), it.scope.text()] + }.toSet() + def expected = configurations.api.dependencies + .withType(ExternalModuleDependency).collect { + [it.group, it.name, it.version, 'compile'] + }.toSet() + expected.addAll(configurations.implementation.dependencies + .withType(ExternalModuleDependency).collect { + [it.group, it.name, it.version, 'runtime'] + }) + if (actual != expected) { + throw new GradleException("Published POM dependencies differ: expected ${expected}, got ${actual}") + } + if (pom.packaging.text() != 'aar') { + throw new GradleException('Published POM must declare aar packaging') + } + def aarArtifacts = publishing.publications.androidRelease.artifacts.findAll { + it.extension == 'aar' && !it.classifier + } + if (aarArtifacts.size() != 1) { + throw new GradleException('Release publication must contain exactly one unclassified AAR') + } + } + } + tasks.named('check') { + dependsOn verifyPublishedPom + } +} diff --git a/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyUploadWorker.java b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyUploadWorker.java index 0a89439..4483ae8 100644 --- a/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyUploadWorker.java +++ b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyUploadWorker.java @@ -161,7 +161,8 @@ public void onAssemblyFinished(AssemblyResponse response) { return handleFailure(e); } - if (config.shouldWaitForCompletion()) { + // A finished creation response does not start SSE or emit a completion callback. + if (config.shouldWaitForCompletion() && !initial.isFinished()) { try { boolean finished = completionLatch.await(config.getCompletionTimeoutMillis(), TimeUnit.MILLISECONDS); if (!finished) { @@ -180,6 +181,14 @@ public void onAssemblyFinished(AssemblyResponse response) { AssemblyResponse finalResponse = completionResponse.get() != null ? completionResponse.get() : initial; JSONObject json = finalResponse.json(); + if (finalResponse.isFinished() && !finalResponse.isCompleted()) { + return Result.failure(new Data.Builder() + .putString("error", json.optString("error", json.optString("ok", "Assembly did not complete"))) + .putString(OUTPUT_ASSEMBLY_ID, json.optString("assembly_id", null)) + .putString(OUTPUT_ASSEMBLY_URL, json.optString("assembly_url", null)) + .putString(OUTPUT_SSL_URL, json.optString("assembly_ssl_url", null)) + .build()); + } Data output = new Data.Builder() .putString(OUTPUT_ASSEMBLY_ID, finalResponse.getId()) .putString(OUTPUT_ASSEMBLY_URL, json.optString("assembly_url")) 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 4c72d46..9a9c8f0 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 @@ -5,15 +5,24 @@ import androidx.annotation.Nullable; import com.transloadit.sdk.SignatureProvider; +import com.transloadit.sdk.exceptions.LocalOperationException; +import com.transloadit.sdk.exceptions.RequestException; +import com.transloadit.sdk.response.AssemblyResponse; import java.io.IOException; import java.io.InputStream; import java.util.Properties; +import java.util.Random; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; /** * Android-friendly extension of the core {@link com.transloadit.sdk.Transloadit} client. */ public class AndroidTransloadit extends com.transloadit.sdk.Transloadit { + private static final OkHttpClient ASSEMBLY_HTTP_CLIENT = new OkHttpClient(); /** * Creates a client using inline credentials and a custom host. @@ -102,6 +111,44 @@ public AndroidAssembly newAssembly(AndroidAssemblyListener listener, Context con return new AndroidAssembly(this, listener, context); } + /** + * Fetches status directly from an Assembly's {@code assembly_ssl_url}, which needs no signature. + * + * @param url Assembly status URL, absolute or relative to the configured host + * @return current Assembly status + * @throws RequestException if the status request fails + * @throws LocalOperationException if reading the response or waiting to retry fails + */ + @Override + public AssemblyResponse getAssemblyByUrl(String url) throws RequestException, LocalOperationException { + String fullUrl = url.startsWith("https://") || url.startsWith("http://") ? url : getHostUrl() + url; + Request request = new Request.Builder().url(fullUrl) + .header("Transloadit-Client", versionInfo).build(); + int retriesLeft = getRetryAttemptsRequestException(); + while (true) { + try (Response response = ASSEMBLY_HTTP_CLIENT.newCall(request).execute()) { + return new AssemblyResponse(response); + } catch (IOException e) { + boolean qualified = false; + for (String error : getQualifiedErrorsForRetry()) { + if (e.toString().contains(error)) { + qualified = true; + break; + } + } + if (retriesLeft-- <= 0 || !qualified) { + throw new RequestException(e); + } + try { + Thread.sleep(getRetryDelay() + new Random().nextInt(1000)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new LocalOperationException(interrupted); + } + } + } + } + /** * Internal helper used by tests to inspect the configured API key. */ 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 cc390f4..4c1a090 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.2.0" +version="0.2.1" 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/RemoteSigningStatusTest.java b/transloadit-android/src/test/java/com/transloadit/android/sdk/RemoteSigningStatusTest.java new file mode 100644 index 0000000..eb4c24f --- /dev/null +++ b/transloadit-android/src/test/java/com/transloadit/android/sdk/RemoteSigningStatusTest.java @@ -0,0 +1,220 @@ +package com.transloadit.android.sdk; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import androidx.test.core.app.ApplicationProvider; +import androidx.work.ListenableWorker; +import androidx.work.testing.TestListenableWorkerBuilder; + +import com.transloadit.sdk.Assembly; +import com.transloadit.sdk.SignatureProvider; +import com.transloadit.sdk.exceptions.RequestException; +import com.transloadit.sdk.response.AssemblyResponse; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 28) +public class RemoteSigningStatusTest { + private static final String ASSEMBLY_ID = "0123456789abcdef0123456789abcdef"; + + @Test + public void relativeStatusUrlWorksWithoutSigning() throws Exception { + try (MockWebServer api = new MockWebServer()) { + api.start(); + String path = "/assemblies/" + ASSEMBLY_ID; + api.enqueue(assemblyResponse("ASSEMBLY_COMPLETED", api.url(path).toString())); + SignatureProvider provider = paramsJson -> { + throw new RequestException("Status requests must not ask for a signature"); + }; + AndroidTransloadit transloadit = new AndroidTransloadit("key", provider, + api.url("/").toString().replaceAll("/$", "")); + + assertTrue(transloadit.getAssemblyByUrl(path).isCompleted()); + RecordedRequest request = api.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(request); + assertEquals("GET", request.getMethod()); + assertEquals(path, request.getPath()); + } + } + + @Test + public void pollingWorksWithCreationOnlySignatureProvider() throws Exception { + try (MockWebServer api = new MockWebServer(); MockWebServer status = new MockWebServer()) { + api.start(); + status.start(); + String statusUrl = status.url("/status/" + ASSEMBLY_ID).toString(); + api.enqueue(assemblyResponse("ASSEMBLY_EXECUTING", statusUrl)); + status.enqueue(assemblyResponse("ASSEMBLY_EXECUTING", statusUrl)); + status.enqueue(assemblyResponse("ASSEMBLY_COMPLETED", statusUrl)); + + AtomicInteger signingCalls = new AtomicInteger(); + SignatureProvider provider = paramsJson -> { + signingCalls.incrementAndGet(); + if (!new JSONObject(paramsJson).has("template_id")) { + throw new RequestException("Only Assembly creation with a template_id can be signed"); + } + return "sha384:test-signature"; + }; + AndroidTransloadit transloadit = new AndroidTransloadit("key", provider, + api.url("/").toString().replaceAll("/$", "")); + Assembly assembly = transloadit.newAssembly(); + assembly.addOption("template_id", "template"); + assembly.setShouldWaitForCompletion(true); + + AssemblyResponse response = assembly.save(false); + + assertTrue(response.isCompleted()); + assertEquals(1, signingCalls.get()); + assertEquals(provider, transloadit.getSignatureProvider()); + assertTrue(transloadit.isSigningEnabledForTesting()); + assertEquals(1, api.getRequestCount()); + assertEquals(2, status.getRequestCount()); + for (int i = 0; i < 2; i++) { + RecordedRequest request = status.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(request); + assertEquals("GET", request.getMethod()); + assertEquals("/status/" + ASSEMBLY_ID, request.getPath()); + assertTrue(request.getHeader("Transloadit-Client").contains("android-sdk:")); + } + } + } + + @Test + public void workerWaitsForCompletionWithCreationOnlySigner() throws Exception { + assertWorkerResult("ASSEMBLY_EXECUTING"); + } + + @Test + public void workerReturnsAlreadyCompletedAssembly() throws Exception { + assertWorkerResult("ASSEMBLY_COMPLETED"); + } + + @Test + public void workerFailsForAbortedInitialAssembly() throws Exception { + assertWorkerResult("REQUEST_ABORTED"); + } + + @Test + public void workerFailsForCanceledInitialAssembly() throws Exception { + assertWorkerResult("ASSEMBLY_CANCELED"); + } + + @Test + public void workerFailsForInitialApiError() throws Exception { + assertWorkerResult(null); + } + + private void assertWorkerResult(String initialState) throws Exception { + try (MockWebServer api = new MockWebServer(); MockWebServer status = new MockWebServer()) { + api.start(); + status.start(); + String statusUrl = status.url("/status/" + ASSEMBLY_ID).toString(); + AtomicInteger signingCalls = new AtomicInteger(); + api.setDispatcher(new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + try { + if ("/sign".equals(request.getPath())) { + signingCalls.incrementAndGet(); + if (!new JSONObject(request.getBody().readUtf8()).has("template_id")) { + return new MockResponse().setResponseCode(400) + .setBody("Only Assembly creation with a template_id can be signed"); + } + return new MockResponse().setHeader("Content-Type", "application/json") + .setBody("{\"signature\":\"sha384:test-signature\"}"); + } + if ("POST".equals(request.getMethod()) && request.getPath().startsWith("/assemblies")) { + return new MockResponse().setHeader("Content-Type", "application/json") + .setBody(new JSONObject(assemblyJson(initialState, statusUrl)) + .put("update_stream_url", api.url("/events").toString()).toString()); + } + if ("/events".equals(request.getPath())) { + return new MockResponse().setHeader("Content-Type", "text/event-stream") + .setBody("data: assembly_finished\n\n"); + } + return new MockResponse().setResponseCode(404); + } catch (JSONException e) { + throw new AssertionError(e); + } + } + }); + status.enqueue(assemblyResponse("ASSEMBLY_COMPLETED", statusUrl)); + + AndroidAssemblyWorkConfig config = AndroidAssemblyWorkConfig.newBuilder("key") + .signatureProvider(api.url("/sign").toString()) + .hostUrl(api.url("/").toString().replaceAll("/$", "")) + .paramsJson("{\"template_id\":\"template\"}") + .resumable(false) + .waitForCompletion(true) + .completionTimeoutMillis(5_000) + .build(); + AndroidAssemblyUploadWorker worker = TestListenableWorkerBuilder + .from(ApplicationProvider.getApplicationContext(), AndroidAssemblyUploadWorker.class) + .setInputData(config.toInputData()) + .build(); + + ListenableWorker.Result result = worker.startWork().get(10, TimeUnit.SECONDS); + + boolean needsStatus = "ASSEMBLY_EXECUTING".equals(initialState); + boolean successful = needsStatus || "ASSEMBLY_COMPLETED".equals(initialState); + String message = "Unexpected work result: " + result + " (API requests: " + api.getRequestCount() + + ", signing calls: " + signingCalls.get() + ")"; + if (successful) { + assertTrue(message, result instanceof ListenableWorker.Result.Success); + } else { + assertTrue(message, result instanceof ListenableWorker.Result.Failure); + assertEquals(initialState == null ? "INVALID_TEMPLATE" : initialState, + result.getOutputData().getString("error")); + } + if (initialState == null) { + assertNull(result.getOutputData().getString(AndroidAssemblyUploadWorker.OUTPUT_ASSEMBLY_ID)); + assertNull(result.getOutputData().getString(AndroidAssemblyUploadWorker.OUTPUT_ASSEMBLY_URL)); + assertNull(result.getOutputData().getString(AndroidAssemblyUploadWorker.OUTPUT_SSL_URL)); + } else { + assertEquals(ASSEMBLY_ID, result.getOutputData().getString(AndroidAssemblyUploadWorker.OUTPUT_ASSEMBLY_ID)); + assertEquals("http://unused.example/assembly", + result.getOutputData().getString(AndroidAssemblyUploadWorker.OUTPUT_ASSEMBLY_URL)); + assertEquals(statusUrl, result.getOutputData().getString(AndroidAssemblyUploadWorker.OUTPUT_SSL_URL)); + } + assertEquals(1, signingCalls.get()); + assertEquals(needsStatus ? 3 : 2, api.getRequestCount()); + assertEquals(needsStatus ? 1 : 0, status.getRequestCount()); + if (needsStatus) { + assertEquals("/status/" + ASSEMBLY_ID, status.takeRequest(1, TimeUnit.SECONDS).getPath()); + } + } + } + + private static MockResponse assemblyResponse(String state, String statusUrl) throws JSONException { + return new MockResponse().setHeader("Content-Type", "application/json") + .setBody(assemblyJson(state, statusUrl)); + } + + private static String assemblyJson(String state, String statusUrl) throws JSONException { + if (state == null) { + return new JSONObject().put("error", "INVALID_TEMPLATE").toString(); + } + JSONObject json = new JSONObject().put("assembly_id", ASSEMBLY_ID) + .put("assembly_url", "http://unused.example/assembly") + .put("assembly_ssl_url", statusUrl); + json.put("ok", state); + return json.toString(); + } +}