Google Map

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.

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

Note

Requesting permissions at runtime can be a bit tricky, and support for permissions when using Jetpack Compose is still experimental (and a bit difficult to use properly). For now, we'll be requesting permissions and listening for location using the Activity and updating a view model with the reported locations.

Any needed permissions must be declared first in the AndroidManifest.xml. Here we declare

  • 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 (with definition in the version catalog)

We'll need to track the location. We 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.

In the MainActivity, we define properties for the fused location provider client and the callback that we'll register to receive location updates.

We have to set up a new sequence of events to start our activity. This sequence looks like

graph TD
    A{Check Google Play Services}
    X[Cannot run - exit]
    X2[Cannot run - exit]
    A -->|Not Available|X
    A -->|Available|B

    B{Do we have location permission?}
    B -->|Yes|C
    B -->|No|E
    C[Start location request]
    C --> D
    D[Setup Map]

    E[Request Location Permissions]
    E --> F
    F{Were permissions granted?}
    F -->|No|G
    F -->|Yes|C
    G[Show rationale]
    G --> H
    H{Does user agree?}
    H -->|Yes|I
    H -->|No|X2
    I[Application Info Screen to change permissions - exits application]

We move the UI setup out of onCreate (we'll see it again soon), and check Google Play Services Availability. If Play Services aren't available, we can't run and must exit.

If Play Services are available, we check if we have location permissions. If not, we request permission. If so, we just go ahead and start our location request and set up the UI.

Requesting permissions has gotten a bit easier over the years... We register a launcher that will invoke the system permission requester and return whether the user granted or denied permissions. If granted, we go ahead and start the location request and set up the UI. If not granted, we go into rationale mode.

We've decided that our car finder simply cannot run without knowing the current location, so we display this rationale to the user in a dialog. (Note that this is an old-style "views" dialog, not a Jetpack Compose dialog.) The user can agree to go change the permissions (and we'll send them to the system Application Info screen to do so), or they can say "nope!" and we'll quit the application by calling finish() on the Activity (which pops it off the back stack - because we only have one Activity on the back stack, the application exits).

After all of the availability and permission checking, we can finally start the application in startLocationAndMap(). The first thing we do is request location updates from the fused location provider, which will use our locationCallback to send the results to the view model. This will emit the location, which is collected and made available to our composition.

We pass the location to the GoogleMapDisplay and create a MarkerState to give us a location for a new Marker.

When we run the app, the location marker appears when it's available.

You can use the "Extended Controls" at the top of the emulator to set a fake location.

Code Changes

CHANGED: /app/build.gradle.kts
@Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed
plugins {
    alias(libs.plugins.androidApplication)
    alias(libs.plugins.kotlinAndroid)
alias(libs.plugins.secrets)
} kotlin { jvmToolchain(17) } android { namespace = "com.androidbyexample.googlemap" compileSdk = 34 defaultConfig { applicationId = "com.androidbyexample.googlemap" minSdk = 24 targetSdk = 34 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } } buildTypes { release { isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.3" } packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } } dependencies {
implementation(libs.location.services) implementation(libs.runtime.compose)
implementation(libs.maps.compose) implementation(libs.maps.compose.utils)
implementation(libs.core.ktx) implementation(libs.lifecycle.runtime.ktx) implementation(libs.activity.compose) implementation(platform(libs.compose.bom)) implementation(libs.ui) implementation(libs.ui.graphics) implementation(libs.ui.tooling.preview) implementation(libs.material3) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.ui.test.junit4) debugImplementation(libs.ui.tooling) debugImplementation(libs.ui.test.manifest) }
CHANGED: /app/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

<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.GoogleMap" tools:targetApi="31">
<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.GoogleMap"> <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/googlemap/CarViewModel.kt
package com.androidbyexample.googlemapimport android.app.Applicationimport android.location.Locationimport androidx.lifecycle.AndroidViewModelimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.MutableStateFlowclass CarViewModel(application: Application) : AndroidViewModel(application) {
private val _currentLocation = MutableStateFlow<Location?>(null) val currentLocation: Flow<Location?> get() = _currentLocation
fun updateLocation(location: Location?) { _currentLocation.value = location }
}
CHANGED: /app/src/main/java/com/androidbyexample/googlemap/MainActivity.kt
package com.androidbyexample.googlemap

