Source: https://maps.vietmap.vn/docs/sdk-mobile/vietmap-google-sdk/android/ # 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 1. VietMap API key from https://maps.vietmap.vn/ (call it `VIETMAP_API_KEY`). 2. Google Maps Android SDK already configured (API key in `AndroidManifest`). 3. Min SDK 23+ (recommended), Kotlin + Coroutines. 4. (Recommended) Backend proxy to hide the VietMap key (see section 10) though direct calls are shown here. 5. Enable location permission if you plan to bias autocomplete with `focus=lat,lng`. ### Gradle (KTS) dependencies ```kotlin // 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 ``` Place detail v4: ``` GET https://maps.vietmap.vn/api/place/v4?apikey=YOUR_KEY&refid=REF_ID ``` Route (2 points): ``` 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) ```kotlin 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?, val categories: List?, @Json(name = "entry_points") val entryPoints: List?, @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?, val categories: List?, @Json(name = "entry_points") val entryPoints: List? ) @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?) @JsonClass(generateAdapter = true) data class VMRoutePath(val distance: Double, val time: Double?, val points: String) ``` ## 5. Retrofit service ```kotlin 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 @GET("/api/place/v4") suspend fun placeDetail( @Query("apikey") apiKey: String, @Query("refid") refId: String ): VMPlaceDetail } ``` Route (v4) (dynamic query building): ```kotlin 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>, 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 ```kotlin 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 { 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) ```kotlin import com.google.android.gms.maps.model.LatLng fun decodePolyline(encoded: String, precision: Double = 1e5): List { val coords = ArrayList() 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) ```kotlin 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 ```kotlin 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 = emptyList(), val from: VMPlaceDetail? = null, val to: VMPlaceDetail? = null, val polyline: List = emptyList(), val distanceMeters: Double? = null, val timeSeconds: Double? = null, val loading: Boolean = false, val error: String? = null ) ``` ### 7.3 ViewModel with debounce & routing ```kotlin 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 = _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 -> _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() 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 ```kotlin 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(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 ```kotlin 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 `polyline` changes, compute `LatLngBounds` and call `cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, 80))` within a `LaunchedEffect(state.polyline)`. * In‑memory cache: `MutableMap` keyed by `refId` to short‑circuit duplicate place detail calls. * Persist recent queries via DataStore to pre-populate suggestions offline. * Add location permission & supply `focus=lat,lng` for 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 ```kotlin 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: ```js 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); }); ``` Android then calls your endpoint (no API key in app). ## 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