> For the complete documentation index, see [llms.txt](https://docs.mapbox.com/help/ja/llms.txt)

# Use Offline Maps in an Android app

In this tutorial you will learn how to manage [offline maps](https://docs.mapbox.com/android/maps/guides/offline/) functionality in your Android app using the [Mapbox Maps SDK for Android](https://docs.mapbox.com/android/maps/guides/).

You will build an Android app that displays a map and allows the user to trigger the download of three predefined **tile regions** for offline use. The app will also show download progress and status for each region.

Using your emulator or device, you will test the offline functionality by downloading regions and then using the app without an internet connection. The map will still render the downloaded regions, demonstrating offline functionality.

### What we'll cover:

-   Creating a full screen Map view with Jetpack Compose
-   Initializing the offline manager and downloading a style pack
-   Adding UI to download predefined tile regions
-   Tracking download progress and status
-   Testing offline functionality

Your browser doesn't support HTML5 video. Open [link to the video](https://docs.mapbox.com/help/ja/help/ja/assets/medias/tutorials--offline-maps-android--washington-offline-39cae15e77ddc974ff9b84e968ec82c1.webm).

## Prerequisites

To follow along with this tutorial, you will need:

-   **Android Studio**: The latest version of [Android Studio](https://developer.android.com/studio/install) with Gradle
-   **A Mapbox access token**: Find yours on the [Access token page](https://console.mapbox.com/account/access-tokens/) of your Developer Console.
-   **A project with the Maps SDK installed**: Follow the [Getting Started with the Maps SDK for Android](https://docs.mapbox.com/android/maps/guides/install/) guide.

This tutorial assumes you have basic knowledge of Kotlin and Jetpack Compose and some experience with Android development. It also assumes you have a basic understanding of how to use the Maps SDK for Android.

Once you have a new Android project set up with the Maps SDK for Android, you are ready to start adding offline map functionality.

## Create a full screen map

Start by creating a full screen map in your `MainActivity.kt`. The camera options center the map on North America with a zoom level of 2.

```kotlin
package com.example.mapbox_offline

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import com.mapbox.geojson.Point
import com.mapbox.maps.extension.compose.MapboxMap
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MapboxMap(
                Modifier.fillMaxSize(),
                mapViewportState = rememberMapViewportState {
                    setCameraOptions {
                        zoom(2.0)
                        center(Point.fromLngLat(-98.0, 39.5))
                        pitch(0.0)
                        bearing(0.0)
                    }
                },
            )
        }
    }
}
```

![Android app showing a fullscreen map centered on North America](https://docs.mapbox.com/help/ja/assets/ideal-img/tutorials--offline-maps-android--fullscreen-map.3893ba1.480.png)

For more details on setting up the Maps SDK for Android, see the [installation guide](https://docs.mapbox.com/android/maps/guides/install/).

With a full screen map set up, you are ready to add offline map functionality.

## Create an Offline Region Manager

Create an `OfflineRegionManager` object to handle the offline functionality. This manager has three important functions for setting up offline maps:

1.  `ensureStylePackDownloaded()` — downloads the style pack for offline use.
2.  `downloadRegion(region)` — initiates the download of a specified tile region.
3.  `clearAllRegions()` — removes all downloaded tile regions and clears the cache.

Each function is explained in detail below the code block.

```kotlin
package com.example.mapbox_offline

import android.util.Log
import com.mapbox.bindgen.Value
import com.mapbox.geojson.Geometry
import com.mapbox.maps.Style
import com.mapbox.common.TileRegion
import com.mapbox.common.TileRegionLoadOptions
import com.mapbox.common.TileStore
import com.mapbox.maps.TilesetDescriptorOptions
import com.mapbox.maps.StylePackLoadOptions
import java.util.concurrent.atomic.AtomicReference
import com.mapbox.maps.GlyphsRasterizationMode
import com.mapbox.maps.OfflineManager
import com.mapbox.common.MapboxOptions
import com.mapbox.maps.mapsOptions
import com.mapbox.geojson.Point
import com.mapbox.geojson.Polygon
import com.mapbox.maps.CoordinateBounds

data class OfflineRegion(
    val id: String,
    val name: String,
    val bounds: CoordinateBounds
) {
    // convert CoordinateBounds to Polygon (closed rectangular ring)
    val polygon: Polygon
        get() {
            val sw = bounds.southwest
            val ne = bounds.northeast
            val coords = listOf(
                Point.fromLngLat(sw.longitude(), sw.latitude()),
                Point.fromLngLat(ne.longitude(), sw.latitude()),
                Point.fromLngLat(ne.longitude(), ne.latitude()),
                Point.fromLngLat(sw.longitude(), ne.latitude()),
                Point.fromLngLat(sw.longitude(), sw.latitude())
            )
            return Polygon.fromLngLats(listOf(coords))
        }
}


object OfflineRegionManager {
    private const val TAG = "OfflineRegionManager"
    private const val STYLE_URI = Style.STANDARD
    private const val MIN_ZOOM = 10.0
    private const val MAX_ZOOM = 14.0

    private val offlineManager: OfflineManager = OfflineManager()
    private val tileStore: TileStore? = MapboxOptions.mapsOptions.tileStore


    fun ensureStylePackDownloaded(activity: MainActivity) {
        try {
            val stylePackOptions = StylePackLoadOptions.Builder()
                .glyphsRasterizationMode(GlyphsRasterizationMode.IDEOGRAPHS_RASTERIZED_LOCALLY)
                .metadata(Value("mapbox-standard-stylepack"))
                .acceptExpired(false)
                .build()

            offlineManager.loadStylePack(
                Style.STANDARD,
                stylePackOptions,
                { progress ->
                    //
                },
                { expected ->
                    expected.value?.let { stylePack ->
                        // Style pack download finishes successfully
                        Log.d(TAG, "Style pack downloaded: $stylePack")
                    }
                    expected.error?.let {
                        // Handle error occurred during the style pack download.
                    }
                }
            )

        } catch (e: Throwable) {
            Log.w(TAG, "Style pack API not available or failed: ${e.message}")
        }
    }

    fun downloadRegion(
        region: OfflineRegion,
        downloadingRegions: Set<String>,
        onDownloadingRegionsUpdate: (Set<String>) -> Unit,
        onProgress: (String, Float) -> Unit,
        onCompletion: (String, Result<TileRegion>) -> Unit
    ) {
        if (downloadingRegions.contains(region.id)) {
            Log.d(TAG, "Region ${region.id} is already downloading; skipping")
            return
        }

        val tilesetDescriptorOptions = TilesetDescriptorOptions.Builder()
            .styleURI(STYLE_URI)
            .minZoom(MIN_ZOOM.toInt().toByte())
            .maxZoom(MAX_ZOOM.toInt().toByte())
            .build()

        val tilesetDescriptor = offlineManager.createTilesetDescriptor(tilesetDescriptorOptions)



        val loadOptions: TileRegionLoadOptions =TileRegionLoadOptions.Builder()
            .geometry(region.polygon as Geometry)
            .descriptors(listOf(tilesetDescriptor))
            .metadata(Value(region.name))
            .acceptExpired(true)
            .build()


        val updated = downloadingRegions.toMutableSet()
        updated.add(region.id)
        onDownloadingRegionsUpdate(updated)
        onProgress(region.id, 0.0f)

        // Ensure tileStore is available before calling its methods
        val ts = tileStore
        if (ts == null) {
            Log.e(TAG, "TileStore not available")
            val updatedAfter = updated.toMutableSet()
            updatedAfter.remove(region.id)
            onDownloadingRegionsUpdate(updatedAfter)
            onCompletion(region.id, Result.failure(Exception("TileStore not available")))
            return
        }
        // Safe to use non-null ts from here on
        ts.loadTileRegion(
            region.id,
            loadOptions,
            { progress ->
                val total = maxOf(progress.requiredResourceCount, 1)
                val progressVal = progress.completedResourceCount.toFloat() / total.toFloat()
                onProgress(region.id, progressVal)
            }
        ) { result ->
            val updatedAfter = updated.toMutableSet()
            updatedAfter.remove(region.id)
            onDownloadingRegionsUpdate(updatedAfter)

            if (result.isValue) {
                val tileRegion = result.value!!
                onCompletion(region.id, Result.success(tileRegion))
            } else {
                // result.error may be a Serializable (not a Throwable). Convert safely to Throwable.
                val rawError = result.error
                val throwable = when (rawError) {
                    is Throwable -> rawError
                    null -> Exception("Unknown error")
                    else -> Exception(rawError.toString())
                }
                onCompletion(region.id, Result.failure(throwable))
            }
        }

    }

    fun clearAllRegions(onCompletion: () -> Unit) {
        tileStore?.getAllTileRegions { result ->
            if (result.isValue) {
                val tileRegions = result.value ?: emptyList()
                if (tileRegions.isEmpty()) {
                    tileStore.clearAmbientCache { cacheResult ->
                        if (cacheResult.isValue) {
                            Log.i(TAG, "Cleared ${cacheResult.value} bytes from cache")
                        } else {
                            Log.e(TAG, "Failed to clear cache: ${cacheResult.error}")
                        }
                        onCompletion()
                    }
                }

                val removalsPending = AtomicReference(tileRegions.size)
                for (tileRegion in tileRegions) {
                    tileStore.removeTileRegion(tileRegion.id) { removeResult ->
                        if (removeResult.isValue) {
                            Log.i(TAG, "Removed region: ${tileRegion.id}")
                        } else {
                            Log.e(TAG, "Failed to remove region ${tileRegion.id}: ${removeResult.error}")
                        }
                        val remaining = removalsPending.updateAndGet { it - 1 }
                        if (remaining <= 0) {
                            tileStore.clearAmbientCache { cacheResult ->
                                if (cacheResult.isValue) {
                                    Log.i(TAG, "Cleared ${cacheResult.value} bytes from cache")
                                } else {
                                    Log.e(TAG, "Failed to clear cache: ${cacheResult.error}")
                                }
                                onCompletion()
                            }
                        }
                    }
                }
            } else {
                Log.e(TAG, "Failed to get tile regions: ${result.error}")
                onCompletion()
            }
        }
    }
}

```

### Understanding the Style Pack

`ensureStylePackDownloaded()` downloads the **style pack** for the [Mapbox Standard style](https://docs.mapbox.com/map-styles/standard/) by calling [`OfflineManager.loadStylePack(...)`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.maps/-offline-manager/load-style-pack.html). The style pack contains the map style and its resources (glyphs, fonts, etc.). The style and resources are usually loaded over the network when the map loads, so downloading a style pack is essential for rendering a map without internet access.

In a later step, you will call `ensureStylePackDownloaded()` when the app launches to make sure the style pack is available for offline use.

### Understanding Tile Region Downloads

`downloadRegion(region)` handles the download of specific regions of the map for offline use. It takes an `OfflineRegion` data class as input which contains the region's ID, name, and `CoordinateBounds`.

The bounding box is the geographical area that will be downloaded for offline use, and is all you need to bootstrap the download workflow:

1.  **Initialize the `TileStore` and `OfflineManager`.** The [`TileStore`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.common/-tile-store/) manages the storage and retrieval of map tiles, while the [`OfflineManager`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.maps/-offline-manager/) provides methods for managing offline resources.
2.  **Create a [`TilesetDescriptor`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.common/-tileset-descriptor/)** for the Mapbox Standard style using `OfflineManager.createTilesetDescriptor(...)`. This descriptor defines the style and zoom levels for the tiles to be downloaded.
3.  **Create [`TileRegionLoadOptions`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.common/-tile-region-load-options/)** using the region's bounding box and the tileset descriptor. This object specifies the area to download, the style, and metadata.
4.  **Call [`TileStore.loadTileRegion(...)`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.common/-tile-store/load-tile-region.html)** to start the download. This method takes the region ID, load options, and two callbacks for tracking progress and handling completion.

The `downloadRegion` function also does the following:

-   checks if the region is already being downloaded to avoid duplicate downloads
-   updates state to track downloading regions and progress which can be used to update the UI
-   handles completion of the download, updating state and notifying the caller of success or failure

In a later step, you will set up options to pass to this function when the user initiates a download.

### Understanding Clearing Downloads

`clearAllRegions()` removes all downloaded tile regions using [`TileStore.removeTileRegion(...)`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.common/-tile-store/remove-tile-region.html) and clears the ambient cache using [`TileStore.clearAmbientCache(...)`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.common/-tile-store/clear-ambient-cache.html). This is useful for freeing up storage space or resetting the offline state. In a real-world app, you might want to provide a button or setting to allow users to clear their offline downloads to save space using a similar approach.

---

With `OfflineRegionManager` set up, you can now initialize it in your app and add UI to download tile regions.

## Trigger the Style Pack Download on Launch

In your `MainActivity.kt` call `OfflineRegionManager.ensureStylePackDownloaded(this)` to make sure the style pack is downloaded when the app launches. This runs in `onCreate` and starts the background download if needed.

```kotlin
...
public class MainActivity : ComponentActivity() {
    ...
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // highlight-start
        // Ensure Mapbox standard style pack is present (no-op if already downloaded).
        OfflineRegionManager.ensureStylePackDownloaded(this)
        // highlight-end
        ...
    }
    ...
}
```

Make sure your app builds and runs successfully. You won't see any visible changes yet, but the style pack will be downloaded in the background. `ensureStylePackLoaded()` includes a `Log.d()` statement in the completion handler of `loadStylePack` to confirm the download, but you can add more handling as needed. Check your logcat output for the "Style pack downloaded" message to verify the style pack download.

![Logcat output showing Style pack downloaded message](https://docs.mapbox.com/help/ja/assets/ideal-img/tutorials--offline-maps-android--log-stylepack-downloaded.8b0a25d.480.png)

## Add UI for downloading tile regions

Create a new view called `TileRegionDownloadView` to display a list of tile regions and provide UI to the user to trigger their downloads.

The code below creates `TileRegionDownloadView` and a `RegionRowView` for each region in the list. Each row shows the region name, download status, progress, and a button to start the download.

Add another file `TileRegionDownloadViewModel.kt` which defines the three geographic regions to download.

For now, the buttons will be non-functional, and there is no state management. Placeholder values are passed to the `RegionRowView` to represent download status and progress. You will wire up the state management in the next step.

> **Note: Install the ViewModel Compose and Material Icons dependencies**
> 
> To use `viewModel()` in Jetpack Compose, make sure you have the following dependencies in your `build.gradle` file:
> 
> ```kotlin
> implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
> implementation("androidx.compose.material:material-icons-core:1.7.5")
> implementation("androidx.compose.material:material-icons-extended:1.7.5")
> ```

```kotlin
package com.example.mapbox_offline

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TileRegionDownloadScreen(
    viewModel: TileRegionDownloadViewModel = viewModel(),
    onDone: () -> Unit = {}
) {
    Scaffold(
        topBar = {
            TopAppBar(
                navigationIcon = {
                    IconButton(onClick = onDone) {
                        Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
                    }
                },
                title = { Text("Offline Regions") },
                actions = {
                    TextButton(onClick = {
                        // clear downloaded regions
                    }) {
                        Text("Clear All", color = MaterialTheme.colorScheme.error)
                    }
                }
            )
        }
    ) { padding ->
        LazyColumn(modifier = Modifier
            .fillMaxSize()
            .padding(padding)
            .padding(8.dp)
        ) {
            items(viewModel.regions) { region ->
                RegionRow(
                    region = region,
                    isDownloading = viewModel.downloadingRegions.contains(region.id),
                    progress = viewModel.downloadProgress[region.id] ?: 0f,
                    refreshTrigger = viewModel.refreshTrigger,
                    isDownloaded = viewModel.downloadedRegions[region.id] ?: false,
                    sizeInMB = viewModel.regionSizes[region.id] ?: 0.0,
                    viewModel = viewModel,
                    onDownload = {
                        // handle download
                    }
                )
                Divider()
            }
        }
    }
}

@Composable
fun RegionRow(
    region: OfflineRegion,
    isDownloading: Boolean,
    progress: Float,
    refreshTrigger: Boolean,
    isDownloaded: Boolean,
    sizeInMB: Double,
    viewModel: TileRegionDownloadViewModel,
    onDownload: () -> Unit
) {
    LaunchedEffect(key1 = isDownloading, key2 = refreshTrigger) {
        if (!isDownloading) {
            viewModel.checkIfDownloaded(region.id)
        }
    }

    val TAG = "RegionRow-${region.id}"

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 12.dp, horizontal = 4.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Column(modifier = Modifier.weight(1f)) {
            Text(region.name, style = MaterialTheme.typography.titleLarge)
            Spacer(modifier = Modifier.height(4.dp))
            when {
                isDownloaded -> Text("Downloaded • ${"%.1f".format(sizeInMB)} MB", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
                isDownloading -> Text("Downloading...", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
                else -> Text("Not downloaded", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f))
            }
        }

        Spacer(modifier = Modifier.width(8.dp))

        when {
            isDownloading -> {
                Column(horizontalAlignment = Alignment.CenterHorizontally) {
                    CircularProgressIndicator(progress = progress, modifier = Modifier.size(36.dp))
                    Spacer(modifier = Modifier.height(4.dp))
                    Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary)
                }
            }
            isDownloaded -> {
                Icon(Icons.Filled.CheckCircle, contentDescription = "Downloaded", tint = MaterialTheme.colorScheme.primary)
            }
            else -> {
                Button(onClick = onDownload) {
                    Text("Download")
                }
            }
        }
    }
}
```

```kotlin
package com.example.mapbox_offline

import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.mapbox.geojson.Point
import com.mapbox.maps.CoordinateBounds
import com.mapbox.common.MapboxOptions
import com.mapbox.common.TileStore
import com.mapbox.common.TileRegion
import com.mapbox.maps.mapsOptions

class TileRegionDownloadViewModel : ViewModel() {

    var downloadingRegions by mutableStateOf(setOf<String>())
        private set

    var downloadProgress by mutableStateOf(mapOf<String, Float>())
        private set

    var refreshTrigger by mutableStateOf(false)
        private set

    var downloadedRegions by mutableStateOf<Map<String, Boolean>>(emptyMap())
    var regionSizes by mutableStateOf<Map<String, Double>>(emptyMap())
    private val tileStore: TileStore? = MapboxOptions.mapsOptions.tileStore

    val regions = listOf(
        OfflineRegion(
            id = "new-york-region",
            name = "New York",
            bounds = CoordinateBounds(
                Point.fromLngLat(-74.28127, 40.48398),
                Point.fromLngLat(-73.58442, 40.98701)
            )
        ),
        OfflineRegion(
            id = "london-region",
            name = "London",
            bounds = CoordinateBounds(
                Point.fromLngLat(-0.1278, 51.4874),
                Point.fromLngLat(-0.0978, 51.5174)
            )
        ),
        OfflineRegion(
            id = "paris-region",
            name = "Paris",
            bounds = CoordinateBounds(
                Point.fromLngLat(2.3522, 48.8366),
                Point.fromLngLat(2.3822, 48.8666)
            )
        )
    )

    fun checkIfDownloaded(regionId: String) {
        tileStore?.getTileRegion(regionId) { result ->
            if (result.isValue) {
                val tileRegion: TileRegion? = result.value
                if (tileRegion != null) {
                    downloadedRegions = downloadedRegions + (regionId to true)
                    regionSizes = regionSizes + (regionId to (tileRegion.completedResourceSize.toDouble() / (1024.0 * 1024.0)))
                } else {
                    downloadedRegions = downloadedRegions + (regionId to false)
                    regionSizes = regionSizes + (regionId to 0.0)
                }
            } else {
                downloadedRegions = downloadedRegions + (regionId to false)
                regionSizes = regionSizes + (regionId to 0.0)
            }
        }
    }

    init {
        // Check download status for all regions on initialization
        regions.forEach { checkIfDownloaded(it.id) }
    }
}
```

> **Note: Defining bounding boxes**
> 
> The bounding boxes defined in `TileRegionDownloadViewModel.kt` were defined using the Mapbox [Location Helper](https://labs.mapbox.com/location-helper) tool, which helps you quickly find a bounding box using a map drawing interface.

### Understand `TileRegionDownloadView`

`TileRegionDownloadView` is a Jetpack Compose view that displays a list of tile regions and provides UI for downloading them. It lists each region using the `RegionRowView`.

### Understand `RegionRowView`

`RegionRowView` is a Jetpack Compose view that displays a single row in the list representing a tile region. It displays the region name, download status, progress, and a button to start the download.

Notice that `RegionRowView` calls `TileRegionDownloadViewModel.checkIfDownloaded()`, which checks the `TileStore` using [`tileStore.getTileRegion()`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.common/-tile-store/get-tile-region.html) to see if the region has already been downloaded. The [`TileRegion`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.common/-tile-region/) object returned from the store contains the size of the downloaded resources, which is displayed in the UI to remind the user of the storage impact of offline downloads.

### Add `TileRegionDownloadView` to `Main`

Next update `MainActivity.kt` to include `TileRegionDownloadView` by placing both `TileRegionDownloadView` and `MapboxMap` inside a `Box` so that the download UI can overlay the map. Add a button to toggle the visibility of `TileRegionDownloadView`.

```kotlin
package com.example.mapbox_offline

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.mapbox.geojson.Point
import com.mapbox.maps.extension.compose.MapboxMap
import com.mapbox.maps.extension.compose.animation.viewport.rememberMapViewportState

public class MainActivity : ComponentActivity() {
    @OptIn(ExperimentalMaterial3Api::class)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Ensure Mapbox standard style pack is present (no-op if already downloaded).
        OfflineRegionManager.ensureStylePackDownloaded(this)
        setContent {
            var showingDownload by remember { mutableStateOf(false) }

            // Ensure system back button will dismiss the modal when open
            if (showingDownload) {
                BackHandler { showingDownload = false }
            }

            Box(Modifier.fillMaxSize()) {
                MapboxMap(
                    Modifier.fillMaxSize(),
                    mapViewportState = rememberMapViewportState {
                        setCameraOptions {
                            zoom(2.0)
                            center(Point.fromLngLat(-98.0, 39.5))
                            pitch(0.0)
                            bearing(0.0)
                        }
                    },
                )

                // Download button overlay (top-right)
                Box(
                    modifier = Modifier
                        .fillMaxSize()
                        // moved down by increasing top padding
                        .padding(start = 12.dp, end = 12.dp, top = 72.dp, bottom = 12.dp),
                    contentAlignment = Alignment.TopEnd
                ) {
                    Button(onClick = { showingDownload = true }) {
                        Icon(Icons.Default.KeyboardArrowDown, contentDescription = null)
                        Spacer(Modifier.width(8.dp))
                        Text("Manage Offline Regions")
                    }
                }

                // Present the TileRegionDownloadScreen as a full-screen overlay that completely covers the map
                if (showingDownload) {
                    Surface(
                        modifier = Modifier
                            .fillMaxSize(),
                        color = MaterialTheme.colorScheme.background
                    ) {
                        Column(modifier = Modifier.fillMaxSize()) {
                            // pass onDone so the top-left arrow dismisses the modal
                            TileRegionDownloadScreen(onDone = { showingDownload = false })
                        }
                    }
                }

            }
        }
    }
}
```

### Understand `TileRegionDownloadViewModel`

`TileRegionDownloadViewModel` is a [Jetpack Compose `ViewModel`](https://developer.android.com/codelabs/basic-android-kotlin-compose-viewmodel-and-state) that manages the state for the `TileRegionDownloadView`. It defines three predefined regions (New York, London, Paris) with their bounding boxes using [`CoordinateBounds`](https://docs.mapbox.com/android/maps/api/latest/mapbox-maps-android/com.mapbox.maps/-coordinate-bounds/).

In later steps, it will be expanded to track downloading regions and progress, and to handle the download and clear all options.

With `TileRegionDownloadView` added to `ContentView`, you should see a "Manage Offline Regions" button in the top-right corner of the map. Tapping this button will present the `TileRegionDownloadView` as a modal sheet.

In the next step you will connect the UI to the methods in `OfflineRegionManager` to make the download buttons functional and track download progress in the UI.

Your browser doesn't support HTML5 video. Open [link to the video](https://docs.mapbox.com/help/ja/help/ja/assets/medias/tutorials--offline-maps-android--tile-region-ui-db34c8cb7c8f02091a9de8b689dc27db.webm).

## Connect UI to `OfflineRegionManager`

To make the tile region download buttons functional and track download progress as state, you need to integrate the `OfflineRegionManager` methods into the `TileRegionDownloadViewModel` and update the UI based on the state properties.

First, update `TileRegionDownloadViewModel` to include methods for downloading a region and clearing all downloads. These methods will call the corresponding methods in `OfflineRegionManager` and update the state properties to reflect the current state.

Add the highlighted sections below to `TileRegionDownloadViewModel.kt`:

```kotlin
package com.example.mapbox_offline

import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.mapbox.geojson.Point
import com.mapbox.maps.CoordinateBounds
import com.mapbox.common.MapboxOptions
import com.mapbox.common.TileStore
import com.mapbox.common.TileRegion
import com.mapbox.maps.mapsOptions

class TileRegionDownloadViewModel : ViewModel() {
    var downloadingRegions by mutableStateOf(setOf<String>())
        private set

    var downloadProgress by mutableStateOf(mapOf<String, Float>())
        private set

    var refreshTrigger by mutableStateOf(false)
        private set

    var downloadedRegions by mutableStateOf<Map<String, Boolean>>(emptyMap())
    var regionSizes by mutableStateOf<Map<String, Double>>(emptyMap())
    private val tileStore: TileStore? = MapboxOptions.mapsOptions.tileStore

    val regions = listOf(
        OfflineRegion(
            id = "new-york-region",
            name = "New York",
            bounds = CoordinateBounds(
                Point.fromLngLat(-74.28127, 40.48398),
                Point.fromLngLat(-73.58442, 40.98701)
            )
        ),
        OfflineRegion(
            id = "london-region",
            name = "London",
            bounds = CoordinateBounds(
                Point.fromLngLat(-0.1278, 51.4874),
                Point.fromLngLat(-0.0978, 51.5174)
            )
        ),
        OfflineRegion(
            id = "paris-region",
            name = "Paris",
            bounds = CoordinateBounds(
                Point.fromLngLat(2.3522, 48.8366),
                Point.fromLngLat(2.3822, 48.8666)
            )
        )
    )

    init {
        // Check download status for all regions on initialization
        regions.forEach { checkIfDownloaded(it.id) }
    }

    

    fun checkIfDownloaded(regionId: String) {
        tileStore?.getTileRegion(regionId) { result ->
            if (result.isValue) {
                val tileRegion: TileRegion? = result.value
                if (tileRegion != null) {
                    downloadedRegions = downloadedRegions + (regionId to true)
                    regionSizes = regionSizes + (regionId to (tileRegion.completedResourceSize.toDouble() / (1024.0 * 1024.0)))
                } else {
                    downloadedRegions = downloadedRegions + (regionId to false)
                    regionSizes = regionSizes + (regionId to 0.0)
                }
            } else {
                downloadedRegions = downloadedRegions + (regionId to false)
                regionSizes = regionSizes + (regionId to 0.0)
            }
        }
    }

    fun downloadRegion(region: OfflineRegion) {
        OfflineRegionManager.downloadRegion(
            region = region,
            downloadingRegions = downloadingRegions,
            onDownloadingRegionsUpdate = { updatedSet ->
                // replace whole set so Compose re-composes
                downloadingRegions = updatedSet
            },
            onProgress = { regionId, progress ->
                downloadProgress = downloadProgress + (regionId to progress)
            },
            onCompletion = { regionId, result ->
                // remove progress entry on completion
                downloadProgress = downloadProgress - regionId
                when {
                    result.isSuccess -> {
                        val tileRegion: TileRegion? = result.getOrNull()
                        Log.i("TileRegionVM", "Downloaded ${region.name}: ${tileRegion?.completedResourceSize}")
                    }
                    result.isFailure -> {
                        Log.e("TileRegionVM", "Failed to download ${region.name}: ${result.exceptionOrNull()}")
                    }
                }
            }
        )
    }

    fun clearAllRegions() {
        OfflineRegionManager.clearAllRegions {
            downloadingRegions = emptySet()
            downloadProgress = emptyMap()
            refreshTrigger = !refreshTrigger
            // Re-check all regions after clearing
            regions.forEach { checkIfDownloaded(it.id) }
        }
    }
}
```

Next, update `TileRegionDownloadView` to call the new methods in the view model when the user taps the download button or the clear all button, and to pass state from the model into `RegionRow`.

Add the two highlighted lines below to `TileRegionDownloadView.kt`:

```kotlin
...
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TileRegionDownloadScreen(
    viewModel: TileRegionDownloadViewModel = viewModel(),
    onDone: () -> Unit = {}
) {
    Scaffold(
        topBar = {
            TopAppBar(
                navigationIcon = {
                    IconButton(onClick = onDone) {
                        Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
                    }
                },
                title = { Text("Offline Regions") },
                actions = {
                    TextButton(onClick = {
                        // highlight-start
                        viewModel.clearAllRegions()
                        // highlight-end
                    }) {
                        Text("Clear All", color = MaterialTheme.colorScheme.error)
                    }
                }
            )
        }
    ) { padding ->
        LazyColumn(modifier = Modifier
            .fillMaxSize()
            .padding(padding)
            .padding(8.dp)
        ) {
            items(viewModel.regions) { region ->
                RegionRow(
                    region = region,
                    isDownloading = viewModel.downloadingRegions.contains(region.id),
                    progress = viewModel.downloadProgress[region.id] ?: 0f,
                    refreshTrigger = viewModel.refreshTrigger,
                    isDownloaded = viewModel.downloadedRegions[region.id] ?: false,
                    sizeInMB = viewModel.regionSizes[region.id] ?: 0.0,
                    viewModel = viewModel,
                    onDownload = {
                        //highlight-start
                        viewModel.downloadRegion(region) 
                        //highlight-end
                    }
                )
                Divider()
            }
        }
    }
}
...
```

With these changes, the download buttons in `TileRegionDownloadView` will now be functional. Tapping a download button will start the download of the corresponding tile region, and the UI will update to show download progress. The "Clear All" button will remove all downloaded regions and clear the cache.

Your browser doesn't support HTML5 video. Open [link to the video](https://docs.mapbox.com/help/ja/help/ja/assets/medias/tutorials--offline-maps-android--download-7b1b7a2d83d8458ca3692954465a7797.webm).

To recap, when you tap the download button for a region:

1.  `TileRegionDownloadView` calls `viewModel.downloadRegion(region)`
2.  `TileRegionDownloadViewModel.downloadRegion(region)` calls `OfflineRegionManager.downloadRegion(...)`
3.  `OfflineRegionManager.downloadRegion(...)` creates a `TilesetDescriptor`, builds `TileRegionLoadOptions`, and calls `TileStore.loadTileRegion(...)` to trigger the download. It also calls the provided callbacks to update progress and completion status.

No additional steps are necessary to make the map display the downloaded regions. The Maps SDK for Android automatically checks the `TileStore` for offline tiles when rendering the map, so once a region is downloaded, it will be available for offline viewing.

In the next step, you will test the offline functionality by downloading regions and then using the app without an internet connection.

## Test offline functionality

To test that your offline maps are working:

1.  **Download regions**: Run your app and download all three tile regions
    
2.  **Disconnect internet**: The simplest way to simulate an offline environment is to disable the network connection for your development machine. You can temporarily turn off WiFi and unplug any Ethernet cables to make sure the emulator does not have access to the internet.
    
3.  **Test the map outside of the downloaded regions**: Pan and zoom the map to areas outside of the downloaded regions (New York, London, Paris). As you zoom in from zoom level 2 (looking at the globe), you will notice that map features do not load since you are outside the downloaded regions and do not have network access.
    

In this recording, the user zooms in on Washington, D.C. with the emulator's network disabled, demonstrating that no map tiles load since the area is not available for offline use.

Your browser doesn't support HTML5 video. Open [link to the video](https://docs.mapbox.com/help/ja/help/ja/assets/medias/tutorials--offline-maps-android--washington-offline-39cae15e77ddc974ff9b84e968ec82c1.webm).

4.  **Test the map in the downloaded regions**: Pan and zoom the map, moving towards the areas you downloaded (New York, London, Paris). As you zoom in from zoom level 2 (looking at the globe), you will notice that map features do not load since you are outside the downloaded regions and do not have network access. Once you zoom into one of the downloaded regions (between zoom levels 10-14), the map tiles should load and display correctly, even without an internet connection.

If you have viewed certain areas of the map while online, those tiles may be cached and could appear even when offline.

You can remove all cached tiles and start with a clean cache by using the "Wipe data" option in the Android Virtual Device (AVD) Manager, or by uninstalling and reinstalling your app. This will reset the emulator to its factory settings, removing any cached data. Re-run your app, download the regions again, disable the network, and then test the offline functionality.

In this recording, the user zooms in on New York City with the emulator's network disabled. Notice that map data does not appear until zoom level 10, which is the minimum zoom level specified in the code for the downloaded regions. Between zoom levels 10-14, the map tiles load correctly from the offline tilestore.

Your browser doesn't support HTML5 video. Open [link to the video](https://docs.mapbox.com/help/ja/help/ja/assets/medias/tutorials--offline-maps-android--nyc-offline-dc8cd9cd046f8f36d263549129702420.webm).

Congratulations on successfully implementing offline maps in your Android app!

> **Related content (related): [Download the final code](https://github.com/mapbox/tutorials/)**
> 
> The full source code for this tutorial is available on GitHub. You can download the final code and run it in Android Studio to see the complete offline maps functionality in action.

## Next Steps

Congratulations on completing this tutorial! You have successfully implemented offline maps functionality for predefined regions in your Android app using the Mapbox Maps SDK for Android.

### What we covered

-   Created a full screen map using the Maps SDK for Android
-   Initialized the offline manager and downloaded a style pack
-   Added UI to download predefined tile regions
-   Tracked download progress and status and updated the UI based on state
-   Tested offline functionality by downloading regions and using the app without an internet connection

### Things to try

With this minimal offline maps implementation complete, here are some ideas for further exploration:

-   **Change the Regions and Zoom Levels**: Change the predefined regions and zoom levels to suit your app's needs. You can define different cities or areas based on your target audience. Remember that specifying larger areas or higher zoom levels will increase the download and storage size.
-   **Add user-defined regions**: You could allow users to specify areas of interest for offline use by drawing on the map or entering coordinates.
-   **Implement automatic region management**: Add logic to automatically manage offline regions based on user behavior, such as downloading regions they visit often and removing old ones.
-   **Enhance the UI**: Improve the user interface for managing offline maps, such as adding visual indicators for downloaded regions on the map, or providing improved UI for downloading and managing regions.
-   **Handle errors and edge cases**: Implement more robust error handling for network issues, storage limitations, and other potential problems that may arise during downloads.

### Learn more about Offline Maps

> **Related content (related): [Maps SDK for Android Offline documentation](https://docs.mapbox.com/android/maps/guides/offline/)**
> 
> Read the full offline guide for the Maps SDK for Android, which covers more advanced topics such as managing offline regions, handling errors, and optimizing storage.

> **Related content (related): [Use Offline Maps in an Android Views App](https://docs.mapbox.com/android/maps/examples/android-view/offline-map/)**
> 
> Try the offline example in the Maps SDK for Android **examples app**, which provides an alternate implementation of offline maps functionality.