Skip to content

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.

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