---
title: Build a simple app that describes an image and works offline
url: https://calvin.my/posts/build-a-simple-app-that-describes-an-image-and-works-offline
published: 2025-12-14
updated: 2026-09-16
category: AI
tags:
- Android
- Gemini Nano
- ML Kit
summary: The post outlines an Android app that generates English image descriptions using Gemini’s on-device Image Description API. It requires Android SDK 29 or later and supported flagship hardware, not an emulator. The implementation initializes the descriptor, checks device and model availability, prompts users to download the model when needed, and submits selected images for inference. After the model is installed, descriptions work without an internet connection; resources should be closed afterward.
---

# Build a simple app that describes an image and works offline

This article shows a mobile app that uses the Gemini Image Description API on Android.

Reference: [https://developers.google.com/ml-kit/genai/image-description/android](https://developers.google.com/ml-kit/genai/image-description/android)

* * *

## Pre-requisites

1. Support Android SDK Level 29 and above
2. The feature availability is limited to certain flagship devices (Does not work on the emulator)
3. The description output is limited to English

* * *

## Key Steps in the Implementation

1. Initialize the image descriptor

   ```kotlin
   import com.google.mlkit.genai.imagedescription.ImageDescriberOptions
   import com.google.mlkit.genai.imagedescription.ImageDescriber
   ...

       private var imageDescriber: ImageDescriber? = null

       fun initializeClient(context: Context) {
           if (imageDescriber == null) {
               val options = ImageDescriberOptions.builder(context).build()
               imageDescriber = ImageDescription.getClient(options)
           }
       }
   ```

2. To check if the device is supported and to download the model if it is not available

   ```kotlin
   import android.content.Context
   import com.google.mlkit.genai.common.FeatureStatus
   import kotlinx.coroutines.guava.await
   ...

       suspend fun checkFeatureStatus(context: Context): Int {
           initializeClient(context)
           val describer = imageDescriber ?: return FeatureStatus.UNAVAILABLE
           return describer.checkFeatureStatus().await()
       }
   ```

3. Similar to the Web API, the feature status has 4 possible values - Available, Unavailable, Downloading, Downloadable

   If the value is unavailable, you can't proceed further. Your device might not be supported.

4. If the value is downloadable, you need to trigger the model download. This usually happens when you use the API for the first time.

   ```kotlin
   suspendCoroutine { continuation ->
       try {
           val describer = imageDescriber
               ?: throw IllegalStateException("ImageDescriber not initialized")

           describer.downloadFeature(object : DownloadCallback {
               override fun onDownloadStarted(bytesToDownload: Long) {
                   onProgress(0)
               }

               override fun onDownloadProgress(totalBytesDownloaded: Long) {
                   onProgress(totalBytesDownloaded)
               }

               override fun onDownloadCompleted() {
                   continuation.resume(true)
               }

               override fun onDownloadFailed(error: GenAiException) {
                   continuation.resumeWithException(error)
               }
           })
       } catch (e: Exception) {
           continuation.resumeWithException(e)
       }
   }
   ```

5. Once the model is downloaded, you can now use the API on the device without an active internet connection.

6. We can now pass a Bitmap and ask for a description.

   ```kotlin
   return suspendCoroutine { continuation ->
       try {
           val describer = imageDescriber
               ?: throw IllegalStateException("ImageDescriber not initialized")

           val request = ImageDescriptionRequest.builder(bitmap).build()
           val result = StringBuilder()
           var lastUpdateTime = System.currentTimeMillis()
           var isResumed = false

           describer.runInference(request) { text ->
               result.append(text)
               lastUpdateTime = System.currentTimeMillis()
           }

           CoroutineScope(Dispatchers.Default).launch {
               while (!isResumed) {
                   delay(100)
                   val timeSinceLastUpdate = System.currentTimeMillis() - lastUpdateTime
                   if (timeSinceLastUpdate > 500 && result.isNotEmpty()) {
                       if (!isResumed) {
                           isResumed = true
                           continuation.resume(result.toString())
                       }
                   }
                   // Timeout after 30 seconds
                   if (timeSinceLastUpdate > 30000) {
                       if (!isResumed) {
                           isResumed = true
                           if (result.isEmpty()) {
                               continuation.resumeWithException(Exception("Timeout: No description generated"))
                           } else {
                               continuation.resume(result.toString())
                           }
                       }
                   }
               }
           }
       } catch (e: Exception) {
           continuation.resumeWithException(e)
       }
   }
   ```

7. Finally, we should call the close method to clean up the initialized resource.

   ```kotlin
   fun cleanup() {
       imageDescriber?.close()
       imageDescriber = null
   }
   ```

* * *

## A sample screen flow illustration

1. Prompt the user to download the model.

   ![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/10cb8505-fa1f-4247-9ccb-f3285d109d55.png)

   ![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/8ce6d715-9b88-48b9-a2e3-71471971b452.png)

2. Ask for storage permission to add a picker.

   ![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/465bc586-2cdb-4c0c-9e69-59fa8ff24216.png)

   ![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/0df58923-9cff-458c-abdf-32eba0313e96.png)

3. And we are ready to test.

* * *

## The results

![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/78881c63-6e6a-4851-aab2-d9262881fcfb.png)

![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/9d6a6d43-75fc-4592-a29c-48645e05275c.png)
