Integrating VietMap APIs inside a Google Maps Android app (Direct HTTP)¶
Requirement: Keep using Google Maps (Google Play Services Maps) for base map rendering, while ALL search (autocomplete / merged search v4), place detail (v4) and routing (v4) data is sourced from VietMap APIs (no Google Places SDK usage). This guide shows a clean Kotlin + Coroutines + Retrofit approach.
Need vector tile styling, traffic layers or turn‑by‑turn? Migrate later to the native VietMap SDKs. This document focuses on a hybrid approach: Google map canvas + VietMap data.
1. Prerequisites¶
- VietMap API key from https://maps.vietmap.vn/ (call it
VIETMAP_API_KEY). - Google Maps Android SDK already configured (API key in
AndroidManifest). - Min SDK 23+ (recommended), Kotlin + Coroutines.
- (Recommended) Backend proxy to hide the VietMap key (see section 10) though direct calls are shown here.
- Enable location permission if you plan to bias autocomplete with
focus=lat,lng.
Gradle (KTS) dependencies¶
// module build.gradle.kts
dependencies {
implementation("com.google.android.gms:play-services-maps:18.2.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.14")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation("com.squareup.moshi:moshi-kotlin:1.15.1")
kapt("com.squareup.moshi:moshi-kotlin-codegen:1.15.1")
}
2. VietMap endpoints used¶
| Purpose | Method & Path | Notes |
|---|---|---|
| Autocomplete / Merged Search v4 | GET /api/autocomplete/v4 |
Returns list (NO coordinates) |
| Place Detail v4 | GET /api/place/v4 |
Use refid to fetch lat/lng & address parts |
| Route v4 | GET /api/route/v4 |
Encoded polyline in paths[].points |
Base host: https://maps.vietmap.vn
3. Raw examples¶
Autocomplete v4 with focus:
GET https://maps.vietmap.vn/api/autocomplete/v4?apikey=YOUR_KEY&text=197 Tran Phu&focus=10.7769,106.7008&display_type=1
GET https://maps.vietmap.vn/api/route/v4?apikey=YOUR_KEY&vehicle=car&points_encoded=true&point=10.7769,106.7008&point=10.7801,106.7102
4. Data models (Moshi)¶
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class VMSearchItem(
@Json(name = "ref_id") val refId: String,
val distance: Double?,
val address: String?,
val name: String?,
val display: String?,
val boundaries: List<VMBoundary>?,
val categories: List<String>?,
@Json(name = "entry_points") val entryPoints: List<VMEntryPoint>?,
@Json(name = "data_old") val dataOld: VMSearchVariant?,
@Json(name = "data_new") val dataNew: VMSearchVariant?
)
@JsonClass(generateAdapter = true)
data class VMBoundary(
val type: Int?,
val id: Int?,
val name: String?,
val prefix: String?,
@Json(name = "full_name") val fullName: String?
)
@JsonClass(generateAdapter = true)
data class VMEntryPoint(
@Json(name = "ref_id") val refId: String?,
val name: String?
)
@JsonClass(generateAdapter = true)
data class VMSearchVariant(
@Json(name = "ref_id") val refId: String?,
val distance: Double?,
val address: String?,
val name: String?,
val display: String?,
val boundaries: List<VMBoundary>?,
val categories: List<String>?,
@Json(name = "entry_points") val entryPoints: List<VMEntryPoint>?
)
@JsonClass(generateAdapter = true)
data class VMPlaceDetail(
val display: String?,
val name: String?,
@Json(name = "hs_num") val hsNum: String?,
val street: String?,
val address: String?,
@Json(name = "city_id") val cityId: Int?,
val city: String?,
@Json(name = "district_id") val districtId: Int?,
val district: String?,
@Json(name = "ward_id") val wardId: Int?,
val ward: String?,
val lat: Double?,
val lng: Double?
)
@JsonClass(generateAdapter = true)
data class VMRouteResponse(val paths: List<VMRoutePath>?)
@JsonClass(generateAdapter = true)
data class VMRoutePath(val distance: Double, val time: Double?, val points: String)
5. Retrofit service¶
import retrofit2.http.GET
import retrofit2.http.Query
import vn.vietmap.vietmapgooglesdk.data.VMPlaceDetail
import vn.vietmap.vietmapgooglesdk.data.VMSearchItem
interface VietMapApi {
@GET("/api/autocomplete/v4")
suspend fun autocomplete(
@Query("apikey") apiKey: String,
@Query("text") text: String,
@Query("display_type") displayType: Int = 1,
@Query("focus") focus: String? = null
): List<VMSearchItem>
@GET("/api/place/v4")
suspend fun placeDetail(
@Query("apikey") apiKey: String,
@Query("refid") refId: String
): VMPlaceDetail
}
Route (v4) (dynamic query building):
import okhttp3.OkHttpClient
import okhttp3.Request
import vn.vietmap.vietmapgooglesdk.data.VMRouteResponse
import com.squareup.moshi.Moshi
class VietMapRouteClient(private val apiKey: String, private val client: OkHttpClient, private val moshi: Moshi) {
suspend fun route(points: List<Pair<Double,Double>>, vehicle: String = "car", encoded: Boolean = true): VMRouteResponse =
kotlinx.coroutines.Dispatchers.IO.let { dispatcher ->
kotlinx.coroutines.withContext(dispatcher) {
require(points.size >= 2) { "Need at least 2 points" }
val base = StringBuilder("https://maps.vietmap.vn/api/route/v4?apikey=$apiKey&vehicle=$vehicle&points_encoded=$encoded")
points.forEach { (lat,lng) -> base.append("&point=$lat,$lng") }
val req = Request.Builder().url(base.toString()).get().build()
client.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) throw IllegalStateException("Route failed ${resp.code}")
val body = resp.body.string()
val adapter = moshi.adapter(VMRouteResponse::class.java)
adapter.fromJson(body) ?: VMRouteResponse(emptyList())
}
}
}
}
Building Retrofit¶
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
object VietMapServiceFactory {
fun create(apiKey: String, logging: Boolean = true): Triple<VietMapApi, VietMapRouteClient, OkHttpClient> {
val interceptor = HttpLoggingInterceptor().apply {
level = if (logging) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
}
val client = OkHttpClient.Builder().addInterceptor(interceptor).build()
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
val retrofit = Retrofit.Builder()
.baseUrl("https://maps.vietmap.vn/")
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(client)
.build()
val api = retrofit.create(VietMapApi::class.java)
val routeClient = VietMapRouteClient(apiKey = apiKey, client = client, moshi = moshi)
return Triple(api, routeClient, client)
}
}
6. Polyline decoder (precision 1e5)¶
import com.google.android.gms.maps.model.LatLng
fun decodePolyline(encoded: String, precision: Double = 1e5): List<LatLng> {
val coords = ArrayList<LatLng>()
var index = 0
val len = encoded.length
var lat = 0
var lng = 0
while (index < len) {
var b: Int
var shift = 0
var result = 0
do {
b = encoded[index++].code - 63
result = result or ((b and 0x1f) shl shift)
shift += 5
} while (b >= 0x20 && index < len)
val dlat = if ((result and 1) != 0) (result shr 1).inv() else (result shr 1)
lat += dlat
shift = 0
result = 0
do {
b = encoded[index++].code - 63
result = result or ((b and 0x1f) shl shift)
shift += 5
} while (b >= 0x20 && index < len)
val dlng = if ((result and 1) != 0) (result shr 1).inv() else (result shr 1)
lng += dlng
coords += LatLng(lat / precision, lng / precision)
}
return coords
}
7. Jetpack Compose integration (no XML, no Fragments)¶
This section provides a fully declarative implementation using Jetpack Compose & Google Maps Compose. It replaces the previous XML / RecyclerView / SupportMapFragment approach.
7.1 Add (or extend) dependencies (Compose + Maps)¶
dependencies {
implementation("com.google.android.gms:play-services-maps:18.2.0")
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-moshi:2.11.0")
implementation("com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.14")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
// Compose BOM & core
implementation(platform("androidx.compose:compose-bom:2024.05.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.9.0")
// Google Maps Compose
implementation("com.google.maps.android:maps-compose:4.4.0")
}
7.2 UI State¶
import com.google.android.gms.maps.model.LatLng
import vn.vietmap.vietmapgooglesdk.data.VMPlaceDetail
import vn.vietmap.vietmapgooglesdk.data.VMSearchItem
data class RouteUiState(
val query: String = "",
val suggestions: List<VMSearchItem> = emptyList(),
val from: VMPlaceDetail? = null,
val to: VMPlaceDetail? = null,
val polyline: List<LatLng> = emptyList(),
val distanceMeters: Double? = null,
val timeSeconds: Double? = null,
val loading: Boolean = false,
val error: String? = null
)
7.3 ViewModel with debounce & routing¶
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import vn.vietmap.vietmapgooglesdk.services.VietMapApi
import vn.vietmap.vietmapgooglesdk.services.VietMapRouteClient
import vn.vietmap.vietmapgooglesdk.data.VMSearchItem
import vn.vietmap.vietmapgooglesdk.data.VMPlaceDetail
import vn.vietmap.vietmapgooglesdk.helper.decodePolyline
import com.google.android.gms.maps.model.LatLng
import vn.vietmap.vietmapgooglesdk.data.VMRouteResponse
import kotlinx.coroutines.delay as delay1
class VietMapComposeViewModel(
private val api: VietMapApi,
private val routeClient: VietMapRouteClient,
private val apiKey: String
) : ViewModel() {
private val _state = MutableStateFlow(RouteUiState())
val state: StateFlow<RouteUiState> = _state.asStateFlow()
private var searchJob: Job? = null
fun onQueryChange(newQuery: String) {
_state.update { it.copy(query = newQuery, loading = true, error = null) }
searchJob?.cancel()
if (newQuery.isBlank()) {
_state.update { it.copy(suggestions = emptyList(), loading = false) }
return
}
searchJob = viewModelScope.launch {
delay1(timeMillis = 350)
runCatching { withContext(Dispatchers.IO) { api.autocomplete(apiKey, newQuery) } }
.onSuccess { list: List<VMSearchItem> ->
_state.update {
it.copy(
suggestions = list,
loading = false
)
}
}
.onFailure { e: Throwable ->
_state.update {
it.copy(
error = e.message,
loading = false
)
}
}
}
}
fun onSuggestionClick(item: VMSearchItem) {
viewModelScope.launch {
runCatching { withContext(Dispatchers.IO) { api.placeDetail(apiKey, item.refId) } }
.onSuccess { detail: VMPlaceDetail ->
val curr = state.value
val (newFrom, newTo) = when {
curr.from == null -> detail to null
curr.to == null -> curr.from to detail
else -> detail to null // reset start
}
_state.update { it.copy(from = newFrom, to = newTo, error = null) }
if (newTo != null) buildRoute(newFrom, newTo) else clearPolyline()
}
.onFailure { e: Throwable -> _state.update { it.copy(error = e.message) } }
}
}
private fun buildRoute(from: VMPlaceDetail, to: VMPlaceDetail) {
val fLat = from.lat; val fLng = from.lng; val tLat = to.lat; val tLng = to.lng
if (fLat == null || fLng == null || tLat == null || tLng == null) return
viewModelScope.launch {
_state.update { it.copy(loading = true, error = null) }
runCatching { withContext(Dispatchers.IO) { routeClient.route(listOf(fLat to fLng, tLat to tLng)) } }
.onSuccess { resp: VMRouteResponse ->
val path = resp.paths?.firstOrNull()
val points = path?.points
val decoded = if (points.isNullOrBlank()) emptyList<LatLng>() else decodePolyline(points)
_state.update { it.copy(polyline = decoded, distanceMeters = path?.distance, timeSeconds = path?.time, loading = false) }
}
.onFailure { e: Throwable -> _state.update { it.copy(error = e.message, loading = false) } }
}
}
private fun clearPolyline() = _state.update { it.copy(polyline = emptyList(), distanceMeters = null, timeSeconds = null) }
fun reset() {
_state.update { it.copy(from = null, to = null, polyline = emptyList(), distanceMeters = null, timeSeconds = null, error = null) }
}
}
7.4 Composable screen¶
import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.*
import androidx.compose.material3.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import com.google.android.gms.maps.MapView
import com.google.android.gms.maps.GoogleMap
import com.google.android.gms.maps.model.*
import com.google.android.gms.maps.CameraUpdateFactory
@Composable
fun VietMapComposeScreen(viewModel: VietMapComposeViewModel) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
var googleMap by remember { mutableStateOf<GoogleMap?>(null) }
val mapView = remember {
MapView(context).apply {
onCreate(null)
getMapAsync { map ->
googleMap = map
map.uiSettings.isZoomControlsEnabled = true
map.moveCamera(CameraUpdateFactory.newLatLngZoom(LatLng(10.776889, 106.700806), 12f))
}
}
}
// Update markers & polyline when state changes
LaunchedEffect(state.from, state.to, state.polyline) {
val map = googleMap ?: return@LaunchedEffect
map.clear()
val boundsBuilder = LatLngBounds.builder()
var addedAny = false
state.from?.let { f ->
val lat = f.lat; val lng = f.lng
if (lat != null && lng != null) {
val pos = LatLng(lat, lng)
map.addMarker(MarkerOptions().position(pos).title("From").snippet(f.display))
boundsBuilder.include(pos); addedAny = true
}
}
state.to?.let { t ->
val lat = t.lat; val lng = t.lng
if (lat != null && lng != null) {
val pos = LatLng(lat, lng)
map.addMarker(MarkerOptions().position(pos).title("To").snippet(t.display))
boundsBuilder.include(pos); addedAny = true
}
}
if (state.polyline.isNotEmpty()) {
val opts = PolylineOptions().color(0xFF1E88E5.toInt()).width(8f)
opts.addAll(state.polyline)
map.addPolyline(opts)
state.polyline.forEach { boundsBuilder.include(it) }
addedAny = true
}
if (addedAny) {
try {
val bounds = boundsBuilder.build()
map.animateCamera(CameraUpdateFactory.newLatLngBounds(bounds, 100))
} catch (_: Exception) { /* ignore (e.g. only one point) */ }
}
}
Column(Modifier.fillMaxSize()) {
OutlinedTextField(
value = state.query,
onValueChange = viewModel::onQueryChange,
modifier = Modifier.fillMaxWidth().padding(8.dp),
label = { Text("Search (v4)") },
singleLine = true
)
if (state.loading) LinearProgressIndicator(Modifier.fillMaxWidth())
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 12.dp)) }
Text(
text = "From: ${state.from?.display ?: "-"} | To: ${state.to?.display ?: "-"}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp)
)
if (state.distanceMeters != null || state.timeSeconds != null) {
Text(
text = buildString {
state.distanceMeters?.let { append("Distance: ${"%.1f".format(it/1000)} km ") }
state.timeSeconds?.let { append("Time: ${"%.1f".format(it/60)} min") }
}.trim(),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 12.dp)
)
}
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp)) {
if (state.from != null || state.to != null || state.polyline.isNotEmpty()) {
TextButton(onClick = viewModel::reset) { Text("Reset") }
}
}
if (state.suggestions.isNotEmpty()) {
LazyColumn(Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
items(state.suggestions, key = { it.refId }) { item ->
Column(Modifier.fillMaxWidth().clickable { viewModel.onSuggestionClick(item) }.padding(12.dp)) {
Text(item.display ?: item.name ?: "(no name)", style = MaterialTheme.typography.bodyMedium)
item.address?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
HorizontalDivider()
}
}
}
Box(Modifier.weight(1f)) {
AndroidView(factory = { mapView }, modifier = Modifier.fillMaxSize()) { v -> v.onResume() }
}
}
}
7.5 Hosting Activity¶
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import vn.vietmap.vietmapgooglesdk.services.VietMapServiceFactory
import vn.vietmap.vietmapgooglesdk.ui.VietMapComposeScreen
import vn.vietmap.vietmapgooglesdk.ui.VietMapComposeViewModel
import vn.vietmap.vietmapgooglesdk.ui.theme.VietMapGoogleSDKTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
VietMapGoogleSDKTheme {
val (api, routeClient, _) = remember { VietMapServiceFactory.create(BuildConfig.VIETMAP_API_KEY) }
val viewModel = remember { VietMapComposeViewModel(api, routeClient, BuildConfig.VIETMAP_API_KEY) }
Scaffold(modifier = Modifier.fillMaxSize()) { _ ->
VietMapComposeScreen(viewModel)
}
}
}
}
}
7.6 Optional improvements¶
- Auto-fit route bounds: when
polylinechanges, computeLatLngBoundsand callcameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, 80))within aLaunchedEffect(state.polyline). - In‑memory cache:
MutableMap<String, VMPlaceDetail>keyed byrefIdto short‑circuit duplicate place detail calls. - Persist recent queries via DataStore to pre-populate suggestions offline.
- Add location permission & supply
focus=lat,lngfor relevance.
7.7 Compose vs legacy (removed) summary¶
| Concern | Compose | Legacy (removed) |
|---|---|---|
| UI paradigm | Declarative state -> UI | Imperative findViewById |
| List | LazyColumn | RecyclerView Adapter |
| Map | GoogleMap composable |
SupportMapFragment |
| Debounce | In ViewModel with coroutine | Manual Job in Activity |
| State holder | Single data class + Flow | Activity fields |
| XML layout | None | Required |
8. Notes & best practices¶
- Debounce typing (>=300 ms) before calling autocomplete (shown via coroutine delay).
- Cache suggestions (query + rounded focus) ~30–60 s to reduce quota.
- Always resolve coordinates via Place detail (autocomplete v4 omits lat/lng).
- Validate at least 2 points before requesting a route; handle empty
paths. - Cancel in-flight search coroutine when user edits query again.
- Consider offline fallback / last-known results if network slow.
9. Basic error handling pattern¶
scope.launch {
try {
val suggestions = withContext(Dispatchers.IO) { api.autocomplete(API_KEY, query) }
// update UI
} catch (e: HttpException) {
statusText.text = "HTTP ${e.code()}"
} catch (e: IOException) {
statusText.text = "Network error"
} catch (e: Exception) {
statusText.text = "Unexpected: ${e.message}"
}
}
10. Optional backend proxy (recommended production)¶
Benefits: Hide API key, centralized logging, quota throttling, input validation, simple caching of popular queries.
Minimal Express snippet:
app.get('/vm/autocomplete', async (req,res)=>{
const { text, focus, display_type = '1'} = req.query;
if(!text) return res.status(400).json({error:'text required'});
const url = new URL('https://maps.vietmap.vn/api/autocomplete/v4');
url.searchParams.set('apikey', process.env.VIETMAP_KEY);
url.searchParams.set('text', text);
url.searchParams.set('display_type', display_type);
if(focus) url.searchParams.set('focus', focus);
const up = await fetch(url);
const data = await up.json();
res.status(up.status).json(data);
});
11. Quick verification checklist¶
| Step | Expect |
|---|---|
| Type "197 Tran Phu" | List of search suggestions (no lat/lng) |
| Tap first suggestion | Origin marker (From) appears |
| Tap second suggestion | Route drawn (polyline) + To marker |
| Tap third suggestion | Reset & set new From marker |
| Network off then search | Error shown (no crash) |
12. Conclusion¶
You can enrich an existing Google Maps Android app with VietMap search, place detail and routing using a small Retrofit + Compose stack. When you need advanced features (turn‑by‑turn navigation, vector tiles, traffic layers) explore the native VietMap SDK.
© VietMap