Google Map
Current Location
Note
You only need to add the Google Play Services support here if your application needs to display the user's current location.
Next, let's add the user's current location. A little later we'll use this for the initial map position and to keep track of where the user parked their car.
The user's Location is determined by the "Fused Location Provider". This service uses technologies such as GPS, cell towers, and wi-fi to determine the current location. Some of these provide precise location (such as GPS), while others might only be able to approximate user location.
Permissions overview
Because an application could send location information somewhere else (a server on the internet, for example), location is considered a "dangerous" permission, and we must ask the user if it's ok to use while the application is running.
The user has a choice: they can allow precise or approximate location information, for all runs of the application or just the current run, or deny the request. Ideally, your application should gracefully handle denied function. For our car-finder application, if the user denies current location tracking, we could, for example, allow the user to tap the location of their car on the map rather than automatically using the current location. (For this example application, we won't do that; we'll just tell the user the application cannot function without location.)
When requesting permissions at runtime, you should inform the user why you're requesting a permission. In this app, we'll put up a dialog telling them they need to provide location to track their car. But there's a problem here...
If the user denies permission twice, or explicitly denies the permission under the system application info, the request is considered permanently denied. You have a choice:
- If the functionality isn't critical to the app, silently disable the functionality that requires the permission.
- If the functionality is critical to the application, you can direct the user to the system application info screen where they can grant the permission.
The problem is that Android doesn't give you any way to determine if the request was permanently
denied. You can ask if you shouldShowRequestPermissionRationale(), but this function has an
ambiguous result. It returns true if the user denied once, the idea being that the first time
you don't show the rationale, and if they deny, explain why it's needed. In some cases, it might be
obvious why you're requesting a permission. For example, if there's a microphone button on the app,
and the user presses it, and you request microphone access, you probably don't need the rationale
the first time...
I generally like having a rationale always show unless it's extremely obvious.
shouldShowRequestPermissionRationale() returns false for two cases:
- The user has not requested the permission yet (that "first time" case)
- The user has permanently denied the request
Some apps keep a flag in the application's preferences marking if they requested permission at least once. But this doesn't work properly if the user turns the permission off via app settings!
The strategy we'll use in our sample app is to always show the rationale if we don't have permission. The dialog has three options:
- Ok - make the normal system permission request
- Quit - exit the application
- App Settings - jump to the app settings screen.