import android.Manifestimport android.annotation.SuppressLintimport android.app.AlertDialogimport android.content.Intentimport android.content.pm.PackageManagerimport android.location.Locationimport android.net.Uriimport android.os.Bundle
import android.os.Looperimport android.provider.Settingsimport android.widget.Toastimport androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContractsimport androidx.activity.viewModelsimport 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.material3.Surface
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.Offsetimport androidx.compose.ui.res.stringResourceimport androidx.core.app.ActivityCompatimport androidx.lifecycle.compose.collectAsStateWithLifecycleimport com.androidbyexample.googlemap.ui.theme.GoogleMapTheme
import com.google.android.gms.common.GoogleApiAvailabilityimport com.google.android.gms.location.FusedLocationProviderClientimport com.google.android.gms.location.LocationCallbackimport com.google.android.gms.location.LocationRequestimport com.google.android.gms.location.LocationResultimport com.google.android.gms.location.LocationServicesimport com.google.android.gms.location.Priorityimport com.google.android.gms.maps.model.CameraPosition
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.MarkerStateimport com.google.maps.android.compose.rememberCameraPositionState
//import com.google.maps.android.compose.rememberMarkerState
private val googleHQ = LatLng(37.42423291057923, -122.08811454627153)
private val defaultCameraPosition = CameraPosition.fromLatLngZoom(googleHQ, 11f)
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 getLocationPermission = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { isGranted -> if (isGranted.values.any { it }) { startLocationAndMap() } else { // if the user denied permissions, tell them they // cannot use the app without them. In general, // you should try to just reduce function and let the // user continue, but location is a key part of this // application. // (Note that a real version of this application // might allow the user to manually click on the map // to set their current location, and we wouldn't // show this dialog, or perhaps only show it once) // NOTE: This is a normal Android-View-based dialog, not a compose one! AlertDialog.Builder(this) .setTitle("Permissions Needed") .setMessage( "We need coarse-location or fine-location permission " + "to locate a car (fine location is highly " + "recommended for accurate car locating). " + "Please allow these permissions via App Info " + "settings") .setCancelable(false) .setNegativeButton("Quit") { _, _ -> finish() } .setPositiveButton("App Info") { _, _ -> startActivity( Intent( Settings.ACTION_APPLICATION_DETAILS_SETTINGS ).apply { data = Uri.parse("package:$packageName") } ) finish() } .show() } }
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
GoogleApiAvailability.getInstance() .makeGooglePlayServicesAvailable(this) .addOnSuccessListener {
if (ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_FINE_LOCATION ) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission( this, Manifest.permission.ACCESS_COARSE_LOCATION ) != PackageManager.PERMISSION_GRANTED ) { getLocationPermission.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) ) } else { startLocationAndMap() }
}.addOnFailureListener(this) { Toast.makeText( this, "Google Play services required (or upgrade required)", Toast.LENGTH_SHORT ).show() finish() }
} @SuppressLint("MissingPermission") fun startLocationAndMap() {
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() )
setContent { GoogleMapTheme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) {
val cameraPositionState = rememberCameraPositionState { position = defaultCameraPosition }
val currentLocation by viewModel.currentLocation.collectAsStateWithLifecycle( initialValue = null )
GoogleMapDisplay( currentLocation = currentLocation, place = googleHQ, placeDescription = "Google HQ", cameraPositionState = cameraPositionState, modifier = Modifier.fillMaxSize(), )
} } } } }
@Composable fun GoogleMapDisplay( currentLocation: Location?, place: LatLng, placeDescription: String, 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 =// rememberMarkerState(key = place.toString(), 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 .fillMaxWidth() .weight(1f),
) {
currentLocationState?.let { MarkerInfoWindowContent( // state = placeState,// title = placeDescription,// onClick = {// placeState.showInfoWindow()// true// } state = it,
anchor = Offset(0.5f, 0.5f),
title = stringResource( id = 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/res/values/strings.xml
<resources>
    <string name="app_name">Google map</string>
    <string name="map_type">Map Type</string>
    <string name="current_location">Current Location</string></resources>
CHANGED: /gradle/libs.versions.toml
[versions]
agp = "8.2.0-beta06"
kotlin = "1.9.10"
core-ktx = "1.12.0"
junit = "4.13.2"
androidx-test-ext-junit = "1.1.5"
espresso-core = "3.5.1"
lifecycle-runtime-ktx = "2.6.2"
activity-compose = "1.8.0"
compose-bom = "2023.10.00"
secrets = "2.0.1"
maps-compose = "3.0.0" # bug in 3.1.0 - map only takes up 50% of column
location-services = "21.0.1"runtime-compose = "2.6.2" [libraries] core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle-runtime-ktx" } activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } ui = { group = "androidx.compose.ui", name = "ui" } ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } 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" }runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "runtime-compose" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" }