Skip to content

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_KEY and VIDEXO_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:

Kotlin
dependencies {
    implementation("com.squareup.okhttp3:okhttp:5.5.1")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
}

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:

examples/kotlin/app/src/main/java/io/videxo/example/BackendClient.kt · plain HTTP
/** 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
}

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:

examples/kotlin/app/src/main/java/io/videxo/example/BackendClient.kt · plain HTTP
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"),
    )
}

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:

examples/kotlin/app/src/main/java/io/videxo/example/BackendClient.kt · plain HTTP
/**
 * 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)
            }
        }
    }
}

The screen runs the whole flow: pick, upload, create, wait.

examples/kotlin/app/src/main/java/io/videxo/example/MainActivity.kt · plain HTTP
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
        }
    }
}

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:

examples/kotlin/app/src/main/java/io/videxo/example/BackendClient.kt · plain HTTP
/** 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}")

Run it#

  1. Start a backend from a server guide on port 3000.
  2. Run the app in the Android emulator; http://10.0.2.2:3000 reaches the backend on your computer. Plain http needs android:usesCleartextTraffic="true" or a network security config in development; use the backend's https address for a device and in production.
  3. 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.