Bỏ qua

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

// 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)

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 polyline changes, compute LatLngBounds and call cameraPositionState.animate(CameraUpdateFactory.newLatLngBounds(bounds, 80)) within a LaunchedEffect(state.polyline).
  • In‑memory cache: MutableMap<String, VMPlaceDetail> 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

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}"
    }
}

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);
});
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

facebook
Tổng đài hỗ trợ
089.616.4567
facebook Chat Facebook zalo Chat Zalo