DocsIntegrations
Android with Kotlin
An Android app that uploads a video straight to Videxo with an upload URL from your backend, has your backend create the job and follows it to the downloads.
What you build#
An Android app (Jetpack Compose) that picks a video, uploads it straight to Videxo and shows the
outputs when the job ends. The app never holds an API key: it talks only to your backend, which
creates the upload and the job with its key. The backend is any server guide in this section; they
all serve the same routes (POST /api/uploads, POST /api/jobs, GET /api/jobs/{id} and
GET /api/jobs/{id}/outputs/{alias}). The app uses OkHttp with plain HTTP to your backend.
The whole project: videxo-kotlin.zip, or browse it in the examples repository (opens in a new tab).
Before you start#
- A backend from a server guide, running: Express,
Next.js, SvelteKit,
FastAPI, Go or any other in this
section. It holds
VIDEXO_API_KEYandVIDEXO_WEBHOOK_SECRET. - Android Studio and an app with Jetpack Compose.
Never put the API key in the app: anyone can read it out of the APK.
Install#
Add OkHttp and the coroutines library to the app module's build.gradle.kts, and the
INTERNET permission to the manifest:
dependencies {
implementation("com.squareup.okhttp3:okhttp:5.5.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
}Copy failed. The text is selected; copy it with your keyboard.
org.json comes with Android. Set the backend's address at the top of MainActivity.kt.
Create a job#
The app asks your backend to create the job from the upload; the backend sends the JSON body to Videxo with its key and an idempotency key. Then the app reads the job every 2 seconds until it ends:
/** Asks the backend to create the job from the upload: "transcode" (one MP4) or "jobs" (several outputs). */
suspend fun createJob(inputUrl: String, kind: String): Job = withContext(Dispatchers.IO) {
job(send("POST", "api/jobs", JSONObject().put("kind", kind).put("input_url", inputUrl)))
}
/** Reads the job every 2 seconds until it ends. A push from your backend (after its webhook) can replace the polling. */
suspend fun waitForJob(start: Job): Job {
var current = start
while (current.isActive) {
delay(2_000)
current = withContext(Dispatchers.IO) { job(send("GET", "api/jobs/${current.id}")) }
}
return current
}Copy failed. The text is selected; copy it with your keyboard.
Every call goes through one send function. An error response is problem details, as Videxo
returns them and as the backends pass them on; it becomes a Problem exception whose message the
app shows:
private fun send(method: String, path: String, body: JSONObject? = null): JSONObject {
val requestBody = body?.toString()?.toRequestBody("application/json".toMediaType())
val request = Request.Builder().url("$baseUrl/$path").method(method, requestBody).build()
http.newCall(request).execute().use { response ->
check(response)
return JSONObject(response.body.string())
}
}
/** An answer outside 2xx becomes its problem details, so the app can show the detail. */
private fun check(response: Response) {
if (response.isSuccessful) return
val problem = runCatching { JSONObject(response.body.string()) }.getOrNull()
throw Problem(
problem?.optString("title") ?: "The server answered with an error.",
problem?.optString("detail") ?: "HTTP ${response.code}",
problem?.optString("code"),
)
}Copy failed. The text is selected; copy it with your keyboard.
Receive the webhook#
On your backend, never in the app: Videxo signs each webhook with your account's secret, which only the backend holds. The server guides show the route and the signature check. To tell the app without polling, have that route send a push message (Firebase Cloud Messaging) to the device that started the job, and let the app read the job once when the message arrives.
Upload a file#
The app asks your backend for a one-time upload URL and streams the file there with PUT, with its
exact length. When the file's provider does not tell the size (some cloud providers), the app copies
the file to its cache first and sends the copy. The URL holds no API key and takes one PUT, so
the body is one-shot and the client for it never retries: a failed PUT throws, and the next try
creates a new upload. The file goes straight to Videxo, not through your backend:
/**
* Asks the backend for a one-time upload URL, then sends the file straight to Videxo with it.
* The upload URL holds no API key and takes one PUT, so the PUT is never retried: when it
* fails this throws, and the next call creates a new upload. [cacheDir] holds a copy of a file
* whose provider does not tell its size (some cloud providers), to send its exact length.
*/
suspend fun upload(resolver: ContentResolver, file: Uri, cacheDir: File): String = withContext(Dispatchers.IO) {
val contentType = resolver.getType(file) ?: "application/octet-stream"
val (name, reported) = resolver.query(file, null, null, null, null)!!.use { cursor ->
cursor.moveToFirst()
val size = cursor.getColumnIndexOrThrow(OpenableColumns.SIZE)
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) to
(if (cursor.isNull(size)) null else cursor.getLong(size))
}
// An unknown or empty size: copy the file once, up to the direct upload limit, and send the
// copy, whose length is exact. The finally below deletes the copy, also after a failed one.
var copy: File? = null
try {
if (reported == null || reported <= 0) {
val target = File.createTempFile("upload", null, cacheDir)
copy = target
copyAtMost(resolver, file, target, MAX_UPLOAD_BYTES)
}
val size = copy?.length() ?: reported!!
val created = send(
"POST", "api/uploads",
JSONObject().put("filename", plainName(name)).put("size_bytes", size).put("content_type", contentType),
)
val bytes = object : RequestBody() {
override fun contentType() = contentType.toMediaType()
override fun contentLength() = size
// OkHttp must not send the body twice: the upload URL takes one PUT.
override fun isOneShot() = true
override fun writeTo(sink: BufferedSink) {
(copy?.inputStream() ?: resolver.openInputStream(file)!!).source().use { sink.writeAll(it) }
}
}
val put = Request.Builder().url(created.getString("upload_url")).put(bytes).build()
uploads.newCall(put).execute().use { check(it) }
created.getString("input_url")
} finally {
copy?.delete()
}
}
/** Copies [file] into [target]; throws once it passes [limit] bytes, so a huge file never fills the cache. */
private fun copyAtMost(resolver: ContentResolver, file: Uri, target: File, limit: Long) {
resolver.openInputStream(file)!!.use { input ->
target.outputStream().use { output ->
val buffer = ByteArray(64 * 1024)
var total = 0L
while (true) {
val read = input.read(buffer)
if (read < 0) break
total += read
if (total > limit) throw Problem("The file is too large.", "A direct upload is at most 100 MB.", "limit_exceeded")
output.write(buffer, 0, read)
}
}
}
}Copy failed. The text is selected; copy it with your keyboard.
The screen runs the whole flow: pick, upload, create, wait.
val pick = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { file: Uri? ->
if (file == null) return@rememberLauncherForActivityResult
busy = true
scope.launch {
try {
problem = null
job = null
status = "Uploading"
val inputUrl = backend.upload(context.contentResolver, file, context.cacheDir)
val created = backend.createJob(inputUrl, kind)
status = "Job ${created.id}: ${created.state}"
val ended = backend.waitForJob(created)
status = "Job ${ended.id}: ${ended.state}"
job = ended
} catch (error: Exception) {
status = ""
problem = error.message
} finally {
busy = false
}
}
}Copy failed. The text is selected; copy it with your keyboard.
See Files for the size limit and how long an upload lasts.
Download the outputs#
Each output opens your backend's download route, which redirects to a fresh signed link:
/** The backend's download route: it redirects to a fresh signed link, which the browser or DownloadManager follows. */
fun downloadUrl(job: Job, output: Output): Uri = Uri.parse("$baseUrl/api/jobs/${job.id}/outputs/${output.alias}")Copy failed. The text is selected; copy it with your keyboard.
Run it#
- Start a backend from a server guide on port 3000.
- Run the app in the Android emulator;
http://10.0.2.2:3000reaches the backend on your computer. Plainhttpneedsandroid:usesCleartextTraffic="true"or a network security config in development; use the backend'shttpsaddress for a device and in production. - Pick a video and choose the job.
Next steps#
- iOS with Swift: the same app for iOS.
- Webhooks: what your backend receives when a job ends.
- Files: upload limits and how long links last.