Skip to content

Both copy text to your clipboard — Build with AI copies a setup prompt to paste into Claude Code, Cursor, Codex or Copilot; Copy page as Markdown copies this page to paste into a chat. How it works

Kotlin SDK

mapatlas-geocoder is a pure Kotlin/JVM client for the v2 geocoding API — autocomplete, place retrieval, and forward/reverse geocoding. Because it's plain Kotlin/JVM (not an Android library module), the exact same artifact runs on Android and on a server-side JVM.

Not published yet

Not on Maven Central. Consume it as a Gradle composite build or a local Maven artifact today — see below.

Install

As a composite build (settings.gradle.kts in your app):

kotlin
includeBuild("../Geocoder-SDK/Android") {
    dependencySubstitution {
        substitute(module("net.mapmetrics:mapatlas-geocoder")).using(project(":"))
    }
}
kotlin
// app/build.gradle.kts
dependencies {
    implementation("net.mapmetrics:mapatlas-geocoder:1.0.0")
}

As a local Maven artifact:

bash
cd Geocoder-SDK/Android
gradle publishToMavenLocal
kotlin
repositories { mavenLocal() }
dependencies {
    implementation("net.mapmetrics:mapatlas-geocoder:1.0.0")
}

Why plain Kotlin/JVM, not an Android library module? It uses the kotlin("jvm") Gradle plugin, not the Android Gradle Plugin — no Android SDK is required to build or unit test it. Android compatibility comes from targeting JVM 11 bytecode and relying only on java.net.http, which Android's core library desugaring supports back to minSdk 21. Enable desugaring in your app module:

kotlin
android {
    compileOptions {
        isCoreLibraryDesugaringEnabled = true
    }
}
dependencies {
    coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.2")
}

Quickstart

kotlin
import net.mapmetrics.geocoder.MapAtlas
import net.mapmetrics.geocoder.SearchOptions
import net.mapmetrics.geocoder.GeocodeSearchResult

val mapatlas = MapAtlas(token = "YOUR_API_KEY")

// One session covers a whole search — every keystroke, then the pick.
val session = mapatlas.geocoding.createSession()

// As the user types. Debounced for you; suggestions carry no coordinates.
val results = session.suggest("Nieuwezijds")

// When they pick one, hand back the suggestion — not an id.
val place = session.retrieve(results[0])
println("${place.latitude}, ${place.longitude}")

// Resolving several at once is both fewer round trips and cheaper billing —
// see Sessions & Billing.
val places = session.retrieveBatch(results.take(3))

// One-shot lookups, when there's no user typing to debounce.
val fwd = mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147", SearchOptions(country = "nl"))
val rev = mapatlas.geocoding.reverse(latitude = 52.37, longitude = 4.89)
check(fwd is GeocodeSearchResult.Pelias) // always true on the default v2 tier

mapatlas.close() // releases the HTTP engine's threads and the coroutine scope backing Session debouncing

The reactive layer: GeocodeSearchController

GeocodeSearchController exposes a single StateFlow — own it (and a MapAtlas) in a ViewModel, collect it in a Compose screen, and you have a working, correctly-billed, debounced search box. It debounces, discards stale out-of-order responses, and turns failures into typed state rather than thrown exceptions.

kotlin
class AddressSearchViewModel : ViewModel() {
    private val mapatlas = MapAtlas(token = "YOUR_API_KEY")

    val controller = GeocodeSearchController(
        client = mapatlas,
        scope = viewModelScope,
        country = "nl",
        minLength = 2,
    )

    fun onQueryChanged(text: String) = controller.setQuery(text)

    suspend fun onSuggestionPicked(suggestion: Suggestion): RetrievedPlace =
        controller.select(suggestion)

    override fun onCleared() {
        controller.close()
        mapatlas.close()
    }
}
kotlin
@Composable
fun AddressSearchScreen(viewModel: AddressSearchViewModel = viewModel()) {
    val state by viewModel.controller.state.collectAsState()
    val scope = rememberCoroutineScope()

    Column {
        TextField(
            value = state.query,
            onValueChange = viewModel::onQueryChanged,
            placeholder = { Text("Search an address…") },
        )
        if (state.isLoading) CircularProgressIndicator()
        state.error?.let { Text("${it::class.simpleName}: ${it.message}", color = Color.Red) }

        LazyColumn {
            items(state.suggestions) { suggestion ->
                Text(
                    text = suggestion.placeName ?: suggestion.text.orEmpty(),
                    modifier = Modifier.clickable {
                        scope.launch {
                            val place = viewModel.onSuggestionPicked(suggestion)
                            // navigate / use place.latitude, place.longitude
                        }
                    },
                )
            }
        }

        state.selected?.let { place ->
            Text("Selected: ${place.latitude}, ${place.longitude}")
        }
    }
}