This is not ideal, but I think it's the only realistic option at this point...
Declaring and Requesting Permissions
Any needed permissions must be declared first in the AndroidManifest.xml. Here we declare
show in full file app/src/main/AndroidManifest.xml
// ...
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
// ...
</manifest>
- Coarse Location (for "approximate" location)
- Fine Location (for "precise" location)
Both of these are "dangerous" permissions and must be requested at runtime.
The Google Play Location Services allows us to set up a listener to receive location updates. We'll want to store the current location somewhere, and we'll need it when setting the car's location. The car's location will be persisted, and we want to ensure it stays across configuration changes. This sounds like a job for a View Model.
We access Play Location Services by adding a new dependency.
show in full file gradle/libs.versions.toml
[versions]
// ...
secrets = "2.0.1"
maps-compose = "8.4.0"
location-services = "21.4.0"
[libraries]
// ...
maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "maps-compose" }
maps-compose-utils = { group = "com.google.maps.android", name = "maps-compose-utils", version.ref = "maps-compose" }
location-services = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location-services" }
[plugins]
// ...
show in full file app/build.gradle.kts
// ...
dependencies {
implementation(libs.location.services)
implementation(libs.maps.compose)
implementation(libs.maps.compose.utils)
// ...
}
// ...
We'll need to track the location. We create a CarViewModel and set up a MutableStateFlow
as a private property in the view model. By convention, we prefix it with an underscore,
indicating it's the actual flow that we'll emit to. We want our view model to keep control of
emitted values, so we only expose a read-only Flow publicly.
But our MainActivity will be the thing that actually talks with the Fused Location Provider;
it'll need to update the current location (by calling updateLocation).
show in full file app/src/main/java/com/androidbyexample/google/maps/CarViewModel.kt
// ...
class CarViewModel(application: Application) : AndroidViewModel(application) {
// ...
}
val currentLocation: Flow<Location?>
field = MutableStateFlow<Location?>(null)
fun updateLocation(location: Location?) {
currentLocation.value = location
}
}
In the MainActivity, we define properties for the fused location provider client and the callback
that we'll register to receive location updates.
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
private val viewModel: CarViewModel by viewModels()
private lateinit var fusedLocationProviderClient: FusedLocationProviderClient
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
viewModel.updateLocation(locationResult.lastLocation)
}
}
private val locationPermissions = arrayOf(
// ...
}
Application state machine
There are several phases our application must go through before we can display the map with the current location. We'll represent these as a state machine, where the current state is exposed as a flow in the view model
show in full file app/src/main/java/com/androidbyexample/google/maps/CarViewModel.kt
// ...
class CarViewModel(application: Application) : AndroidViewModel(application) {
val appState: Flow<AppState>
field = MutableStateFlow<AppState>(Startup)
fun updateAppState(state: AppState) {
appState.value = state
}
val currentLocation: Flow<Location?>
// ...
}
App state is defined using a sealed interface
show in full file app/src/main/java/com/androidbyexample/google/maps/AppState.kt
package com.androidbyexample.google.maps
// app state machine
sealed interface AppState
data object Startup: AppState
// check play services
// if not available -> PlayServicesRequired
// else -> PlayServicesOk
data object PlayServicesRequired: AppState
// report play services required and force user to exit app
data object PlayServicesOk: AppState
// if coarse or fine permission already granted -> Granted
// else -> RequestPermission
data object PermissionsGranted: AppState
// start current-location request
// -> ShowMap
data object RequestPermission: AppState
// show dialog explaining necessary permission
// user presses quit -> (finish activity)
// user presses App Info -> launch system app info to change permissions
// user presses Ok -> launch system app info to request permissions, (finish activity)
data object ShowMap: AppState
// show the map
Let's take a look at the flow of the application through these states:
graph TD
ExitApp[/Exit Application/]
Startup([State: Startup]) --> CheckPlayServices
CheckPlayServices{Are Google Play Services available?}
CheckPlayServices -->|No|PlayServicesRequired
CheckPlayServices -->|Yes|PlayServicesOk
PlayServicesRequired([State: PlayServicesRequired]) --> PlayServicesRequiredDialog
PlayServicesRequiredDialog{Dialog: Tell user Play Services are required}
PlayServicesRequiredDialog -->|Quit|ExitApp
PlayServicesOk([State: PlayServicesOk]) --> CheckPermissions
CheckPermissions{Are location permissions already granted?}
CheckPermissions -->|No|RequestPermission
PermissionsGranted([State: PermissionsGranted]) --> StartLocationRequest
StartLocationRequest[/Start location requests/]
StartLocationRequest --> ShowMap
RequestPermission([State: RequestPermission]) --> RequestPermissionDialog
RequestPermissionDialog{Dialog: Explain permission and ask user how to proceed}
RequestPermissionDialog -->|Ok|LaunchPermissionRequest
RequestPermissionDialog -->|App Info|LaunchAppInfo
RequestPermissionDialog -->|Quit|ExitApp
LaunchPermissionRequest[/Launch system permission request/]
LaunchPermissionRequest --> PermissionResponse
PermissionResponse{Did user grant a location permission?}
PermissionResponse -->|Yes|PermissionsGranted
PermissionResponse -->|No|RequestPermission
LaunchAppInfo[/Launch system app info dialog/]
LaunchAppInfo --> ExitApp
CheckPermissions -->|Yes|PermissionsGranted
ShowMap([State: ShowMap])
We'll display dialogs in a similar way to different screens in an application, though they appear on top of whatever other interface is showing. In this application, all dialogs are displayed before the Map, so nothing will be displayed alongside them.
To make this simpler, we move the map setup into its own function in the activity
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
}
@Composable
fun ShowMap(
modifier: Modifier,
) {
val googleHQ = LatLng(37.42423291057923, -122.08811454627153)
val defaultCameraPosition = CameraPosition.fromLatLngZoom(googleHQ, 11f)
val cameraPositionState = rememberCameraPositionState {
position = defaultCameraPosition
}
// GoogleMapDisplay(
// place = googleHQ,
// placeDescription = "Google HQ",
// cameraPositionState = cameraPositionState,
// modifier = Modifier.padding(innerPadding).fillMaxSize(),
val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle(
initialValue = null
)
GoogleMapDisplay(
currentLocation = currentLocation,
cameraPositionState = cameraPositionState,
modifier = modifier.fillMaxSize(),
)
}
@Composable
// ...
}
and while we're here, we collect the location from the view model, and add support for the location
to GoogleMapDisplay:
show in full file app/src/main/java/com/androidbyexample/google/maps/GoogleMapDisplay.kt
// ...
@Composable
fun GoogleMapDisplay(
// place: LatLng,
// placeDescription: String,
currentLocation: Location?,
cameraPositionState: CameraPositionState,
modifier: Modifier,
) {
// ...
var mapProperties by remember {
mutableStateOf(MapProperties(mapType = MapType.NORMAL))
}
// val placeState =
// rememberUpdatedMarkerState(position = place)
val currentLocationState = remember(currentLocation) {
currentLocation?.let {
MarkerState(
LatLng(
it.latitude,
it.longitude
)
)
}
}
Box(
// ...
) {
Column(
// ...
) {
// ...
GoogleMap(
// ...
.weight(1f),
) {
currentLocationState?.let {
MarkerInfoWindowContent(
// state = placeState,
// title = placeDescription,
// onClick = {
// placeState.showInfoWindow()
// true
// }
state = it,
anchor = Offset(0.5f, 1f),
// the actual location is at the point at the center/bottom of the icon
title = stringResource(R.string.current_location),
)
}
}
}
// ...
}
}
We create a helper function to check if Google Play Services are available. This check runs asynchronously, and when it's finished, updates the current app state in the view model.
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
}
private fun checkPlayServices() {
GoogleApiAvailability.getInstance()
.makeGooglePlayServicesAvailable(this)
.addOnSuccessListener {
viewModel.updateAppState(PlayServicesOk)
}.addOnFailureListener(this) {
viewModel.updateAppState(PlayServicesRequired)
}
}
@Composable
// ...
}
We call this at the start of onCreate()
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkPlayServices()
enableEdgeToEdge()
// ...
}
// ...
}
While that check is being done, we're in the Startup app state. The main UI logic in
onCreate()
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
// ...
GooglemapsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val modifier = Modifier.padding(innerPadding)
when (appState) {
Startup -> Loading(modifier)
PlayServicesOk -> {
viewModel.updateAppState(
when {
locationPermissionsGranted() -> PermissionsGranted
else -> RequestPermission
}
)
}
PermissionsGranted -> {
startLocation()
viewModel.updateAppState(ShowMap)
}
ShowMap -> ShowMap(modifier)
RequestPermission -> ExplainPermission()
PlayServicesRequired -> PlayServicesRequired()
}
}
}
}
}
// ...
}
displays the loading screen as long as we're in the Startup state
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
}
@Composable
fun Loading(
modifier: Modifier,
) {
AnimatedVisibility(
visible = true,
modifier = modifier.fillMaxSize(),
enter = EnterTransition.None,
exit = fadeOut()
) {
CircularProgressIndicator(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.wrapContentSize()
)
}
}
// }
// }
}
If Play Services are unavailable, we display a dialog that tells the user their only choice is to exit the application
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
}
@Composable
fun PlayServicesRequired() {
ThreeButtonDialog(
titleId = R.string.play_services_required,
text =
// Note - I'm including the text as a literal so it's easier to understand
// the example on the web page. Text like this should always be defined
// in strings.xml
"Google Play services required (or upgrade required)",
quitOption,
)
}
@SuppressLint("MissingPermission")
// ...
}
This dialog uses a helper I've defined to allow up to three buttons. (AlertDialog normally only
allows two buttons)
show in full file app/src/main/java/com/androidbyexample/google/maps/ThreeButtonDialog.kt
// ...
import androidx.compose.ui.res.stringResource
data class DialogOption(
@StringRes val textId: Int,
val action: () -> Unit,
)
@Composable
fun ThreeButtonDialog(
@StringRes titleId: Int,
text: String,
vararg actions: DialogOption,
) {
AlertDialog(
title = {
Text(text = stringResource(titleId))
},
text = { Text(text) },
onDismissRequest = { }, // user must choose
confirmButton = {
Row(modifier = Modifier.fillMaxWidth()) {
// third action should be left justified
actions.forEachIndexed { index, option ->
val modifier =
if (index == 0 && actions.size == 3) Modifier.weight(1f) else Modifier
TextButton(onClick = option.action, modifier = modifier) {
Text(stringResource(option.textId))
}
}
}
},
)
}
If Play Services are available, we check if the location permissions were already granted.
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
private val getLocationPermission =
registerForActivityResult(
// ...
}
// helper to make the checks more readable in onCreate
private fun locationPermissionsGranted() =
locationPermissions.any { permission ->
ActivityCompat.checkSelfPermission(this, permission) ==
PackageManager.PERMISSION_GRANTED
}
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
// ...
GooglemapsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
// ...
when (appState) {
Startup -> Loading(modifier)
PlayServicesOk -> {
viewModel.updateAppState(
when {
locationPermissionsGranted() -> PermissionsGranted
else -> RequestPermission
}
)
}
PermissionsGranted -> {
startLocation()
// ...
}
}
}
}
}
// ...
}
If not, we switch to the RequestPermission state, which displays the explanation dialog
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
}
@Composable
fun ExplainPermission() {
ThreeButtonDialog(
titleId = R.string.permissions_needed,
text =
// Note - I'm including the text as a literal so it's easier to understand
// the example on the web page. Text like this should always be defined
// in strings.xml
"This application requires location permissions to save and locate " +
"the position of your car. Please grant " +
"'Precise Location' permission.\n\n" +
"Press Ok for permission request.\n\n" +
"If Ok does nothing, this means you " +
"have previously permanently denied the request. In this case, press " +
"'App Info' to change the permissions and restart the app.",
DialogOption(R.string.app_info) {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:$packageName".toUri()
}
)
finish()
},
DialogOption(R.string.quit) { finish() },
DialogOption(R.string.ok) {
getLocationPermission.launch(locationPermissions)
}
)
}
@Composable
// ...
}
If the user selects "Ok" to ask for permissions, we launch the permission request for the system to handle.
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
}
private val locationPermissions = arrayOf(
android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION,
)
private val quitOption = DialogOption(R.string.quit) { finish() }
private val getLocationPermission =
registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { isGranted ->
viewModel.updateAppState(
when {
isGranted.values.any { it } -> PermissionsGranted
else -> RequestPermission // ask again
}
)
}
// helper to make the checks more readable in onCreate
// ...
@Composable
fun ExplainPermission() {
ThreeButtonDialog(
// ...
},
DialogOption(R.string.quit) { finish() },
DialogOption(R.string.ok) {
getLocationPermission.launch(locationPermissions)
}
)
}
// ...
}
If they had already permanently denied the location permission request, they can press the "App Info" button to go to the system app info screen and change the permissions.
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
@Composable
fun ExplainPermission() {
ThreeButtonDialog(
// ...
"have previously permanently denied the request. In this case, press " +
"'App Info' to change the permissions and restart the app.",
DialogOption(R.string.app_info) {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:$packageName".toUri()
}
)
finish()
},
DialogOption(R.string.quit) { finish() },
// ...
)
}
// ...
}
Note that we call finish() to exit the application. Unfortunately, the system takes a moment to
process the permission change on the app info page, and if we just return to the application,
we'd see the old values and repeat the request for permission. To get around this, we quit the app,
and the user must restart it.
If the permission was granted, during this or a previous run of the application, we send a request
to the FusedLocationProviderClient to inform us of our current location.
show in full file app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
// ...
override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
// ...
GooglemapsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
// ...
when (appState) {
// ...
)
}
PermissionsGranted -> {
startLocation()
viewModel.updateAppState(ShowMap)
}
ShowMap -> ShowMap(modifier)
RequestPermission -> ExplainPermission()
// ...
}
}
}
}
}
// ...
}
@SuppressLint("MissingPermission")
fun startLocation() {
val locationRequest =
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000)
.setWaitForAccurateLocation(false)
.setMinUpdateIntervalMillis(0)
.setMaxUpdateDelayMillis(5000)
.build()
fusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(this)
fusedLocationProviderClient.requestLocationUpdates(
locationRequest,
locationCallback,
Looper.getMainLooper()
)
}
@Composable
// ...
}
When we run the app, the location marker appears when it's available.
Note
You can use the "Extended Controls" at the top of the emulator to set a fake location. This will prove very useful when testing this application, as we can set a location, save the car at that location, change the location to simulate walking away from the car, then press navigate to see how to get back to the car.
All code changes
CHANGED: app/build.gradle.kts
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.secrets)
}
android {
namespace = "com.androidbyexample.google.maps"
compileSdk {
version = release(37)
}
defaultConfig {
applicationId = "com.androidbyexample.google.maps"
minSdk = 24
targetSdk = 37
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
optimization {
enable = false
}
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.location.services)
implementation(libs.maps.compose)
implementation(libs.maps.compose.utils)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}
secrets {
propertiesFileName = "secrets.properties"
}
CHANGED: app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Googlemaps">
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${MAPS_API_KEY}" />
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Googlemaps"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
ADDED: app/src/main/java/com/androidbyexample/google/maps/AppState.kt
package com.androidbyexample.google.maps
// app state machine
sealed interface AppState
data object Startup: AppState
// check play services
// if not available -> PlayServicesRequired
// else -> PlayServicesOk
data object PlayServicesRequired: AppState
// report play services required and force user to exit app
data object PlayServicesOk: AppState
// if coarse or fine permission already granted -> Granted
// else -> RequestPermission
data object PermissionsGranted: AppState
// start current-location request
// -> ShowMap
data object RequestPermission: AppState
// show dialog explaining necessary permission
// user presses quit -> (finish activity)
// user presses App Info -> launch system app info to change permissions
// user presses Ok -> launch system app info to request permissions, (finish activity)
data object ShowMap: AppState
// show the map
ADDED: app/src/main/java/com/androidbyexample/google/maps/CarViewModel.kt
package com.androidbyexample.google.maps
import android.app.Application
import android.location.Location
import androidx.lifecycle.AndroidViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class CarViewModel(application: Application) : AndroidViewModel(application) {
val appState: Flow<AppState>
field = MutableStateFlow<AppState>(Startup)
fun updateAppState(state: AppState) {
appState.value = state
}
val currentLocation: Flow<Location?>
field = MutableStateFlow<Location?>(null)
fun updateLocation(location: Location?) {
currentLocation.value = location
}
}
CHANGED: app/src/main/java/com/androidbyexample/google/maps/GoogleMapDisplay.kt
package com.androidbyexample.google.maps
import android.location.Location
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.res.stringResource
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapType
import com.google.maps.android.compose.MarkerInfoWindowContent
//import com.google.maps.android.compose.rememberUpdatedMarkerState
import com.google.maps.android.compose.MarkerState
@Composable
fun GoogleMapDisplay(
// place: LatLng,
// placeDescription: String,
currentLocation: Location?,
cameraPositionState: CameraPositionState,
modifier: Modifier,
) {
var mapLoaded by remember { mutableStateOf(false) }
var currentMapType by remember { mutableStateOf(MapType.NORMAL) }
var mapProperties by remember {
mutableStateOf(MapProperties(mapType = MapType.NORMAL))
}
// val placeState =
// rememberUpdatedMarkerState(position = place)
val currentLocationState = remember(currentLocation) {
currentLocation?.let {
MarkerState(
LatLng(
it.latitude,
it.longitude
)
)
}
}
Box(
modifier = modifier,
) {
Column(
modifier = Modifier.fillMaxSize()
) {
MapTypeSelector(
currentValue = currentMapType,
modifier = Modifier.fillMaxWidth(),
) {
mapProperties = mapProperties.copy(mapType = it)
currentMapType = it
}
GoogleMap(
cameraPositionState = cameraPositionState,
onMapLoaded = {
mapLoaded = true
},
properties = mapProperties,
modifier = Modifier
.fillMaxSize()
.weight(1f),
) {
currentLocationState?.let {
MarkerInfoWindowContent(
// state = placeState,
// title = placeDescription,
// onClick = {
// placeState.showInfoWindow()
// true
// }
state = it,
anchor = Offset(0.5f, 1f),
// the actual location is at the point at the center/bottom of the icon
title = stringResource(R.string.current_location),
)
}
}
}
if (!mapLoaded) {
AnimatedVisibility(
visible = true,
modifier = Modifier.fillMaxSize(),
enter = EnterTransition.None,
exit = fadeOut()
) {
CircularProgressIndicator(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.wrapContentSize()
)
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/google/maps/MainActivity.kt
package com.androidbyexample.google.maps
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.Looper
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.core.app.ActivityCompat
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.androidbyexample.google.maps.ui.theme.GooglemapsTheme
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.rememberCameraPositionState
class MainActivity : ComponentActivity() {
private val viewModel: CarViewModel by viewModels()
private lateinit var fusedLocationProviderClient: FusedLocationProviderClient
private val locationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
viewModel.updateLocation(locationResult.lastLocation)
}
}
private val locationPermissions = arrayOf(
android.Manifest.permission.ACCESS_FINE_LOCATION,
android.Manifest.permission.ACCESS_COARSE_LOCATION,
)
private val quitOption = DialogOption(R.string.quit) { finish() }
private val getLocationPermission =
registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { isGranted ->
viewModel.updateAppState(
when {
isGranted.values.any { it } -> PermissionsGranted
else -> RequestPermission // ask again
}
)
}
// helper to make the checks more readable in onCreate
private fun locationPermissionsGranted() =
locationPermissions.any { permission ->
ActivityCompat.checkSelfPermission(this, permission) ==
PackageManager.PERMISSION_GRANTED
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkPlayServices()
enableEdgeToEdge()
setContent {
val appState by viewModel.appState.collectAsStateWithLifecycle(Startup)
GooglemapsTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val modifier = Modifier.padding(innerPadding)
when (appState) {
Startup -> Loading(modifier)
PlayServicesOk -> {
viewModel.updateAppState(
when {
locationPermissionsGranted() -> PermissionsGranted
else -> RequestPermission
}
)
}
PermissionsGranted -> {
startLocation()
viewModel.updateAppState(ShowMap)
}
ShowMap -> ShowMap(modifier)
RequestPermission -> ExplainPermission()
PlayServicesRequired -> PlayServicesRequired()
}
}
}
}
}
private fun checkPlayServices() {
GoogleApiAvailability.getInstance()
.makeGooglePlayServicesAvailable(this)
.addOnSuccessListener {
viewModel.updateAppState(PlayServicesOk)
}.addOnFailureListener(this) {
viewModel.updateAppState(PlayServicesRequired)
}
}
@Composable
fun ExplainPermission() {
ThreeButtonDialog(
titleId = R.string.permissions_needed,
text =
// Note - I'm including the text as a literal so it's easier to understand
// the example on the web page. Text like this should always be defined
// in strings.xml
"This application requires location permissions to save and locate " +
"the position of your car. Please grant " +
"'Precise Location' permission.\n\n" +
"Press Ok for permission request.\n\n" +
"If Ok does nothing, this means you " +
"have previously permanently denied the request. In this case, press " +
"'App Info' to change the permissions and restart the app.",
DialogOption(R.string.app_info) {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = "package:$packageName".toUri()
}
)
finish()
},
DialogOption(R.string.quit) { finish() },
DialogOption(R.string.ok) {
getLocationPermission.launch(locationPermissions)
}
)
}
@Composable
fun PlayServicesRequired() {
ThreeButtonDialog(
titleId = R.string.play_services_required,
text =
// Note - I'm including the text as a literal so it's easier to understand
// the example on the web page. Text like this should always be defined
// in strings.xml
"Google Play services required (or upgrade required)",
quitOption,
)
}
@SuppressLint("MissingPermission")
fun startLocation() {
val locationRequest =
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000)
.setWaitForAccurateLocation(false)
.setMinUpdateIntervalMillis(0)
.setMaxUpdateDelayMillis(5000)
.build()
fusedLocationProviderClient =
LocationServices.getFusedLocationProviderClient(this)
fusedLocationProviderClient.requestLocationUpdates(
locationRequest,
locationCallback,
Looper.getMainLooper()
)
}
@Composable
fun ShowMap(
modifier: Modifier,
) {
val googleHQ = LatLng(37.42423291057923, -122.08811454627153)
val defaultCameraPosition = CameraPosition.fromLatLngZoom(googleHQ, 11f)
val cameraPositionState = rememberCameraPositionState {
position = defaultCameraPosition
}
// GoogleMapDisplay(
// place = googleHQ,
// placeDescription = "Google HQ",
// cameraPositionState = cameraPositionState,
// modifier = Modifier.padding(innerPadding).fillMaxSize(),
val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle(
initialValue = null
)
GoogleMapDisplay(
currentLocation = currentLocation,
cameraPositionState = cameraPositionState,
modifier = modifier.fillMaxSize(),
)
}
@Composable
fun Loading(
modifier: Modifier,
) {
AnimatedVisibility(
visible = true,
modifier = modifier.fillMaxSize(),
enter = EnterTransition.None,
exit = fadeOut()
) {
CircularProgressIndicator(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.wrapContentSize()
)
}
}
// }
// }
}
ADDED: app/src/main/java/com/androidbyexample/google/maps/ThreeButtonDialog.kt
package com.androidbyexample.google.maps
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
data class DialogOption(
@StringRes val textId: Int,
val action: () -> Unit,
)
@Composable
fun ThreeButtonDialog(
@StringRes titleId: Int,
text: String,
vararg actions: DialogOption,
) {
AlertDialog(
title = {
Text(text = stringResource(titleId))
},
text = { Text(text) },
onDismissRequest = { }, // user must choose
confirmButton = {
Row(modifier = Modifier.fillMaxWidth()) {
// third action should be left justified
actions.forEachIndexed { index, option ->
val modifier =
if (index == 0 && actions.size == 3) Modifier.weight(1f) else Modifier
TextButton(onClick = option.action, modifier = modifier) {
Text(stringResource(option.textId))
}
}
}
},
)
}
CHANGED: app/src/main/res/values/strings.xml
<resources>
<string name="app_name">google-maps</string>
<string name="map_type">Map Type</string>
<string name="current_location">Current Location</string>
<string name="permissions_needed">Permissions Needed</string>
<string name="app_info">App Info</string>
<string name="quit">Quit</string>
<string name="ok">Ok</string>
<string name="play_services_required">Play Services Required</string>
</resources>
CHANGED: gradle/libs.versions.toml
[versions]
agp = "9.3.1"
coreKtx = "1.19.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0"
kotlin = "2.4.10"
composeBom = "2026.08.00"
secrets = "2.0.1"
maps-compose = "8.4.0"
location-services = "21.4.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "maps-compose" }
maps-compose-utils = { group = "com.google.maps.android", name = "maps-compose-utils", version.ref = "maps-compose" }
location-services = { group = "com.google.android.gms", name = "play-services-location", version.ref = "location-services" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }