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 @@ + + + + + + + +