GeocodeSearchState carries query, suggestions, isLoading, error (a typed MapAtlasException?), and selected. Out-of-order responses are discarded automatically: type "a" then quickly "ab", and a slow response for "a" arriving after "ab" is dropped rather than flashing stale results — the controller lets the older request finish (it doesn't race to cancel it) and simply ignores its result once superseded.

The OSM tier

tier = MapAtlasTier.OSM talks to the free, OpenStreetMap-only endpoints via the osm-geocode scope. No session/retrieve flow — search(), reverse(), and autocomplete() are the only three operations, returning GeocodeSearchResult.Osm / OsmResponse (a permissive wrapper around the raw JSON). Calling createSession() on this tier throws MapAtlasException.TierUnsupported. Rate-limited to 10,000 requests/key/day plus a global monthly cap; exhausting it throws MapAtlasException.QuotaExceeded with a selfHostUrl.

Self-hosting

The free tier's engine is open source: MapMetrics/atlas-osm-geocoder, deployable on Cloudflare Workers with a prebuilt planet bundle. Point this package at your own instance with MapAtlas.selfHosted():

kotlin
val mapatlas = MapAtlas.selfHosted(baseUrl = "https://my-worker.workers.dev")

mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147")
mapatlas.geocoding.autocomplete("Nieuwezijds")
mapatlas.geocoding.reverse(latitude = 52.37, longitude = 4.89)

A self-hosted instance takes no token and uses different paths (/search, /reverse, /autocomplete, no token parameter) — this package never sends a credential to it. createSession() (and therefore Session.retrieve()/retrieveBatch()) throw TierUnsupported on a self-hosted client, exactly as on the hosted osm tier — this engine has no session concept either. QuotaExceeded cannot occur self-hosted (no quota).

Choosing a key for an Android app

Origin restriction is a browser-only feature — it works by checking the Origin header a browser sends, and native apps don't send one. This is the error an Android developer will hit if they reuse a browser-restricted key: an origin-restricted key can never work from Android. Use an unrestricted key for native/server clients. See API Keys & Security for the full model.

Errors

All errors extend the sealed MapAtlasException. Kotlin's when is exhaustive over a sealed class, so the compiler flags anything you miss:

  • TokenNotFound — key was never provisioned.
  • TokenInactive — key exists but is deactivated.
  • ScopeNotAllowed — key is valid but not scoped for this operation.
  • OriginRequired — key is origin-restricted, no Origin header sent. See Choosing a key for an Android app.
  • OriginNotAllowed — request's Origin isn't on the key's allow-list.
  • QuotaExceeded — OSM tier cap exhausted; carries selfHostUrl.
  • TierUnsupported — thrown by createSession() on the osm tier and on MapAtlas.selfHosted() clients, and by autocomplete() on the v2 tier.
  • NotRetrievable — thrown by retrieve()/retrieveBatch() when a suggestion has no ord (see below).
  • NetworkError — the request never completed.
  • DecodingError — a response was received but couldn't be parsed.
  • Unknown — any other non-2xx response this package doesn't have a specific subclass for; still carries status/code where available.
kotlin
try {
    mapatlas.geocoding.search("Nieuwezijds Voorburgwal 147", SearchOptions(country = "nl"))
} catch (e: MapAtlasException.ScopeNotAllowed) {
    // ...
} catch (e: MapAtlasException) {
    // catches every other documented failure mode
}

Every exception carries status: Int? and code: String? alongside message.

Suggestions without coordinates, or an ord

Suggestion deliberately carries no coordinates — only retrieve()/ retrieveBatch() return them. Separately, ord is nullable: the gateway returns injected locality rows among ordinary results for some queries (two rows for q=Amsterdam at last check) that carry no ord at all. Check suggestion.isRetrievable before calling retrieve() on a row you're not sure about:

kotlin
val suggestions = session.suggest("Amsterdam")
val retrievable = suggestions.filter { it.isRetrievable }
val place = session.retrieve(retrievable.first())

Calling retrieve() on a non-retrievable suggestion throws MapAtlasException.NotRetrievable with a message explaining why, rather than crashing on a null or silently 404ing. retrieveBatch() rejects the whole call if any item in the list is non-retrievable, rather than silently dropping the offending item(s).

Not implemented yet

This package covers geocoding only — routing, matrix, map matching, optimization, and isochrones are not implemented here. The TypeScript and Dart SDKs cover those endpoints; see JavaScript or Flutter if you need routing from a shared backend or a cross-platform Dart layer.

Endpoint reference

This SDK is a typed wrapper — for the full parameter list, response shape, and gateway-level gotchas each call is protecting you from, see: