Skip to content

Android SDK ​

mn.dock:dock-android 0.65.0, Kotlin. Java 8 bytecode, Android API 21 and up. Two dependencies, both standard on Android: kotlinx-coroutines and kotlinx-serialization. Search, reverse, building codes, routes to the door, places by id, changes, door reports, driver confirmations, usage, search telemetry and trip recording. Same calls, names and errors as the JS SDK and the iOS SDK. One client holds the key. A first cut: no map view yet.

Install ​

Not on Maven Central yet. Until it is, ask your contact for the Maven repository folder (./gradlew publish in sdk/android writes it to build/repo), put it in your project as dock-repo/ and add it to the repositories. The SDK's POM brings kotlinx-coroutines and kotlinx-serialization, so the app adds one line:

kotlin
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url = uri("dock-repo") }
    }
}
kotlin
dependencies {
    implementation("mn.dock:dock-android:0.65.0")
}

Once it is on Maven Central, drop the dock-repo line; the dependency line stays the same.

A client ​

kotlin
import mn.dock.*

val dock = Client("dk_...")

The constructor throws DockError invalid_key when the key is not dk_ and 32 characters. baseUrl defaults to https://geo.dock.mn. Every call is a suspend function. Call it from a coroutine (lifecycleScope.launch { ... }); the network part runs on Dispatchers.IO.

Search, reverse, building codes ​

kotlin
val found = dock.geocode("Сансар 24", limit = 5)
for (place in found.results) {
    println(label(place, Lang.EN))
}

val typing = dock.geocode("Сан", proximity = LonLat(106.9177, 47.9186))
println("${typing.results.size} results")
val streets = dock.geocode("Сансар", types = listOf(GeocodeType.STREET, GeocodeType.AREA))
println("${streets.results.size} streets")

val here = dock.reverse(LonLat(106.9301, 47.9201), radius = 50.0)
println("${here.districtEn ?: "?"} ${here.results.size}")
val landmarks = dock.reverse(LonLat(106.9301, 47.9201), radius = 300.0, types = listOf(ReverseType.LANDMARK))
println("${landmarks.results.size} landmarks")

when (val typed = parseDoorCode("7qsvх 17")) {
    is DoorCodeInput.Code -> println(label(dock.door(typed.code, unit = typed.unit), Lang.EN))
    is DoorCodeInput.Invalid -> println(typed.error.name)
}
doorLink("7qsvх", "17")?.let { println(it) }
val opened = parseDoorLink("https://map.dock.mn/d/7QSVX?u=17")
if (opened is DoorCodeInput.Code) println(opened.code + " " + (opened.unit ?: "-"))
doorQr("7qsvх", "17")?.let { println(it.size.toString()) }
val qrSvg = doorQrSvg("7qsvх", "17")
doorQrPixels("7qsvх", "17")?.let { println(it.side.toString()) }

Points are LonLat(lon, lat), longitude first, as on the wire. Building codes accept Cyrillic lookalike keys (К, М, Т, Х, Р, С, В, Н). Always show a door or place with label(place, lang), Lang.MN or Lang.EN. It picks the spoken form, then the name, then the long form, then the code. Anything without a matched official address ends in "(unofficial)", as the API requires. Ids are for calls, never shown to people as an address. The flat or office type is DoorUnit (Unit in JS and Swift), so it does not hide Kotlin's Unit.

While the person types, pass proximity, where they are or the map centre: among names the typed text starts equally well, nearer places come first. The point goes with that request only. It is not kept on the device, the API does not log it, and telemetry never carries it. Offline answers do not read it.

parseDoorCode(text) reads a typed or pasted code as the API does, with no request: DoorCodeInput.Code(code, unit), or DoorCodeInput.Invalid(error, reason) with error EMPTY, LENGTH (not 5 characters) or LETTERS (a vowel, I, L, O, U, or a sign). Check the field as the person types and show your own words for error. door(code, unit) refuses such a text the same way, DockError 400 invalid_code, before any request.

doorLink(text, unit) makes the link to share a door: https://map.dock.mn/d/7QSVX?u=17, for a share sheet, a message or a QR. null when the text cannot be a code. It makes no request and does not check that a door has the code. parseDoorLink(text) reads the first door link in a text: a pasted message, or the URL a link opened your app with. null when there is none, otherwise the same answer as parseDoorCode. The address field looks a pasted link up as its door alone. map.dock.mn does not serve the door page yet; the link reads in every SDK now.

doorQrPixels(text, unit, px, margin) draws the door's QR as ARGB pixels: the doorLink link inside, black on white, for Bitmap.createBitmap(qr.argb, qr.side, qr.side, Bitmap.Config.ARGB_8888), a share sheet or print. A phone camera opens the same link a message would. Each module is px pixels (default 8), with margin light modules around it (default 4, the standard's minimum). doorQrSvg(text, unit, px, margin) gives the same QR as SVG text (px default 4.0), doorQr(text, unit) the modules (modules[y][x] is true for dark, size, version) to draw on a Canvas yourself. ISO/IEC 18004, error correction level M (15%), the same modules as the JS and Swift SDKs. Offline, no library needed. null when the text cannot be a code. A px or margin out of range throws IllegalArgumentException.

Route ​

kotlin
val trip = dock.route(Client.End.Point(LonLat(106.9177, 47.9186)), Client.End.Code("7QSVX"), RouteMode.DRIVING)
println("${trip.distanceM} m, ${trip.durationS} s, walk ${trip.to.gapM} m")
trip.to.door?.let { println(label(it, Lang.EN)) }

from and to are each Client.End.Point or Client.End.Code. A door code routes to its door. A code of a building or a yard ends at the gate when the map has one, else at the street side: the point of the outline nearest the street the line ends on, so the walk is the few metres from the street to the fence or wall. to.point is that end; to.unit.streetSide is true for a gate, or within about 25 m of the street point. When the walk is over 15 m the last step says "Leave the car and walk 17 m to the gate." ("Машинаа тавиад 17 м алхана уу."), and the arrival names the gate and its side ("Хаалга зүүн талд байна"). RouteMode.DRIVING, WALKING, CYCLING. The straight walk left is to.gapM metres. Needs route:read, and door:read for a code. durationS is free-flow; a driving route also has measured (API 0.48.0), durationS with each road that has a published moving speed (traceRoads) at that speed and roadsM metres of them, null when walking or cycling. With departAt (API 0.50.0, a java.util.Date) it reads the speeds of that hour band and names it in measured.band ("am_peak", "pm_peak", "off_peak"); without it, all hours and band is null. When to is a code, to.door is that door, shown with label(place, lang); for a point it is null.

via (API 0.67.0) is up to ROUTE_VIA_MAX (10) ends passed on the way, in the order given, each a point or a code, like Mapbox's waypoints between the first and the last. legs has distanceM and durationS for each hop, via each via end as a route end, snapped where the line passes it. The line may turn back at a via end. An empty list is none; a longer one, or a code holding ;, throws DockError invalid_via before any request.

Matrix ​

kotlin
val couriers = listOf(Client.End.Point(LonLat(106.9177, 47.9186)), Client.End.Point(LonLat(106.9050, 47.9210)))
val times = dock.matrix(couriers, listOf(Client.End.Code("7QSVX"), Client.End.Point(LonLat(106.9301, 47.9201))))
// Seconds from each courier to the first stop; null where no road joins them.
val toFirst = times.durationsS.map { it[0] ?: Double.POSITIVE_INFINITY }
val nearest = toFirst.indexOf(toFirst.min())
println("nearest $nearest, ${toFirst[nearest]} s")

matrix(from, to, mode) (API 0.63.0) gives the free-flow time and distance from every end in from to every end in to in one call, for a dispatcher who picks the nearest courier. Each list holds 1 to MATRIX_MAX (10) ends, each as in route. durationsS[i][j] and distancesM[i][j] go from from[i] to to[j]; null where no path joins them. from and to come back as route ends, gapM included. No measured, no departAt, no line: ask route for the one you pick. Closures are avoided as for a route. Needs route:read, and door:read for a code. An empty or longer list, or a code holding ;, is refused before any request with 400 invalid_from or invalid_to. A code that is no door is 404 not_found, named by its place (to[1]). One call, whatever the number of cells. Coverage: Ulaanbaatar.

Isochrone ​

kotlin
val zone = dock.isochrone(Client.End.Point(LonLat(106.9177, 47.9186)), listOf(10, 20, 30))
// The largest first; each feature holds one or more polygons of [lon, lat] rings.
for (f in zone.features) println("${f.properties.contour} min, pieces ${f.geometry.polygons.size}")

isochrone(from, contoursMinutes, mode) (API 0.64.0) gives the area reached from one end within each of 1 to ISOCHRONE_CONTOURS_MAX (4) times, whole minutes from 1 to ISOCHRONE_MINUTES_MAX (60), increasing: a shop's or a depot's delivery zone. from is a building code (from its door) or a point as in route. The answer is GeoJSON: one feature per contour, the largest first, properties.contour in minutes, geometry an IsochroneGeometry: Polygon or, where the area is in pieces, MultiPolygon. geometry.polygons gives every piece as its rings, outer first, to fill the same way; encoded back with Isochrone.serializer(), the answer is GeoJSON again, for a MapLibre GeoJSON source. from comes back as a route end, gapM included. Free-flow speeds, no departAt; closures are avoided as for a route. Needs route:read, and door:read for a code. Other contours are refused before any request with 400 invalid_contours. A point with no road near is 422 no_route. One call, whatever the number of contours. Coverage: Ulaanbaatar.

Map matching ​

kotlin
val trip = dock.matching(
    listOf(LonLat(106.9177, 47.9186), LonLat(106.92, 47.9188), LonLat(106.923, 47.919)),
    timestamps = listOf(1790000000L, 1790000030L, 1790000060L),
)
// One matching per unbroken piece, each a line of [lon, lat]; a tracepoint is null where no road was found.
for (m in trip.matchings) println("${m.distanceM.toInt()} m, ${m.geometry.coordinates.size} points")

matching(coordinates, timestamps, mode) (API 0.65.0) puts a GPS trace on the roads it went along: a courier's trip on the roads, where the raw points zigzag. coordinates is 2 to MATCHING_MAX (100) points in the order recorded; timestamps, optional, one whole Unix second per point, never going back; with them two far points seconds apart are a break, not a fast road. The answer has matchings, one per unbroken piece (distanceM, durationS at free-flow speeds without turn costs, geometry a LineString, coordinates of LonLat), and tracepoints, one MatchTracepoint per point in its order (matchingsIndex, location, gapM), null where no road was found or the point is inside a restricted site. Closures are not avoided: a trace is where someone went. The trace is neither stored nor logged; to share a trip with the map, use startTrace. Needs route:read. Other counts and timestamps are refused before any request with 400 invalid_coordinates or invalid_timestamps. No road near the trace is 422 no_match; fewer than 2 points outside restricted sites is 422 not_accepted. One call. Coverage: Ulaanbaatar.

Optimized trips ​

kotlin
val round = dock.optimizedTrips(
    listOf(
        Client.End.Point(LonLat(106.9177, 47.9186)), Client.End.Code("7QSVX"),
        Client.End.Point(LonLat(106.905, 47.921)), Client.End.Point(LonLat(106.925, 47.915)),
    ),
)
// The stops in the order to drive them, each by its place in the list sent.
val order = round.waypoints.indices.sortedBy { round.waypoints[it].waypointIndex }
println("$order, ${round.trips[0].distanceM.toInt()} m, ${round.trips[0].durationS.toInt()} s")

optimizedTrips(stops, roundtrip, mode) (API 0.66.0) puts stops in the order with the least time on the road and gives the trip in that order: a courier's round for the day in one call. stops is 2 to OPTIMIZED_TRIPS_MAX (12) ends, each a building code (to its door) or a point, as in route. The trip starts at the first. roundtrip, true by default, goes back to it; false ends at the last sent. The answer has trips, one Trip (distanceM, durationS at free-flow speeds, geometry a LineString, and legs, one TripLeg per hop), and waypoints, one TripWaypoint per stop in the order sent, each a route end with waypointIndex, its place in the trip. Today's closures are avoided. No time at the stops. Needs route:read, and door:read for a code. A short or long list, or a code holding ;, is refused before any request with 400 invalid_coordinates. A stop no road reaches is 422 no_route; 404 not_found names the stop by its place (coordinates[1]: ...). One call. Coverage: Ulaanbaatar.

entrancesNear(point, radius, limit) gives the approved doors of the building nearest a point (API 0.85.0), for a long-press on the map. Nearest first, each with its building and distance_m. The list is empty while the building's owner or СӨХ has not approved doors. radius in metres, 50 by default, up to 1000; limit 1 by default, up to 20. Needs geocode:read.

venue(venueId), venueLevel(venueId, level), venueSpot(venueId, unitId), venueRoute(venueId, from, unitId, fromLevel) and venueDirections(venueId, from, unitId, mode) answer the indoor map of a building: its floors (ordinal 0 ground, -1 basement), one floor's units, openings and amenities (each with source and confidence), what a printed plate says, the walk inside to a unit with Mongolian steps and the floor on every leg, and the road to the right entrance followed by that walk. Take the venue id from indoor.venue_id on a geocode row. Same names and fields as the JS SDK. venue, venueLevel, venueSpot and venueRoute need door:read, venueDirections needs route:read. 404 not_found for an unknown venue, floor or unit; 422 no_route while no corridor is drawn. Nothing is invented.

Tilequery ​

kotlin
val tapped = dock.tilequery(LonLat(106.9177, 47.9186), radius = 30.0, limit = 10)
// Nearest first: the building the point is in, then the entrances within 30 m.
for (f in tapped.features) println("${f.properties.tilequery.layer} ${f.properties.tilequery.distance} m")

tilequery(point, sources, radius, limit, geometry) (API 0.68.0) gives the buildings and doors at or near a point, as Mapbox's Tilequery API: what a tap on the map is on. sources is both TilequerySource values by default, or one of them. radius is in metres, 0 to TILEQUERY_RADIUS_MAX_M (1000), sent to the centimetre; without it the API finds only the building the point is inside, and an door needs a radius. limit is 1 to TILEQUERY_LIMIT_MAX (50), 5 by default. geometry (a TilequeryGeometry) keeps one type. The answer is GeoJSON, nearest first: each feature is a Point, the part of it nearest the point sent, with its tile fields (a building's nameMn, type, levels; a door's kind, letter) and tilequery: distance in metres (0 inside), geometry, layer. The same rows as the tiles, no building codes: door(code) takes a code. Needs tiles:read; counts against rate_limit and daily_quota, not the tile budgets. Anything the API would refuse is refused before any request with its error: 404 not_found for a source, 400 invalid_point, invalid_radius, invalid_limit. Coverage: Ulaanbaatar.

Nearby ​

kotlin
val near = dock.nearby(LonLat(106.9177, 47.9186), listOf(NearbyCategory.PHARMACY, NearbyCategory.BANK), radius = 1500.0, limit = 20)
// Nearest first. A store has no code: say so, and show its official address.
for (place in near.results) {
    val code = place.code ?: "no code"
    println("${place.category} ${place.distanceM.toInt()} m $code")
}

nearby(point, categories, radius, limit) gives the places of those categories near a point, nearest first, each with category and distanceM: the chips under a search field. A "Food" chip is listOf(NearbyCategory.RESTAURANT, NearbyCategory.CAFE). radius 1000 m by default, up to 10000; limit 10, up to 20. Each category is sent once, in the order given. Banks, fuel, pharmacies, restaurants, cafes and shops are businesses matched to the state registry only; until one matches, none. A store of a registered chain has code null: its door is not known, so show "код алга" and officialAddress, the building it stands in. A bus stop has its city lines in lines. An empty list is refused before any request with DockError 400 invalid_category. Needs geocode:read.

Road closures ​

kotlin
val closures = dock.closures()
val avoid = excludePolygons(closures, "2026-10-05")
println("${closures.current} closures, ${avoid.ids.size} on the day, ${avoid.rings.size} rings")
println(avoid.closureAt(LonLat(106.9303, 47.9202)) ?: "no closure at the door")

Closures partners reported (flood, washout, bridge down, snow, mud, works), every one not over today, each with the ring around it. No partner name, no note. Confidence 0.5: one partner's word. Keep the answer on the phone: it is the same shape as an offline pack's closures block. excludePolygons(closures, day) works offline: it keeps the items active on the route day (from <= day, and day <= to when to is set; default today, Ulaanbaatar) and gives their rings for your own Valhalla's exclude_polygons. Over Valhalla's limits for one request (10,000 m of ring, 100 vertices) it throws ClosuresOverLimitException: refuse the route, never drop a closure. A day before asOf throws IllegalArgumentException: fetch again. idsSha256 changes when a closure is added or lifted. avoid.closureAt(point) names the closure whose ring holds a route end, or gives null: Valhalla drops every road inside a ring, so an end there has no road and your Valhalla answers "No path could be found for input". Check both ends first. GET /v1/route avoids today's closures itself, names them in closures.avoided, and refuses an end inside one with 422 end_in_closure. route:read.

Report a closure ​

kotlin
val retryKey = java.util.UUID.randomUUID().toString()
val report = dock.reportClosure(
    ClosureReport(ClosureGeometry.Point(LonLat(106.9303, 47.9202)), ClosureKind.FLOOD, to = "2026-10-09", note = "Ford under 1 m of water"),
    idempotencyKey = retryKey,
)
val until = report.properties.to ?: "until lifted"
println("${report.id} ${report.properties.from} $until")
val mine = dock.closure(report.id)
println(mine.properties.note)
val opened = dock.liftClosure(report.id)
println(opened.properties.lifted ?: "not lifted")

reportClosure(report, idempotencyKey) tells dock that a stretch or a crossing cannot be driven. kind is one of FLOOD, WASHOUT, BRIDGE_OUT, SNOW, MUD, WORKS, OTHER; geometry a ClosureGeometry.Point on the stretch, not on a junction, or a ClosureGeometry.LineString of at most 3000 m. from is the first closed day, today when null; to the last, null until lifted. The report is kept forever, never edited or deleted. Routes on dock avoid it from its from day, and closures() serves it to every partner without your name or note. Confidence 0.5: one partner's word. Give each report its own idempotencyKey (UUID.randomUUID() fits) and keep it until the answer comes: a retry after a timeout with the same key and the same report gets the first report back, nothing is stored twice. The same key with another report is 422 idempotency_key_reused. A key the API would refuse (1 to 64 of letters, digits and ._:-, as IDEMPOTENCY_KEY_RE) throws DockError bad_idempotency_key before any request. A note with a run of 6 digits is 400 note_has_phone: no phone numbers. Inside a restricted site, 400 restricted. closure(id) reads one of your reports back with its note: your export. liftClosure(id, on) opens the road again from on, today when null, never a later day (400 lift_in_future); the report stays, with lifted set once. Other organizations' ids are 404 not_found. All three need closures:write.

Pack files, offline ​

kotlin
import java.io.File

// In an app: File(packDir, "manifest.json").readText()
val manifest = PackManifestFiles.fromJson("""
{"files":[{"name":"pois.geojson","bytes":2,"sha256":"44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"},
 {"name":"water.geojson","bytes":265463,"sha256":"e9973b42c4b0e6827348e501acd798a1fff25206f4b67708b5f06179e3f6c199"}]}
""")
// In an app: the folder the pack was downloaded to
val packDir = File(System.getProperty("java.io.tmpdir"), "dock-pack-" + System.nanoTime()).apply { mkdirs() }
File(packDir, "pois.geojson").writeText("{}")
val check = checkPack(manifest, packDir)
for (f in check.files) println("${f.name} ${f.status}")
println("ok ${check.ok} bad ${check.bad.joinToString(" ")}")

manifest is the pack's manifest.json; only its files rows are read: the name, size and sha256 of each file in the pack folder (tiles.tar, crossings.geojson, pois.geojson, basemap.pmtiles, wetlands.geojson, passes.geojson, sand.geojson, water.geojson in the current pack). checkPack(manifest, directory) checks each listed file in directory, in manifest order, by its size, then its sha256, 1 MB at a time. status is OK, MISSING (no such file, or a folder by that name), BYTES (another size, as a stopped download; not hashed) or SHA256 (same size, other bytes). ok is true only when every listed file is OK. bad names the rest: download them again, and use no file from the pack until ok. Files the manifest does not list are not read. A manifest with no files, a name that is not a plain file name (a folder, .., a leading dot), a name listed twice, a negative bytes or a sha256 that is not 64 lower case hex throws IllegalArgumentException before any file is read, so a manifest from the network cannot name a file outside the pack folder. It blocks while it hashes (the base map is 113 MB): call it on Dispatchers.IO. The current pack has 8 files, 180 MB. No network, no key.

Route on a pack, offline ​

kotlin
// In an app: File(packDir, "manifest.json").readText()
val manifest = PackRouteManifest.fromJson("""
{"id":"mongolia-260927-416081b1","bbox":[87.7845,41.03732,120.01834,52.25102],
 "request":{"costing":"auto","costing_options":{"auto":{"use_tracks":0.5}}},"confidence":"osm_only",
 "attribution":"© OpenStreetMap contributors (ODbL 1.0, openstreetmap.org/copyright). Track scores © dock.","note":"route cost is from track scores, not an ETA",
 "closures":{"method":"dock_steppe.closures v1","source":"partner reports","as_of":"2026-10-03","current":1,"ids_sha256":null,
  "buffer_m":15,"rule":"","active_when":"from <= route day, and route day <= to when to is set",
  "valhalla_limits":{"perimeter_m":10000,"vertices":100},
  "items":[{"id":"cl-000002","kind":"washout","from":"2026-10-04","to":null,"confidence":0.5,"perimeter_m":890,"vertices":5,
   "ring":[[104.499,47.499],[104.501,47.499],[104.501,47.501],[104.499,47.501],[104.499,47.499]]}]}}
""")
val r = packRoute(manifest, listOf(LonLat(106.9176, 47.9187), LonLat(102.833, 47.196)), "2026-10-04")
// In an app: POST r.body.toString() to /route on your own Valhalla, the pack tiles.tar as mjolnir.tile_extract
println("${r.closures.joinToString(" ")} ${r.body["costing"]}")
println(r.traceNote ?: "scored")
try {
    packRoute(manifest, listOf(LonLat(106.9176, 47.9187), LonLat(104.5, 47.5)), "2026-10-04")
} catch (e: EndInClosureException) {
    println("refused ${e.stop} ${e.closure}")
}

manifest is the pack's manifest.json. packRoute(manifest, stops, day, closures) builds the request for a route on the pack through stops (2 or more LonLat). body (a JsonObject) is what to POST to /route on your own Valhalla, with the pack's tiles.tar as mjolnir.tile_extract. It carries the manifest's request (costing auto, costing_options.auto.use_tracks 0.5 today), the rings of the closures active on the route day as exclude_polygons (day, default today in Ulaanbaatar), and units km. The closures come from the pack's closures block, or from closures, a newer GET /v1/closures answer. closures in the answer names the ones avoided: show them with the route. A stop outside the pack's bbox throws IllegalArgumentException. A stop inside an avoided ring throws EndInClosureException (stop, closure): Valhalla has no road there; refuse the route, as GET /v1/route does. Over Valhalla's ring limits it throws ClosuresOverLimitException; a day before the closures' as_of throws IllegalArgumentException, as excludePolygons. Show attribution with every route. scored is false when the pack has no trace scores (confidence osm_only, the current pack): say traceNote (traceNoteMn in Mongolian) to the driver. It is the pack's own sentence, confidence_note and confidence_note_mn in its manifest (offline v10 and later), when it has both; else the SDK's PACK_TRACE_NOTE. confidenceNote (confidenceNoteMn) in the answer says what confidence means on any pack: the manifest's, else the SDK's when not scored, else null. On the current pack the body for Ulaanbaatar to Kharkhorin routes 359.8 km in pyvalhalla 3.9.0, as the pack's own check; with a test closure on the middle of that route, the same call goes around it, 690.3 km. Check the pack files first (above). No network, no key.

River crossings, offline ​

kotlin
// In an app: File(packDir, "crossings.geojson").readText()
val crossings = PackCrossings.fromJson("""
{"type":"FeatureCollection","match_m":5,"note":"From OSM tags, not field checked.",
 "note_mn":"OSM-ийн шошгоос, газар дээр шалгаагүй.",
 "flow":{"attribution":"River flow: GEOGLOWS ECMWF Streamflow Service v2, CC BY 4.0"},
 "features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[101.95425,46.77663]},"properties":{
  "kind":"ford","waterway":"river","name":"Улаан гол","name_en":"Ulaan gol","intermittent":false,
  "flow":{"river_id":320103663,"normal_m3s":[0.59,0.42,0.63,0.88,1.17,1.37,2.19,2.3,1.57,1.14,0.94,0.8],
   "high_months":[7,8],"reach":"agrees","confidence":0.4}}}]}
""")
val line = listOf(LonLat(101.94, 46.775), LonLat(101.95425, 46.77663), LonLat(101.96, 46.78))
val r = crossingsOnRoute(crossings, line, 7)
for (w in r.warnings) println("${w.kind} ${w.name} ${w.highWater} ${w.monthM3s} ${w.alongM}")
println(r.flowAttribution ?: "")

For a route on your own Valhalla with an offline steppe pack. crossings is the pack's crossings.geojson (manifest files[1]); check its sha256 against the manifest before use, as for the tiles. crossingsOnRoute(crossings, line, month) gives the fords (kind ford) and the crossings where OSM has no bridge or ford (kind none) that the route line passes within matchM (5 m), in route order. alongM is metres along the route to its nearest point, offM metres from it. On a river, highWater is true when month (1 to 12, default ubMonth(), this month in Ulaanbaatar) is one of highMonths, and monthM3s is that month's 1991 to 2020 normal. On a reach marked doubtful (the model reach is 10 times below its river's), and on any reach at confidence 0.1 (LOW_FLOW_CONFIDENCE) or under, monthM3s is null: show the months, not the number. snap says why: none_fits, no model reach follows the river there; main_stem, the channel follows only a small reach and the river's main stem is used. The steppe dashboard does the same. Streams and canals have no flow: highWater is null. On a delta channel, where a river splits into a lake and no model reach says what share a channel carries, the flow is null and delta gives lake, lakeOsm and river: say "delta channel, flow unknown". delta is null elsewhere and in packs built before the rule. lakeNameOsm is the lake's OSM text when lake is folded to one script, else null; show lake. Packs before national crossings v11 lack it: null. An unnamed river or stream whose two ends lie on one named river is a channel of it: channelOf gives name, nameEn and riverWayId; say "unnamed channel of Улаан гол". From the OSM drawing, not checked on the ground. channelOf is null elsewhere and in packs built before that rule. Another unnamed river or stream whose water reaches one named river downstream, by the direction OSM draws the ways, has flowsInto: name, nameEn, riverWayId and ways, the fewest unnamed ways to that river; say "unnamed, flows into Орхон гол". From the OSM drawing, not checked on the ground; a way drawn the wrong way round gives a wrong river or none. channelOf wins when both are set. flowsInto is null elsewhere and in packs built before that rule. road gives the OSM highway and surface tags of the route ways and paved: true when one is paved, false when all are unpaved, null when a way has no surface tag. road is null on a new track alone and in packs built before that rule. A none crossing on a paved road may be a bridge or culvert OSM does not tag; say "paved road, OSM tags no bridge here". The pack's onPaved counts the paved crossings; roadRule and roadRuleMn give the rule. The pack's nearBridge counts the none crossings left out of the pack because a bridge is drawn just off the water; bridgeRule and bridgeRuleMn give the rule. onePoint counts the crossings joined into the one beside them because their water way is split into two ways at the road; splitRule and splitRuleMn give the rule. fromSeasonal counts the crossings marked intermittent from the OSM seasonal tag alone, with no intermittent tag; intermittentRule and intermittentRuleMn give the rule. A ford with fordNodeId set comes from an OSM ford node alone: OSM draws no water line there, so waterway is unknown, with no name and no flow; say "OSM ford, water not drawn". fordNodeId is null on other crossings and in packs built before that rule. The pack's fordNodes counts them; fordNodeRule and fordNodeRuleMn give the rule. Show note (noteMn in Mongolian) with the warnings: they come from OSM tags, not a field check. Show flowAttribution when it is not null. A line under 2 points or a bad month throws IllegalArgumentException. No network, no key. Same rule and the same shared test cases as the JS SDK.

Fuel and ger camps, offline ​

kotlin
// In an app: File(packDir, "pois.geojson").readText()
val pois = PackPois.fromJson("""
{"type":"FeatureCollection","registry_checked":true,
 "counts":{"fuel":{"registered":1,"unregistered":1268},"ger_camp":{"registered":0,"unregistered":175}},
 "written":1,"rule":"every registered fuel station and ger camp, all Mongolia","rule_mn":"бүртгэлтэй бүх ШТС, гэр бааз",
 "source":"OpenStreetMap","note":"From OSM, not field checked. Unregistered places are counted, not shown.",
 "note_mn":"OSM-ээс, газар дээр шалгаагүй. Бүртгэлгүй газрыг тоолсон, харуулаагүй.",
 "features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[101.9601,46.7801]},"properties":{
  "kind":"fuel","osm":"N1","name":"Жишээ ШТС","name_en":"Example fuel station","reg_number":"0000000"}}]}
""")
val route = listOf(LonLat(101.94, 46.775), LonLat(101.95425, 46.77663), LonLat(101.96, 46.78))
val near = poisOnRoute(pois, route)
for (p in near.pois) println("${p.kind} ${p.name} ${p.regNumber} ${p.alongM} ${p.offM}")
println("${near.registryChecked} ${near.unlistedFuel} ${near.unlistedGerCamp}")

pois is the pack's pois.geojson (manifest files[2], pois_sha256 in index.json); check its sha256 against the manifest before use, as for the tiles. poisOnRoute(pois, line, nearM) gives the fuel stations (kind fuel) and ger camps (kind ger_camp) within nearM of the route line, in route order. The default is 2000 m (POI_NEAR_M), as on the steppe route pages. alongM is metres along the route to its nearest point, offM metres from it, both whole metres. Only places matched to the state registry are in the file, with their registered name and regNumber. The rest are counted by kind for all Mongolia, with no name and no place: unlistedFuel and unlistedGerCamp. An empty list is not "no fuel on this route" while unlistedFuel is above 0. registryChecked false means the registry could not be read when the pack was built: say "not checked", not "none". Show note (noteMn in Mongolian) with the places. Packs built before 2026-10-03 have no pois.geojson. The current pack lists 0 places: 1,268 fuel stations and 175 ger camps counted, none matched to the registry yet. A line under 2 points or a nearM not above 0 throws IllegalArgumentException. No network, no key.

Wet ground, offline ​

kotlin
// In an app: File(packDir, "wetlands.geojson").readText()
val wetlands = PackWetlands.fromJson("""
{"type":"FeatureCollection","written":1,"km":0.4,"areas":1,"counts":{"marsh":{"stretches":1,"km":0.4}},
 "match_m":5,"rule":"the part of one OSM car way inside an OSM natural=wetland area",
 "rule_mn":"OSM-ийн natural=wetland талбай доторх нэг машин замын хэсэг","source":"OpenStreetMap",
 "note":"From OSM tags, not field checked. Soft ground in spring and after rain.",
 "note_mn":"OSM-ийн шошгоос, газар дээр шалгаагүй. Хавар, бороо орсны дараа зөөлөн газар.",
 "features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[101.94285,46.775326],[101.94855,46.775978]]},
  "properties":{"wetland":"marsh","area":"way/1","name":null,"km":0.441,"way_id":2,"new_track":false,
  "road":{"highway":["track"],"surface":["ground"],"paved":false}}}]}
""")
val route = listOf(LonLat(101.94, 46.775), LonLat(101.95425, 46.77663), LonLat(101.96, 46.78))
val wet = wetlandsOnRoute(wetlands, route)
for (s in wet.stretches) println("${s.wetland} ${s.alongM} ${s.onM} ${s.km}")
println("wet ${wet.onM}")

wetlands is the pack's wetlands.geojson (a manifest files row and its wetlands block, wetlands_sha256 in index.json); check its sha256 against the manifest before use, as for the tiles. Each stretch is the part of one car way inside an OSM natural=wetland area, cut at the area's edge: soft ground in spring and after rain. wetlandsOnRoute(wetlands, line) gives the stretches the route runs on, in route order. The stretch is cut in pieces of at most matchM (5 m); a piece is on the route when both its ends lie within matchM of the line. A stretch is on the route when those pieces make at least 20 m (WETLAND_MIN_ON_M), or all of the stretch: a route that only crosses a wet way is not on it. alongM is metres along the route to where it meets the stretch, onM the metres of the stretch it runs on, both whole metres; km is the whole stretch, from the pack. wetland is the OSM kind (marsh, bog, wet_meadow, fen, ...), unknown when the area has no wetland tag; area is the OSM area, as relation/8128712. wayId and road are null on a new track alone. Say "OSM marsh for 1.2 km, soft in spring". Show note (noteMn in Mongolian) with the stretches: from OSM tags, not field checked; an old wetland may be dry now, and one OSM does not draw is not here. An empty list is not "dry all the way". The current pack has 356 stretches, 247.3 km in 86 OSM areas, 208.4 km of it marsh. Packs built before 2026-10-03 have no wetlands.geojson. A line under 2 points throws IllegalArgumentException. No network, no key.

Mountain passes, offline ​

kotlin
// In an app: File(packDir, "passes.geojson").readText()
val passes = PackPasses.fromJson("""
{"type":"FeatureCollection","written":1,"counts":{"mountain_pass":1},"named":1,"with_ele":1,"with_dem":1,"match_m":5,
 "rule":"an OSM node tagged mountain_pass=yes or natural=saddle that is a node of a car way",
 "rule_mn":"машин замын зангилаа болох, mountain_pass=yes эсвэл natural=saddle шошготой OSM зангилаа","source":"OpenStreetMap",
 "note":"From OSM tags, not field checked. A climb, and snow and ice outside summer.",
 "note_mn":"OSM-ийн шошгоос, газар дээр шалгаагүй. Өгсүүр зам, зуны бус улиралд цас мөс.",
 "features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[101.957125,46.778315]},
  "properties":{"kind":"mountain_pass","name":"Улаан даваа","name_en":null,"ele_m":2391,"ele_osm":"2391","node_id":3,"dem_m":2388,
  "route_way_ids":[2],"new_track":false,"road":{"highway":["track"],"surface":["ground"],"paved":false}}}],
 "dem":{"source":"copernicus-dem-glo30","attribution":"Copernicus DEM GLO-30","tiles":1,"missing":[],
  "rule":"the height of the GLO-30 cell the node falls in","rule_mn":"зангилаа байрлах GLO-30 нүдний өндөр",
  "note":"A surface model, not field checked.","note_mn":"Гадаргын загвар, газар дээр шалгаагүй."}}
""")
val route = listOf(LonLat(101.94, 46.775), LonLat(101.95425, 46.77663), LonLat(101.96, 46.78))
val over = passesOnRoute(passes, route)
for (p in over.passes) {
    val height = p.eleM?.let { "${it.toInt()} m" } ?: "no height in OSM"
    println("${p.name ?: p.kind} ${p.alongM} ${p.offM} $height")
}
println("top ${over.topM?.toInt() ?: "none"}")
println("top DEM ${over.topDemM?.toInt() ?: "none"} ${over.dem?.attribution ?: ""}")

passes is the pack's passes.geojson (a manifest files row and its passes block, passes_sha256 in index.json); check its sha256 against the manifest before use, as for the tiles. Each pass is an OSM node tagged mountain_pass=yes or natural=saddle that is a node of a car way: a climb, with snow and ice outside summer. passesOnRoute(passes, line) gives the passes the route goes over, in route order: those whose node lies within matchM (5 m) of the line, the rule of the steppe route pages. alongM is metres along the route to its point nearest the pass, whole metres; offM is metres off the line, to 0.1 m. kind is mountain_pass, or saddle when OSM has only natural=saddle. eleM is the OSM ele tag read as metres, null when OSM has none or other text, as "7000 ft"; eleOsm is the tag as written. topM is the highest eleM of the passes on the route, null when none has a height: not flat, OSM has no number. demM is a second height, from the Copernicus GLO-30 DEM: the whole-metre height of the 30 m cell the node falls in, null when the pack has none there. It is never mixed into eleM. topDemM is the highest demM on the route, null when none has one. Say "Pass at km 120, 2,391 m (OSM)", or "about 1,911 m (DEM)" when OSM has no number, and show dem.attribution wherever a DEM height shows. Packs with passes v1 have dem null and every demM null. road is null on a new track alone. Show note (noteMn in Mongolian) with the passes: from OSM tags, not field checked; a pass OSM does not draw is not here. An empty list is not "flat all the way". The current pack has 74 passes on car ways (68 mountain_pass, 6 saddle), 41 named, 40 with an OSM height, 1,210 to 3,104 m, 74 with a DEM height, 1,214 to 3,202 m. Packs built before 2026-10-03 have no passes.geojson. A line under 2 points throws IllegalArgumentException. No network, no key.

heightsDiffer is the pack's flag: true when eleM and demM are both there and more than dem.differM (50 m) apart, false within, null when either is missing. true says one of the two is wrong, not which: show both heights, "2,241 m (OSM); about 2,349 m (DEM)", and dem.differRule. differ is how many passes on the route have it true, null when the pack has no flag: not checked, not 0. Packs with passes v2 have no flag. In the current pack 1 of 74 is true, 39 false, 34 null.

Sand, offline ​

kotlin
// In an app: File(packDir, "sand.geojson").readText()
val sand = PackSand.fromJson("""
{"type":"FeatureCollection","written":1,"km":0.346,"areas":1,"named":1,"on_paved":0,"match_m":5,
 "rule":"the part of one OSM car way inside an OSM natural=sand area; an area also tagged golf is left out",
 "rule_mn":"OSM-ийн natural=sand талбай доторх нэг машин замын хэсэг; golf шошготой талбайг тооцохгүй","source":"OpenStreetMap",
 "note":"From OSM tags, not field checked. A car can dig in and stop.",
 "note_mn":"OSM-ийн шошгоос, газар дээр шалгаагүй. Машин суух аюултай.",
 "features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[101.9554,46.777304],[101.95885,46.779326]]},
  "properties":{"area":"way/1","name":"Бага элс","km":0.346,"way_id":2,"new_track":false,
  "road":{"highway":["track"],"surface":["sand"],"paved":false}}}]}
""")
val route = listOf(LonLat(101.94, 46.775), LonLat(101.95425, 46.77663), LonLat(101.96, 46.78))
val onSand = sandOnRoute(sand, route)
for (s in onSand.stretches) println("${s.name ?: s.area} ${s.alongM} ${s.onM} ${s.km}")
println("sand ${onSand.onM}")

sand is the pack's sand.geojson (a manifest files row and its sand block, sand_sha256 in index.json); check its sha256 against the manifest before use, as for the tiles. Each stretch is the part of one car way inside an OSM natural=sand area, cut at the area's edge; an area also tagged golf is a bunker and is left out. A car can dig in and stop. sandOnRoute(sand, line) gives the stretches the route runs on, in route order, by the rule of wetlandsOnRoute: the stretch is cut in pieces of at most matchM (5 m); a piece is on the route when both its ends lie within matchM of the line. A stretch is on the route when those pieces make at least 20 m (SAND_MIN_ON_M), or all of the stretch: a route that only crosses a sandy way is not on it. alongM is metres along the route to where it meets the stretch, onM the metres of the stretch it runs on, both whole metres; km is the whole stretch, from the pack. area is the OSM area, as relation/5149547, name its OSM name or null. wayId and road are null on a new track alone. Some town streets are drawn inside sand: 25 stretches lie on a paved road, 24 of them in Zamyn-Uud; road?.paved tells them apart. Say "Sand at km 40, 1.2 km". Show note (noteMn in Mongolian) with the stretches: from OSM tags, not field checked; dunes move, and sand OSM does not draw is not here. An empty list is not "no sand on the way". The current pack has 686 stretches, 669.6 km in 66 OSM areas, 153 in a named one; Bor Khar Els has the most, 70.3 km. Packs built before 2026-10-03 have no sand.geojson. A line under 2 points throws IllegalArgumentException. No network, no key.

Water points, offline ​

kotlin
// In an app: File(packDir, "water.geojson").readText()
val water = PackWater.fromJson("""
{"type":"FeatureCollection","written":1,"counts":{"water_well":1},"named":1,"drinkable":{"yes":0,"no":0,"unknown":1},"near_m":2000,
 "rule":"an OSM node or way tagged amenity=drinking_water or water_point, man_made=water_well or water_tap, or natural=spring",
 "rule_mn":"amenity=drinking_water эсвэл water_point, man_made=water_well эсвэл water_tap, эсвэл natural=spring шошготой OSM зангилаа эсвэл зам",
 "near_rule":"within 2000 m of the route line","near_rule_mn":"маршрутын шугамаас 2000 м дотор","source":"OpenStreetMap",
 "note":"From OSM tags, not field checked. A well may be dry, broken or locked.",
 "note_mn":"OSM-ийн шошгоос, газар дээр шалгаагүй. Худаг ширгэсэн, эвдэрсэн эсвэл цоожтой байж болно.",
 "features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[101.951,46.7805]},
  "properties":{"kind":"water_well","name":"Хар худаг","name_en":null,"drinkable":null,"drinking_water":null,"pump":null,"osm":"node/4"}}]}
""")
val route = listOf(LonLat(101.94, 46.775), LonLat(101.95425, 46.77663), LonLat(101.96, 46.78))
val near = waterOnRoute(water, route)
for (w in near.water) {
    val drink = w.drinkable?.let { if (it) "drinkable" else "not drinkable" } ?: "not known"
    println("${w.name ?: w.kind} ${w.alongM} ${w.offM} $drink")
}
println("drinkable ${near.drinkable.yes} of ${near.water.size}")

water is the pack's water.geojson (a manifest files row and its water block, water_sha256 in index.json); check its sha256 against the manifest before use, as for the tiles. Each point is an OSM node or way tagged amenity=drinking_water or water_point, man_made=water_well or water_tap, or natural=spring; a way counts at a point on its surface. waterOnRoute(water, line) gives the points within nearM of the route line, in route order, as fuel and ger camps: the pack's nearM (2000 m) unless you pass one. alongM is metres along the route to its point nearest the water, offM metres off the line, both whole metres. kind is the first of drinking_water, water_point, water_tap, water_well, spring the element carries. drinkable is only what OSM says: true for drinking_water=yes or a drinking water point with no such tag, false for drinking_water=no, null when OSM says nothing; it is never guessed from the kind, so null is "not known", not "not drinkable". drinkingWater and pump are the OSM tags as written. drinkable on the result counts yes, no and unknown among the points found. Say "Well 1.2 km off the route at km 40". Show note (noteMn in Mongolian) with the points: from OSM tags, not field checked; a well may be dry, broken or locked, and a water point OSM does not draw is not here. An empty list is not "no water on the way". The current pack has 1,064 points (691 wells, 211 springs, 68 drinking water, 48 water points, 46 taps), 200 named; OSM says drinkable for 97, not for 5, nothing for 962; 205 lie south of 46 N. Packs built before 2026-10-03 have no water.geojson. A line under 2 points or a nearM not above 0 throws IllegalArgumentException. No network, no key.

Names in wetlands.geojson, passes.geojson, sand.geojson, water.geojson and crossings.geojson: OSM sometimes writes a Latin letter inside a Cyrillic word, as "Хонгoрын Элс" with a Latin o. The pack folds such a word to one script when every other letter has a twin that looks the same. name is the folded name, nameOsm the OSM text, null when name is the OSM text. Show name. Each answer carries nameOsm, and nameRule and nameRuleMn, the pack's rule, null when the pack has none. In the current pack 17 of 686 sand stretches are folded, all "Хонгорын Элс"; 0 in wetlands, passes, water and crossings. Packs before offline v11 (sand), v12 (wetlands, passes, water) and v13 (crossings) have neither: null.

Base map, offline ​

kotlin
// In an app: File(packDir, "manifest.json").readText()
val basemap = PackBasemap.fromManifest("""
{"basemap":{"method":"dock_steppe.basemap v1","file":"basemap.pmtiles","format":"pmtiles","schema":"openmaptiles",
 "schema_version":"3.16.0","tile_type":"mvt","tiles":305104,"minzoom":0,"maxzoom":14,
 "bounds":[87.70255,41.50035,120.02014,52.19582],"layers":["boundary","building","landcover","landuse","park","place",
 "transportation","transportation_name","water","water_name","waterway"],"excluded_layers":["poi","housenumber"],
 "languages":["mn","en"],"osm_time":"2026-09-27T20:23:36Z",
 "attribution":"© OpenMapTiles (openmaptiles.org) © OpenStreetMap contributors","rule":"no poi, no housenumber",
 "rule_mn":"poi, housenumber давхаргагүй"}}
""")
val style = basemapStyle(basemap, "file:///data/user/0/com.example/files/pack/basemap.pmtiles", BasemapStyleOptions(lang = "en"))
println("${basemap?.tiles} ${style.keys}")

PackBasemap.fromManifest(text) reads the basemap block of the pack's manifest.json, null for a pack without one (built before 2026-10-03). basemap.pmtiles sits next to it (manifest files[3], basemap_sha256 in index.json; check its sha256 as for the tiles). basemapStyle(basemap, url, options) gives a whole MapLibre style for it, the same JSON as the JS SDK's basemapStyle: land cover, water and rivers, steppe tracks (brown, dashed; the file has them from zoom 14), roads, rail, country and aimag borders, buildings. Hand it to MapLibre Native as Style.Builder().fromJson(style.toString()); MapLibre Native reads the source's pmtiles:// URL itself. Give a file:// URL: MapLibre Native 13.6.1 draws nothing from asset://, so copy a pack shipped in the APK to the app's files first. The source carries the block's attribution, so the map shows "© OpenStreetMap contributors"; keep it shown. The pack has no fonts, so names need glyphs, a font URL of your own ({fontstack}, {range}), and fonts (default Noto Sans Regular); then places, water and roads are named in lang ("mn" default, or "en"), falling back to the OSM name. The file has no business names and no house numbers: a shop name shows only when registered (poisOnRoute), and an address is the official state address. No block, a block with a poi or housenumber layer or not OpenMapTiles vector PMTiles, a lang the block lacks or a blank url throw IllegalArgumentException. The current pack: 112.6 MB, 305,104 tiles, zoom 0 to 14, OSM of 2026-09-27. No network, no key. Each release draws it on the emulators from a small PMTiles file over file:// and checks the water and land colors.

Report a wrong door ​

kotlin
val receipt = dock.reportDoor(DoorReport.WrongLocation("7QSVX", LonLat(106.9303, 47.9202)))
println("${receipt.status} ${receipt.next}")

Also DoorReport.Missing, DoorReport.Duplicate and DoorReport.Unmapped. A person reviews every report; door data does not change until then. No names or phone numbers in note.

A place again, by id ​

kotlin
val first = dock.geocode("Сансар").results[0]
val p = dock.place(first.id)
val inside = if (p.parents == null) "no parents" else "in a building"
println("${p.kind} $inside")

The id from geocode, reverse or changes. parents holds the ids a door stands in; null for other kinds. A retired id is DockError 410 retired, never reused. geocode:read.

Changes since a time ​

kotlin
import java.util.Date

val copy = HashMap<String, Address>()
var page = dock.changes(Date(0), limit = 1000)
while (true) {
    for (c in page.changes) {
        if (c.state == "current") copy[c.id] = dock.place(c.id) else copy.remove(c.id)
    }
    val next = page.next ?: break
    page = dock.changes(null, limit = 1000, cursor = next)
}
println("${copy.size} until ${page.until}")

Keeps a copy in sync. One page per call, oldest first; poll again later with since = page.until. With a cursor, since is null. Pass limit on every call (default 100). current: fetch with place(). retired and hidden: drop it. Partner keys (changes:read).

Driver confirmations ​

kotlin
val r = dock.confirm(Confirmation(
    Confirmation.Target.Code("7QSVX"), Confirmation.Result.Confirmed(LonLat(106.93012, 47.92011)),
    device = LonLat(106.9302, 47.9203), driver = "drv-7f3a"))
println("${r.status} ${r.next}")

The result is Confirmed(tap), WrongLocation(tap) or Missing. The door by Target.Code, or by Target.Entrance(id) (the id in the door tile layer) when it has no code yet. tap is where the driver tapped the door on the map; device is the phone GPS on site, a check only, within 40 m of the tap. driver is your own id for the driver, stored only as a hash: never a phone number, e-mail or name. observedAt for confirmations queued offline, at most 7 days old. Partner keys (confirm:write).

Confirmations per month ​

kotlin
val own = dock.ownConfirmations("2026-10")
println("${own.month} ${own.closed} ${own.totals.confirmed} ${own.drivers} ${own.flagged.size}")

ownConfirmations is your dashboard of driver confirmations: your organization's confirmations and door reports in a month (YYYY-MM, Ulaanbaatar), no other organization read. totals and each of days count by result: confirmed, wrongLocation, missing, and from door reports duplicate and unmapped. A row counts on the Ulaanbaatar day the driver was on site (observedAt, else when received). drivers is the number of different drivers by your driver ids, never the ids. flagged is the failure points: up to 100 doors your drivers said are wrong, missing or a duplicate, most first; code is null when they named the door. None inside a restricted site. The month you are in is answered too; closed is true from 8 days after the month ends, when no queued confirmation can join it. Needs confirm:read, a read-only scope for a dashboard key: confirm:write does not read it, and confirm:read cannot confirm. A bad month is 400 bad_month.

Door names ​

kotlin
val mine = dock.registerDoorName("Сансар бэйкери", "7QSVX")
val named = dock.doorByName("сансар-бэйкери")
println("${mine.status} ${mine.expiresAt} ${named.door.code} ${label(named.door, Lang.EN)}")

A business names its door once; anyone with a key finds the door by that name (API 0.52.0). Every call needs the door_names feature: until dock opens it for your org, each answers 403 feature_off (see features in usage). registerDoorName(name, code) needs names:write. The name is held for a year, no fee yet. Your own name with another code moves to that door (status is moved). 409 name_taken, 410 retired_code, 422 too_many past 50 names. doorByName(name) needs door:read and answers the door as door does, official address first. Any spelling finds it: case, spaces, punctuation, Latin letters that look like Cyrillic ones. A miss counts against the door miss cap. doorNames() lists your own names, A to Z. To hand a name to another org (API 0.53.0): doorNameTransferCode(name) gives a dnt_ code, shown once, good for 7 days. The other org calls claimDoorName(name, transferCode); the door and expiry stay. cancelDoorNameTransfer(name) clears the code. A name with no letter, a phone number or an e-mail is refused with the API's 400 before any request: the name travels in the URL.

Door notes ​

kotlin
val sent = dock.writeDoorNotes(
    "Сансар бэйкери", instructionsMn = "Зүүн талын хаалга", instructionsEn = "East side, 2nd entrance",
)
val mine = dock.doorNotes("Сансар бэйкери")
val state = mine.notes.firstOrNull()?.state ?: "none"
println("${sent.status} $state")

A hotel, host or restaurant that holds a door name writes how guests find its door with writeDoorNotes(name, instructionsMn, instructionsEn) (API 0.58.0): instructions_mn, instructions_en or both, up to 500 characters each. Needs names:write and the hotel.notes feature, else 403 feature_off. The note waits for a person to review it; door serves the notes the map holds until then. status is queued, then updated or unchanged when you write again. A phone number or an e-mail in a note is 400 personal_data. doorNotes(name) (API 0.59.0) lists your own notes for that door, each waiting, accepted or refused, with reviewed_at; never another org's. A name your org does not hold is 404 not_found. The name is checked as for door names: refused before any request.

Door photo ​

kotlin
import java.io.File

// In an app: the file the camera wrote, in context.cacheDir
val gate = File(System.getProperty("java.io.tmpdir"), "gate.jpg")
val sent = dock.sendDoorPhoto("Сансар бэйкери", gate.readBytes())
val shown = dock.doorPhoto("Сансар бэйкери")
// Later: pass the sha256 you hold; an unchanged photo comes back with jpeg null
val again = dock.doorPhoto("Сансар бэйкери", sha256 = shown.sha256)
val size = shown.jpeg?.size ?: 0
println("${sent.status} $size ${again.jpeg == null}")

A host that holds a door name sends a photo of its gate with sendDoorPhoto(name, jpeg) (API 0.60.0): the door as a guest sees it on arrival. One JPEG file, up to 2 MB, 320 to 8000 px a side, sent as the camera saved it. Its metadata is removed before it is stored: GPS, the phone, the time. The orientation stays. The photo waits for a person to review it, who refuses one that shows a face, a car plate or a phone number. status is queued, then updated or unchanged when you send again. sha256 is of the photo as stored, not of the file you sent. Not a JPEG is 400 not_jpeg; invalid_photo, photo_too_small, photo_too_big; over 2 MB 413 too_large. doorPhoto(name) (API 0.61.0) reads back the newest photo of yours a person accepted: never a waiting or refused one, never another org's. Show it only inside your app, never on a public page. Pass the sha256 you hold and an unchanged photo comes back with jpeg null: no bytes. Both need names:write and the hotel.notes feature, else 403 feature_off. A name your org does not hold, a retired door, a restricted site or no accepted photo is 404 not_found. The name is checked as for door names: refused before any request.

Usage ​

kotlin
val usage = dock.usage(days = 7)
println("${usage.caps.dailyQuota ?: 0} ${usage.days.firstOrNull()?.calls ?: 0}")

Free with any key. Still answers when the daily quota is used up. features (API 0.51.0) lists the features open to your org, A to Z, like cargo.eta. A route of any other feature answers 403 feature_off. null from an older API. endpoints (API 0.62.0) gives calls and door misses per route over the same days, most calls first, like GET /v1/door/:code. null from an older API.

Search telemetry ​

kotlin
dock.setTelemetry(true)
val hits = dock.geocode("Сансар 24")
hits.results.firstOrNull()?.let { dock.pick(it) }
val sent = dock.flushTelemetry()
println("${sent?.stored ?: 0} ${sent?.dropped ?: 0}")

Off by default. Turn it on only after the user opted in, for example with a switch, and off when they turn it off. Off drops every search not yet sent. When on, the client keeps each geocode search: the text as finally typed (the calls made while typing "Сан", "Сансар" count once), the number of results, and the result passed to pick(result) with its rank. It sends them to POST /v1/telemetry/search 50 at a time, on appInBackground(), and on flushTelemetry(). Call appInBackground() from onStop of your activity or a ProcessLifecycleOwner observer: it sends without waiting. Not sent: time, point, device, session. The API removes phone numbers and e-mail addresses before storage. A failed send is dropped: no retry, nothing stored on the device. Client(key, telemetry = true) is the same as setTelemetry(true). Needs geocode:read; one send counts as one call. Same rules as the JS SDK and the iOS SDK.

kotlin
val found = dock.geocode("Сансар 24")
found.results.firstOrNull()?.let { dock.pick(it) }
println("${found.offline} ${dock.places().size}")
dock.forgetPlaces()

Online, found.offline is false and the API answers. Each place passed to pick(result) is kept on the device, on by default, with or without telemetry. When geocode gets no answer (DockError code network), it answers from these places with offline true: every typed word must start a word of the name, long form or code, any case. Most picked first, score is the number of picks, layer 1, near null, the fields as the API last gave them; a later online answer updates them. No matching place: the network error is thrown as before. A cancelled coroutine or an API refusal (429, 401) never answers from the device.

Kept: the place fields only (places() gives them as Place). Not kept: the typed text, time, point, score. 100 places by default (max 1 to 1000, else IllegalArgumentException); the least picked go first. In memory unless you pass store, a PlaceStore with three calls: Client(key, placeCache = PlaceCacheOptions(store = myStore)). Over SharedPreferences: getString(key, null), edit().putString(key, value).apply(), edit().remove(key).apply(). A store that throws or holds a broken value is ignored. Same key (dock.places.v1) and format as the JS SDK. Nothing here is sent to dock. placeCache = null turns it off. Same rules as the JS SDK and the iOS SDK.

Address field ​

kotlin
import kotlinx.coroutines.coroutineScope

coroutineScope { // in an app: lifecycleScope or viewModelScope, on the main thread
    val field = Suggester(
        dock, this, proximity = { UB_CENTER },
        show = { items, q -> println("$q: ${items.joinToString("; ") { label(it, Lang.EN) }}") },
        fail = { error -> println("$error") },
    )
    field.input("Сансар") // from the text field's onValueChange
}

One field for a checkout or a pickup form. Suggester asks from the third character, 200 ms after the last key (SUGGEST_MIN_CHARS, SUGGEST_DELAY_MS). A newer key cancels the request in flight, so a late answer never replaces the list. Text that reads as a building code (parseDoorCode) is looked up with door first, on top of the list; a code with no door falls through to geocode. Requests run in the scope you pass; show gets the list and the text it is for there; an empty list for 3 characters or more means no match. fail gets a DockError. Each item is a Suggestion, Suggestion.Door or Suggestion.Result; label(item, lang) gives its line, with "(unofficial)" when not matched. When the person taps one, call field.choose(item): it stops what is in flight and passes a result to pick (offline search, and telemetry if the user opted in); a door from a code is not passed. Call input, cancel and choose from the main thread. Options: limit (default 5), proximity (asked on each request), types (only these kinds, as on geocode: listOf(GeocodeType.STREET) for a street picker; a code or a door link is looked up as a door only when types has DOOR), minChars, delayMs. No view: draw the list in your own Compose LazyColumn or RecyclerView. Same rules as attachSuggest in the JS SDK.

Trips ​

kotlin
val feed = PositionFeed()
dock.startTrace(Consent.OPT_IN, feed)
val now = System.currentTimeMillis()
feed.push(PositionFix(106.9177, 47.9186, accuracy = 8.0, timeMs = now))
feed.push(PositionFix(106.9183, 47.9189, accuracy = 8.0, timeMs = now + 5_000))
val trips = dock.stopTrace()
trips.firstOrNull()?.id?.let { id ->
    val stored = dock.trace(id)
    println("${stored.properties.points} points until ${stored.properties.purgeAfter}")
    dock.deleteTrace(id)
}

Off until startTrace(consent, source, maxAccuracyM). Call it only after the user said yes to sharing this trip; Consent.OPT_IN is the only value. The SDK never asks for location permission: your app does, with its own reason text. In your LocationCallback (FusedLocationProviderClient) or LocationListener, push each location: feed.push(PositionFix(l.longitude, l.latitude, if (l.hasAccuracy()) l.accuracy.toDouble() else null, l.time)). Or pass your own PositionSource. The client keeps [lon, lat, t]: 6 decimals, whole seconds UTC, one point a second at most. Left out: fixes less accurate than maxAccuracyM (default 50 m), fixes outside Mongolia, fixes that go back in time. Accuracy, speed, device and user are never sent. Points stay in memory, never on disk. tracing is true while recording. stopTrace() sends the trip to POST /v1/traces, split every 20,000 points, and returns one receipt per part. Keep each id: deleteTrace(id) erases the points now, trace(id) is your export. Under 2 points sends nothing. A failed send throws and keeps the points in memory: call stopTrace() again to retry, cancelTrace() to drop them. Points inside a restricted site are not stored. Raw points are erased after 90 days. Needs trace:write: use a separate key for the recorder, not the app's map key.

Trip cells ​

kotlin
val box = BBox(106.7, 47.8, 107.2, 48.0)
val own = dock.ownTraceCells("2026-10", box)
println("${own.closed} ${own.features.size} ${own.features.firstOrNull()?.properties?.trips ?: 0}")
val all = dock.traceCells("2026-09", box)
println("${all.month} ${all.features.size}")
val speeds = dock.traceSpeeds("2026-09", box)
println("${speeds.month} ${speeds.features.size} ${speeds.features.firstOrNull()?.properties?.kmH ?: 0}")
val roads = dock.traceRoads("2026-09", box)
println("${roads.month} ${roads.features.size} ${roads.features.firstOrNull()?.properties?.kmH ?: 0} ${roads.features.firstOrNull()?.geometry?.lines?.size ?: 0}")
val peak = dock.traceRoads("2026-09", box, RoadBand.AM_PEAK)
println("am_peak ${peak.features.size} ${peak.features.firstOrNull()?.properties?.kmH ?: 0}")

ownTraceCells is your dashboard: your organization's trips per 500 m cell and month, every cell with one of them, no other organization read. The month you are in is answered too and grows as trips arrive; closed is true from 14 days after the month ends, when the counts are final. Needs trace:read, a read-only scope for a dashboard key: it cannot send, export or delete a trip, and trace:write does not read the cells. traceCells is all partners together, the same grid: a cell shows only with trips from 3 or more organizations and 5 or more trips left after taking out the biggest one. No cell means too few, not zero. Test and internal organizations do not count. Whole months only, from 14 days after the month ends (400 month_open before). Needs insights:read. Both take month as YYYY-MM (Ulaanbaatar) and a BBox inside Mongolia, sides at most 1 degree (400 bad_bbox). rule keeps the API's keys as written (min_orgs). No ids, times or lines.

traceSpeeds is the moving speed of all partners' trips per cell, the same grid, month and box, under the same rule. A trip is cut into segments between its stored points. A segment counts when its points are 1 to 60 seconds apart, it moves at 3 to 130 km/h, and its midpoint is more than 200 m from the trip's first and last point: parking and the walk to the door are left out. kmH is the counted meters over the counted seconds in the cell, to 0.1, so a cell with more trips weighs more. Per cell; per road is traceRoads, below. No cell means too few trips, not a slow road. No trip count. Needs insights:read.

traceRoads is the same speed per road. Each trip is matched to OSM ways with Valhalla map matching when it is stored, and a segment counts on a way when both its points matched that way, under the traceSpeeds rule. One feature per road: id is the OSM way id as text, the line is dock's current road with that id, and properties has kmH (counted meters over counted seconds on the way, to 0.1), nameMn, nameEn and highway, null when unknown. The geometry is a TraceRoadGeometry, LineString, or MultiLineString when the road is in pieces; geometry.lines lists every piece, so one drawing code takes both. A road shows only under the same rule; no road means too few trips, not a slow road. A way crossing a restricted site is left out. Empty until dock's road lines are loaded. No trip count. Needs insights:read.

band (API 0.49.0) gives one hour band's trips only, by the Ulaanbaatar time of a trip's first stored point: RoadBand.AM_PEAK Monday to Friday 07:00 to 09:59, RoadBand.PM_PEAK Monday to Friday 17:00 to 19:59, RoadBand.OFF_PEAK every other hour, Saturday and Sunday whole. Each band passes the same rule on its own trips, so a road shown for all hours can be missing from a band. Left out: all hours. rule["hours"] says which.

Arrival minutes ​

kotlin
val arrivals = dock.ownArrivals("2026-10")
println("${arrivals.month} ${arrivals.closed} ${arrivals.trips} ${arrivals.slow} ${arrivals.median ?: 0} ${arrivals.features.size}")

ownArrivals is how long your trips spend in the last 200 m: for each trip, whole minutes from the first point after it was last more than 200 m from its end, to its end; 60 means 60 or more. Circling inside 200 m counts; a trip that never left 200 m of its end is not counted. The minutes include parking and the walk to the door: compare months and places with them. trips, slow (5 minutes or more), median, p75 and p90 (nearest rank, null with no trips) cover the month; histogram is trips per minute, minutes with none left out. features lists up to 100 cells where trips ended, on the trip cells grid, by slow trips, then trips; cells counts them all. Your organization only, no other organization read. The month you are in is answered too; closed is true from 14 days after the month ends. Deleting a trip takes it out; the 90-day erase of the raw points keeps it. Needs trace:read. A bad month is 400 bad_month.

Trip hours ​

kotlin
val hours = dock.ownHours("2026-10")
println("${hours.month} ${hours.closed} ${hours.trips} ${hours.byWeekday[0]} ${hours.byHour[9]} ${hours.slots.size}")
val all = dock.allHours("2026-09")
println("${all.month} ${all.slots.size} ${all.slots.firstOrNull()?.trips ?: 0}")
val khoroos = dock.traceKhoroos("2026-09")
println("${khoroos.month} ${khoroos.features.size} ${khoroos.features.firstOrNull()?.properties?.khoroo ?: 0}")
val myKhoroos = dock.ownKhoroos("2026-10")
println("${myKhoroos.month} ${myKhoroos.closed} ${myKhoroos.trips} ${myKhoroos.noKhoroo} ${myKhoroos.features.size}")
val myKinds = dock.ownKinds("2026-10")
println("${myKinds.month} ${myKinds.closed} ${myKinds.trips} ${myKinds.kinds.size} ${myKinds.kinds.firstOrNull()?.trips ?: 0}")

ownHours is when your trips start: each trip counts once, in the Ulaanbaatar weekday (1 Monday to 7 Sunday) and hour (0 to 23) of its first stored point. trips is the month total; byWeekday has 7 counts, Monday first; byHour has 24, hour 0 first; slots lists every weekday and hour with trips, weekday then hour, and leaves out the ones with none. A trip belongs to the month it started in, as in ownTraceCells. Your organization only, no other organization read. The month you are in is answered too; closed is true from 14 days after the month ends. Deleting a trip takes it out; the 90-day erase of the raw points keeps it. Needs trace:read. A bad month is 400 bad_month.

allHours is all partners together, under the trip cells rule: a weekday and hour shows only with trips from 3 or more organizations and 5 or more trips left after taking out the biggest one. No slot means too few, not zero. There is no total and no weekday or hour sums, so a left-out slot cannot be worked out. Test and internal organizations do not count. Whole months only, from 14 days after the month ends (400 month_open before). Needs insights:read.

traceKhoroos is where and when trips end, all partners together, under the same rule: each trip counts once, in the Ulaanbaatar hour (0 to 23) of its last stored point and the khoroo holding the centre of its end cell. One feature per khoroo: the polygon, districtMn, districtEn, the khoroo number and hours, each hour that passed the rule. No hour means too few, not zero. There is no khoroo total. Current khoroo polygons only; a khoroo with no polygon is not shown. Whole months only, from 14 days after the month ends (400 month_open before). Needs insights:read.

ownKhoroos is where and when your own trips end, per khoroo and hour of arrival, read as traceKhoroos but with no group rule: the trips are yours. trips is the month total, noKhoroo the trips that ended in no current khoroo polygon, and each feature has the khoroo's trips and every hour with trips. trips is noKhoroo plus every khoroo's trips. Until khoroo polygons are loaded every trip is in noKhoroo. Your organization only. The month you are in is answered too; closed is true from 14 days after the month ends. Needs trace:read. A bad month is 400 bad_month.

ownKinds is what kind of building your trips end at: each trip counts once, by the building whose footprint holds its last stored point, else the nearest within 30 m, and that building's type when the trip was stored (apartment, ger_house, commercial, public, industrial, garage, other). unknown is a building with no type yet, none no building within 30 m. A building inside a restricted site is not read. trips is the month total and the sum of every kind's trips; kinds lists the kinds with trips, most first. Your organization only, no group rule. The month you are in is answered too; closed is true from 14 days after the month ends. Needs trace:read. A bad month is 400 bad_month.

Errors ​

kotlin
try {
    dock.door("00000")
} catch (e: DockError) {
    when {
        e.code == "not_found" -> println("no door has this code")
        e.retryAfter != null -> println("wait ${e.retryAfter} s")
        else -> println("${e.status} ${e.code} ${e.message}")
    }
}

status is the HTTP status, code the API error (invalid_code, not_found, retired_code, rate_limited, quota_exceeded, miss_cap, ...). No response: status 0, code network. retryAfter is in seconds on 429 and 503, otherwise null. The SDK does not retry on its own. Coroutine cancellation passes through.

Map ​

kotlin
val style = dock.styleJson(StyleOptions(building = "#1f5c8f"))
val saved = dock.styleUrl("st_7m4xq2b9kd")
println(java.net.URI(saved).path)

styleJson(options) gives the dock style as MapLibre style JSON: background, buildings, doors colored by confidence, the same style as the JS SDK's dockStyle. Its two tile sources carry your key. styleUrl(id) gives a style saved in the style editor, keyed; it throws IllegalArgumentException unless the id is st_ and 10 characters. The API carries the key from these URLs into the tile URLs, so MapLibre Native needs no request interceptor. Both strings hold the key: do not log them.

kotlin
// app build.gradle.kts: implementation("org.maplibre.gl:android-sdk:13.6.1") next to the dock line
MapLibre.getInstance(this)
mapView.getMapAsync { map ->
    map.setStyle(Style.Builder().fromJson(dock.styleJson()))
    map.cameraPosition = CameraPosition.Builder().target(LatLng(UB_CENTER.lat, UB_CENTER.lon)).zoom(15.0).build()
}

MapLibre Native Android 13 needs Android 6 (API 23); this SDK runs from API 21. Each release draws both styles with MapLibre Native 13.6.1 on an Android 16 emulator, with both its OpenGL ES build (android-sdk-opengl) and its Vulkan build (android-sdk), and checks the colors and which GPU driver drew. To put dock buildings and doors on your own basemap style, add dockSources(baseUrl) and dockLayers(options) to it and key the sources with authorizedUrl(). tileJsonUrl(source) gives the TileJSON URL of building or entrance, without the key; authorizedUrl() adds access_token to dock URLs only, any other URL comes back as it was.

Deprecated routes ​

kotlin
val logged = Client("dk_...", onDeprecation = { n -> println("${n.route} ${n.sunset ?: "-"}") })

When an answer says its route is deprecated (see Status), the client calls onDeprecation once per route with route (the pattern, never the URL), deprecated, sunset and link. The default prints one line per route. No route is deprecated today.

Networking ​

UrlConnectionTransport (HttpURLConnection) by default. To use OkHttp or your own client, pass a DockTransport as transport: it gets method, URL, headers and body, and throws IOException when no answer came back. On Android, plain http (a local stack) needs a network security config; https://geo.dock.mn does not.

Keys ​

The key is sent only in the Authorization header, and access_token only on URLs from authorizedUrl(). toString() of a client or a request shows the base URL or the method and URL only. An app carries its key to every user: give it only tiles:read, geocode:read and door:read.

Reference ​

Every type and signature, with its doc comment, written from the code: Android SDK reference.

Name
ClientClient(key, baseUrl, transport, onDeprecation, telemetry, placeCache). Holds the key. Throws DockError on a malformed key.
geocode(q, limit, proximity, types)Name or address to places, best first. proximity: nearer first among equal name matches. types (GeocodeType) keeps only those kinds, chosen before the limit. geocode:read.
reverse(point, radius, limit, types)Places at and around a point, nearest first, with district and khoroo. types (ReverseType) keeps only those kinds, chosen before the limit. geocode:read.
nearby(point, categories, radius, limit)Places of these categories near a point, nearest first, with category and distance. categories (NearbyCategory) sent once each, in order; none is a 400 invalid_category with no request. A store has code null: show "код алга" and the official address. geocode:read.
door(code, unit)A door from its code. door:read.
route(from, to, mode, departAt, via)A route between points or codes, to the door for a code, through via in order. route:read.
isochrone(from, contoursMinutes, mode)The area reached from one end within 1 to 4 times of up to 60 minutes, as GeoJSON. route:read.
matching(coordinates, timestamps, mode)A GPS trace on the roads it went along: one line per unbroken piece, one place per point. 2 to MATCHING_MAX (100) points. route:read.
optimizedTrips(stops, roundtrip, mode)Stops in the order with the least time on the road, and the trip in that order. 2 to OPTIMIZED_TRIPS_MAX (12) stops. route:read.
tilequery(point, sources, radius, limit, geometry)The buildings and doors at or near a point, nearest first, as GeoJSON. Radius up to TILEQUERY_RADIUS_MAX_M (1000) m, up to TILEQUERY_LIMIT_MAX (50) features. tiles:read.
matrix(from, to, mode)Time and distance from every from to every to in one call, up to MATRIX_MAX (10) a side. route:read.
place(id)A place again by its id, with parents. geocode:read.
changes(since, limit, cursor)One page of ids changed in [since, until). changes:read.
confirm(confirmation)A driver on site confirms a door, or says it is elsewhere or gone. confirm:write.
ownConfirmations(month)Your drivers' confirmations and door reports in a month, by result and day, with failure points; closed once final. confirm:read.
closures()Road closures not over today, each with its ring. route:read.
reportClosure(report, idempotencyKey)Report a stretch or crossing that cannot be driven; idempotencyKey makes a retry safe. closures:write.
closure(id)One of your closure reports as stored, note included. closures:write.
liftClosure(id, on)The road is open again from on, today when null. closures:write.
excludePolygons(closures, day)Offline: the rings of the closures active on a route day, for Valhalla's exclude_polygons. Throws ClosuresOverLimitException.
ExcludePolygons.closureAt(point)Offline: the id of the closure whose ring holds the point, or null.
crossingsOnRoute(crossings, line, month)Offline: the fords and unbridged crossings a route line passes, from a steppe pack's crossings.geojson, with high water for the month.
LOW_FLOW_CONFIDENCE0.1. At this flow confidence or under, monthM3s is null.
PackCrossings.fromJson(text)A pack's crossings.geojson, from the file's text.
ubMonth()This month in Ulaanbaatar, 1 to 12.
poisOnRoute(pois, line, nearM)Offline: the registered fuel stations and ger camps within nearM of a route line, from a steppe pack's pois.geojson, with the counts of unregistered ones.
POI_NEAR_M2000.0, poisOnRoute's default nearM, m.
wetlandsOnRoute(wetlands, line)Offline: the stretches through OSM wetland a route line runs on, from a steppe pack's wetlands.geojson, with metres along and on.
WETLAND_MIN_ON_M20.0, m: a route on less of a stretch only crosses it, unless it runs on all of it.
passesOnRoute(passes, line)Offline: the OSM mountain passes a route line goes over, from a steppe pack's passes.geojson, with metres along, metres off, the OSM and DEM heights and the highest of each, and where the two disagree.
sandOnRoute(sand, line)Offline: the stretches through OSM sand a route line runs on, from a steppe pack's sand.geojson, with metres along and on.
SAND_MIN_ON_M20.0, m: a route on less of a stretch only crosses it, unless it runs on all of it.
waterOnRoute(water, line, nearM)Offline: the OSM wells, springs, taps and drinking water within nearM (default the pack's, 2000 m) of a route line, from a steppe pack's water.geojson, with metres along, metres off and what OSM says about drinking it.
basemapStyle(basemap, url, options)Offline: a whole MapLibre style for a steppe pack's basemap.pmtiles, the same JSON as the JS SDK.
PackBasemap.fromManifest(text)The basemap block of a pack's manifest.json, null if none.
BASEMAP_EXCLUDEDpoi, housenumber: layers the base map must not carry.
PackPois.fromJson(text)A pack's pois.geojson, from the file's text.
checkPack(manifest, directory)Offline: each file a steppe pack's manifest lists, in the pack folder, by size and sha256; a status per file: OK, MISSING, BYTES, SHA256. Blocks: call on Dispatchers.IO.
PackManifestFiles.fromJson(text)A pack's manifest.json, from the file's text; only files is read.
packRoute(manifest, stops, day, closures)Offline: the Valhalla request for a route on a steppe pack: the manifest's costing, the route day's closures as exclude_polygons, units km; with the closures avoided, attribution, confidenceNote and traceNote when the pack has no trace scores, in the pack's words when its manifest has them. Throws IllegalArgumentException, ClosuresOverLimitException, EndInClosureException.
PackRouteManifest.fromJson(text)A pack's manifest.json, from the file's text; costingOptions keeps its keys as written.
PACK_TRACE_NOTE, PACK_TRACE_NOTE_MNWhat to say when a pack has no trace scores (confidence osm_only).
SuggesterThe address field: Suggester(client, scope, limit, proximity, types, minChars, delayMs, show, fail), then input(text) on each change, choose(item) on a tap, cancel(). Main thread.
SuggestionSuggestion.Door(door) from a typed code, or Suggestion.Result(result). label(item, lang) reads it.
SUGGEST_MIN_CHARS, SUGGEST_DELAY_MS3 and 200.
parseDoorCode(text)Offline: a typed or pasted building code as the API reads it, DoorCodeInput.Code(code, unit) or DoorCodeInput.Invalid(error, reason).
doorLink(text, unit)Offline: the link to share a door, https://map.dock.mn/d/7QSVX?u=17, or null when the text cannot be a code.
parseDoorLink(text)Offline: the first door link in a text, read as parseDoorCode reads a code and a unit, or null when there is none.
DOOR_LINK_BASE"https://map.dock.mn/d/".
doorQr(text, unit)Offline: the QR of a door link, DoorQr(link, version, size, modules), or null when the text cannot be a code.
doorQrSvg(text, unit, px, margin)Offline: that QR as SVG text, px per module (4.0), margin light modules (4), or null.
doorQrPixels(text, unit, px, margin)Offline: that QR as DoorQrPixels(link, side, argb) for a Bitmap, px pixels a module (8), margin (4), or null.
DOOR_CODE_ALPHABET, DOOR_CODE_LENGTHThe 30 characters a code uses, and 5.
reportDoor(report)Report a wrong, missing, duplicate or unmapped door. door:read.
usage(days)Your organization's caps and last 1 to 90 days.
tileJsonUrl(source)TileJSON URL of building or entrance, without the key.
authorizedUrl(url)Adds access_token to a dock URL, for MapLibre Native.
styleJson(options)The dock style as MapLibre style JSON, its sources keyed. For Style.Builder().fromJson.
styleUrl(styleId)A saved style's URL, keyed. For Style.Builder().fromUri.
StyleOptionsbackground, building, buildingOutline colors, extrude (default false).
dockStyle(baseUrl, options), dockSources(baseUrl), dockLayers(options)The style, its sources and its layers, without the key.
UB_CENTERSükhbaatar Square, LonLat(106.9177, 47.9186).
setTelemetry(on)Search telemetry on or off, after the user's choice. Off drops what waits.
pick(result)The user chose this geocode result. Kept for offline search; sent only with telemetry on.
places()Places the user picked on this device, most picked first.
forgetPlaces()Forgets them, in memory and in the store.
PlaceCacheOptionsmax (default 100) and store.
PlaceStoreThree calls: getString(key), putString(key, value), remove(key).
PlaceA kept place: the GeocodeResult fields without score, layer, near.
flushTelemetry()Sends waiting searches now. null when off or nothing waits.
appInBackground()Sends waiting searches without waiting. Call it from onStop.
startTrace(consent, source, maxAccuracyM)Starts recording a trip, after the user said yes.
stopTrace()Stops and sends the trip. One TraceReceipt per 20,000 points. trace:write.
cancelTrace()Stops and drops the trip unsent.
tracingtrue while a trip is being recorded.
trace(id)One of your trips as stored, GeoJSON. trace:write.
deleteTrace(id)Erases a trip's points now. trace:write.
ownTraceCells(month, bbox)Your own trips per 500 m cell and month, every cell with one; closed once final. trace:read.
ownArrivals(month)Your trips' minutes in the last 200 m in a month: median, p75, p90, per minute and per end cell; closed once final. trace:read.
ownHours(month)Your trips per weekday and hour in a month, at the first point's Ulaanbaatar time; closed once final. trace:read.
allHours(month)All partners' trips per weekday and hour, a published month, only slots from 3 or more organizations. insights:read.
traceCells(month, bbox)All partners' trips per cell, a published month, only cells from 3 or more organizations. insights:read.
traceSpeeds(month, bbox)All partners' moving speed per cell, a published month, only cells from 3 or more organizations. insights:read.
traceRoads(month, bbox, band)All partners' moving speed per road, a published month, only roads from 3 or more organizations; band for one hour band. insights:read.
traceKhoroos(month)All partners' trips per khoroo and hour of arrival, a published month, only hours from 3 or more organizations. insights:read.
ownKhoroos(month)Your trips per khoroo and hour of arrival in a month, and those in no khoroo; closed once final. trace:read.
ownKinds(month)Your trips per kind of building at the trip end in a month, most first; closed once final. trace:read.
PositionFeedA PositionSource you push PositionFix values into, from your location callback.
PositionFixOne position: lon, lat, accuracy, timeMs (as Location.getTime()). Only lon, lat and time are sent.
ConsentConsent.OPT_IN, the user said yes. The only value.
DockErrorEvery failure: status, code, message, retryAfter.
DockTransportOne suspend method, send(request: HttpRequest): HttpResponse. Default UrlConnectionTransport.
label(place, lang)Text to show a person for a door or place, with "(unofficial)" when not matched. Same rules as JS.
LangLang.MN or Lang.EN.
IDEMPOTENCY_KEY_REThe Idempotency-Key format: 1 to 64 of letters, digits and ._:-.
isValidKey(key)The key format: dk_ and 32 characters.
DOCK_SDK_VERSION"0.65.0".

Every response type (Door, GeocodeResponse, Route, UsageResponse, ...) is checked field by field against the API reference in ./gradlew test. Every block on this page except the two Gradle files runs there too, on a fake transport. Each release also runs on Android emulators, API 21 and API 36, inside an app built with the Android Gradle Plugin that takes the SDK from the Maven repository: building codes, closure rings, river crossings, HttpURLConnection against a local server, search telemetry, and a trip recorded from the emulator's GPS through LocationManager and a PositionFeed. Every call in the jar is checked against API 21 at build time.