# Android (Kotlin) Integration Guide

How to call the Image Editing API from an Android app using Retrofit + OkHttp + coroutines.

## Before you start: where should `API_KEY` live?

`API_KEY` is a **shared secret for server-to-server calls**, not a per-user credential. Any string
baked into an APK can be extracted (decompile, or just sniff the network traffic), so if you ship
`API_KEY` inside the app, it's effectively public — anyone can pull it out and burn your
OpenAI/Gemini spend directly against your server.

- **Prototyping / internal testing app**: fine to call this API directly from the app, as shown
  below.
- **Shipping to real users**: put your own backend in front of this API (it already is one — this
  guide's calls belong in *that* backend, not the phone). The app authenticates to *your* backend
  (Firebase Auth, your own JWT, etc.), and your backend — which holds `API_KEY` server-side — calls
  this image API. Same Retrofit code below, just pointed at your backend's URL instead.

The rest of this guide assumes the direct-call setup; swap the base URL if you add a proxy.

## 1. Gradle dependencies

`app/build.gradle.kts`:

```kotlin
dependencies {
    implementation("com.squareup.retrofit2:retrofit:2.11.0")
    implementation("com.squareup.okhttp3:okhttp:4.12.0")
    implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
}
```

No JSON library needed — the only JSON response (`GET /api/tasks`) is small enough to parse with
Android's built-in `org.json`. Every `POST /api/:task` response is raw image bytes.

## 2. Manifest

```xml
<uses-permission android:name="android.permission.INTERNET" />
```

If you're testing against a local server from the emulator, use `http://10.0.2.2:3000` (the
emulator's alias for your host machine's `localhost`) and allow cleartext traffic for dev only:

```xml
<application
    android:usesCleartextTraffic="true"
    ...>
```

Remove `usesCleartextTraffic` (or scope it to a debug-only manifest) once you're pointed at a real
HTTPS deployment.

## 3. API client

```kotlin
// ApiClient.kt
object ApiClient {
    private const val BASE_URL = "http://10.0.2.2:3000/" // emulator -> host machine
    private const val API_KEY = "REPLACE_WITH_YOUR_API_KEY" // see security note above

    private val authInterceptor = Interceptor { chain ->
        val request = chain.request().newBuilder()
            .addHeader("x-api-key", API_KEY)
            .build()
        chain.proceed(request)
    }

    private val client = OkHttpClient.Builder()
        .addInterceptor(authInterceptor)
        .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
        .connectTimeout(30, TimeUnit.SECONDS)
        .readTimeout(60, TimeUnit.SECONDS) // image generation can take a while
        .writeTimeout(60, TimeUnit.SECONDS)
        .build()

    val retrofit: Retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .client(client)
        .build()

    val service: ImageEditApi = retrofit.create(ImageEditApi::class.java)
}
```

## 4. Retrofit interface

```kotlin
// ImageEditApi.kt
interface ImageEditApi {
    @GET("api/tasks")
    suspend fun listTasks(): Response<ResponseBody>

    @Multipart
    @POST("api/{task}")
    suspend fun editImage(
        @Path("task") task: String,
        @Part image: MultipartBody.Part,
        @Part mask: MultipartBody.Part? = null,
        @Part prompt: MultipartBody.Part? = null
    ): Response<ResponseBody>
}
```

`task` is one of: `upscale`, `object-removal`, `background-removal`, `enhance`, `cartoonize`,
`sketch`, `colorize`. `mask` is only used for `object-removal`.

## 5. Uri -> multipart part

```kotlin
// MultipartUtils.kt
fun Context.uriToMultipart(uri: Uri, partName: String): MultipartBody.Part {
    val mimeType = contentResolver.getType(uri) ?: "image/jpeg"
    val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() }
        ?: throw IllegalArgumentException("Could not read $uri")
    val requestBody = bytes.toRequestBody(mimeType.toMediaType())
    val fileName = "upload.${mimeType.substringAfterLast('/')}"
    return MultipartBody.Part.createFormData(partName, fileName, requestBody)
}
```

## 6. Repository function

```kotlin
// ImageEditRepository.kt
sealed class ImageEditResult {
    data class Success(val bitmap: Bitmap) : ImageEditResult()
    data class Error(val message: String) : ImageEditResult()
}

class ImageEditRepository(private val context: Context) {

    suspend fun edit(
        task: String,
        imageUri: Uri,
        maskUri: Uri? = null,
        prompt: String? = null
    ): ImageEditResult = withContext(Dispatchers.IO) {
        try {
            val imagePart = context.uriToMultipart(imageUri, "image")
            val maskPart = maskUri?.let { context.uriToMultipart(it, "mask") }
            val promptPart = prompt?.takeIf { it.isNotBlank() }
                ?.toRequestBody("text/plain".toMediaType())
                ?.let { MultipartBody.Part.createFormData("prompt", null, it) }

            val response = ApiClient.service.editImage(task, imagePart, maskPart, promptPart)

            if (!response.isSuccessful) {
                val errorBody = response.errorBody()?.string()
                val message = runCatching { JSONObject(errorBody ?: "").optString("error") }
                    .getOrNull()
                    ?.takeIf { it.isNotBlank() }
                    ?: when (response.code()) {
                        401 -> "Invalid API key."
                        429 -> "Rate limited — try again shortly."
                        400 -> "Invalid request (bad file type or missing image)."
                        else -> "Request failed (${response.code()})."
                    }
                return@withContext ImageEditResult.Error(message)
            }

            val bytes = response.body()?.bytes()
                ?: return@withContext ImageEditResult.Error("Empty response.")
            val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
                ?: return@withContext ImageEditResult.Error("Could not decode image.")

            ImageEditResult.Success(bitmap)
        } catch (e: IOException) {
            ImageEditResult.Error("Network error: ${e.message}")
        }
    }
}
```

## 7. Usage from a ViewModel / Compose screen

```kotlin
class EditorViewModel(private val repo: ImageEditRepository) : ViewModel() {
    var resultBitmap by mutableStateOf<Bitmap?>(null)
        private set
    var errorMessage by mutableStateOf<String?>(null)
        private set
    var isLoading by mutableStateOf(false)
        private set

    fun runTask(task: String, imageUri: Uri, maskUri: Uri? = null) {
        viewModelScope.launch {
            isLoading = true
            errorMessage = null
            when (val result = repo.edit(task, imageUri, maskUri)) {
                is ImageEditResult.Success -> resultBitmap = result.bitmap
                is ImageEditResult.Error -> errorMessage = result.message
            }
            isLoading = false
        }
    }
}
```

Picking an image with the modern Activity Result API:

```kotlin
val pickImage = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
    uri?.let { viewModel.runTask("cartoonize", it) }
}

// trigger it:
pickImage.launch("image/*")
```

## 8. Saving the result

```kotlin
fun saveBitmapToGallery(context: Context, bitmap: Bitmap, displayName: String) {
    val values = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
        put(MediaStore.Images.Media.MIME_TYPE, "image/png")
        put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
    }
    val uri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
        ?: return
    context.contentResolver.openOutputStream(uri)?.use { out ->
        bitmap.compress(Bitmap.CompressFormat.PNG, 100, out)
    }
}
```

## Error handling reference

| HTTP status | Meaning | App behavior |
|---|---|---|
| 400 | Bad request (unsupported file type, missing `image`, unknown task) | Show the server's `error` message, let the user retry |
| 401 | Missing/invalid `x-api-key` | Config issue — don't retry, check the key |
| 429 | Rate limited | Back off and retry after a delay |
| 500 | Unexpected server/provider error | Generic "something went wrong," allow retry |

See [README.md](../README.md) for the full task list, request/response shape, and server-side
security config.
