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):
includeBuild("../Geocoder-SDK/Android") {
dependencySubstitution {
substitute(module("net.mapmetrics:mapatlas-geocoder")).using(project(":"))
}
}// app/build.gradle.kts
dependencies {
implementation("net.mapmetrics:mapatlas-geocoder:1.0.0")
}As a local Maven artifact:
cd Geocoder-SDK/Android
gradle publishToMavenLocalrepositories { 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 from using only APIs that exist in android.jar.
Do not enable core library desugaring for this
Earlier versions of this page claimed the HTTP layer used java.net.http and that Android's core library desugaring covered it. That was wrong, and it failed at runtime.
java.net.http is not in android.jar at any API level, and it is not covered by desugar_jdk_libs. Enabling desugaring did not help, and — worse — D8 emitted zero warnings, so the app compiled and installed cleanly and then died on the first MapAtlas(...) construction:
java.lang.NoClassDefFoundError: Failed resolution of: Ljava/net/http/HttpClient;This is fixed in the SDK: the HTTP engine is reimplemented on java.net.HttpURLConnection, which is present on every supported API level. You need no desugaring configuration, and adding it will not fix a NoClassDefFoundError if you are pinned to an older build — upgrade instead.
Quickstart
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 inside the client (see below); 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 debouncingThe 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 discards stale out-of-order responses and turns failures into typed state rather than thrown exceptions.
Where the debounce actually lives
The controller does not debounce. Coalescing happens one layer down, in Session.suggest() inside the client — configured by MapAtlas(debounceMs = …), default 150. setQuery() therefore fires immediately and the client absorbs the burst.
Do not add a second timer around setQuery(). Stacking one on top of the client's produces a laggy field and does not reduce request count further. If 150 ms is wrong for you, change debounceMs on the client — that is the single knob.
One exception: on the OSM tier there is no Session, so nothing downstream coalesces, and the controller debounces there itself using the same debounceMs value.
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()
}
}@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():
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, noOriginheader sent. See Choosing a key for an Android app.OriginNotAllowed— request'sOriginisn't on the key's allow-list.QuotaExceeded— OSM tier cap exhausted; carriesselfHostUrl.TierUnsupported— thrown bycreateSession()on theosmtier and onMapAtlas.selfHosted()clients, and byautocomplete()on thev2tier.NotRetrievable— thrown byretrieve()/retrieveBatch()when a suggestion has noord(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 carriesstatus/codewhere available.
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 of the fifteen rows for q=amsterdam&country=nl at last check) that carry no ord at all — the key is absent from the JSON, not present-and-null. This SDK decodes that to ord == null for you, so you can test it normally; raw HTTP callers cannot. Check suggestion.isRetrievable before calling retrieve() on a row you're not sure about:
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).
Never key a LazyColumn on suggestion.id
id is not unique within one response
q=amsterdam&country=nl returns two rows sharing "id": "place.0". Passing that to items(state.suggestions, key = { it.id }) throws:
IllegalArgumentException: Key "place.0" was already used.
If you are using LazyColumn/LazyRow please make sure you provide a unique keyThis is a real crash we hit in a Compose demo, not a hypothetical.
The items(state.suggestions) { … } form in the sample above is safe — it keys on index. If you need stable keys across recompositions, derive one from content, or use the mapatlas-geocoder-ui module, whose SearchRowKeys.uniquify suffixes repeats (place.0, place.0#1) so keys are unique without dropping rows. See Search UI.
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: