diff --git a/.deepsource.toml b/.deepsource.toml
index 6628987..5219bfb 100644
--- a/.deepsource.toml
+++ b/.deepsource.toml
@@ -3,12 +3,12 @@ version = 1
[[analyzers]]
name = "kotlin"
- [analyzers.meta]
- runtime_version = "1.8"
- language_version = "1.7"
+[analyzers.meta]
+runtime_version = "1.8"
+language_version = "1.7"
[[analyzers]]
name = "java"
- [analyzers.meta]
- runtime_version = "11"
\ No newline at end of file
+[analyzers.meta]
+runtime_version = "11"
diff --git a/.gitignore b/.gitignore
index 87f66f5..c9f706d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,10 +1,10 @@
*.iml
.gradle
-/local.properties
+local.properties
.idea/
.DS_Store
/build
+build/
/captures
.externalNativeBuild
.cxx
-local.properties
diff --git a/LICENSE b/LICENSE
index 261eeb9..16510ae 100644
--- a/LICENSE
+++ b/LICENSE
@@ -186,7 +186,7 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
- Copyright [yyyy] [name of copyright owner]
+ Copyright 2025 FastPix, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/README.md b/README.md
index 3f08172..460455a 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,17 @@ uploader.cancel()
Every listener method has a no-op default — you only override what you need.
+## Example apps
+
+The [`examples/`](examples) folder has three runnable sample apps — the same upload flow (background
+upload, pause/resume/cancel, progress notification) in different stacks:
+
+- [`kotlin-xml`](examples/kotlin-xml) — Kotlin with XML views
+- [`kotlin-compose`](examples/kotlin-compose) — Kotlin with Jetpack Compose
+- [`java-xml`](examples/java-xml) — Java with XML views
+
+See [`examples/README.md`](examples/README.md) for setup and how the background upload works.
+
## Public API
### `FastPixUploader.Builder`
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..bf02dfd
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..61e04d8
--- /dev/null
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..4771ed1
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,38 @@
+# Example apps
+
+Three small apps that upload a file to FastPix with the [`:uploader`](../uploader) SDK. They all do
+the same thing — pick a file, create an upload, and push it in the background with pause/resume/cancel
+— just in different stacks, so copy whichever one matches your project:
+
+- **[kotlin-xml](kotlin-xml)** — Kotlin, XML views
+- **[kotlin-compose](kotlin-compose)** — Kotlin, Jetpack Compose
+- **[java-xml](java-xml)** — Java, XML views
+
+## Setup
+
+Add your FastPix credentials to the project-root `local.properties` (it's gitignored, so they stay
+out of the repo):
+
+```properties
+fastpix.token=YOUR_ACCESS_TOKEN_ID
+fastpix.secretKey=YOUR_SECRET_KEY
+```
+
+You'll find them in the [FastPix dashboard](https://dashboard.fastpix.com/) under Access Tokens. For
+a real app, create the upload from your own backend instead, so the secret key never ships in the APK.
+
+## Running
+
+Open the project in Android Studio, pick one of the example modules from the run dropdown, and hit
+Run. (The command-line `./gradlew` needs JDK 17 or 21 — Gradle 8.11 won't run on newer JDKs.)
+
+## How the background upload works
+
+Each app keeps its uploader in a small singleton (`UploadManager`) instead of the Activity, and runs
+a foreground service (`UploadService`) while an upload is in flight. That's what lets the upload keep
+going when you leave the screen or rotate the device, with progress shown in a notification.
+
+It's deliberately not persisted — if the app's process is killed, the upload stops with it. If you
+need uploads to survive that, move the work into WorkManager and recreate the session from a saved
+session URL and file path; the SDK re-checks the server's offset on resume, so it continues from where
+it left off.
diff --git a/examples/java-xml/README.md b/examples/java-xml/README.md
new file mode 100644
index 0000000..a9ea8ba
--- /dev/null
+++ b/examples/java-xml/README.md
@@ -0,0 +1,25 @@
+# java-xml
+
+The upload flow in Java with XML views. The app is pure Java (no Kotlin plugin) and talks to the
+Kotlin `:uploader` SDK directly, so it's a handy reference if your codebase is Java.
+
+## Running
+
+1. Put your `fastpix.token` and `fastpix.secretKey` in the project-root `local.properties`
+ (see [../README.md](../README.md)).
+2. Open the project in Android Studio, select the `java-xml` module, and Run.
+3. Pick a file, tap Start, then use Pause / Resume / Abort. Send the app to the background and the
+ upload keeps going, with progress in a notification.
+
+## Where things live
+
+- `MainActivity.java` — the screen; observes `UploadManager` between `onStart` and `onStop`.
+- `UploadManager.java` — holds the running upload and notifies observers.
+- `UploadService.java` — foreground service that keeps the upload alive in the background.
+- `FastPixApi.java` — the "create upload" API call.
+- `Config.java` — credentials and chunk size.
+
+One Java quirk worth knowing: the SDK's `UploadListener` is a Kotlin interface, so the anonymous
+implementation has to override all nine callbacks — Java doesn't see Kotlin's default methods.
+
+See [../README.md](../README.md) for how the background upload works.
diff --git a/examples/java-xml/build.gradle.kts b/examples/java-xml/build.gradle.kts
new file mode 100644
index 0000000..402a43f
--- /dev/null
+++ b/examples/java-xml/build.gradle.kts
@@ -0,0 +1,55 @@
+import java.util.Properties
+
+plugins {
+ alias(libs.plugins.android.application)
+}
+
+// Credentials live in local.properties (gitignored) so they never reach source control.
+val localProps = Properties().apply {
+ rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) }
+}
+
+android {
+ namespace = "io.fastpix.uploads.java"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "io.fastpix.uploads.java"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ buildConfigField("String", "FASTPIX_TOKEN", "\"${localProps.getProperty("fastpix.token", "")}\"")
+ buildConfigField("String", "FASTPIX_SECRET_KEY", "\"${localProps.getProperty("fastpix.secretKey", "")}\"")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ buildFeatures {
+ viewBinding = true
+ buildConfig = true
+ }
+}
+
+dependencies {
+ implementation(project(":uploader"))
+
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.okhttp)
+}
diff --git a/examples/java-xml/proguard-rules.pro b/examples/java-xml/proguard-rules.pro
new file mode 100644
index 0000000..fb164d6
--- /dev/null
+++ b/examples/java-xml/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/examples/java-xml/src/main/AndroidManifest.xml b/examples/java-xml/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8c5fd07
--- /dev/null
+++ b/examples/java-xml/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/java-xml/src/main/java/io/fastpix/uploads/java/Config.java b/examples/java-xml/src/main/java/io/fastpix/uploads/java/Config.java
new file mode 100644
index 0000000..5e43dd8
--- /dev/null
+++ b/examples/java-xml/src/main/java/io/fastpix/uploads/java/Config.java
@@ -0,0 +1,13 @@
+package io.fastpix.uploads.java;
+
+// Credentials come from local.properties (see README). In production, sign uploads on your
+// backend so the secret key never ships in the APK.
+public final class Config {
+ private Config() {}
+
+ public static final String TOKEN = BuildConfig.FASTPIX_TOKEN;
+ public static final String SECRET_KEY = BuildConfig.FASTPIX_SECRET_KEY;
+
+ /** 8 MiB — must be a multiple of 256 KiB (GCS requirement, enforced by the SDK). */
+ public static final long CHUNK_SIZE = 8L * 1024 * 1024;
+}
diff --git a/examples/java-xml/src/main/java/io/fastpix/uploads/java/FastPixApi.java b/examples/java-xml/src/main/java/io/fastpix/uploads/java/FastPixApi.java
new file mode 100644
index 0000000..80779a2
--- /dev/null
+++ b/examples/java-xml/src/main/java/io/fastpix/uploads/java/FastPixApi.java
@@ -0,0 +1,67 @@
+package io.fastpix.uploads.java;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Base64;
+
+import androidx.annotation.NonNull;
+
+import org.json.JSONObject;
+
+import java.io.IOException;
+
+import okhttp3.Call;
+import okhttp3.Callback;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+// Calls the FastPix API to create an upload; delivers the result on the main thread.
+public final class FastPixApi {
+ private FastPixApi() {}
+
+ public interface UrlCallback {
+ void onUrl(String url);
+ void onError(String message);
+ }
+
+ private static final String ENDPOINT = "https://api.fastpix.com/v1/on-demand/upload";
+ private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
+ private static final String BODY =
+ "{\"corsOrigin\":\"*\",\"pushMediaSettings\":{\"accessPolicy\":\"public\",\"maxResolution\":\"2160p\"}}";
+
+ private static final OkHttpClient client = new OkHttpClient();
+ private static final Handler main = new Handler(Looper.getMainLooper());
+
+ public static void createUpload(UrlCallback cb) {
+ String credentials = Config.TOKEN + ":" + Config.SECRET_KEY;
+ String auth = "Basic " + Base64.encodeToString(credentials.getBytes(), Base64.NO_WRAP);
+ Request request = new Request.Builder()
+ .url(ENDPOINT)
+ .header("Authorization", auth)
+ .header("Content-Type", "application/json")
+ .post(RequestBody.create(JSON, BODY))
+ .build();
+ client.newCall(request).enqueue(new Callback() {
+ @Override public void onFailure(@NonNull Call call, @NonNull IOException e) {
+ main.post(() -> cb.onError(e.getMessage()));
+ }
+
+ @Override public void onResponse(@NonNull Call call, @NonNull Response response) {
+ try (Response r = response) {
+ String body = r.body() != null ? r.body().string() : "";
+ if (!r.isSuccessful()) {
+ main.post(() -> cb.onError("HTTP " + r.code() + ": " + body));
+ return;
+ }
+ String url = new JSONObject(body).getJSONObject("data").getString("url");
+ main.post(() -> cb.onUrl(url));
+ } catch (Exception e) {
+ main.post(() -> cb.onError(e.getMessage()));
+ }
+ }
+ });
+ }
+}
diff --git a/examples/java-xml/src/main/java/io/fastpix/uploads/java/MainActivity.java b/examples/java-xml/src/main/java/io/fastpix/uploads/java/MainActivity.java
new file mode 100644
index 0000000..8989875
--- /dev/null
+++ b/examples/java-xml/src/main/java/io/fastpix/uploads/java/MainActivity.java
@@ -0,0 +1,117 @@
+package io.fastpix.uploads.java;
+
+import android.Manifest;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.provider.OpenableColumns;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.appcompat.app.AppCompatActivity;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+
+import io.fastpix.uploads.java.databinding.ActivityMainBinding;
+
+public class MainActivity extends AppCompatActivity {
+
+ private ActivityMainBinding binding;
+ private File selectedFile;
+
+ private final ActivityResultLauncher pickFile =
+ registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> {
+ if (uri != null) handlePickedFile(uri);
+ });
+
+ private final ActivityResultLauncher requestNotifications =
+ registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted -> {});
+
+ private final UploadManager.Observer observer = this::renderState;
+
+ @Override protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ binding = ActivityMainBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+
+ binding.pickFileButton.setOnClickListener(v -> pickFile.launch(new String[]{"*/*"}));
+ binding.startUploadButton.setOnClickListener(v -> startUpload());
+ binding.pauseButton.setOnClickListener(v -> UploadManager.get().pause());
+ binding.resumeButton.setOnClickListener(v -> UploadManager.get().resume());
+ binding.abortButton.setOnClickListener(v -> UploadManager.get().cancel());
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS);
+ }
+ }
+
+ @Override protected void onStart() {
+ super.onStart();
+ UploadManager.get().addObserver(observer);
+ }
+
+ @Override protected void onStop() {
+ UploadManager.get().removeObserver(observer);
+ super.onStop();
+ }
+
+ private void startUpload() {
+ if (selectedFile == null) {
+ Toast.makeText(this, R.string.error_file_required, Toast.LENGTH_SHORT).show();
+ return;
+ }
+ final File file = selectedFile;
+ binding.statusText.setText("Status: creating upload…");
+ FastPixApi.createUpload(new FastPixApi.UrlCallback() {
+ @Override public void onUrl(String url) {
+ UploadManager.get().startUpload(MainActivity.this, file, url);
+ }
+
+ @Override public void onError(String message) {
+ binding.statusText.setText("Status: " + message);
+ }
+ });
+ }
+
+ private void renderState(UploadUiState state) {
+ binding.uploadProgress.setProgress(state.percent);
+ binding.progressText.setText(state.percent + "%");
+ binding.statusText.setText("Status: " + state.status);
+ binding.startUploadButton.setEnabled(!state.active);
+ binding.pickFileButton.setEnabled(!state.active);
+ binding.pauseButton.setEnabled(state.active);
+ binding.abortButton.setEnabled(state.active);
+ }
+
+ private void handlePickedFile(Uri uri) {
+ String name = queryDisplayName(uri);
+ if (name == null) name = "upload_" + System.currentTimeMillis();
+ File dest = new File(getCacheDir(), name);
+ try (InputStream in = getContentResolver().openInputStream(uri);
+ FileOutputStream out = new FileOutputStream(dest)) {
+ if (in == null) throw new IllegalStateException("cannot open " + uri);
+ byte[] buf = new byte[8192];
+ int n;
+ while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
+ selectedFile = dest;
+ binding.selectedFileText.setText(dest.getName() + " (" + dest.length() + " bytes)");
+ } catch (Exception e) {
+ selectedFile = null;
+ Toast.makeText(this, R.string.error_copy_failed, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ private String queryDisplayName(Uri uri) {
+ try (Cursor c = getContentResolver().query(uri, null, null, null, null)) {
+ if (c != null) {
+ int i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME);
+ if (i >= 0 && c.moveToFirst()) return c.getString(i);
+ }
+ }
+ return null;
+ }
+}
diff --git a/examples/java-xml/src/main/java/io/fastpix/uploads/java/UploadManager.java b/examples/java-xml/src/main/java/io/fastpix/uploads/java/UploadManager.java
new file mode 100644
index 0000000..26674ef
--- /dev/null
+++ b/examples/java-xml/src/main/java/io/fastpix/uploads/java/UploadManager.java
@@ -0,0 +1,138 @@
+package io.fastpix.uploads.java;
+
+import android.content.Context;
+
+import java.io.File;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import io.fastpix.uploads.FastPixUploader;
+import io.fastpix.uploads.UploadError;
+import io.fastpix.uploads.UploadListener;
+import io.fastpix.uploads.UploadState;
+
+// Holds the single in-flight upload outside the Activity so it survives rotation and
+// backgrounding. Not persisted, so killing the app ends the upload.
+public final class UploadManager {
+
+ public interface Observer {
+ void onState(UploadUiState state);
+ }
+
+ private static final UploadManager INSTANCE = new UploadManager();
+
+ public static UploadManager get() {
+ return INSTANCE;
+ }
+
+ private UploadManager() {}
+
+ private final UploadUiState state = new UploadUiState();
+ private final CopyOnWriteArrayList observers = new CopyOnWriteArrayList<>();
+ private FastPixUploader uploader;
+
+ public UploadUiState state() {
+ return state;
+ }
+
+ public void addObserver(Observer o) {
+ observers.add(o);
+ o.onState(state);
+ }
+
+ public void removeObserver(Observer o) {
+ observers.remove(o);
+ }
+
+ public void startUpload(Context context, File file, String sessionUri) {
+ Context app = context.getApplicationContext();
+ cancel();
+ try {
+ uploader = new FastPixUploader.Builder(app)
+ .file(file)
+ .sessionUri(sessionUri)
+ .chunkSize(Config.CHUNK_SIZE)
+ .listener(listener)
+ .build();
+ } catch (UploadError e) {
+ state.active = false;
+ state.status = "Error: " + e.getMessage();
+ notifyObservers();
+ return;
+ }
+ state.active = true;
+ state.percent = 0;
+ state.fileName = file.getName();
+ state.status = "Starting";
+ notifyObservers();
+ UploadService.start(app);
+ uploader.start();
+ }
+
+ public void pause() {
+ if (uploader != null) uploader.pause();
+ }
+
+ public void resume() {
+ if (uploader != null) uploader.resume();
+ }
+
+ public void cancel() {
+ if (uploader != null) {
+ uploader.cancel();
+ uploader = null;
+ }
+ }
+
+ private void notifyObservers() {
+ for (Observer o : observers) o.onState(state);
+ }
+
+ private final UploadListener listener = new UploadListener() {
+ @Override public void onStateChange(UploadState s) {
+ state.active = !s.isTerminal();
+ state.status = s.name();
+ notifyObservers();
+ }
+
+ @Override public void onProgress(long bytesUploaded, long totalBytes, double percentage) {
+ state.percent = (int) Math.min(100, Math.max(0, Math.round(percentage)));
+ notifyObservers();
+ }
+
+ // The callbacks below aren't used by this sample; the UI only tracks state and progress.
+ @Override public void onPrepared(int totalChunks, long totalBytes, long chunkSize) {
+ // No-op.
+ }
+
+ @Override public void onChunkUploaded(int chunkIndex, int totalChunks, long bytesAcked) {
+ // No-op.
+ }
+
+ @Override public void onRetryScheduled(int attempt, long delayMillis, UploadError cause) {
+ // No-op.
+ }
+
+ @Override public void onNetworkStateChange(boolean online) {
+ // No-op.
+ }
+
+ @Override public void onSuccess(long elapsedMillis) {
+ state.percent = 100;
+ state.active = false;
+ state.status = "Completed";
+ notifyObservers();
+ }
+
+ @Override public void onFailure(UploadError error, long elapsedMillis) {
+ state.active = false;
+ state.status = "Failed: " + error.getMessage();
+ notifyObservers();
+ }
+
+ @Override public void onCancelled(long elapsedMillis) {
+ state.active = false;
+ state.status = "Cancelled";
+ notifyObservers();
+ }
+ };
+}
diff --git a/examples/java-xml/src/main/java/io/fastpix/uploads/java/UploadService.java b/examples/java-xml/src/main/java/io/fastpix/uploads/java/UploadService.java
new file mode 100644
index 0000000..844861d
--- /dev/null
+++ b/examples/java-xml/src/main/java/io/fastpix/uploads/java/UploadService.java
@@ -0,0 +1,75 @@
+package io.fastpix.uploads.java;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.os.IBinder;
+
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.ServiceCompat;
+import androidx.core.content.ContextCompat;
+
+// Keeps the process alive while an upload runs in the background and shows a progress
+// notification. Stops itself once the upload finishes.
+public class UploadService extends Service {
+
+ private static final String CHANNEL_ID = "fastpix_uploads";
+ private static final int NOTIFICATION_ID = 1;
+
+ public static void start(Context context) {
+ ContextCompat.startForegroundService(context, new Intent(context, UploadService.class));
+ }
+
+ private final UploadManager.Observer observer = state -> {
+ manager().notify(NOTIFICATION_ID, buildNotification(state));
+ if (!state.active) stopSelf();
+ };
+
+ @Nullable
+ @Override public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override public int onStartCommand(Intent intent, int flags, int startId) {
+ int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ ? ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC : 0;
+ ServiceCompat.startForeground(
+ this, NOTIFICATION_ID, buildNotification(UploadManager.get().state()), type);
+ UploadManager.get().addObserver(observer);
+ return START_NOT_STICKY;
+ }
+
+ @Override public void onDestroy() {
+ UploadManager.get().removeObserver(observer);
+ super.onDestroy();
+ }
+
+ private Notification buildNotification(UploadUiState state) {
+ ensureChannel();
+ String text = state.active ? state.fileName + " — " + state.percent + "%" : state.status;
+ return new NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("Uploading to FastPix")
+ .setContentText(text)
+ .setSmallIcon(android.R.drawable.stat_sys_upload)
+ .setOngoing(state.active)
+ .setProgress(100, state.percent, false)
+ .setOnlyAlertOnce(true)
+ .build();
+ }
+
+ private void ensureChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
+ manager().createNotificationChannel(
+ new NotificationChannel(CHANNEL_ID, "Uploads", NotificationManager.IMPORTANCE_LOW));
+ }
+
+ private NotificationManager manager() {
+ return (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
+ }
+}
diff --git a/examples/java-xml/src/main/java/io/fastpix/uploads/java/UploadUiState.java b/examples/java-xml/src/main/java/io/fastpix/uploads/java/UploadUiState.java
new file mode 100644
index 0000000..2546702
--- /dev/null
+++ b/examples/java-xml/src/main/java/io/fastpix/uploads/java/UploadUiState.java
@@ -0,0 +1,9 @@
+package io.fastpix.uploads.java;
+
+// Current upload state shown in the UI.
+public final class UploadUiState {
+ public boolean active = false;
+ public int percent = 0;
+ public String fileName = "";
+ public String status = "Idle";
+}
diff --git a/examples/java-xml/src/main/res/layout/activity_main.xml b/examples/java-xml/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..7152269
--- /dev/null
+++ b/examples/java-xml/src/main/res/layout/activity_main.xml
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/java-xml/src/main/res/values/strings.xml b/examples/java-xml/src/main/res/values/strings.xml
new file mode 100644
index 0000000..31d6fb8
--- /dev/null
+++ b/examples/java-xml/src/main/res/values/strings.xml
@@ -0,0 +1,16 @@
+
+ FastPix Java Upload
+
+ Pick a file
+ Start upload
+ Pause
+ Resume
+ Abort
+
+ No file selected
+ 0%
+ Status: idle
+
+ Please pick a file first
+ Could not read the selected file
+
diff --git a/examples/java-xml/src/main/res/values/themes.xml b/examples/java-xml/src/main/res/values/themes.xml
new file mode 100644
index 0000000..7e9102d
--- /dev/null
+++ b/examples/java-xml/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/examples/java-xml/src/main/res/xml/backup_rules.xml b/examples/java-xml/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..8f60a8b
--- /dev/null
+++ b/examples/java-xml/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/examples/java-xml/src/main/res/xml/data_extraction_rules.xml b/examples/java-xml/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..a7199c3
--- /dev/null
+++ b/examples/java-xml/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/kotlin-compose/README.md b/examples/kotlin-compose/README.md
new file mode 100644
index 0000000..16bfc7d
--- /dev/null
+++ b/examples/kotlin-compose/README.md
@@ -0,0 +1,26 @@
+# kotlin-compose
+
+The upload flow in Kotlin with Jetpack Compose.
+
+## Running
+
+1. Put your `fastpix.token` and `fastpix.secretKey` in the project-root `local.properties`
+ (see [../README.md](../README.md)).
+2. Open the project in Android Studio, select the `kotlin-compose` module, and Run.
+3. Pick a file, tap Start, then use Pause / Resume / Cancel. Send the app to the background and the
+ upload keeps going, with progress in a notification.
+
+## Where things live
+
+- `MainActivity.kt` — the Compose screen; collects `UploadManager.state` and calls into it.
+- `UploadManager.kt` — holds the running upload and exposes its state as a `StateFlow`.
+- `UploadService.kt` — foreground service that keeps the upload alive in the background.
+- `FastPixApi.kt` — the "create upload" API call.
+- `Config.kt` — credentials and chunk size.
+
+## Want a fuller Compose app?
+
+This screen is intentionally minimal. For a complete Compose app built around the FastPix upload flow,
+take a look at [FastPix/android-StreamGate](https://github.com/FastPix/android-StreamGate).
+
+See [../README.md](../README.md) for how the background upload works.
diff --git a/examples/kotlin-compose/build.gradle.kts b/examples/kotlin-compose/build.gradle.kts
new file mode 100644
index 0000000..d0a6aa8
--- /dev/null
+++ b/examples/kotlin-compose/build.gradle.kts
@@ -0,0 +1,66 @@
+import java.util.Properties
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.compose.compiler)
+}
+
+// Credentials live in local.properties (gitignored) so they never reach source control.
+val localProps = Properties().apply {
+ rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) }
+}
+
+android {
+ namespace = "io.fastpix.uploads.compose"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "io.fastpix.uploads.compose"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ buildConfigField("String", "FASTPIX_TOKEN", "\"${localProps.getProperty("fastpix.token", "")}\"")
+ buildConfigField("String", "FASTPIX_SECRET_KEY", "\"${localProps.getProperty("fastpix.secretKey", "")}\"")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+
+ buildFeatures {
+ compose = true
+ buildConfig = true
+ }
+}
+
+dependencies {
+ implementation(project(":uploader"))
+
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.material3)
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.okhttp)
+}
diff --git a/examples/kotlin-compose/proguard-rules.pro b/examples/kotlin-compose/proguard-rules.pro
new file mode 100644
index 0000000..fb164d6
--- /dev/null
+++ b/examples/kotlin-compose/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/examples/kotlin-compose/src/main/AndroidManifest.xml b/examples/kotlin-compose/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..af03bd1
--- /dev/null
+++ b/examples/kotlin-compose/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/Config.kt b/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/Config.kt
new file mode 100644
index 0000000..5329106
--- /dev/null
+++ b/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/Config.kt
@@ -0,0 +1,11 @@
+package io.fastpix.uploads.compose
+
+// Credentials come from local.properties (see README). In production, sign uploads on your
+// backend so the secret key never ships in the APK.
+object Config {
+ val TOKEN = BuildConfig.FASTPIX_TOKEN
+ val SECRET_KEY = BuildConfig.FASTPIX_SECRET_KEY
+
+ /** 8 MiB — must be a multiple of 256 KiB (GCS requirement, enforced by the SDK). */
+ const val CHUNK_SIZE = 8L * 1024 * 1024
+}
diff --git a/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/FastPixApi.kt b/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/FastPixApi.kt
new file mode 100644
index 0000000..9e9d105
--- /dev/null
+++ b/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/FastPixApi.kt
@@ -0,0 +1,40 @@
+package io.fastpix.uploads.compose
+
+import android.util.Base64
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+
+data class SignedUpload(val url: String, val uploadId: String)
+
+// Calls the FastPix API to create an upload and get its resumable session URL.
+object FastPixApi {
+
+ private const val ENDPOINT = "https://api.fastpix.com/v1/on-demand/upload"
+ private const val BODY =
+ "{\"corsOrigin\":\"*\",\"pushMediaSettings\":{\"accessPolicy\":\"public\",\"maxResolution\":\"2160p\"}}"
+
+ private val client = OkHttpClient()
+ private val json = "application/json; charset=utf-8".toMediaType()
+
+ suspend fun createUpload(): SignedUpload = withContext(Dispatchers.IO) {
+ val credentials = "${Config.TOKEN}:${Config.SECRET_KEY}"
+ val auth = "Basic " + Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP)
+ val request = Request.Builder()
+ .url(ENDPOINT)
+ .header("Authorization", auth)
+ .header("Content-Type", "application/json")
+ .post(BODY.toRequestBody(json))
+ .build()
+ client.newCall(request).execute().use { response ->
+ val body = response.body?.string().orEmpty()
+ require(response.isSuccessful) { "HTTP ${response.code}: $body" }
+ val data = JSONObject(body).getJSONObject("data")
+ SignedUpload(data.getString("url"), data.getString("uploadId"))
+ }
+ }
+}
diff --git a/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/MainActivity.kt b/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/MainActivity.kt
new file mode 100644
index 0000000..0364ede
--- /dev/null
+++ b/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/MainActivity.kt
@@ -0,0 +1,127 @@
+package io.fastpix.uploads.compose
+
+import android.Manifest
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.OpenableColumns
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.compose.setContent
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import java.io.File
+import java.io.FileOutputStream
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ MaterialTheme {
+ Surface(modifier = Modifier.fillMaxSize()) { UploadScreen() }
+ }
+ }
+ }
+}
+
+@Composable
+private fun UploadScreen() {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val state by UploadManager.state.collectAsState()
+ var pickedFile by remember { mutableStateOf(null) }
+ var errorMsg by remember { mutableStateOf(null) }
+
+ // Needed to show the upload notification on Android 13+.
+ val notifPermission = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { }
+ LaunchedEffect(Unit) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ notifPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
+ val picker = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenDocument()
+ ) { uri -> if (uri != null) pickedFile = copyToCache(context, uri) }
+
+ Column(
+ modifier = Modifier.fillMaxSize().padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text("FastPix Background Upload", style = MaterialTheme.typography.headlineSmall)
+
+ Button(onClick = { picker.launch(arrayOf("*/*")) }, enabled = !state.active) {
+ Text("Pick file")
+ }
+ Text(pickedFile?.let { "${it.name} (${it.length()} bytes)" } ?: "No file selected")
+
+ Button(
+ onClick = {
+ val file = pickedFile ?: return@Button
+ errorMsg = null
+ scope.launch {
+ runCatching { FastPixApi.createUpload() }
+ .onSuccess { UploadManager.startUpload(context, file, it.url) }
+ .onFailure { errorMsg = "Create upload failed: ${it.message}" }
+ }
+ },
+ enabled = pickedFile != null && !state.active,
+ ) { Text("Start upload") }
+
+ LinearProgressIndicator(
+ progress = { state.percent / 100f },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text("${state.percent}% • ${errorMsg ?: state.status}")
+
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(onClick = { UploadManager.pause() }, enabled = state.active) { Text("Pause") }
+ Button(onClick = { UploadManager.resume() }, enabled = state.active) { Text("Resume") }
+ Button(onClick = { UploadManager.cancel() }, enabled = state.active) { Text("Cancel") }
+ }
+ }
+}
+
+private fun copyToCache(context: Context, uri: Uri): File? {
+ val name = queryDisplayName(context, uri) ?: "upload_${System.currentTimeMillis()}"
+ val dest = File(context.cacheDir, name)
+ return runCatching {
+ context.contentResolver.openInputStream(uri)?.use { input ->
+ FileOutputStream(dest).use { output -> input.copyTo(output) }
+ } ?: error("cannot open $uri")
+ dest
+ }.getOrNull()
+}
+
+private fun queryDisplayName(context: Context, uri: Uri): String? {
+ context.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ if (index >= 0 && cursor.moveToFirst()) return cursor.getString(index)
+ }
+ return null
+}
diff --git a/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/UploadManager.kt b/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/UploadManager.kt
new file mode 100644
index 0000000..f240dd1
--- /dev/null
+++ b/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/UploadManager.kt
@@ -0,0 +1,78 @@
+package io.fastpix.uploads.compose
+
+import android.content.Context
+import io.fastpix.uploads.FastPixUploader
+import io.fastpix.uploads.UploadError
+import io.fastpix.uploads.UploadListener
+import io.fastpix.uploads.UploadState
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.io.File
+import kotlin.math.roundToInt
+
+data class UploadUiState(
+ val active: Boolean = false,
+ val state: UploadState? = null,
+ val percent: Int = 0,
+ val fileName: String = "",
+ val status: String = "Idle",
+)
+
+// Holds the single in-flight upload outside the Activity so it survives rotation and
+// backgrounding. Not persisted, so killing the app ends the upload.
+object UploadManager {
+
+ private val _state = MutableStateFlow(UploadUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private var uploader: FastPixUploader? = null
+
+ fun startUpload(context: Context, file: File, sessionUri: String) {
+ val app = context.applicationContext
+ cancel()
+ uploader = runCatching {
+ FastPixUploader.Builder(app)
+ .file(file)
+ .sessionUri(sessionUri)
+ .chunkSize(Config.CHUNK_SIZE)
+ .listener(listener)
+ .build()
+ }.getOrElse { e ->
+ _state.value = UploadUiState(status = "Error: ${e.message}")
+ return
+ }
+ _state.value = UploadUiState(active = true, fileName = file.name, status = "Starting")
+ UploadService.start(app)
+ uploader?.start()
+ }
+
+ fun pause() { uploader?.pause() }
+ fun resume() { uploader?.resume() }
+
+ fun cancel() {
+ uploader?.cancel()
+ uploader = null
+ }
+
+ private inline fun update(block: (UploadUiState) -> UploadUiState) {
+ _state.value = block(_state.value)
+ }
+
+ private val listener = object : UploadListener {
+ override fun onStateChange(state: UploadState) =
+ update { it.copy(state = state, active = !state.isTerminal, status = state.name) }
+
+ override fun onProgress(bytesUploaded: Long, totalBytes: Long, percentage: Double) =
+ update { it.copy(percent = percentage.roundToInt().coerceIn(0, 100)) }
+
+ override fun onSuccess(elapsedMillis: Long) =
+ update { it.copy(percent = 100, active = false, status = "Completed") }
+
+ override fun onFailure(error: UploadError, elapsedMillis: Long) =
+ update { it.copy(active = false, status = "Failed: ${error.message}") }
+
+ override fun onCancelled(elapsedMillis: Long) =
+ update { it.copy(active = false, status = "Cancelled") }
+ }
+}
diff --git a/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/UploadService.kt b/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/UploadService.kt
new file mode 100644
index 0000000..4e2410c
--- /dev/null
+++ b/examples/kotlin-compose/src/main/java/io/fastpix/uploads/compose/UploadService.kt
@@ -0,0 +1,85 @@
+package io.fastpix.uploads.compose
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+// Keeps the process alive while an upload runs in the background and shows a progress
+// notification. Stops itself once the upload finishes.
+class UploadService : Service() {
+
+ private val scope = CoroutineScope(Dispatchers.Main + Job())
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ buildNotification(UploadManager.state.value),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0,
+ )
+ scope.launch {
+ UploadManager.state.collectLatest { state ->
+ notificationManager().notify(NOTIFICATION_ID, buildNotification(state))
+ if (!state.active) stopSelf()
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun buildNotification(state: UploadUiState): Notification {
+ ensureChannel()
+ val text = if (state.active) "${state.fileName} — ${state.percent}%" else state.status
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("Uploading to FastPix")
+ .setContentText(text)
+ .setSmallIcon(android.R.drawable.stat_sys_upload)
+ .setOngoing(state.active)
+ .setProgress(100, state.percent, false)
+ .setOnlyAlertOnce(true)
+ .build()
+ }
+
+ private fun ensureChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ notificationManager().createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "Uploads", NotificationManager.IMPORTANCE_LOW)
+ )
+ }
+
+ private fun notificationManager() =
+ getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ companion object {
+ private const val CHANNEL_ID = "fastpix_uploads"
+ private const val NOTIFICATION_ID = 1
+
+ fun start(context: Context) {
+ ContextCompat.startForegroundService(
+ context, Intent(context, UploadService::class.java)
+ )
+ }
+ }
+}
diff --git a/examples/kotlin-compose/src/main/res/values/strings.xml b/examples/kotlin-compose/src/main/res/values/strings.xml
new file mode 100644
index 0000000..549fde2
--- /dev/null
+++ b/examples/kotlin-compose/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ FastPix Compose Upload
+
diff --git a/examples/kotlin-compose/src/main/res/xml/backup_rules.xml b/examples/kotlin-compose/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..8f60a8b
--- /dev/null
+++ b/examples/kotlin-compose/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/examples/kotlin-compose/src/main/res/xml/data_extraction_rules.xml b/examples/kotlin-compose/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..a7199c3
--- /dev/null
+++ b/examples/kotlin-compose/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/kotlin-xml/.gitignore b/examples/kotlin-xml/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/examples/kotlin-xml/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/examples/kotlin-xml/README.md b/examples/kotlin-xml/README.md
new file mode 100644
index 0000000..ceede59
--- /dev/null
+++ b/examples/kotlin-xml/README.md
@@ -0,0 +1,21 @@
+# kotlin-xml
+
+The upload flow in Kotlin with plain XML views.
+
+## Running
+
+1. Put your `fastpix.token` and `fastpix.secretKey` in the project-root `local.properties`
+ (see [../README.md](../README.md)).
+2. Open the project in Android Studio, select the `kotlin-xml` module, and Run.
+3. Pick a file, tap Start, then use Pause / Resume / Abort. Send the app to the background and the
+ upload keeps going, with progress in a notification.
+
+## Where things live
+
+- `MainActivity.kt` — the screen; gets a signed URL, hands off to `UploadManager`, and observes its state.
+- `UploadManager.kt` — holds the running upload and exposes its state as a `StateFlow`.
+- `UploadService.kt` — foreground service that keeps the upload alive in the background.
+- `OkHttpHelper.kt` — the "create upload" API call.
+- `Config.kt` — credentials and chunk size.
+
+See [../README.md](../README.md) for how the background upload works.
diff --git a/examples/kotlin-xml/build.gradle.kts b/examples/kotlin-xml/build.gradle.kts
new file mode 100644
index 0000000..76fbb05
--- /dev/null
+++ b/examples/kotlin-xml/build.gradle.kts
@@ -0,0 +1,69 @@
+import java.util.Properties
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+}
+
+// Credentials live in local.properties (gitignored) so they never reach source control.
+val localProps = Properties().apply {
+ rootProject.file("local.properties").takeIf { it.exists() }?.inputStream()?.use { load(it) }
+}
+
+android {
+ namespace = "io.fastpix.uploads.sample"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "io.fastpix.uploads.sample"
+ minSdk = 24
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+
+ buildConfigField("String", "FASTPIX_TOKEN", "\"${localProps.getProperty("fastpix.token", "")}\"")
+ buildConfigField("String", "FASTPIX_SECRET_KEY", "\"${localProps.getProperty("fastpix.secretKey", "")}\"")
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+
+ buildFeatures {
+ viewBinding = true
+ buildConfig = true
+ }
+}
+
+dependencies {
+ implementation(project(":uploader"))
+
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ implementation(libs.androidx.activity)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.kotlinx.coroutines.android)
+
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.espresso.core)
+}
diff --git a/examples/kotlin-xml/proguard-rules.pro b/examples/kotlin-xml/proguard-rules.pro
new file mode 100644
index 0000000..fb164d6
--- /dev/null
+++ b/examples/kotlin-xml/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/examples/kotlin-xml/src/main/AndroidManifest.xml b/examples/kotlin-xml/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..f59ebfa
--- /dev/null
+++ b/examples/kotlin-xml/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/Config.kt b/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/Config.kt
new file mode 100644
index 0000000..e976ec1
--- /dev/null
+++ b/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/Config.kt
@@ -0,0 +1,11 @@
+package io.fastpix.uploads.sample
+
+// Credentials come from local.properties (see README). In production, sign uploads on your
+// backend so the secret key never ships in the APK.
+object Config {
+ val TOKEN = BuildConfig.FASTPIX_TOKEN
+ val SECRET_KEY = BuildConfig.FASTPIX_SECRET_KEY
+
+ /** 8 MiB — must be a multiple of 256 KiB (GCS requirement, enforced by the SDK). */
+ const val CHUNK_SIZE = 8L * 1024 * 1024
+}
diff --git a/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/MainActivity.kt b/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/MainActivity.kt
new file mode 100644
index 0000000..ad3604f
--- /dev/null
+++ b/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/MainActivity.kt
@@ -0,0 +1,135 @@
+package io.fastpix.uploads.sample
+
+import android.Manifest
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.OpenableColumns
+import android.util.Base64
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import io.fastpix.uploads.sample.databinding.ActivityMainBinding
+import kotlinx.coroutines.launch
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.json.JSONObject
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+
+class MainActivity : AppCompatActivity() {
+
+ private lateinit var binding: ActivityMainBinding
+ private var selectedFile: File? = null
+
+ private val pickFile =
+ registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) handlePickedFile(uri)
+ }
+
+ private val requestNotifications =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ supportActionBar?.hide()
+
+ binding.pickFileButton.setOnClickListener { pickFile.launch(arrayOf("*/*")) }
+ binding.startUploadButton.setOnClickListener { getSignedUrl() }
+ binding.pauseButton.setOnClickListener { UploadManager.pause() }
+ binding.resumeButton.setOnClickListener { UploadManager.resume() }
+ binding.abortButton.setOnClickListener { UploadManager.cancel() }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ requestNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.STARTED) {
+ UploadManager.state.collect { render(it) }
+ }
+ }
+ }
+
+ private fun render(state: UploadUiState) {
+ binding.uploadProgress.progress = state.percent
+ binding.progressText.text = "${state.percent}%"
+ binding.statusText.text = "Status: ${state.status}"
+ binding.startUploadButton.isEnabled = !state.active
+ binding.pickFileButton.isEnabled = !state.active
+ binding.pauseButton.isEnabled = state.active
+ binding.abortButton.isEnabled = state.active
+ }
+
+ private fun handlePickedFile(uri: Uri) {
+ val displayName = queryDisplayName(uri) ?: "upload_${System.currentTimeMillis()}"
+ val destination = File(cacheDir, displayName)
+ runCatching {
+ contentResolver.openInputStream(uri)?.use { input ->
+ FileOutputStream(destination).use { output -> input.copyTo(output) }
+ } ?: error("Unable to open input stream")
+ }.onSuccess {
+ selectedFile = destination
+ binding.selectedFileText.text = "${destination.name} (${destination.length()} bytes)"
+ }.onFailure {
+ selectedFile = null
+ Toast.makeText(this, R.string.error_copy_failed, Toast.LENGTH_LONG).show()
+ }
+ }
+
+ private fun queryDisplayName(uri: Uri): String? {
+ contentResolver.query(uri, null, null, null, null)?.use { cursor ->
+ val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ if (nameIndex >= 0 && cursor.moveToFirst()) return cursor.getString(nameIndex)
+ }
+ return null
+ }
+
+ private fun signedUrlRequestBody(): RequestBody {
+ val mediaType = "application/json; charset=utf-8".toMediaType()
+ return "{\"corsOrigin\":\"*\",\"pushMediaSettings\":{\"accessPolicy\":\"public\",\"maxResolution\":\"2160p\"}}"
+ .toRequestBody(mediaType)
+ }
+
+ private fun getSignedUrl() {
+ val file = selectedFile
+ if (file == null) {
+ Toast.makeText(this, R.string.error_file_required, Toast.LENGTH_SHORT).show()
+ return
+ }
+ val credentials = "${Config.TOKEN}:${Config.SECRET_KEY}"
+ val auth = "Basic " + Base64.encodeToString(credentials.toByteArray(), Base64.NO_WRAP)
+ val headers = mapOf("Authorization" to auth, "Content-Type" to "application/json")
+ binding.statusText.text = "Status: creating upload…"
+ OkHttpHelper.post(
+ url = "https://api.fastpix.com/v1/on-demand/upload",
+ headers = headers,
+ body = signedUrlRequestBody(),
+ callback = object : Callback {
+ override fun onFailure(call: Call, e: IOException) {
+ runOnUiThread { binding.statusText.text = "Status: ${e.message}" }
+ }
+
+ override fun onResponse(call: Call, response: Response) {
+ val body = response.body?.string()
+ if (response.isSuccessful && body != null) {
+ val url = JSONObject(body).getJSONObject("data").getString("url")
+ runOnUiThread { UploadManager.startUpload(this@MainActivity, file, url) }
+ } else {
+ runOnUiThread { binding.statusText.text = "Status: HTTP ${response.code}" }
+ }
+ }
+ },
+ )
+ }
+}
diff --git a/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/OkHttpHelper.kt b/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/OkHttpHelper.kt
new file mode 100644
index 0000000..fe8f83e
--- /dev/null
+++ b/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/OkHttpHelper.kt
@@ -0,0 +1,65 @@
+package io.fastpix.uploads.sample
+
+
+
+import okhttp3.Callback
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.logging.HttpLoggingInterceptor
+import java.util.concurrent.TimeUnit
+
+object OkHttpHelper {
+
+ private val client: OkHttpClient by lazy {
+ val interceptor = HttpLoggingInterceptor()
+ interceptor.level = HttpLoggingInterceptor.Level.BODY
+ val builder = OkHttpClient.Builder()
+ builder.addInterceptor(interceptor)
+ builder.build()
+ OkHttpClient.Builder()
+ .addInterceptor(interceptor)
+ .readTimeout(10000, TimeUnit.SECONDS)
+ .writeTimeout(10000, TimeUnit.SECONDS)
+ .connectTimeout(10000, TimeUnit.SECONDS)
+ .build()
+ }
+
+ fun post(
+ url: String,
+ headers: Map = emptyMap(),
+ body: RequestBody,
+ callback: Callback
+ ) {
+ val request = Request.Builder()
+ .url(url)
+ .post(body)
+ .apply {
+ headers.forEach { (key, value) ->
+ addHeader(key, value)
+ }
+ }
+ .build()
+
+ client.newCall(request).enqueue(callback)
+ }
+
+ fun put(
+ url: String,
+ fileContent: ByteArray,
+ callback: Callback
+ ) {
+ val requestBody =
+ fileContent.toRequestBody(
+ "application/octet-stream".toMediaTypeOrNull(),
+ )
+
+ val request = Request.Builder()
+ .url(url)
+ .put(requestBody) // Use .post() for POST requests
+ .build();
+ client.newCall(request).enqueue(callback)
+ }
+}
\ No newline at end of file
diff --git a/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/UploadManager.kt b/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/UploadManager.kt
new file mode 100644
index 0000000..317f9d4
--- /dev/null
+++ b/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/UploadManager.kt
@@ -0,0 +1,78 @@
+package io.fastpix.uploads.sample
+
+import android.content.Context
+import io.fastpix.uploads.FastPixUploader
+import io.fastpix.uploads.UploadError
+import io.fastpix.uploads.UploadListener
+import io.fastpix.uploads.UploadState
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import java.io.File
+import kotlin.math.roundToInt
+
+data class UploadUiState(
+ val active: Boolean = false,
+ val state: UploadState? = null,
+ val percent: Int = 0,
+ val fileName: String = "",
+ val status: String = "Idle",
+)
+
+// Holds the single in-flight upload outside the Activity so it survives rotation and
+// backgrounding. Not persisted, so killing the app ends the upload.
+object UploadManager {
+
+ private val _state = MutableStateFlow(UploadUiState())
+ val state: StateFlow = _state.asStateFlow()
+
+ private var uploader: FastPixUploader? = null
+
+ fun startUpload(context: Context, file: File, sessionUri: String) {
+ val app = context.applicationContext
+ cancel()
+ uploader = runCatching {
+ FastPixUploader.Builder(app)
+ .file(file)
+ .sessionUri(sessionUri)
+ .chunkSize(Config.CHUNK_SIZE)
+ .listener(listener)
+ .build()
+ }.getOrElse { e ->
+ _state.value = UploadUiState(status = "Error: ${e.message}")
+ return
+ }
+ _state.value = UploadUiState(active = true, fileName = file.name, status = "Starting")
+ UploadService.start(app)
+ uploader?.start()
+ }
+
+ fun pause() { uploader?.pause() }
+ fun resume() { uploader?.resume() }
+
+ fun cancel() {
+ uploader?.cancel()
+ uploader = null
+ }
+
+ private inline fun update(block: (UploadUiState) -> UploadUiState) {
+ _state.value = block(_state.value)
+ }
+
+ private val listener = object : UploadListener {
+ override fun onStateChange(state: UploadState) =
+ update { it.copy(state = state, active = !state.isTerminal, status = state.name) }
+
+ override fun onProgress(bytesUploaded: Long, totalBytes: Long, percentage: Double) =
+ update { it.copy(percent = percentage.roundToInt().coerceIn(0, 100)) }
+
+ override fun onSuccess(elapsedMillis: Long) =
+ update { it.copy(percent = 100, active = false, status = "Completed") }
+
+ override fun onFailure(error: UploadError, elapsedMillis: Long) =
+ update { it.copy(active = false, status = "Failed: ${error.message}") }
+
+ override fun onCancelled(elapsedMillis: Long) =
+ update { it.copy(active = false, status = "Cancelled") }
+ }
+}
diff --git a/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/UploadService.kt b/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/UploadService.kt
new file mode 100644
index 0000000..fab614f
--- /dev/null
+++ b/examples/kotlin-xml/src/main/java/io/fastpix/uploads/sample/UploadService.kt
@@ -0,0 +1,85 @@
+package io.fastpix.uploads.sample
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+
+// Keeps the process alive while an upload runs in the background and shows a progress
+// notification. Stops itself once the upload finishes.
+class UploadService : Service() {
+
+ private val scope = CoroutineScope(Dispatchers.Main + Job())
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ buildNotification(UploadManager.state.value),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0,
+ )
+ scope.launch {
+ UploadManager.state.collectLatest { state ->
+ notificationManager().notify(NOTIFICATION_ID, buildNotification(state))
+ if (!state.active) stopSelf()
+ }
+ }
+ return START_NOT_STICKY
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ private fun buildNotification(state: UploadUiState): Notification {
+ ensureChannel()
+ val text = if (state.active) "${state.fileName} — ${state.percent}%" else state.status
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle("Uploading to FastPix")
+ .setContentText(text)
+ .setSmallIcon(android.R.drawable.stat_sys_upload)
+ .setOngoing(state.active)
+ .setProgress(100, state.percent, false)
+ .setOnlyAlertOnce(true)
+ .build()
+ }
+
+ private fun ensureChannel() {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ notificationManager().createNotificationChannel(
+ NotificationChannel(CHANNEL_ID, "Uploads", NotificationManager.IMPORTANCE_LOW)
+ )
+ }
+
+ private fun notificationManager() =
+ getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+
+ companion object {
+ private const val CHANNEL_ID = "fastpix_uploads"
+ private const val NOTIFICATION_ID = 1
+
+ fun start(context: Context) {
+ ContextCompat.startForegroundService(
+ context, Intent(context, UploadService::class.java)
+ )
+ }
+ }
+}
diff --git a/examples/kotlin-xml/src/main/res/layout/activity_main.xml b/examples/kotlin-xml/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..02ca894
--- /dev/null
+++ b/examples/kotlin-xml/src/main/res/layout/activity_main.xml
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/kotlin-xml/src/main/res/values/colors.xml b/examples/kotlin-xml/src/main/res/values/colors.xml
new file mode 100644
index 0000000..648cfde
--- /dev/null
+++ b/examples/kotlin-xml/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+ #FF6200EE
+ #FF3700B3
+ #FF03DAC5
+ #FF018786
+ #FF000000
+ #FFFFFFFF
+
diff --git a/examples/kotlin-xml/src/main/res/values/strings.xml b/examples/kotlin-xml/src/main/res/values/strings.xml
new file mode 100644
index 0000000..df50695
--- /dev/null
+++ b/examples/kotlin-xml/src/main/res/values/strings.xml
@@ -0,0 +1,20 @@
+
+
+ FastPix Uploads Sample
+
+ Signed upload URL
+ Create upload
+ Pick a file
+ Start upload
+ Pause
+ Resume
+ Abort
+
+ No file selected
+ 0%
+ Status: idle
+
+ Please enter a signed URL
+ Please pick a file first
+ Could not read the selected file
+
diff --git a/examples/kotlin-xml/src/main/res/values/themes.xml b/examples/kotlin-xml/src/main/res/values/themes.xml
new file mode 100644
index 0000000..a1f57d2
--- /dev/null
+++ b/examples/kotlin-xml/src/main/res/values/themes.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/examples/kotlin-xml/src/main/res/xml/backup_rules.xml b/examples/kotlin-xml/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..8f60a8b
--- /dev/null
+++ b/examples/kotlin-xml/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/examples/kotlin-xml/src/main/res/xml/data_extraction_rules.xml b/examples/kotlin-xml/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..a7199c3
--- /dev/null
+++ b/examples/kotlin-xml/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e77b199..99518a3 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -12,6 +12,8 @@ loggingInterceptor = "4.12.0"
material = "1.12.0"
activity = "1.8.0"
constraintlayout = "2.1.4"
+activityCompose = "1.9.2"
+composeBom = "2024.09.00"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -26,9 +28,14 @@ material = { group = "com.google.android.material", name = "material", version.r
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "loggingInterceptor" }
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
+androidx-compose-ui = { module = "androidx.compose.ui:ui" }
+androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
android-library = { id = "com.android.library", version.ref = "agp" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
diff --git a/gradlew b/gradlew
old mode 100644
new mode 100755
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 010c8fe..b5ac069 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -20,5 +20,7 @@ dependencyResolutionManagement {
}
rootProject.name = "Uploads Sdk GCP"
-include(":app")
include(":uploader")
+include(":examples:kotlin-xml")
+include(":examples:kotlin-compose")
+include(":examples:java-xml")