diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b49e3d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gradle +build +out +.idea +.DS_Store +*.iml +.android-docker/ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..97d5f5f --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.jpg binary diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 6280d0d..156c70e 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -12,23 +12,43 @@ on: jobs: android: + name: ${{ matrix.name }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: Gradle (Java 17) + java: '17' + run-script: false + - name: Docker Harness (Java 21) + java: '21' + run-script: true + env: + TRANSLOADIT_KEY: ${{ secrets.TRANSLOADIT_KEY }} + TRANSLOADIT_SECRET: ${{ secrets.TRANSLOADIT_SECRET }} + ANDROID_SDK_E2E: "true" steps: - uses: actions/checkout@v4 - - name: set up JDK 19 + - name: Set up JDK ${{ matrix.java }} uses: actions/setup-java@v4 with: - java-version: '19' - distribution: 'adopt' + java-version: ${{ matrix.java }} + distribution: 'temurin' cache: gradle - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Build with Gradle + if: ${{ matrix.run-script == false }} run: ./gradlew assemble - name: Run Tests + if: ${{ matrix.run-script == false }} run: ./gradlew check + - name: Run Dockerized Check + if: ${{ matrix.run-script == true }} + run: ./scripts/test-in-docker.sh check slack-on-failure: needs: [android] diff --git a/.gitignore b/.gitignore index 931c4d1..e65a86a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ out/ # Gradle files .gradle/ build/ +.android-docker/ # Local configuration file (sdk path, etc) local.properties @@ -54,3 +55,4 @@ google-services.json freeline.py freeline/ freeline_project_description.json +.env diff --git a/.vscode/android-sdk.code-workspace b/.vscode/android-sdk.code-workspace new file mode 100644 index 0000000..64bfb87 --- /dev/null +++ b/.vscode/android-sdk.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": ".." + } + ], + "settings": {} +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 651494c..7b81642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,42 +1,71 @@ -### 0.0.10 / 2024-03-20 +# Changelog + +## 0.2.0 / TO BE RELEASED + +Below 1.0 SemVer allows us to make breaking changes, and we have shipped a number of them in this release, please review carefully. + +- **Breaking:** Removed dependency on the Java SDK's deprecated `AsyncAssembly` API and introduced a new `AndroidAssembly` wrapper built on the modern SSE-based workflow +- **Breaking:** SharedPreferences backing resumable uploads now uses `transloadit_android_sdk_urls` (previously typo’d `tansloadit_android_sdk_urls`). Existing persisted tus entries will need manual migration if backward compatibility is required. +- **Breaking:** Building the SDK now requires JDK 17+. Published AARs still target Java 11 bytecode so consuming apps can desugar on older toolchains. +- Upgrade dependency to `com.transloadit.sdk:transloadit:2.2.4` to align with the latest Java SDK release and pick up the simplified SSE handling. +- Keep the Android Docker and CI parity harness aligned with the Java SDK release that ships the stabilized SSE behaviour, ensuring both suites exercise the same SSE fixtures. +- Default `AndroidAssembly` callbacks to the Android main thread and add opt-in APIs for background/custom executors. +- Added `pauseUploadsSafely`/`resumeUploadsSafely` helpers and an optional WorkManager integration (`AndroidAssemblyWorkConfig` + `AndroidAssemblyUploadWorker`) to persist resumable uploads in the background. +- Added a runnable Kotlin WorkManager sample (`examples/…/WorkManagerSample.kt`) and matching E2E test to showcase background uploads with the new API surface, including external signature-provider usage. +- Added `AndroidAssemblyListener` to replace the old `AssemblyProgressListener` +- Updated samples, documentation, and tests to use the new asynchronous API +- Added environment-aware Docker tests plus live assembly integration coverage + +## 0.1.0 / 2025-10-15 + +- Added support for external signature generation to improve security ([#19](https://github.com/transloadit/android-sdk/issues/19)) + - New constructors in `AndroidTransloadit` accepting `SignatureProvider` instead of secret + - Enables secure signature generation on backend servers instead of embedding secrets in APK + - Prevents secret extraction through APK decompilation + - Added comprehensive documentation and examples for signature injection + - Added unit tests for signature provider functionality +- Adopted `com.transloadit.sdk:transloadit:2.1.0` and tus-java-client 0.5.1 to match java-sdk +- Added Docker-based test harness for reproducible local builds + +## 0.0.10 / 2024-03-20 - 0.0.9 has been published without AAR files, this release ships them. -### 0.0.9 / 2024-03-20 +## 0.0.9 / 2024-03-20 - Updated dependency for Transloadit Java SDK to 1.0.0 - This update includes the updated signature authentication method, which is now required for all requests. -### 0.0.8 / 2023-07-17 +## 0.0.8 / 2023-07-17 - Changing method signatures including Activity to Context in order to make the SDKs usage more flexible. - This is considered as not breaking, as a Activity is a Context, and the SDK will still work as before. -### 0.0.7 / 2022-10-30 +## 0.0.7 / 2022-10-30 - Updated dependency for Transloadit Java SDK to 0.4.4 => includes Socket-IO 4 and a security patch - Updated to androidx.appcompat:appcompat:1.5.1 - Set compileSdkVersion to 31, and targetSdkVersion 31 -### 0.0.6 / 2022-02-03 +## 0.0.6 / 2022-02-03 - Update dependency for Transloadit Java SDK to 0.4.2 - Add Android SDK version to Transloadit-Client header - Updated Tus-Android to 0.1.10 -### 0.0.5 / 2022-01-10 +## 0.0.5 / 2022-01-10 - Update dependency for Transloadit Java SDK to 0.4.1, this update is recommended as it contains patches for known security vulnerabilities. -### 0.0.4 / 2021-02-25 +## 0.0.4 / 2021-02-25 - Update dependency for Transloadit Java SDK to 0.1.6 -### 0.0.3 / 2018-07-18 +## 0.0.3 / 2018-07-18 - Update dependency -### 0.0.2 / 2018-04-07 +## 0.0.2 / 2018-04-07 - Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..438ad3e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing to the Transloadit Android SDK + +Thanks for your interest in contributing! This document explains how to get set up, run tests, and how releases are produced for the Android library. + +## Getting Started + +1. Fork the repository and clone your fork. +2. Install JDK 11+ (CI currently uses JDK 19 via `actions/setup-java`). +3. Install [Android SDK command line tools](https://developer.android.com/studio#command-tools) if you plan to run Gradle directly outside of Docker. +4. Install [Docker](https://docs.docker.com/get-docker/) if you want to mirror the CI environment. +5. Run `./gradlew assemble` to make sure the project builds. + +## Running Tests + +We rely on standard Gradle tasks and an optional Docker wrapper: + +- **Host JVM:** `./gradlew check` runs unit tests for both the library and example app. +- **Docker (CI parity):** `./scripts/test-in-docker.sh` executes the same Gradle tasks inside the image used in CI. This is helpful before pushing changes to ensure a clean environment. + +End-to-end tests hit the live Transloadit API. To enable them locally, create a `.env` file with: + +``` +TRANSLOADIT_KEY=your-key +TRANSLOADIT_SECRET=your-secret +``` + +Without these variables the integration tests are skipped automatically. + +## Pull Requests + +- Keep changes focused. For larger efforts, please open an issue first to discuss the approach. +- Add or update tests alongside code changes. +- Run `./gradlew check` (and optionally the docker script) before submitting the PR. +- Provide context in the PR description, including any manual testing performed. + +## Publishing Releases + +Releases are handled by the Transloadit maintainers through the [release workflow](./.github/workflows/release.yml), which publishes artifacts to Maven Central under `com.transloadit.android:transloadit-android`. + +High-level checklist for maintainers: + +1. Update version information in `transloadit-android/src/main/resources/android-sdk-version/version.properties` and refresh `CHANGELOG.md`. +2. Merge the release branch into `main`. +3. Create a git tag for `main` that matches the new versions +4. Publish a GitHub release (include the changelog). This triggers the release workflow. (via the GitHub UI, `gh release creates v1.0.1 --title "v1.0.1" --notes-file <(cat CHANGELOG.md section)`) +5. Wait for Sonatype to sync the artifact (this can take a few hours). + +The required signing keys and credentials are stored as GitHub secrets. If you need access or spot an issue with the release automation, please reach out to the Transloadit team via the issue tracker or support channels. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d3bbf4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +FROM eclipse-temurin:21-jdk AS base + +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV ANDROID_HOME=/opt/android-sdk +ENV PATH=$PATH:$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + wget \ + unzip \ + libstdc++6 \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +# Install Android command line tools +RUN mkdir -p $ANDROID_SDK_ROOT/cmdline-tools \ + && cd /tmp \ + && wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \ + && unzip -q commandlinetools-linux-11076708_latest.zip \ + && mkdir -p $ANDROID_SDK_ROOT/cmdline-tools/latest \ + && mv cmdline-tools/* $ANDROID_SDK_ROOT/cmdline-tools/latest/ \ + && rm -rf commandlinetools-linux-11076708_latest.zip cmdline-tools + +# Accept licenses and install the SDK components required for unit tests +RUN yes | sdkmanager --licenses >/dev/null \ + && yes | sdkmanager \ + "platforms;android-34" \ + "build-tools;34.0.0" \ + "platform-tools" + +WORKDIR /workspace diff --git a/README.md b/README.md index bb371dd..7dbac6f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ An **Android** Integration for [Transloadit](https://transloadit.com)'s file upl This is an **Android** SDK to make it easy to talk to the [Transloadit](https://transloadit.com) REST API. +## Requirements + +- Build tooling: JDK 17 or newer (we test on JDK 17 and 21 via Gradle/Docker). +- Runtime bytecode: the published AAR targets Java 11, so consuming apps can desugar on older toolchains. + ## Install The JARs can be downloaded manually from [Maven Central](https://search.maven.org/artifact/com.transloadit.android.sdk/transloadit-android). @@ -17,7 +22,7 @@ The JARs can be downloaded manually from [Maven Central](https://search.maven.or **Gradle:** ```groovy -implementation 'com.transloadit.android.sdk:transloadit-android:0.0.10' +implementation 'com.transloadit.android.sdk:transloadit-android:0.2.0' ``` **Maven:** @@ -26,24 +31,89 @@ implementation 'com.transloadit.android.sdk:transloadit-android:0.0.10' com.transloadit.android.sdk transloadit-android - 0.0.10 + 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. + ## Usage -All interactions with the SDK begin with the `com.transloadit.android.sdk.Transloadit` class. +All interactions with the SDK begin with the `com.transloadit.android.sdk.AndroidTransloadit` class. + +### Authentication Methods + +The SDK supports two authentication methods: + +#### 1. Traditional Authentication (with Secret Key) + +⚠️ **Security Warning**: Including your secret key in the Android app is a security risk as APK files can be decompiled to extract secrets. Use this method only for development or internal apps. + +```java +AndroidTransloadit transloadit = new AndroidTransloadit("YOUR_KEY", "YOUR_SECRET"); +``` + +#### 2. Signature Authentication (Recommended for Production) + +For production apps, we strongly recommend using external signature generation. This keeps your secret key on your backend server, preventing it from being extracted from the APK. + +```java +// Create a signature provider that fetches signatures from your backend +SignatureProvider signatureProvider = new SignatureProvider() { + @Override + public String generateSignature(String paramsJson) throws Exception { + // Make a request to your backend to sign the parameters + // This is just an example - implement according to your backend API + HttpURLConnection conn = (HttpURLConnection) new URL("https://your-backend.com/sign").openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + 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:..." + } +}; + +// Initialize Transloadit with the signature provider +AndroidTransloadit transloadit = new AndroidTransloadit("YOUR_KEY", signatureProvider); +``` + +Your backend should implement an endpoint that: + +1. Validates the request (authentication, rate limiting, etc.) +2. Signs the parameters using your Transloadit secret +3. Returns the signature + +Example backend implementation (Node.js): + +```javascript +const crypto = require('crypto') + +app.post('/sign', authenticate, (req, res) => { + const paramsJson = req.body + const signature = crypto + .createHmac('sha384', process.env.TRANSLOADIT_SECRET) + .update(Buffer.from(paramsJson, 'utf-8')) + .digest('hex') + + res.send(`sha384:${signature}`) +}) +``` ### Create an Assembly To create an assembly, you use the `newAssembly` method. -To use this functionality in it's full glory, you need implement the `AssemblyProgressListener` +To use this functionality in its full glory, implement the `AndroidAssemblyListener` interface. ```java -public class MyAssemblyProgressListener implements AssemblyProgressListener { +public class MyAssemblyListener implements AndroidAssemblyListener { @Override public void onUploadFinished() { System.out.println("upload finished!!! waiting for execution ..."); @@ -87,10 +157,10 @@ public class MainActivity extends AppCompatActivity { progressBar = (ProgressBar) findViewById(R.id.progressBar); - AssemblyProgressListener listener = new MyAssemblyProgressListener(); + AndroidAssemblyListener listener = new MyAssemblyListener(); AndroidTransloadit transloadit = new AndroidTransloadit("key", "secret"); - AndroidAsyncAssembly assembly = transloadit.newAssembly(listener); + AndroidAssembly assembly = transloadit.newAssembly(listener, this /* activity context */); assembly.addFile(new File("path/to/file.jpg"), "file"); Map stepOptions = new HashMap<>(); @@ -99,16 +169,102 @@ public class MainActivity extends AppCompatActivity { stepOptions.put("resize_strategy", "pad"); assembly.addStep("resize", "/image/resize", stepOptions); - assembly.save(); + assembly.saveAsync(); } } ``` +Listener callbacks (`onUploadProgress`, `onAssemblyFinished`, etc.) are dispatched on the Android main thread by default so UI components can be updated safely. To opt out—for example, to process updates off the UI thread—call `useDirectCallbacks()` or install a custom executor: + +```java +AndroidAssembly assembly = transloadit.newAssembly(listener); +assembly.useDirectCallbacks(); // run callbacks on the calling thread +// or provide any Executor: assembly.setListenerCallbackExecutor(Executors.newSingleThreadExecutor()); +``` + +## Migration guide (0.x → 0.2) + +- Replace `AndroidAsyncAssembly` with the new `AndroidAssembly` wrapper. It returns a `Future` from `saveAsync()` and reports lifecycle events through `AndroidAssemblyListener`. +- Update listeners: `AssemblyProgressListener` and friends were removed. Implement `AndroidAssemblyListener` instead, which exposes upload progress, SSE results, and completion/error hooks. +- Callbacks now fire on the Android main thread by default. If you relied on background delivery, call `assembly.useDirectCallbacks()` or `assembly.setListenerCallbackExecutor(...)`. +- Prefer external signature generation via `new AndroidTransloadit(key, signatureProvider)`. Passing the secret into your APK still works but is no longer recommended for production. +- Tests, CI, and examples now execute against the bundled `chameleon.jpg` fixture and require `result: true` on resize steps so SSE result payloads arrive consistently. +- The SDK depends on `com.transloadit.sdk:transloadit:2.2.4` or newer. Ensure any overrides or local builds are upgraded in lockstep. +- Persisted tus uploads now live under the SharedPreferences name `transloadit_android_sdk_urls`. If you upgrade from 0.x and rely on resuming uploads saved under the typo’d `tansloadit_android_sdk_urls`, plan a manual migration. + ```java + SharedPreferences legacy = context.getSharedPreferences("tansloadit_android_sdk_urls", Context.MODE_PRIVATE); + SharedPreferences modern = context.getSharedPreferences(AndroidAssembly.DEFAULT_PREFERENCE_NAME, Context.MODE_PRIVATE); + if (!legacy.getAll().isEmpty()) { + SharedPreferences.Editor editor = modern.edit(); + for (Map.Entry entry : legacy.getAll().entrySet()) { + editor.putString(entry.getKey(), String.valueOf(entry.getValue())); + } + editor.apply(); + legacy.edit().clear().apply(); // optional: clean up + } + ``` + Run this once at app start to preserve any paused uploads created by pre-1.0 builds. + +## Resumable uploads & WorkManager + +- Call `pauseUploadsSafely()` / `resumeUploadsSafely()` on `AndroidAssembly` to control active tus uploads without micromanaging exceptions. Errors are routed to `onAssemblyStatusUpdateFailed` so your listener can surface them to the UI. +- Use unique preference names via `assembly.preferenceName("my_store")` when running multiple concurrent assemblies so tus resume information can be partitioned per job. +- To move uploads into background execution, build a `OneTimeWorkRequest` with `AndroidAssemblyWorkConfig` and enqueue it through WorkManager: + +```java +AndroidAssemblyWorkConfig config = AndroidAssemblyWorkConfig + .newBuilder("TRANSLOADIT_KEY", "TRANSLOADIT_SECRET") + .paramsJson(paramsJsonString) + .preferenceName("my_transloadit_store") + .addFile(new File(context.getCacheDir(), "photo.jpg"), "image") + .build(); + +WorkManager.getInstance(context).enqueue(config.toWorkRequest()); +``` + +`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 For fully working examples take a look at [examples/](https://github.com/transloadit/android-sdk/tree/main/examples). +Notably, `examples/src/main/kotlin/com/transloadit/examples/work/WorkManagerSample.kt` demonstrates how to enqueue background uploads using WorkManager and the new `AndroidAssemblyWorkConfig`, both with embedded secrets and with an external signature-provider endpoint. The accompanying unit test (`examples/src/test/kotlin/.../WorkManagerSampleTest.kt`) can be exercised locally via: + +```bash +./scripts/test-in-docker.sh :examples:testDebugUnitTest --rerun-tasks +``` + +Provide the same environment variables (`ANDROID_SDK_E2E`, `TRANSLOADIT_KEY`, `TRANSLOADIT_SECRET`) as the primary E2E flow to run the sample end-to-end against Transloadit. + +## Development + +Run the unit test suite inside Docker to avoid installing the Android toolchain locally: + +```bash +./scripts/test-in-docker.sh +``` + +The script builds a lightweight image with the necessary Android command-line tools, caches Gradle downloads inside `.android-docker/`, and runs `./gradlew test` with the SDK preconfigured. If you use Colima, the script will automatically fall back to `~/.colima/default/docker.sock` when the default Docker socket is unavailable. + +### End-to-end signature provider verification + +`transloadit-android/src/test/java/com/transloadit/android/sdk/SignatureProviderE2ETest` drives a real upload against Transloadit to exercise the external signature-provider workflow that the SDK ships for production apps (plus Tus pause/resume, SSE progress/results, etc.). The test is opt-in and executes when `ANDROID_SDK_E2E=true` and both `TRANSLOADIT_KEY` and `TRANSLOADIT_SECRET` are present. To run it locally: + +1. Create a `.env` file in the repository root (or export the variables directly) containing: + ``` + ANDROID_SDK_E2E=1 + TRANSLOADIT_KEY=your-key + TRANSLOADIT_SECRET=your-secret + ``` +2. Execute the docker harness with the desired Gradle task, for example: + ```bash + ./scripts/test-in-docker.sh :transloadit-android:testDebugUnitTest --rerun-tasks + ``` + The script automatically passes variables from `.env` into the container. + +The GitHub Actions workflow (`.github/workflows/CI.yml`) sets the same environment variables from repository secrets, so pull requests and the `main` branch continuously run this end-to-end verification alongside the Java SDK’s signature-parity tests. + ## Documentation See [Javadoc](https://javadoc.io/doc/com.transloadit.android.sdk/transloadit-android/latest/index.html) for full API documentation. diff --git a/build.gradle b/build.gradle index 03ca3d9..55d5d45 100644 --- a/build.gradle +++ b/build.gradle @@ -7,13 +7,14 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:8.3.1' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } } plugins { - id("io.github.gradle-nexus.publish-plugin") version "1.3.0" + id("io.github.gradle-nexus.publish-plugin") version "2.0.0" id("maven-publish") } diff --git a/examples/build.gradle b/examples/build.gradle index 1138af8..93d87ca 100644 --- a/examples/build.gradle +++ b/examples/build.gradle @@ -1,33 +1,60 @@ -apply plugin: 'com.android.application' +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} repositories { + google() mavenCentral() } android { compileSdk 34 + defaultConfig { - minSdkVersion 15 + applicationId "com.transloadit.examples" + minSdkVersion 21 targetSdkVersion 34 versionCode 1 versionName "1.0" testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } + buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + + buildFeatures { + buildConfig true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + namespace 'com.transloadit.examples' } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.android.support.constraint:constraint-layout:2.0.4' - implementation 'com.transloadit.sdk:transloadit:0.4.4' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'com.transloadit.sdk:transloadit:2.2.4' implementation project(':transloadit-android') + implementation 'androidx.work:work-runtime-ktx:2.9.0' + implementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.jetbrains.kotlin:kotlin-test-junit:1.9.24' + testImplementation 'androidx.work:work-testing:2.9.0' + testImplementation 'androidx.test:core:1.5.0' + testImplementation 'org.robolectric:robolectric:4.12.1' } - diff --git a/examples/src/main/java/com/transloadit/examples/MainActivity.java b/examples/src/main/java/com/transloadit/examples/MainActivity.java index d19b28b..6f4a8b8 100644 --- a/examples/src/main/java/com/transloadit/examples/MainActivity.java +++ b/examples/src/main/java/com/transloadit/examples/MainActivity.java @@ -14,9 +14,9 @@ import android.widget.ProgressBar; import android.widget.TextView; -import com.transloadit.android.sdk.AndroidAsyncAssembly; +import com.transloadit.android.sdk.AndroidAssembly; +import com.transloadit.android.sdk.AndroidAssemblyListener; import com.transloadit.android.sdk.AndroidTransloadit; -import com.transloadit.sdk.async.AssemblyProgressListener; import com.transloadit.sdk.exceptions.LocalOperationException; import com.transloadit.sdk.exceptions.RequestException; import com.transloadit.sdk.response.AssemblyResponse; @@ -26,15 +26,17 @@ import java.io.FileNotFoundException; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; -public class MainActivity extends AppCompatActivity implements AssemblyProgressListener { +public class MainActivity extends AppCompatActivity implements AndroidAssemblyListener { private final int REQUEST_FILE_SELECT = 1; private TextView status; private Button pauseButton; private Button resumeButton; private ProgressBar progressBar; - private AndroidAsyncAssembly androidAsyncAssembly; + private AndroidAssembly androidAssembly; @Override protected void onCreate(Bundle savedInstanceState) { @@ -42,7 +44,7 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_main); AndroidTransloadit transloadit = new AndroidTransloadit("key", "secret"); - androidAsyncAssembly = transloadit.newAssembly(this, this); + androidAssembly = transloadit.newAssembly(this, this); status = (TextView) findViewById(R.id.status); progressBar = (ProgressBar) findViewById(R.id.progressBar); @@ -78,7 +80,7 @@ public void onClick(View v) { private void submitAssembly(Uri uri) { try { - androidAsyncAssembly.addFile(getContentResolver().openInputStream(uri), "file"); + androidAssembly.addFile(getContentResolver().openInputStream(uri), "file"); } catch (FileNotFoundException e) { e.printStackTrace(); showError(e); @@ -87,7 +89,7 @@ private void submitAssembly(Uri uri) { stepOptions.put("width", 75); stepOptions.put("height", 75); stepOptions.put("resize_strategy", "pad"); - androidAsyncAssembly.addStep("resize", "/image/resize", stepOptions); + androidAssembly.addStep("resize", "/image/resize", stepOptions); SaveTask saveTask = new SaveTask(this); saveTask.execute(true); @@ -100,7 +102,7 @@ public void setPauseButtonEnabled(boolean enabled) { public void pauseUpload() { try { - androidAsyncAssembly.pauseUpload(); + androidAssembly.pauseUploads(); } catch (LocalOperationException e) { showError(e); } @@ -109,8 +111,8 @@ public void pauseUpload() { public void resumeUpload() { try { - androidAsyncAssembly.resumeUpload(); - } catch (LocalOperationException e) { + androidAssembly.resumeUploads(); + } catch (LocalOperationException | RequestException e) { showError(e); } setPauseButtonEnabled(true); @@ -118,14 +120,14 @@ public void resumeUpload() { @Override public void onUploadFinished() { - setStatus("You AndroidAsyncAssembly Upload is done and it's now executing"); + setStatus("Upload finished! Assembly is now executing"); pauseButton.setEnabled(false); } @Override public void onAssemblyFinished(AssemblyResponse response) { try { - setStatus("Your AndroidAsyncAssembly is done executing with status: " + response.json().getString("ok")); + setStatus("Assembly finished with status: " + response.json().getString("ok")); } catch (JSONException e) { showError(e); } @@ -143,7 +145,9 @@ public void onAssemblyStatusUpdateFailed(Exception exception) { @Override public void onUploadProgress(long uploadedBytes, long totalBytes) { - progressBar.setProgress((int) ((double) uploadedBytes / totalBytes * 100)); + if (totalBytes > 0) { + progressBar.setProgress((int) ((double) uploadedBytes / totalBytes * 100)); + } } private void setStatus(String text) { @@ -204,19 +208,20 @@ private class SaveTask extends AsyncTask { @Override protected void onPostExecute(AssemblyResponse response) { - activity.setStatus("Your androidAsyncAssembly is running on " + response.getUrl()); + if (response != null) { + activity.setStatus("Assembly running on " + response.getUrl()); + } activity.setPauseButtonEnabled(true); } @Override protected AssemblyResponse doInBackground(Boolean... params) { try { - return androidAsyncAssembly.save(params[0]); - } catch (LocalOperationException e) { - showError(e); - } catch (RequestException e) { - e.printStackTrace(); - showError(e); + Future future = androidAssembly.saveAsync(params[0]); + return future.get(); + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + showError(new RuntimeException(e)); } return null; diff --git a/examples/src/main/java/com/transloadit/examples/SignatureProviderExample.java b/examples/src/main/java/com/transloadit/examples/SignatureProviderExample.java new file mode 100644 index 0000000..c48d4ea --- /dev/null +++ b/examples/src/main/java/com/transloadit/examples/SignatureProviderExample.java @@ -0,0 +1,296 @@ +package com.transloadit.examples; + +import android.content.Context; +import android.os.AsyncTask; +import android.util.Log; + +import com.transloadit.android.sdk.AndroidAssembly; +import com.transloadit.android.sdk.AndroidAssemblyListener; +import com.transloadit.android.sdk.AndroidTransloadit; +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.JSONObject; + +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.lang.ref.WeakReference; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; + +/** + * Example demonstrating how to use external signature generation + * for improved security in production Android applications. + * + * This approach keeps your Transloadit secret on your backend server + * instead of embedding it in the APK, preventing extraction through + * decompilation. + */ +public class SignatureProviderExample { + private static final String TAG = "SigProviderExample"; + + // Your backend endpoint that generates signatures + private static final String BACKEND_SIGN_URL = "https://your-backend.com/api/transloadit/sign"; + + // Your Transloadit API key (safe to include in APK) + private static final String TRANSLOADIT_KEY = "YOUR_TRANSLOADIT_KEY"; + + /** + * Custom SignatureProvider that fetches signatures from your backend + */ + static class BackendSignatureProvider implements SignatureProvider { + private final String backendUrl; + private final String authToken; // Your app's auth token for backend requests + + public BackendSignatureProvider(String backendUrl, String authToken) { + this.backendUrl = backendUrl; + this.authToken = authToken; + } + + @Override + public String generateSignature(String paramsJson) throws Exception { + // Make a synchronous HTTP request to your backend + // In production, you might want to use a more sophisticated HTTP client + // like OkHttp or Retrofit + + URL url = new URL(backendUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + + try { + // Configure the request + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("Authorization", "Bearer " + authToken); + conn.setDoOutput(true); + conn.setConnectTimeout(10000); // 10 seconds + conn.setReadTimeout(10000); // 10 seconds + + // Send the params JSON to your backend + try (OutputStream os = conn.getOutputStream()) { + os.write(paramsJson.getBytes("UTF-8")); + } + + // Check response code + int responseCode = conn.getResponseCode(); + if (responseCode != HttpURLConnection.HTTP_OK) { + throw new Exception("Backend returned error: " + responseCode); + } + + // Read the signature from the response + StringBuilder response = new StringBuilder(); + try (BufferedReader br = new BufferedReader( + new InputStreamReader(conn.getInputStream(), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) { + response.append(line); + } + } + + // Parse the response (assuming JSON format) + JSONObject jsonResponse = new JSONObject(response.toString()); + String signature = jsonResponse.getString("signature"); + + Log.d(TAG, "Received signature from backend: " + signature.substring(0, 20) + "..."); + + return signature; + + } finally { + conn.disconnect(); + } + } + } + + /** + * Example AndroidAssemblyListener implementation + */ + static class ExampleAssemblyListener implements AndroidAssemblyListener { + @Override + public void onUploadFinished() { + Log.i(TAG, "Upload finished! Waiting for assembly to complete..."); + } + + @Override + public void onUploadProgress(long uploadedBytes, long totalBytes) { + if (totalBytes > 0) { + int progress = (int) ((uploadedBytes * 100) / totalBytes); + Log.d(TAG, "Upload progress: " + progress + "%"); + } + } + + @Override + public void onAssemblyFinished(AssemblyResponse response) { + try { + Log.i(TAG, "Assembly completed successfully!"); + Log.i(TAG, "Assembly ID: " + response.getId()); + Log.i(TAG, "Assembly URL: " + response.getUrl()); + + // Process results + if (response.getStepResult("resize") != null) { + JSONArray resizeResults = response.getStepResult("resize"); + Log.i(TAG, "Resize result: " + resizeResults.toString(2)); + } + } catch (Exception e) { + Log.e(TAG, "Error processing assembly response", e); + } + } + + @Override + public void onUploadFailed(Exception exception) { + Log.e(TAG, "Upload failed", exception); + } + + @Override + public void onAssemblyStatusUpdateFailed(Exception exception) { + Log.e(TAG, "Failed to get assembly status update", exception); + } + } + + /** + * Demonstrates how to use the SignatureProvider with AndroidTransloadit + */ + public static void createAssemblyWithSignatureProvider(Context context, File imageFile, String authToken) { + // Create the signature provider + SignatureProvider signatureProvider = new BackendSignatureProvider(BACKEND_SIGN_URL, authToken); + + // Initialize AndroidTransloadit with the signature provider (no secret needed!) + AndroidTransloadit transloadit = new AndroidTransloadit(TRANSLOADIT_KEY, signatureProvider); + + // Create an assembly listener + AndroidAssemblyListener listener = new ExampleAssemblyListener(); + + // Create a new assembly + AndroidAssembly assembly = transloadit.newAssembly(listener, context); + + // Add the file to upload + assembly.addFile(imageFile, "image"); + + // Add a resize step + Map resizeOptions = new HashMap<>(); + resizeOptions.put("width", 200); + resizeOptions.put("height", 200); + resizeOptions.put("resize_strategy", "fit"); + resizeOptions.put("format", "jpg"); + assembly.addStep("resize", "/image/resize", resizeOptions); + + // Save the assembly (this will trigger the upload) + Future future = assembly.saveAsync(); + try { + future.get(); + Log.i(TAG, "Assembly creation started with external signature generation"); + } catch (InterruptedException | ExecutionException e) { + Log.e(TAG, "Failed to create assembly", e); + Thread.currentThread().interrupt(); + } + } + + /** + * Alternative: Using AsyncTask for better UI integration + */ + public static class CreateAssemblyTask extends AsyncTask { + private final WeakReference contextRef; + private final String authToken; + private final AssemblyTaskListener listener; + + public interface AssemblyTaskListener { + void onAssemblyCreated(AssemblyResponse response); + void onAssemblyFailed(Exception error); + void onProgressUpdate(int progress); + } + + public CreateAssemblyTask(Context context, String authToken, AssemblyTaskListener listener) { + this.contextRef = new WeakReference<>(context.getApplicationContext()); + this.authToken = authToken; + this.listener = listener; + } + + @Override + protected AssemblyResponse doInBackground(File... files) { + if (files.length == 0) { + return null; + } + + try { + Context context = contextRef.get(); + if (context == null) { + Log.w(TAG, "Context reference lost before assembly creation"); + return null; + } + + // Create signature provider + SignatureProvider signatureProvider = new BackendSignatureProvider(BACKEND_SIGN_URL, authToken); + + // Initialize Transloadit + AndroidTransloadit transloadit = new AndroidTransloadit(TRANSLOADIT_KEY, signatureProvider); + + // Create assembly with progress tracking + AndroidAssembly assembly = transloadit.newAssembly(new AndroidAssemblyListener() { + @Override + public void onUploadProgress(long uploadedBytes, long totalBytes) { + if (totalBytes > 0) { + int progress = (int) ((uploadedBytes * 100) / totalBytes); + publishProgress(progress); + } + } + + @Override + public void onUploadFinished() { + Log.d(TAG, "Upload completed"); + } + + @Override + public void onUploadFailed(Exception exception) { + Log.e(TAG, "Upload failed", exception); + } + + @Override + public void onAssemblyStatusUpdateFailed(Exception exception) { + Log.e(TAG, "Status update failed", exception); + } + }, context); + + // Add file and steps + assembly.addFile(files[0], "image"); + + Map resizeOptions = new HashMap<>(); + resizeOptions.put("width", 200); + resizeOptions.put("height", 200); + assembly.addStep("resize", "/image/resize", resizeOptions); + + // Execute assembly + Future future = assembly.saveAsync(); + return future.get(); + + } catch (Exception e) { + Log.e(TAG, "Failed to create assembly", e); + return null; + } + } + + @Override + protected void onProgressUpdate(Integer... values) { + if (listener != null && values.length > 0) { + listener.onProgressUpdate(values[0]); + } + } + + @Override + protected void onPostExecute(AssemblyResponse response) { + if (listener != null) { + if (response != null) { + listener.onAssemblyCreated(response); + } else { + listener.onAssemblyFailed(new Exception("Failed to create assembly")); + } + } + } + } +} diff --git a/examples/src/main/kotlin/com/transloadit/examples/work/WorkManagerSample.kt b/examples/src/main/kotlin/com/transloadit/examples/work/WorkManagerSample.kt new file mode 100644 index 0000000..5c93cc2 --- /dev/null +++ b/examples/src/main/kotlin/com/transloadit/examples/work/WorkManagerSample.kt @@ -0,0 +1,63 @@ +package com.transloadit.examples.work + +import android.content.Context +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import com.transloadit.android.sdk.AndroidAssemblyUploadWorker +import com.transloadit.android.sdk.AndroidAssemblyWorkConfig +import java.io.File + +/** + * Helper for enqueuing Transloadit uploads via WorkManager. + */ +object WorkManagerSample { + private const val UNIQUE_WORK_NAME = "transloadit-workmanager-sample" + + fun enqueueUpload( + context: Context, + authKey: String, + authSecret: String, + paramsJson: String, + file: File, + field: String = "file" + ): OneTimeWorkRequest { + val config = AndroidAssemblyWorkConfig + .newBuilder(authKey, authSecret) + .paramsJson(paramsJson) + .addFile(file, field) + .waitForCompletion(true) + .build() + + val request = config.toWorkRequest() + WorkManager.getInstance(context) + .enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, request) + return request + } + + fun enqueueUploadWithSignatureProvider( + context: Context, + authKey: String, + signatureEndpoint: String, + paramsJson: String, + file: File, + field: String = "file", + headers: Map = emptyMap(), + method: String = "POST" + ): OneTimeWorkRequest { + val builder = AndroidAssemblyWorkConfig + .newBuilder(authKey) + .signatureProvider(signatureEndpoint) + .signatureProviderMethod(method) + .paramsJson(paramsJson) + .addFile(file, field) + .waitForCompletion(true) + + headers.forEach { (key, value) -> builder.addSignatureProviderHeader(key, value) } + + val request = builder.build().toWorkRequest() + WorkManager.getInstance(context) + .enqueueUniqueWork(UNIQUE_WORK_NAME, ExistingWorkPolicy.REPLACE, request) + return request + } +} diff --git a/examples/src/test/kotlin/com/transloadit/examples/work/WorkManagerSampleTest.kt b/examples/src/test/kotlin/com/transloadit/examples/work/WorkManagerSampleTest.kt new file mode 100644 index 0000000..5213fc2 --- /dev/null +++ b/examples/src/test/kotlin/com/transloadit/examples/work/WorkManagerSampleTest.kt @@ -0,0 +1,115 @@ +package com.transloadit.examples.work + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.work.Configuration +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import com.transloadit.android.sdk.AndroidAssemblyWorkConfig +import org.junit.Assume.assumeTrue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.File +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlin.random.Random + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28]) +class WorkManagerSampleTest { + private lateinit var context: Context + + companion object { + @Volatile + private var workManagerInitialized = false + } + + @Before + fun setUp() { + val e2eEnabled = System.getenv("ANDROID_SDK_E2E")?.toBoolean() == true + assumeTrue("Skipping WorkManager E2E test", e2eEnabled) + context = ApplicationProvider.getApplicationContext() + val config = Configuration.Builder() + .setExecutor(SynchronousExecutor()) + .build() + if (!workManagerInitialized) { + synchronized(WorkManagerSampleTest::class) { + if (!workManagerInitialized) { + try { + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + } catch (_: IllegalStateException) { + // Already initialized for this process. + } + workManagerInitialized = true + } + } + } + } + + @Test + fun workRequestWithSecretCompletes() { + val authKeyValue = System.getenv("TRANSLOADIT_KEY") + val authSecretValue = System.getenv("TRANSLOADIT_SECRET") + assumeTrue(!authKeyValue.isNullOrBlank()) + assumeTrue(!authSecretValue.isNullOrBlank()) + val authKey = authKeyValue!! + val authSecret = authSecretValue!! + + val sampleFile = createTempFile() + val paramsJson = """{"steps":{"resize":{"robot":"/image/resize","width":32,"height":32,"result":true}}}""" + + val request = WorkManagerSample.enqueueUpload(context, authKey, authSecret, paramsJson, sampleFile) + waitForSuccess(request.id) + } + + @Test + fun signatureProviderConfigCanBeBuilt() { + val config = AndroidAssemblyWorkConfig.newBuilder("key") + .signatureProvider("https://example.com/sign") + .signatureProviderMethod("POST") + .addSignatureProviderHeader("Authorization", "Bearer token") + .paramsJson("{\"steps\":{\"noop\":{\"robot\":\"/file/import\"}}}") + .addFile(createTempFile(), "file") + .build() + + assertEquals("https://example.com/sign", config.getSignatureProviderUrl()) + assertEquals("POST", config.getSignatureProviderMethod()) + assertEquals("Bearer token", config.getSignatureProviderHeaders()["Authorization"]) + } + + private fun createTempFile(): File { + val file = File.createTempFile("transloadit-e2e-sample", ".bin") + file.outputStream().use { out -> + val bytes = ByteArray(4 * 1024) + Random.nextBytes(bytes) + repeat(16) { out.write(bytes) } + } + return file + } + + private fun waitForSuccess(id: UUID) { + val testDriver = WorkManagerTestInitHelper.getTestDriver(context)!! + testDriver.setAllConstraintsMet(id) + + val workManager = WorkManager.getInstance(context) + val deadline = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5) + var info: WorkInfo + do { + info = workManager.getWorkInfoById(id).get(10, TimeUnit.SECONDS) + if (info.state == WorkInfo.State.SUCCEEDED) { + break + } + Thread.sleep(1_000) + } while (info.state == WorkInfo.State.RUNNING && System.currentTimeMillis() < deadline) + + assertTrue("Worker should succeed", info.state == WorkInfo.State.SUCCEEDED) + } + +} diff --git a/scripts/test-in-docker.sh b/scripts/test-in-docker.sh new file mode 100755 index 0000000..9cebd09 --- /dev/null +++ b/scripts/test-in-docker.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE_NAME=${IMAGE_NAME:-transloadit-android-sdk-dev} +CACHE_ROOT=.android-docker +GRADLE_CACHE_DIR="$CACHE_ROOT/gradle" +HOME_DIR="$CACHE_ROOT/home" +ANDROID_SDK_ROOT=/opt/android-sdk +USE_LOCAL_JAVA_SDK="${ANDROID_SDK_USE_LOCAL_JAVA_SDK:-0}" + +ensure_docker() { + if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required to run this script." >&2 + exit 1 + fi + + if ! docker info >/dev/null 2>&1; then + if [[ -z "${DOCKER_HOST:-}" && -S "$HOME/.colima/default/docker.sock" ]]; then + export DOCKER_HOST="unix://$HOME/.colima/default/docker.sock" + fi + fi + + if ! docker info >/dev/null 2>&1; then + echo "Docker daemon is not reachable. Start Docker (or Colima) and retry." >&2 + exit 1 + fi +} + +configure_platform() { + if [[ -z "${DOCKER_PLATFORM:-}" ]]; then + local arch + arch=$(uname -m) + if [[ "$arch" == "arm64" || "$arch" == "aarch64" ]]; then + DOCKER_PLATFORM=linux/amd64 + fi + fi +} + +ensure_docker +configure_platform + +if [[ $# -eq 0 ]]; then + RUN_CMD='set -e; ./gradlew --no-daemon assemble --stacktrace && ./gradlew --no-daemon check --stacktrace' +else + GRADLE_CMD=("./gradlew" "--no-daemon") + GRADLE_CMD+=("$@") + GRADLE_CMD+=("--stacktrace") + printf -v RUN_CMD '%q ' "${GRADLE_CMD[@]}" +fi + +mkdir -p "$GRADLE_CACHE_DIR" "$HOME_DIR/.android" + +BUILD_ARGS=() +if [[ -n "${DOCKER_PLATFORM:-}" ]]; then + BUILD_ARGS+=(--platform "$DOCKER_PLATFORM") +fi +BUILD_ARGS+=(-t "$IMAGE_NAME" -f Dockerfile .) + +docker build "${BUILD_ARGS[@]}" + +CONTAINER_HOME=/workspace/$HOME_DIR + +DOCKER_ARGS=(\ + --rm\ + --user "$(id -u):$(id -g)"\ + -e ANDROID_SDK_ROOT="$ANDROID_SDK_ROOT"\ + -e ANDROID_HOME="$ANDROID_SDK_ROOT"\ + -e ANDROID_SDK_USE_LOCAL_JAVA_SDK="$USE_LOCAL_JAVA_SDK"\ + -e GRADLE_USER_HOME=/workspace/$GRADLE_CACHE_DIR\ + -e HOME="$CONTAINER_HOME"\ + -v "$PWD":/workspace\ + -w /workspace\ +) + +if [[ -n "${DOCKER_PLATFORM:-}" ]]; then + DOCKER_ARGS+=(--platform "$DOCKER_PLATFORM") +fi + +if [[ -f .env ]]; then + DOCKER_ARGS+=(--env-file "$PWD/.env") +fi + +E2E_FLAG="${ANDROID_SDK_E2E:-1}" +DOCKER_ARGS+=(-e "ANDROID_SDK_E2E=$E2E_FLAG") + +for var in TRANSLOADIT_HOST TRANSLOADIT_KEY TRANSLOADIT_SECRET ANDROID_SDK_FORCE_RESULTLESS; do + value="${!var:-}" + if [[ -n "$value" ]]; then + DOCKER_ARGS+=(-e "$var=$value") + fi +done + +if [[ "$USE_LOCAL_JAVA_SDK" != "0" ]]; then + HOST_JAVA_SDK="$(cd "$(dirname "$PWD")" && pwd)/java-sdk" + if [[ -d "$HOST_JAVA_SDK" ]]; then + DOCKER_ARGS+=(-v "$HOST_JAVA_SDK":/workspace/../java-sdk) + fi +fi + +exec docker run "${DOCKER_ARGS[@]}" "$IMAGE_NAME" bash -lc "$RUN_CMD" diff --git a/settings.gradle b/settings.gradle index 4c98e7f..903470b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,15 @@ include ':transloadit-android' include ':examples' + +def javaSdkDir = file("../java-sdk") +def useLocalJavaSdkEnv = System.getenv("ANDROID_SDK_USE_LOCAL_JAVA_SDK") +def preferLocalJavaSdk = (useLocalJavaSdkEnv != null) && + (useLocalJavaSdkEnv.equalsIgnoreCase("true") || useLocalJavaSdkEnv == "1") + +if (preferLocalJavaSdk && javaSdkDir.exists()) { + includeBuild(javaSdkDir) { + dependencySubstitution { + substitute(module("com.transloadit.sdk:transloadit")).using(project(":")) + } + } +} diff --git a/transloadit-android/build.gradle b/transloadit-android/build.gradle index 04850f1..2e80bad 100644 --- a/transloadit-android/build.gradle +++ b/transloadit-android/build.gradle @@ -15,25 +15,48 @@ android { targetSdkVersion 34 testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } + testOptions { + unitTests { + includeAndroidResources = true + all { + testLogging { + events 'failed', 'standardOut', 'standardError' + exceptionFormat 'full' + showCauses true + showExceptions true + showStackTraces true + } + } + } + } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } namespace 'com.transloadit.sdk' } dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.transloadit.sdk:transloadit:1.0.0' + implementation 'com.transloadit.sdk:transloadit:2.2.4' implementation 'io.tus.android.client:tus-android-client:0.1.10' - implementation 'io.tus.java.client:tus-java-client:0.4.5' + 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' testImplementation 'junit:junit:4.13.2' testImplementation 'org.mockito:mockito-core:4.8.0' testImplementation 'org.mock-server:mockserver-junit-rule:5.15.0' + testImplementation 'androidx.test:core:1.5.0' + testImplementation 'org.robolectric:robolectric:4.12.1' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.12.0' + testImplementation 'androidx.work:work-testing:2.9.0' } def config = new ConfigSlurper().parse(new File("${projectDir}/src/main/resources/android-sdk-version/version.properties").toURI().toURL()) @@ -123,5 +146,3 @@ signing { useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) sign publishing.publications.androidRelease } - - diff --git a/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssembly.java b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssembly.java new file mode 100644 index 0000000..b0dfcea --- /dev/null +++ b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssembly.java @@ -0,0 +1,260 @@ +package com.transloadit.android.sdk; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Handler; +import android.os.Looper; + +import com.transloadit.sdk.Assembly; +import com.transloadit.sdk.AssemblyListener; +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.JSONObject; + +import java.io.Closeable; +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +import io.tus.android.client.TusPreferencesURLStore; +import io.tus.java.client.TusURLStore; + +/** + * Android-friendly Assembly wrapper that runs uploads asynchronously and reports progress via + * {@link AndroidAssemblyListener}. Callbacks are dispatched on the main thread by default and can be + * rerouted to a custom executor via {@link #setListenerCallbackExecutor(Executor)}. + */ +public class AndroidAssembly extends Assembly implements Closeable { + /** + * SharedPreferences key where resumable upload URLs are cached. + * NOTE: renamed from "tansloadit_" in 0.x; existing persisted uploads will not resume automatically. + */ + public static final String DEFAULT_PREFERENCE_NAME = "transloadit_android_sdk_urls"; // NOTE: renamed from "tansloadit_" in 0.x; existing persisted uploads will not resume automatically. + + private static final AtomicInteger THREAD_COUNTER = new AtomicInteger(1); + private static final ThreadFactory THREAD_FACTORY = runnable -> { + Thread thread = new Thread(runnable, "android-assembly-" + THREAD_COUNTER.getAndIncrement()); + thread.setDaemon(true); + return thread; + }; + private static final ExecutorService SHARED_EXECUTOR = Executors.newCachedThreadPool(THREAD_FACTORY); + + private final Context context; + private final AndroidAssemblyListener listener; + private final ExecutorService executor; + private final Executor mainThreadExecutor; + private volatile Executor listenerExecutor; + + /** + * Creates a new Android-aware assembly wrapper. + * + * @param transloadit underlying SDK instance used for network calls + * @param listener callback receiver for upload lifecycle events + * @param context Android context used for persistence and main-thread dispatching + */ + public AndroidAssembly(AndroidTransloadit transloadit, AndroidAssemblyListener listener, Context context) { + super(transloadit); + this.context = context.getApplicationContext(); + this.listener = listener; + this.executor = SHARED_EXECUTOR; + this.mainThreadExecutor = new MainThreadExecutor(); + this.listenerExecutor = this.mainThreadExecutor; + setPreferenceName(DEFAULT_PREFERENCE_NAME); + } + + /** + * Applies a shared preferences backed Tus URL store for resumable uploads. + * + * @param name preferences file name that stores resumable upload URLs + */ + public void setPreferenceName(String name) { + SharedPreferences pref = context.getSharedPreferences(name, Context.MODE_PRIVATE); + TusURLStore store = new TusPreferencesURLStore(pref); + setTusURLStore(store); + } + + /** + * Runs the assembly asynchronously, returning the initial save response. + * + * @return {@link Future} that resolves with the initial {@link AssemblyResponse} + */ + public Future saveAsync() { + return saveAsync(true); + } + + /** + * Runs the assembly asynchronously with optional resumable uploads. + * + * @param isResumable whether resumable uploads should be enabled + * @return {@link Future} that resolves with the initial {@link AssemblyResponse} + */ + public Future saveAsync(boolean isResumable) { + setAssemblyListener(createListenerAdapter()); + Callable task = () -> { + try { + return AndroidAssembly.super.save(isResumable); + } catch (LocalOperationException | RequestException e) { + dispatchListener(l -> l.onUploadFailed(e)); + throw e; + } catch (Exception e) { + dispatchListener(l -> l.onUploadFailed(e)); + throw e; + } + }; + return executor.submit(task); + } + + /** + * Overrides the executor used for dispatching listener callbacks. + * + * @param executor executor that should receive listener callbacks + */ + public void setListenerCallbackExecutor(Executor executor) { + this.listenerExecutor = Objects.requireNonNull(executor, "listener executor cannot be null"); + } + + /** + * Routes listener callbacks to Android's main thread. + */ + public void useMainThreadCallbacks() { + this.listenerExecutor = this.mainThreadExecutor; + } + + /** + * Routes listener callbacks directly on the worker thread. + */ + public void useDirectCallbacks() { + this.listenerExecutor = Runnable::run; + } + + /** + * Attempts to pause uploads and reports status via the listener. + * + * @return {@code true} if the pause operation succeeded + */ + public boolean pauseUploadsSafely() { + try { + super.pauseUploads(); + return true; + } catch (LocalOperationException e) { + dispatchListener(l -> l.onAssemblyStatusUpdateFailed(e)); + return false; + } + } + + /** + * Attempts to resume uploads and reports status via the listener. + * + * @return {@code true} if the resume operation succeeded + */ + public boolean resumeUploadsSafely() { + try { + super.resumeUploads(); + return true; + } catch (LocalOperationException | RequestException e) { + dispatchListener(l -> l.onAssemblyStatusUpdateFailed(e)); + return false; + } + } + + @androidx.annotation.VisibleForTesting + Executor getListenerExecutorForTesting() { + return listenerExecutor; + } + + @androidx.annotation.VisibleForTesting + AssemblyListener createListenerAdapterForTesting() { + return createListenerAdapter(); + } + + private AssemblyListener createListenerAdapter() { + return new AssemblyListener() { + @Override + public void onAssemblyFinished(AssemblyResponse response) { + dispatchListener(l -> l.onAssemblyFinished(response)); + } + + @Override + public void onError(Exception error) { + dispatchListener(l -> l.onAssemblyStatusUpdateFailed(error)); + } + + @Override + public void onMetadataExtracted() { + // no-op + } + + @Override + public void onAssemblyUploadFinished() { + dispatchListener(AndroidAssemblyListener::onUploadFinished); + } + + @Override + public void onFileUploadFinished(JSONObject uploadInformation) { + // no-op + } + + @Override + public void onFileUploadPaused(String name) { + // no-op + } + + @Override + public void onFileUploadResumed(String name) { + // no-op + } + + @Override + public void onFileUploadProgress(long uploadedBytes, long totalBytes) { + dispatchListener(l -> l.onUploadProgress(uploadedBytes, totalBytes)); + } + + @Override + public void onAssemblyProgress(org.json.JSONObject progressPerOriginalFile) { + dispatchListener(l -> l.onAssemblyProgress(progressPerOriginalFile)); + } + + @Override + public void onAssemblyResultFinished(JSONArray result) { + dispatchListener(l -> l.onAssemblyResultFinished(result)); + } + }; + } + + private void dispatchListener(ListenerAction action) { + Executor executor = listenerExecutor; + executor.execute(() -> action.invoke(listener)); + } + + @Override + public void close() throws IOException { + // no per-instance resources to close; executor is shared. + } + + private static class MainThreadExecutor implements Executor { + private final Handler handler = new Handler(Looper.getMainLooper()); + + @Override + public void execute(Runnable command) { + if (Looper.myLooper() == handler.getLooper()) { + command.run(); + } else { + handler.post(command); + } + } + } + + @FunctionalInterface + interface ListenerAction { + void invoke(AndroidAssemblyListener listener); + } +} diff --git a/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyListener.java b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyListener.java new file mode 100644 index 0000000..749da86 --- /dev/null +++ b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyListener.java @@ -0,0 +1,66 @@ +package com.transloadit.android.sdk; + +import com.transloadit.sdk.response.AssemblyResponse; +import org.json.JSONArray; +import org.json.JSONObject; + +/** + * Listener for receiving lifecycle callbacks during Android assembly execution. + */ +public interface AndroidAssemblyListener { + + /** + * Called periodically with upload progress. + * + * @param uploadedBytes number of bytes uploaded so far + * @param totalBytes total bytes expected for the upload + */ + default void onUploadProgress(long uploadedBytes, long totalBytes) { + } + + /** + * Called when all uploads have finished successfully. + */ + default void onUploadFinished() { + } + + /** + * Called when the upload fails before completion. + * + * @param exception error that caused the failure + */ + default void onUploadFailed(Exception exception) { + } + + /** + * Called once the assembly has finished processing on Transloadit. + * + * @param response final assembly response + */ + default void onAssemblyFinished(AssemblyResponse response) { + } + + /** + * Called if polling or SSE updates encounter an error. + * + * @param exception error emitted by the status update mechanism + */ + default void onAssemblyStatusUpdateFailed(Exception exception) { + } + + /** + * Called with periodic assembly progress updates. + * + * @param progressPerOriginalFile progress details keyed by original file + */ + default void onAssemblyProgress(JSONObject progressPerOriginalFile) { + } + + /** + * Called when individual assembly results become available. + * + * @param result array containing result entries + */ + default void onAssemblyResultFinished(JSONArray result) { + } +} 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 new file mode 100644 index 0000000..0a89439 --- /dev/null +++ b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyUploadWorker.java @@ -0,0 +1,283 @@ +package com.transloadit.android.sdk; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.work.Data; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +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.JSONObject; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.BufferedReader; +import java.io.BufferedOutputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.Charset; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * WorkManager worker that executes a Transloadit assembly in the background. + */ +public class AndroidAssemblyUploadWorker extends Worker { + + private static final Charset UTF8 = Charset.forName("UTF-8"); + + /** Output key exposing the created assembly id. */ + public static final String OUTPUT_ASSEMBLY_ID = "assembly_id"; + /** Output key exposing the HTTP URL of the assembly. */ + public static final String OUTPUT_ASSEMBLY_URL = "assembly_url"; + /** Output key exposing the HTTPS URL of the assembly. */ + public static final String OUTPUT_SSL_URL = "assembly_ssl_url"; + + /** + * Creates a new worker instance. + * + * @param appContext application context used for dependency resolution + * @param workerParams WorkManager parameters for this execution + */ + public AndroidAssemblyUploadWorker(@NonNull Context appContext, @NonNull WorkerParameters workerParams) { + super(appContext, workerParams); + } + + /** + * Executes the assembly upload synchronously on a worker thread. + * + * @return WorkManager {@link Result} describing the outcome + */ + @NonNull + @Override + public Result doWork() { + AndroidAssemblyWorkConfig config; + try { + config = AndroidAssemblyWorkConfig.fromInputData(getInputData()); + } catch (IllegalArgumentException e) { + return Result.failure(new Data.Builder().putString("error", e.getMessage()).build()); + } + + AndroidTransloadit transloadit; + String authSecret = config.getAuthSecret(); + String signatureUrl = config.getSignatureProviderUrl(); + if (authSecret != null && !authSecret.isEmpty()) { + if (config.getHostUrl() == null) { + transloadit = new AndroidTransloadit(config.getAuthKey(), authSecret); + } else { + transloadit = new AndroidTransloadit(config.getAuthKey(), authSecret, config.getHostUrl()); + } + } else if (signatureUrl != null) { + SignatureProvider provider = buildSignatureProvider(signatureUrl, + config.getSignatureProviderMethod(), + config.getSignatureProviderHeaders()); + if (config.getHostUrl() == null) { + transloadit = new AndroidTransloadit(config.getAuthKey(), provider); + } else { + transloadit = new AndroidTransloadit(config.getAuthKey(), provider, config.getHostUrl()); + } + } else { + return Result.failure(new Data.Builder().putString("error", "Missing authSecret or signature provider").build()); + } + + CountDownLatch completionLatch = config.shouldWaitForCompletion() ? new CountDownLatch(1) : null; + AtomicReference completionResponse = new AtomicReference<>(); + AtomicReference completionError = new AtomicReference<>(); + + AndroidAssemblyListener listener = new AndroidAssemblyListener() { + @Override + public void onUploadFailed(Exception exception) { + completionError.compareAndSet(null, exception); + if (completionLatch != null) { + completionLatch.countDown(); + } + } + + @Override + public void onAssemblyStatusUpdateFailed(Exception exception) { + completionError.compareAndSet(null, exception); + if (completionLatch != null) { + completionLatch.countDown(); + } + } + + @Override + public void onAssemblyFinished(AssemblyResponse response) { + completionResponse.set(response); + if (completionLatch != null) { + completionLatch.countDown(); + } + } + }; + + AndroidAssembly assembly = createAssembly(transloadit, listener); + try { + assembly.useDirectCallbacks(); + if (config.getPreferenceName() != null) { + assembly.setPreferenceName(config.getPreferenceName()); + } + + List files = config.getFiles(); + for (AndroidAssemblyWorkConfig.FileSpec spec : files) { + File file = new File(spec.getPath()); + if (!file.exists()) { + return Result.failure(new Data.Builder() + .putString("error", "File not found: " + spec.getPath()) + .build()); + } + assembly.addFile(file, spec.getField()); + } + + try { + AndroidAssemblyWorkConfig.applyParamsToAssembly(assembly, config.getParams()); + } catch (Exception e) { + return Result.failure(new Data.Builder().putString("error", e.getMessage()).build()); + } + + Future future = assembly.saveAsync(config.isResumable()); + AssemblyResponse initial; + try { + initial = future.get(config.getUploadTimeoutMillis(), TimeUnit.MILLISECONDS); + } catch (ExecutionException executionException) { + Throwable cause = executionException.getCause(); + if (cause instanceof Exception) { + completionError.compareAndSet(null, (Exception) cause); + } + return handleFailure(completionError.get()); + } catch (Exception e) { + completionError.compareAndSet(null, e); + return handleFailure(e); + } + + if (config.shouldWaitForCompletion()) { + try { + boolean finished = completionLatch.await(config.getCompletionTimeoutMillis(), TimeUnit.MILLISECONDS); + if (!finished) { + return Result.retry(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Result.retry(); + } + } + + Exception completionException = completionError.get(); + if (completionException != null) { + return handleFailure(completionException); + } + + AssemblyResponse finalResponse = completionResponse.get() != null ? completionResponse.get() : initial; + JSONObject json = finalResponse.json(); + Data output = new Data.Builder() + .putString(OUTPUT_ASSEMBLY_ID, finalResponse.getId()) + .putString(OUTPUT_ASSEMBLY_URL, json.optString("assembly_url")) + .putString(OUTPUT_SSL_URL, finalResponse.getSslUrl()) + .build(); + return Result.success(output); + } finally { + try { + assembly.close(); + } catch (IOException ignored) { + // Best effort to terminate executor; ignore close failures. + } + } + } + + /** + * Maps assembly exceptions to WorkManager outcomes. + * + * @param error failure emitted by the assembly + * @return {@link Result#failure(Data)} for deterministic errors, {@link Result#retry()} otherwise + */ + private Result handleFailure(Exception error) { + if (error instanceof RequestException || error instanceof LocalOperationException) { + Data output = new Data.Builder() + .putString("error", error.getMessage()) + .build(); + return Result.failure(output); + } + return Result.retry(); + } + + /** + * Builds a signature provider that proxies calls to a remote HTTP endpoint. + * + * @param url URL of the signature provider + * @param method HTTP method to use (defaults to POST) + * @param headers headers to include in requests + * @return {@link SignatureProvider} that fetches signatures over HTTP + */ + private SignatureProvider buildSignatureProvider(String url, String method, java.util.Map headers) { + final String httpMethod = method == null ? "POST" : method.toUpperCase(); + final java.util.Map requestHeaders = headers == null ? java.util.Collections.emptyMap() : headers; + return paramsJson -> { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + connection.setRequestMethod(httpMethod); + connection.setConnectTimeout(15_000); + connection.setReadTimeout(15_000); + connection.setDoInput(true); + for (java.util.Map.Entry entry : requestHeaders.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + if ("POST".equals(httpMethod) || "PUT".equals(httpMethod)) { + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + try (OutputStream os = new BufferedOutputStream(connection.getOutputStream())) { + os.write(paramsJson.getBytes(UTF8)); + } + } + + int code = connection.getResponseCode(); + InputStream stream = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream(); + String response = ""; + try { + if (stream != null) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, UTF8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + response = sb.toString(); + } + } else if (code < 200 || code >= 300) { + throw new RequestException("Signature provider returned status " + code + " with empty body"); + } else { + throw new RequestException("Signature provider response missing body"); + } + } finally { + connection.disconnect(); + } + + if (code < 200 || code >= 300) { + throw new RequestException("Signature provider returned status " + code + ": " + response); + } + + JSONObject json = new JSONObject(response); + String signature = json.optString("signature", null); + if (signature == null || signature.isEmpty()) { + throw new RequestException("Signature provider response missing signature field"); + } + return signature; + }; + } + + /** + * Factory for creating assemblies; overridden in tests. + */ + protected AndroidAssembly createAssembly(AndroidTransloadit transloadit, AndroidAssemblyListener listener) { + return transloadit.newAssembly(listener, getApplicationContext()); + } +} diff --git a/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyWorkConfig.java b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyWorkConfig.java new file mode 100644 index 0000000..a0c1d29 --- /dev/null +++ b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAssemblyWorkConfig.java @@ -0,0 +1,661 @@ +package com.transloadit.android.sdk; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.work.Constraints; +import androidx.work.Data; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +/** + * Immutable configuration object used to enqueue background Transloadit uploads via WorkManager. + */ +public final class AndroidAssemblyWorkConfig { + static final String CONFIG_JSON_KEY = "transloadit.work.config"; + private static final String CONFIG_VERSION = "1"; + + private final String authKey; + private final String authSecret; + private final String signatureProviderUrl; + private final String signatureProviderMethod; + private final Map signatureProviderHeaders; + private final String hostUrl; + private final boolean resumable; + private final boolean waitForCompletion; + private final long completionTimeoutMillis; + private final long uploadTimeoutMillis; + private final String preferenceName; + private final JSONObject params; + private final List files; + + private AndroidAssemblyWorkConfig(Builder builder) { + this.authKey = builder.authKey; + this.authSecret = builder.authSecret; + this.hostUrl = builder.hostUrl; + this.resumable = builder.resumable; + this.waitForCompletion = builder.waitForCompletion; + this.completionTimeoutMillis = builder.completionTimeoutMillis; + this.uploadTimeoutMillis = builder.uploadTimeoutMillis; + this.preferenceName = builder.preferenceName; + this.params = builder.params == null ? new JSONObject() : builder.params; + this.files = Collections.unmodifiableList(new ArrayList<>(builder.files)); + this.signatureProviderUrl = builder.signatureProviderUrl; + this.signatureProviderMethod = builder.signatureProviderMethod; + this.signatureProviderHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(builder.signatureProviderHeaders)); + } + + /** + * @return Transloadit API key used for the assembly. + */ + public String getAuthKey() { + return authKey; + } + + /** + * @return Transloadit API secret, or {@code null} when using an external signature provider. + */ + public String getAuthSecret() { + return authSecret; + } + + /** + * @return URL of the external signature provider, if configured. + */ + @Nullable + public String getSignatureProviderUrl() { + return signatureProviderUrl; + } + + /** + * @return HTTP method used when calling the signature provider. + */ + @Nullable + public String getSignatureProviderMethod() { + return signatureProviderMethod; + } + + /** + * @return additional headers attached to signature provider requests. + */ + public Map getSignatureProviderHeaders() { + return signatureProviderHeaders; + } + + /** + * @return Transloadit API host URL. + */ + public String getHostUrl() { + return hostUrl; + } + + /** + * @return {@code true} when resumable uploads are enabled. + */ + public boolean isResumable() { + return resumable; + } + + /** + * @return {@code true} when the worker should wait for assembly completion. + */ + public boolean shouldWaitForCompletion() { + return waitForCompletion; + } + + /** + * @return maximum time in milliseconds to wait for completion status updates. + */ + public long getCompletionTimeoutMillis() { + return completionTimeoutMillis; + } + + /** + * @return maximum time in milliseconds to wait for the initial save response. + */ + public long getUploadTimeoutMillis() { + return uploadTimeoutMillis; + } + + /** + * @return SharedPreferences file name used for resumable upload metadata. + */ + public String getPreferenceName() { + return preferenceName; + } + + /** + * @return assembly params payload (steps, fields, options). + */ + public JSONObject getParams() { + return params; + } + + /** + * @return immutable list of files to upload. + */ + public List getFiles() { + return files; + } + + /** + * Serializes this configuration into WorkManager input data. + * + * @return {@link Data} representation of the config + */ + public Data toInputData() { + Data.Builder builder = new Data.Builder(); + builder.putString(CONFIG_JSON_KEY, toJson().toString()); + return builder.build(); + } + + /** + * Serializes the configuration to JSON for persistence. + * + * @return JSON representation of the config + */ + public JSONObject toJson() { + try { + JSONObject json = new JSONObject(); + json.put("version", CONFIG_VERSION); + json.put("authKey", authKey); + if (authSecret != null) { + json.put("authSecret", authSecret); + } + json.put("hostUrl", hostUrl); + json.put("resumable", resumable); + json.put("waitForCompletion", waitForCompletion); + json.put("completionTimeoutMillis", completionTimeoutMillis); + json.put("uploadTimeoutMillis", uploadTimeoutMillis); + json.put("preferenceName", preferenceName == null ? JSONObject.NULL : preferenceName); + json.put("params", params); + JSONArray fileArray = new JSONArray(); + for (FileSpec spec : files) { + fileArray.put(spec.toJson()); + } + json.put("files", fileArray); + if (signatureProviderUrl != null) { + JSONObject sig = new JSONObject(); + sig.put("url", signatureProviderUrl); + sig.put("method", signatureProviderMethod); + JSONObject headers = new JSONObject(); + for (Map.Entry entry : signatureProviderHeaders.entrySet()) { + headers.put(entry.getKey(), entry.getValue()); + } + sig.put("headers", headers); + json.put("signatureProvider", sig); + } + return json; + } catch (JSONException e) { + throw new IllegalStateException("Failed to serialize work config", e); + } + } + + /** + * Creates a WorkManager request that uploads the configured assembly. + * + * @return configured {@link OneTimeWorkRequest} + */ + public OneTimeWorkRequest toWorkRequest() { + Constraints constraints = new Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(); + return new OneTimeWorkRequest.Builder(AndroidAssemblyUploadWorker.class) + .setInputData(toInputData()) + .setConstraints(constraints) + .build(); + } + + /** + * Restores a configuration from its JSON form. + * + * @param json serialized configuration + * @return parsed {@link AndroidAssemblyWorkConfig} + */ + public static AndroidAssemblyWorkConfig fromJson(JSONObject json) { + try { + String version = json.optString("version", ""); + if (!CONFIG_VERSION.equals(version)) { + throw new IllegalArgumentException("Unsupported config version: " + version); + } + Builder builder = new Builder(json.getString("authKey")); + if (json.has("authSecret")) { + builder.authSecret(json.getString("authSecret")); + } + String host = json.optString("hostUrl", null); + if (host != null && !host.isEmpty()) { + builder.hostUrl(host); + } + builder.resumable(json.optBoolean("resumable", true)); + builder.waitForCompletion(json.optBoolean("waitForCompletion", true)); + builder.completionTimeoutMillis(json.optLong("completionTimeoutMillis", Builder.DEFAULT_COMPLETION_TIMEOUT_MS)); + builder.uploadTimeoutMillis(json.optLong("uploadTimeoutMillis", Builder.DEFAULT_UPLOAD_TIMEOUT_MS)); + String pref = json.optString("preferenceName", null); + if (pref != null && !pref.isEmpty() && !JSONObject.NULL.equals(pref)) { + builder.preferenceName(pref); + } + builder.params(json.optJSONObject("params")); + JSONArray filesArray = json.optJSONArray("files"); + if (filesArray != null) { + for (int i = 0; i < filesArray.length(); i++) { + JSONObject item = filesArray.getJSONObject(i); + builder.addFile(new File(item.getString("path")), item.getString("field")); + } + } + JSONObject sig = json.optJSONObject("signatureProvider"); + if (sig != null) { + builder.signatureProvider(sig.getString("url")); + builder.signatureProviderMethod(sig.optString("method", "POST")); + JSONObject headers = sig.optJSONObject("headers"); + if (headers != null) { + Iterator headerKeys = headers.keys(); + while (headerKeys.hasNext()) { + String key = headerKeys.next(); + builder.addSignatureProviderHeader(key, headers.optString(key, "")); + } + } + } + return builder.build(); + } catch (JSONException e) { + throw new IllegalArgumentException("Invalid Transloadit work configuration", e); + } + } + + /** + * Restores a configuration from WorkManager input data. + * + * @param data input data provided to the worker + * @return parsed {@link AndroidAssemblyWorkConfig} + */ + public static AndroidAssemblyWorkConfig fromInputData(Data data) { + String json = data.getString(CONFIG_JSON_KEY); + if (json == null) { + throw new IllegalArgumentException("Missing Transloadit work configuration"); + } + try { + return fromJson(new JSONObject(json)); + } catch (JSONException e) { + throw new IllegalArgumentException("Invalid Transloadit work configuration", e); + } + } + + /** + * Creates a new builder configured with inline credentials. + * + * @param authKey Transloadit API key + * @param authSecret Transloadit API secret + * @return builder instance + */ + public static Builder newBuilder(@NonNull String authKey, @NonNull String authSecret) { + Builder builder = new Builder(authKey); + builder.authSecret(authSecret); + return builder; + } + + /** + * Creates a new builder that expects an external signature provider. + * + * @param authKey Transloadit API key + * @return builder instance + */ + public static Builder newBuilder(@NonNull String authKey) { + return new Builder(authKey); + } + + /** + * Builder for {@link AndroidAssemblyWorkConfig}. + */ + public static final class Builder { + static final long DEFAULT_COMPLETION_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(15); + static final long DEFAULT_UPLOAD_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(15); + + private final String authKey; + private String authSecret; + private String hostUrl = AndroidTransloadit.DEFAULT_HOST_URL; + private boolean resumable = true; + private boolean waitForCompletion = true; + private long completionTimeoutMillis = DEFAULT_COMPLETION_TIMEOUT_MS; + private long uploadTimeoutMillis = DEFAULT_UPLOAD_TIMEOUT_MS; + private String preferenceName = AndroidAssembly.DEFAULT_PREFERENCE_NAME; + private JSONObject params; + private final List files = new ArrayList<>(); + private String signatureProviderUrl; + private String signatureProviderMethod = "POST"; + private final Map signatureProviderHeaders = new LinkedHashMap<>(); + + private Builder(String authKey) { + this.authKey = Objects.requireNonNull(authKey, "authKey"); + } + + /** + * Sets the Transloadit API secret for inline signing. + * + * @param authSecret secret corresponding to {@code authKey} + * @return this builder + */ + public Builder authSecret(@Nullable String authSecret) { + this.authSecret = authSecret; + return this; + } + + /** + * Overrides the Transloadit API host URL. + * + * @param hostUrl custom host URL, or {@code null} to use the default + * @return this builder + */ + public Builder hostUrl(@Nullable String hostUrl) { + if (hostUrl != null && !hostUrl.isEmpty()) { + this.hostUrl = hostUrl; + } + return this; + } + + /** + * Enables or disables resumable uploads. + * + * @param resumable {@code true} to enable tus resumable uploads + * @return this builder + */ + public Builder resumable(boolean resumable) { + this.resumable = resumable; + return this; + } + + /** + * Configures whether the worker should wait for assembly completion. + * + * @param waitForCompletion {@code true} to wait for completion before finishing the job + * @return this builder + */ + public Builder waitForCompletion(boolean waitForCompletion) { + this.waitForCompletion = waitForCompletion; + return this; + } + + /** + * Sets the timeout for waiting on completion status updates. + * + * @param completionTimeoutMillis timeout in milliseconds + * @return this builder + */ + public Builder completionTimeoutMillis(long completionTimeoutMillis) { + if (completionTimeoutMillis <= 0) { + throw new IllegalArgumentException("completionTimeoutMillis must be positive"); + } + this.completionTimeoutMillis = completionTimeoutMillis; + return this; + } + + /** + * Sets the timeout for waiting on the initial upload response. + * + * @param uploadTimeoutMillis timeout in milliseconds + * @return this builder + */ + public Builder uploadTimeoutMillis(long uploadTimeoutMillis) { + if (uploadTimeoutMillis <= 0) { + throw new IllegalArgumentException("uploadTimeoutMillis must be positive"); + } + this.uploadTimeoutMillis = uploadTimeoutMillis; + return this; + } + + /** + * Overrides the SharedPreferences file name used for tus metadata. + * + * @param preferenceName name of the preference file + * @return this builder + */ + public Builder preferenceName(@NonNull String preferenceName) { + this.preferenceName = Objects.requireNonNull(preferenceName, "preferenceName"); + return this; + } + + /** + * Copies assembly params into the configuration. + * + * @param params JSON payload containing steps, fields and options + * @return this builder + */ + public Builder params(@Nullable JSONObject params) { + if (params != null) { + try { + this.params = new JSONObject(params.toString()); + } catch (JSONException e) { + throw new IllegalArgumentException("Invalid params", e); + } + } else { + this.params = null; + } + return this; + } + + /** + * Parses assembly params from a JSON string. + * + * @param paramsJson JSON string representation of assembly params + * @return this builder + */ + public Builder paramsJson(@NonNull String paramsJson) { + try { + this.params = new JSONObject(paramsJson); + } catch (JSONException e) { + throw new IllegalArgumentException("Invalid params JSON", e); + } + return this; + } + + /** + * Adds a local file to be uploaded with the assembly. + * + * @param file file on disk + * @param field field name for the file + * @return this builder + */ + public Builder addFile(@NonNull File file, @NonNull String field) { + Objects.requireNonNull(file, "file"); + Objects.requireNonNull(field, "field"); + files.add(new FileSpec(file.getAbsolutePath(), field)); + return this; + } + + /** + * Configures an external signature provider endpoint. When specified, + * {@link AndroidAssemblyUploadWorker} will fetch signatures over HTTP instead of using a locally supplied secret. + * + * @param url URL of the signature provider endpoint + * @return this builder + */ + public Builder signatureProvider(@NonNull String url) { + this.signatureProviderUrl = Objects.requireNonNull(url, "url"); + return this; + } + + /** + * Overrides the HTTP method used for the signature provider request. Defaults to {@code POST}. + * + * @param method HTTP method to use + * @return this builder + */ + public Builder signatureProviderMethod(@NonNull String method) { + this.signatureProviderMethod = Objects.requireNonNull(method, "method").toUpperCase(); + return this; + } + + /** + * Adds a header that will be included when contacting the external signature provider. + * + * @param key header name + * @param value header value + * @return this builder + */ + public Builder addSignatureProviderHeader(@NonNull String key, @NonNull String value) { + this.signatureProviderHeaders.put(Objects.requireNonNull(key, "key"), Objects.requireNonNull(value, "value")); + return this; + } + + /** + * Validates the provided data and builds the immutable configuration. + * + * @return finalized {@link AndroidAssemblyWorkConfig} + */ + public AndroidAssemblyWorkConfig build() { + if ((authSecret == null || authSecret.isEmpty()) && signatureProviderUrl == null) { + throw new IllegalStateException("Either authSecret or signatureProvider must be provided"); + } + return new AndroidAssemblyWorkConfig(this); + } + } + + /** + * Immutable description of a file that should be uploaded. + */ + public static final class FileSpec { + private final String path; + private final String field; + + FileSpec(String path, String field) { + this.path = path; + this.field = field; + } + + /** + * @return absolute file path on disk. + */ + public String getPath() { + return path; + } + + /** + * @return form field name associated with the file. + */ + public String getField() { + return field; + } + + JSONObject toJson() { + try { + JSONObject json = new JSONObject(); + json.put("path", path); + json.put("field", field); + return json; + } catch (JSONException e) { + throw new IllegalStateException("Failed to serialize file spec", e); + } + } + } + + static JSONObject toJSONObject(Data data) { + String json = data.getString(CONFIG_JSON_KEY); + if (json == null) { + throw new IllegalArgumentException("Missing config json"); + } + try { + return new JSONObject(json); + } catch (JSONException e) { + throw new IllegalArgumentException("Invalid config json", e); + } + } + + static void applyParamsToAssembly(AndroidAssembly assembly, JSONObject params) throws JSONException { + if (params == null) { + return; + } + JSONObject steps = params.optJSONObject("steps"); + if (steps != null) { + Iterator stepKeys = steps.keys(); + while (stepKeys.hasNext()) { + String stepName = stepKeys.next(); + JSONObject stepObject = steps.getJSONObject(stepName); + MapBuilder mapBuilder = new MapBuilder(stepObject); + String robot = mapBuilder.removeString("robot"); + if (robot != null && !robot.isEmpty()) { + assembly.addStep(stepName, robot, mapBuilder.build()); + } else { + assembly.addStep(stepName, mapBuilder.build()); + } + } + } + + JSONObject fields = params.optJSONObject("fields"); + if (fields != null) { + assembly.addFields(new MapBuilder(fields).build()); + } + + Iterator keys = params.keys(); + while (keys.hasNext()) { + String key = keys.next(); + if ("steps".equals(key) || "fields".equals(key) || "auth".equals(key)) { + continue; + } + Object value = convertJsonValue(params.get(key)); + assembly.addOption(key, value); + } + } + + private static Object convertJsonValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof JSONObject) { + return new MapBuilder((JSONObject) value).build(); + } + if (value instanceof JSONArray) { + JSONArray array = (JSONArray) value; + List list = new ArrayList<>(array.length()); + for (int i = 0; i < array.length(); i++) { + list.add(convertJsonValue(array.opt(i))); + } + return list; + } + if (value == JSONObject.NULL) { + return null; + } + return value; + } + + private static final class MapBuilder { + private final JSONObject source; + + MapBuilder(JSONObject source) { + this.source = source; + } + + @Nullable + String removeString(String key) { + if (!source.has(key)) { + return null; + } + Object value = source.remove(key); + if (value == null || value == JSONObject.NULL) { + return null; + } + return value.toString(); + } + + java.util.Map build() { + java.util.Map map = new java.util.HashMap<>(); + Iterator keys = source.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object value = source.opt(key); + map.put(key, convertJsonValue(value)); + } + return map; + } + } +} diff --git a/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAsyncAssembly.java b/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAsyncAssembly.java deleted file mode 100644 index c5d99c0..0000000 --- a/transloadit-android/src/main/java/com/transloadit/android/sdk/AndroidAsyncAssembly.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.transloadit.android.sdk; - -import android.content.Context; -import android.content.SharedPreferences; -import android.os.AsyncTask; - -import com.transloadit.sdk.Assembly; -import com.transloadit.sdk.async.AssemblyProgressListener; -import com.transloadit.sdk.async.AsyncAssembly; -import com.transloadit.sdk.exceptions.LocalOperationException; -import com.transloadit.sdk.exceptions.RequestException; -import com.transloadit.sdk.response.AssemblyResponse; - -import java.io.IOException; -import java.util.concurrent.Callable; - -import io.tus.android.client.TusPreferencesURLStore; -import io.tus.java.client.ProtocolException; - - -/** - * This class represents a new assembly being created. - * It is similar to {@link Assembly} but provides Asynchronous functionality. - */ -public class AndroidAsyncAssembly extends AsyncAssembly { - private String preferenceName; - private Context context; - - public static final String DEFAULT_PREFERENCE_NAME = "tansloadit_android_sdk_urls"; - - - /** - * A new instance of {@link AndroidAsyncAssembly} - * - * @param transloadit {@link AndroidTransloadit} the transloadit client - * @param listener an implementation of {@link AssemblyProgressListener} - * @param context {@link Context} the context where this assembly creation is taking place - */ - public AndroidAsyncAssembly(AndroidTransloadit transloadit, AssemblyProgressListener listener, Context context) { - super(transloadit, listener); - this.context = context; - setPreferenceName(DEFAULT_PREFERENCE_NAME); - } - - /** - * Set the Context storage preference name - * - * @param name set the storage preference name - */ - public void setPreferenceName(String name) { - preferenceName = name; - SharedPreferences pref = context.getSharedPreferences(preferenceName, 0); - setTusURLStore(new TusPreferencesURLStore(pref)); - } - - @Override - protected void startExecutor() { - AssemblyStatusUpdateCallable statusUpdateRunnable = new AssemblyStatusUpdateCallable(); - AssemblyStatusUpdateTask statusUpdateTask = new AssemblyStatusUpdateTask(this, statusUpdateRunnable); - statusUpdateRunnable.setExecutor(statusUpdateTask); - - executor = new AsyncAssemblyExecutorImpl(statusUpdateTask); - executor.execute(); - } - - class AssemblyStatusUpdateCallable implements Callable { - private AssemblyStatusUpdateTask executor; - - void setExecutor(AssemblyStatusUpdateTask executor) { - this.executor = executor; - } - - @Override - public AssemblyResponse call() { - try { - return watchStatus(); - } catch (RequestException | LocalOperationException e) { - executor.setError(e); - executor.cancel(false); - } - - return null; - } - } - - class AsyncAssemblyExecutorImpl extends AsyncTask 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