From b459e4a52b7d91cf7202922324ffc82047c11c33 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:21:01 -0600 Subject: [PATCH 1/5] feat(location): add multiplatform Location, LocationSource, and iOS CLLocationManager Flow helpers --- library/api/library.api | 33 +++ library/api/library.klib.api | 39 ++++ library/build.gradle.kts | 3 +- .../android/location/LocationSourceTest.kt | 109 ++++++++++ .../maps/android/location/Location.android.kt | 127 ++++++++++++ .../google/maps/android/location/Location.kt | 141 +++++++++++++ .../maps/android/location/Location.ios.kt | 192 ++++++++++++++++++ .../android/location/CLLocationManagerTest.kt | 143 +++++++++++++ 8 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationSourceTest.kt create mode 100644 library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt create mode 100644 library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt create mode 100644 library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt create mode 100644 library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt diff --git a/library/api/library.api b/library/api/library.api index ca48c5952..fb20ce6c3 100644 --- a/library/api/library.api +++ b/library/api/library.api @@ -718,6 +718,12 @@ public final class com/google/maps/android/location/FusedLocationProviderKt { public static synthetic fun locationEvents$default (Lcom/google/android/gms/location/FusedLocationProviderClient;Lcom/google/android/gms/location/LocationRequest;Landroid/os/Looper;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; } +public final class com/google/maps/android/location/LocationKt { + public static final fun getLatLng (Landroid/location/Location;)Lcom/google/android/gms/maps/model/LatLng; + public static final fun locationEvents (Lcom/google/maps/android/location/LocationSource;JFLcom/google/maps/android/location/LocationPriority;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun locationEvents$default (Lcom/google/maps/android/location/LocationSource;JFLcom/google/maps/android/location/LocationPriority;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + public final class com/google/maps/android/location/LocationManagerKt { public static final fun coarseLocationEvents (Landroid/location/LocationManager;JFLandroid/os/Looper;)Lkotlinx/coroutines/flow/Flow; public static synthetic fun coarseLocationEvents$default (Landroid/location/LocationManager;JFLandroid/os/Looper;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; @@ -725,6 +731,33 @@ public final class com/google/maps/android/location/LocationManagerKt { public static synthetic fun fineLocationEvents$default (Landroid/location/LocationManager;JFLandroid/os/Looper;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; } +public final class com/google/maps/android/location/LocationPriority : java/lang/Enum { + public static final field BALANCED_POWER_ACCURACY Lcom/google/maps/android/location/LocationPriority; + public static final field HIGH_ACCURACY Lcom/google/maps/android/location/LocationPriority; + public static final field LOW_POWER Lcom/google/maps/android/location/LocationPriority; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/google/maps/android/location/LocationPriority; + public static fun values ()[Lcom/google/maps/android/location/LocationPriority; +} + +public abstract interface class com/google/maps/android/location/LocationSource { + public abstract fun locationEvents (JFLcom/google/maps/android/location/LocationPriority;)Lkotlinx/coroutines/flow/Flow; +} + +public final class com/google/maps/android/location/Location_androidKt { + public static final fun asLocationSource (Landroid/location/LocationManager;Landroid/os/Looper;)Lcom/google/maps/android/location/LocationSource; + public static final fun asLocationSource (Lcom/google/android/gms/location/FusedLocationProviderClient;Landroid/os/Looper;)Lcom/google/maps/android/location/LocationSource; + public static synthetic fun asLocationSource$default (Landroid/location/LocationManager;Landroid/os/Looper;ILjava/lang/Object;)Lcom/google/maps/android/location/LocationSource; + public static synthetic fun asLocationSource$default (Lcom/google/android/gms/location/FusedLocationProviderClient;Landroid/os/Looper;ILjava/lang/Object;)Lcom/google/maps/android/location/LocationSource; + public static final fun getAccuracyMeters (Landroid/location/Location;)F + public static final fun getAltitudeMeters (Landroid/location/Location;)D + public static final fun getBearingDegrees (Landroid/location/Location;)F + public static final fun getLatitude (Landroid/location/Location;)D + public static final fun getLongitude (Landroid/location/Location;)D + public static final fun getSpeedMetersPerSecond (Landroid/location/Location;)F + public static final fun getTimestampMillis (Landroid/location/Location;)J +} + public final class com/google/maps/android/model/CameraPositionKt { public static final fun cameraPosition (Lkotlin/jvm/functions/Function1;)Lcom/google/android/gms/maps/model/CameraPosition; } diff --git a/library/api/library.klib.api b/library/api/library.klib.api index a6ac7adaf..b4284098f 100644 --- a/library/api/library.klib.api +++ b/library/api/library.klib.api @@ -6,6 +6,22 @@ // - Show declarations: true // Library unique name: +final enum class com.google.maps.android.location/LocationPriority : kotlin/Enum { // com.google.maps.android.location/LocationPriority|null[0] + enum entry BALANCED_POWER_ACCURACY // com.google.maps.android.location/LocationPriority.BALANCED_POWER_ACCURACY|null[0] + enum entry HIGH_ACCURACY // com.google.maps.android.location/LocationPriority.HIGH_ACCURACY|null[0] + enum entry LOW_POWER // com.google.maps.android.location/LocationPriority.LOW_POWER|null[0] + + final val entries // com.google.maps.android.location/LocationPriority.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // com.google.maps.android.location/LocationPriority.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): com.google.maps.android.location/LocationPriority // com.google.maps.android.location/LocationPriority.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // com.google.maps.android.location/LocationPriority.values|values#static(){}[0] +} + +abstract fun interface com.google.maps.android.location/LocationSource { // com.google.maps.android.location/LocationSource|null[0] + abstract fun locationEvents(kotlin/Long, kotlin/Float, com.google.maps.android.location/LocationPriority): kotlinx.coroutines.flow/Flow // com.google.maps.android.location/LocationSource.locationEvents|locationEvents(kotlin.Long;kotlin.Float;com.google.maps.android.location.LocationPriority){}[0] +} + final object com.google.maps.android/MathUtil { // com.google.maps.android/MathUtil|null[0] final const val EARTH_RADIUS // com.google.maps.android/MathUtil.EARTH_RADIUS|{}EARTH_RADIUS[0] final fun (): kotlin/Double // com.google.maps.android/MathUtil.EARTH_RADIUS.|(){}[0] @@ -54,3 +70,26 @@ final object com.google.maps.android/SphericalUtil { // com.google.maps.android/ final fun getPolylinePrefix(kotlin.collections/List, kotlin/Double): kotlin.collections/List // com.google.maps.android/SphericalUtil.getPolylinePrefix|getPolylinePrefix(kotlin.collections.List;kotlin.Double){}[0] final fun interpolate(com.google.maps.android.model/LatLng, com.google.maps.android.model/LatLng, kotlin/Double): com.google.maps.android.model/LatLng // com.google.maps.android/SphericalUtil.interpolate|interpolate(com.google.maps.android.model.LatLng;com.google.maps.android.model.LatLng;kotlin.Double){}[0] } + +final val com.google.maps.android.location/accuracyMeters // com.google.maps.android.location/accuracyMeters|@platform.CoreLocation.CLLocation{}accuracyMeters[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Float // com.google.maps.android.location/accuracyMeters.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/altitudeMeters // com.google.maps.android.location/altitudeMeters|@platform.CoreLocation.CLLocation{}altitudeMeters[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Double // com.google.maps.android.location/altitudeMeters.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/bearingDegrees // com.google.maps.android.location/bearingDegrees|@platform.CoreLocation.CLLocation{}bearingDegrees[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Float // com.google.maps.android.location/bearingDegrees.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/latLng // com.google.maps.android.location/latLng|@platform.CoreLocation.CLLocation{}latLng[0] + final fun (platform.CoreLocation/CLLocation).(): com.google.maps.android.model/LatLng // com.google.maps.android.location/latLng.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/latitude // com.google.maps.android.location/latitude|@platform.CoreLocation.CLLocation{}latitude[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Double // com.google.maps.android.location/latitude.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/longitude // com.google.maps.android.location/longitude|@platform.CoreLocation.CLLocation{}longitude[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Double // com.google.maps.android.location/longitude.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/speedMetersPerSecond // com.google.maps.android.location/speedMetersPerSecond|@platform.CoreLocation.CLLocation{}speedMetersPerSecond[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Float // com.google.maps.android.location/speedMetersPerSecond.|@platform.CoreLocation.CLLocation(){}[0] +final val com.google.maps.android.location/timestampMillis // com.google.maps.android.location/timestampMillis|@platform.CoreLocation.CLLocation{}timestampMillis[0] + final fun (platform.CoreLocation/CLLocation).(): kotlin/Long // com.google.maps.android.location/timestampMillis.|@platform.CoreLocation.CLLocation(){}[0] + +final fun (com.google.maps.android.location/LocationSource).com.google.maps.android.location/locationEvents(kotlin/Long = ..., kotlin/Float = ..., com.google.maps.android.location/LocationPriority = ...): kotlinx.coroutines.flow/Flow // com.google.maps.android.location/locationEvents|locationEvents@com.google.maps.android.location.LocationSource(kotlin.Long;kotlin.Float;com.google.maps.android.location.LocationPriority){}[0] +final fun (platform.CoreLocation/CLLocationManager).com.google.maps.android.location/asLocationSource(): com.google.maps.android.location/LocationSource // com.google.maps.android.location/asLocationSource|asLocationSource@platform.CoreLocation.CLLocationManager(){}[0] +final fun (platform.CoreLocation/CLLocationManager).com.google.maps.android.location/coarseLocationEvents(kotlin/Float = ...): kotlinx.coroutines.flow/Flow // com.google.maps.android.location/coarseLocationEvents|coarseLocationEvents@platform.CoreLocation.CLLocationManager(kotlin.Float){}[0] +final fun (platform.CoreLocation/CLLocationManager).com.google.maps.android.location/fineLocationEvents(kotlin/Float = ...): kotlinx.coroutines.flow/Flow // com.google.maps.android.location/fineLocationEvents|fineLocationEvents@platform.CoreLocation.CLLocationManager(kotlin.Float){}[0] +final fun (platform.CoreLocation/CLLocationManager).com.google.maps.android.location/locationEvents(kotlin/Float = ..., kotlin/Double = ...): kotlinx.coroutines.flow/Flow // com.google.maps.android.location/locationEvents|locationEvents@platform.CoreLocation.CLLocationManager(kotlin.Float;kotlin.Double){}[0] diff --git a/library/build.gradle.kts b/library/build.gradle.kts index 6d6dbe579..c66b4f9aa 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -75,9 +75,11 @@ kotlin { sourceSets { commonMain.dependencies { api(project(":maps-model")) + api(libs.kotlinx.coroutines.core) } commonTest.dependencies { implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) } androidMain { kotlin.srcDir(generateArtifactIdFile) @@ -85,7 +87,6 @@ kotlin { androidMain.dependencies { api(libs.play.services.maps) compileOnly(libs.play.services.location) - api(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) implementation(libs.appcompat) implementation(libs.core.ktx) diff --git a/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationSourceTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationSourceTest.kt new file mode 100644 index 000000000..f7898b0bb --- /dev/null +++ b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationSourceTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.location + +import android.annotation.SuppressLint +import android.location.LocationListener +import android.location.LocationManager +import android.os.Looper +import com.google.common.truth.Truth.assertThat +import com.google.maps.android.model.LatLng +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentCaptor +import org.mockito.ArgumentMatchers.eq +import org.mockito.Captor +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(MockitoJUnitRunner::class) +public class LocationSourceTest { + + @Mock + private lateinit var locationManager: LocationManager + + @Mock + private lateinit var location: Location + + @Mock + private lateinit var looper: Looper + + @Captor + private lateinit var listenerCaptor: ArgumentCaptor + + @Test + public fun locationExtensionPropertiesMapFromAndroidLocation() { + `when`(location.latitude).thenReturn(51.5074) + `when`(location.longitude).thenReturn(-0.1278) + `when`(location.hasAccuracy()).thenReturn(true) + `when`(location.accuracy).thenReturn(4.5f) + `when`(location.hasAltitude()).thenReturn(true) + `when`(location.altitude).thenReturn(35.5) + `when`(location.hasBearing()).thenReturn(true) + `when`(location.bearing).thenReturn(180.0f) + `when`(location.hasSpeed()).thenReturn(true) + `when`(location.speed).thenReturn(12.25f) + `when`(location.time).thenReturn(1_728_500_000_000L) + + assertThat(location.latLng).isEqualTo(LatLng(51.5074, -0.1278)) + assertThat(location.accuracyMeters).isEqualTo(4.5f) + assertThat(location.altitudeMeters).isEqualTo(35.5) + assertThat(location.bearingDegrees).isEqualTo(180.0f) + assertThat(location.speedMetersPerSecond).isEqualTo(12.25f) + assertThat(location.timestampMillis).isEqualTo(1_728_500_000_000L) + } + + @SuppressLint("MissingPermission") + @Test + public fun locationManagerAsLocationSourceStreamsThroughCommonAbstraction(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.GPS_PROVIDER)) + `when`(location.latitude).thenReturn(37.4220) + `when`(location.longitude).thenReturn(-122.0841) + + val source: LocationSource = locationManager.asLocationSource(looper) + val deferred = async { + source.locationEvents( + intervalMs = 2_000L, + minUpdateDistanceM = 3f, + priority = LocationPriority.HIGH_ACCURACY + ).first() + } + advanceUntilIdle() + + verify(locationManager).requestLocationUpdates( + eq(LocationManager.GPS_PROVIDER), + eq(2_000L), + eq(3f), + listenerCaptor.capture(), + eq(looper) + ) + + listenerCaptor.value.onLocationChanged(location) + val emitted = deferred.await() + + assertThat(emitted.latLng).isEqualTo(LatLng(37.4220, -122.0841)) + verify(locationManager).removeUpdates(eq(listenerCaptor.value)) + } +} diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt new file mode 100644 index 000000000..874c0e85e --- /dev/null +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.location + +import android.Manifest +import android.location.LocationManager +import android.os.Looper +import androidx.annotation.RequiresPermission +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.Priority + +/** + * On Android, [Location] is a direct `typealias` for [android.location.Location]. + * + * This preserves binary and source compatibility with existing Android callers of + * [coarseLocationEvents], [fineLocationEvents], and [fusedLocationEvents]. + */ +public actual typealias Location = android.location.Location + +/** + * Binds to [android.location.Location.getLatitude]. Inside the getter body, Java member/bean + * property resolution takes precedence over this extension property, avoiding recursion. + */ +public actual val Location.latitude: Double + get() = this.latitude + +/** + * Binds to [android.location.Location.getLongitude]. + */ +public actual val Location.longitude: Double + get() = this.longitude + +/** + * Returns [android.location.Location.getAccuracy] in meters when present, or `-1f` when + * [android.location.Location.hasAccuracy] is `false` (matching iOS `CLLocation.horizontalAccuracy` + * semantics for invalid/unavailable accuracy). + */ +public actual val Location.accuracyMeters: Float + get() = if (hasAccuracy()) accuracy else -1f + +/** + * Returns [android.location.Location.getAltitude] in meters when present, or `0.0` otherwise. + */ +public actual val Location.altitudeMeters: Double + get() = if (hasAltitude()) altitude else 0.0 + +/** + * Returns [android.location.Location.getBearing] in degrees when present, or `-1f` when + * [android.location.Location.hasBearing] is `false` (matching iOS `CLLocation.course` semantics). + */ +public actual val Location.bearingDegrees: Float + get() = if (hasBearing()) bearing else -1f + +/** + * Returns [android.location.Location.getSpeed] in meters/second when present, or `-1f` when + * [android.location.Location.hasSpeed] is `false` (matching iOS `CLLocation.speed` semantics). + */ +public actual val Location.speedMetersPerSecond: Float + get() = if (hasSpeed()) speed else -1f + +/** + * Returns the UTC fix timestamp in milliseconds via [android.location.Location.getTime]. + */ +public actual val Location.timestampMillis: Long + get() = time + +/** + * Adapts an Android platform [LocationManager] into a multiplatform [LocationSource] that can be + * consumed from shared `commonMain` code. + * + * [LocationPriority.HIGH_ACCURACY] delegates to [fineLocationEvents] (`GPS_PROVIDER`), while + * [LocationPriority.BALANCED_POWER_ACCURACY] and [LocationPriority.LOW_POWER] delegate to + * [coarseLocationEvents] (`NETWORK_PROVIDER`). + */ +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +public fun LocationManager.asLocationSource( + looper: Looper = Looper.getMainLooper() +): LocationSource = LocationSource { intervalMs, minUpdateDistanceM, priority -> + when (priority) { + LocationPriority.HIGH_ACCURACY -> fineLocationEvents( + minTimeMs = intervalMs, + minDistanceM = minUpdateDistanceM, + looper = looper + ) + LocationPriority.BALANCED_POWER_ACCURACY, + LocationPriority.LOW_POWER -> coarseLocationEvents( + minTimeMs = intervalMs, + minDistanceM = minUpdateDistanceM, + looper = looper + ) + } +} + +/** + * Adapts a Google Play Services [FusedLocationProviderClient] into a multiplatform + * [LocationSource] that can be consumed from shared `commonMain` code. + */ +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +public fun FusedLocationProviderClient.asLocationSource( + looper: Looper = Looper.getMainLooper() +): LocationSource = LocationSource { intervalMs, minUpdateDistanceM, priority -> + val playPriority = when (priority) { + LocationPriority.HIGH_ACCURACY -> Priority.PRIORITY_HIGH_ACCURACY + LocationPriority.BALANCED_POWER_ACCURACY -> Priority.PRIORITY_BALANCED_POWER_ACCURACY + LocationPriority.LOW_POWER -> Priority.PRIORITY_LOW_POWER + } + fusedLocationEvents( + intervalMs = intervalMs, + minUpdateDistanceM = minUpdateDistanceM, + priority = playPriority, + looper = looper + ) +} diff --git a/library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt b/library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt new file mode 100644 index 000000000..b2fd6f59d --- /dev/null +++ b/library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.location + +import com.google.maps.android.model.LatLng +import kotlinx.coroutines.flow.Flow + +/** + * A platform-native geographic location fix emitted by device location providers. + * + * Why an `expect class` with extension properties instead of a wrapper data class? + * Existing Android code in this library ([coarseLocationEvents], [fineLocationEvents], and + * [fusedLocationEvents]) already returns `Flow`. By actualizing + * [Location] as a `typealias` to `android.location.Location` on Android and + * `platform.CoreLocation.CLLocation` on iOS: + * 1. Existing Android callers experience zero breaking changes and zero wrapper allocations. + * 2. Platform-specific code can still access every native method on `android.location.Location` + * or `CLLocation` directly. + * 3. Shared `commonMain` code can read coordinates and metadata uniformly through the + * extension properties below ([latLng], [latitude], [longitude], [accuracyMeters], etc.). + */ +public expect class Location + +/** + * The latitude of this location fix, in degrees. + */ +public expect val Location.latitude: Double + +/** + * The longitude of this location fix, in degrees. + */ +public expect val Location.longitude: Double + +/** + * The horizontal accuracy radius of this location fix in meters, or a negative value if + * horizontal accuracy is unavailable. + */ +public expect val Location.accuracyMeters: Float + +/** + * The altitude of this location fix in meters above the WGS 84 reference ellipsoid. + */ +public expect val Location.altitudeMeters: Double + +/** + * The direction of travel in degrees East of true North (`[0.0, 360.0)`), or a negative value + * if bearing/course is unavailable. + */ +public expect val Location.bearingDegrees: Float + +/** + * The instantaneous speed of the device in meters per second, or a negative value if speed + * is unavailable. + */ +public expect val Location.speedMetersPerSecond: Float + +/** + * The UTC timestamp of this location fix, in milliseconds since the Unix epoch + * (January 1, 1970 00:00:00 UTC). + */ +public expect val Location.timestampMillis: Long + +/** + * Converts this platform [Location] fix into a multiplatform [LatLng] coordinate for direct use + * with camera animations, markers, clustering, and spatial geometry utilities. + */ +public val Location.latLng: LatLng + get() = LatLng(latitude, longitude) + +/** + * Cross-platform quality-of-service priority for location update requests. + * + * Each platform maps these priorities to its native location engine: + * - On Android `FusedLocationProviderClient`, maps to Play Services `Priority` constants. + * - On Android `LocationManager`, selects between `GPS_PROVIDER` ([HIGH_ACCURACY]) and + * `NETWORK_PROVIDER` ([BALANCED_POWER_ACCURACY], [LOW_POWER]). + * - On iOS `CLLocationManager`, maps to `kCLLocationAccuracyBest`, + * `kCLLocationAccuracyNearestTenMeters`, and `kCLLocationAccuracyHundredMeters`. + */ +public enum class LocationPriority { + /** Requests the finest location accuracy available (typically GPS). */ + HIGH_ACCURACY, + + /** Requests block-level accuracy (~10-100m) balancing battery and precision. */ + BALANCED_POWER_ACCURACY, + + /** Requests coarse, city/neighborhood-level accuracy with minimal battery impact. */ + LOW_POWER +} + +/** + * A multiplatform source of reactive device [Location] updates exposed as a cold Kotlin [Flow]. + * + * Shared UI and presentation layers in `commonMain` can depend on [LocationSource] without + * coupling to Android's `FusedLocationProviderClient` / `LocationManager` or iOS's + * `CLLocationManager`. + */ +public fun interface LocationSource { + /** + * Returns a cold [Flow] that starts streaming [Location] updates when collected and + * automatically unregisters the underlying platform listener when the collector cancels. + * + * @param intervalMs Desired interval between updates in milliseconds (used by Android providers; + * iOS `CLLocationManager` filters primarily by [minUpdateDistanceM] and [priority]). + * @param minUpdateDistanceM Minimum displacement in meters before a new update is emitted. + * @param priority Desired accuracy and power trade-off. + */ + public fun locationEvents( + intervalMs: Long, + minUpdateDistanceM: Float, + priority: LocationPriority + ): Flow +} + +/** + * Convenience overload of [LocationSource.locationEvents] with sensible multiplatform defaults + * (`2,000 ms` interval, `1 meter` minimum distance, and [LocationPriority.HIGH_ACCURACY]). + */ +public fun LocationSource.locationEvents( + intervalMs: Long = 2_000L, + minUpdateDistanceM: Float = 1f, + priority: LocationPriority = LocationPriority.HIGH_ACCURACY +): Flow = locationEvents( + intervalMs = intervalMs, + minUpdateDistanceM = minUpdateDistanceM, + priority = priority +) diff --git a/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt b/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt new file mode 100644 index 000000000..783bba1f9 --- /dev/null +++ b/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt @@ -0,0 +1,192 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalForeignApi::class) + +package com.google.maps.android.location + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.useContents +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import platform.CoreLocation.CLLocation +import platform.CoreLocation.CLLocationAccuracy +import platform.CoreLocation.CLLocationManager +import platform.CoreLocation.CLLocationManagerDelegateProtocol +import platform.CoreLocation.kCLAuthorizationStatusDenied +import platform.CoreLocation.kCLAuthorizationStatusRestricted +import platform.CoreLocation.kCLErrorDenied +import platform.CoreLocation.kCLLocationAccuracyBest +import platform.CoreLocation.kCLLocationAccuracyHundredMeters +import platform.CoreLocation.kCLLocationAccuracyNearestTenMeters +import platform.Foundation.NSError +import platform.Foundation.timeIntervalSince1970 +import platform.darwin.NSObject + +/** + * On iOS, [Location] is a direct `typealias` for Apple's [CLLocation]. + * + * This avoids wrapper allocations on every GPS fix while allowing shared `commonMain` code to + * read coordinates and metadata via the multiplatform extension properties below. + */ +public actual typealias Location = CLLocation + +/** + * Extracts the latitude in degrees from the underlying `CLLocationCoordinate2D` C struct. + */ +public actual val Location.latitude: Double + get() = coordinate.useContents { latitude } + +/** + * Extracts the longitude in degrees from the underlying `CLLocationCoordinate2D` C struct. + */ +public actual val Location.longitude: Double + get() = coordinate.useContents { longitude } + +/** + * Returns `CLLocation.horizontalAccuracy` in meters (negative when horizontal accuracy is invalid). + */ +public actual val Location.accuracyMeters: Float + get() = horizontalAccuracy.toFloat() + +/** + * Returns `CLLocation.altitude` in meters above the WGS 84 reference ellipsoid. + */ +public actual val Location.altitudeMeters: Double + get() = altitude + +/** + * Returns `CLLocation.course` in degrees East of true North (negative when course is invalid). + */ +public actual val Location.bearingDegrees: Float + get() = course.toFloat() + +/** + * Returns `CLLocation.speed` in meters per second (negative when speed is invalid). + */ +public actual val Location.speedMetersPerSecond: Float + get() = speed.toFloat() + +/** + * Converts `CLLocation.timestamp` (`NSDate`) into UTC milliseconds since the Unix epoch. + */ +public actual val Location.timestampMillis: Long + get() = (timestamp.timeIntervalSince1970 * 1_000.0).toLong() + +/** + * Returns a cold [Flow] that emits device [Location] updates from this [CLLocationManager]. + * + * Updates begin streaming only when the flow is collected, and `stopUpdatingLocation()` is + * invoked automatically as soon as the collector cancels or completes. + * + * **Why `delegate` is captured inside `awaitClose`:** + * Objective-C declares `CLLocationManager.delegate` as an `assign` / `weak` reference. Capturing + * the Kotlin `delegate` instance inside `awaitClose` ensures Kotlin/Native's garbage collector + * keeps the delegate alive for the entire lifetime of the active flow subscription. + * + * @param minDistanceM Minimum horizontal displacement in meters before a new fix is emitted. + * @param desiredAccuracy Desired CoreLocation accuracy constant (for example, + * [kCLLocationAccuracyBest] or [kCLLocationAccuracyHundredMeters]). + */ +public fun CLLocationManager.locationEvents( + minDistanceM: Float = 1f, + desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest +): Flow = callbackFlow { + val currentStatus = authorizationStatus + if (currentStatus == kCLAuthorizationStatusDenied || + currentStatus == kCLAuthorizationStatusRestricted + ) { + close() + return@callbackFlow + } + + val delegate = object : NSObject(), CLLocationManagerDelegateProtocol { + override fun locationManager(manager: CLLocationManager, didUpdateLocations: List<*>) { + for (item in didUpdateLocations) { + val location = item as? CLLocation ?: continue + trySend(location) + } + } + + override fun locationManager(manager: CLLocationManager, didFailWithError: NSError) { + if (didFailWithError.code == kCLErrorDenied) { + close() + } + } + + override fun locationManagerDidChangeAuthorization(manager: CLLocationManager) { + val status = manager.authorizationStatus + if (status == kCLAuthorizationStatusDenied || + status == kCLAuthorizationStatusRestricted + ) { + close() + } + } + } + + this@locationEvents.desiredAccuracy = desiredAccuracy + this@locationEvents.distanceFilter = minDistanceM.toDouble() + this@locationEvents.delegate = delegate + startUpdatingLocation() + + awaitClose { + stopUpdatingLocation() + if (this@locationEvents.delegate === delegate) { + this@locationEvents.delegate = null + } + } +} + +/** + * Returns a cold [Flow] that emits coarse location updates on iOS using + * [kCLLocationAccuracyHundredMeters], mirroring Android's `LocationManager.coarseLocationEvents`. + */ +public fun CLLocationManager.coarseLocationEvents( + minDistanceM: Float = 1f +): Flow = locationEvents( + minDistanceM = minDistanceM, + desiredAccuracy = kCLLocationAccuracyHundredMeters +) + +/** + * Returns a cold [Flow] that emits fine (GPS-grade) location updates on iOS using + * [kCLLocationAccuracyBest], mirroring Android's `LocationManager.fineLocationEvents`. + */ +public fun CLLocationManager.fineLocationEvents( + minDistanceM: Float = 1f +): Flow = locationEvents( + minDistanceM = minDistanceM, + desiredAccuracy = kCLLocationAccuracyBest +) + +/** + * Adapts an iOS [CLLocationManager] into a multiplatform [LocationSource] that can be consumed + * from shared `commonMain` code alongside Android's `LocationManager` and + * `FusedLocationProviderClient`. + */ +public fun CLLocationManager.asLocationSource(): LocationSource = + LocationSource { _, minUpdateDistanceM, priority -> + val accuracy = when (priority) { + LocationPriority.HIGH_ACCURACY -> kCLLocationAccuracyBest + LocationPriority.BALANCED_POWER_ACCURACY -> kCLLocationAccuracyNearestTenMeters + LocationPriority.LOW_POWER -> kCLLocationAccuracyHundredMeters + } + locationEvents( + minDistanceM = minUpdateDistanceM, + desiredAccuracy = accuracy + ) + } diff --git a/library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt b/library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt new file mode 100644 index 000000000..1f72463cb --- /dev/null +++ b/library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalForeignApi::class, ExperimentalCoroutinesApi::class) + +package com.google.maps.android.location + +import com.google.maps.android.model.LatLng +import com.google.maps.android.model.latitude +import com.google.maps.android.model.longitude +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import platform.CoreLocation.CLLocation +import platform.CoreLocation.CLLocationCoordinate2DMake +import platform.CoreLocation.CLLocationManager +import platform.CoreLocation.kCLErrorDenied +import platform.CoreLocation.kCLErrorDomain +import platform.CoreLocation.kCLLocationAccuracyBest +import platform.CoreLocation.kCLLocationAccuracyHundredMeters +import platform.Foundation.NSDate +import platform.Foundation.NSError +import platform.Foundation.dateWithTimeIntervalSince1970 + +class CLLocationManagerTest { + + @Test + fun locationPropertiesMapFromCLLocation() { + val timestampSeconds = 1_728_500_000.0 + val clLocation = CLLocation( + coordinate = CLLocationCoordinate2DMake(51.5074, -0.1278), + altitude = 35.5, + horizontalAccuracy = 4.5, + verticalAccuracy = 8.0, + course = 180.0, + speed = 12.25, + timestamp = NSDate.dateWithTimeIntervalSince1970(timestampSeconds) + ) + + val location: Location = clLocation + + assertEquals(51.5074, location.latitude, 1e-6) + assertEquals(-0.1278, location.longitude, 1e-6) + assertEquals(LatLng(51.5074, -0.1278), location.latLng) + assertEquals(4.5f, location.accuracyMeters, 1e-4f) + assertEquals(35.5, location.altitudeMeters, 1e-6) + assertEquals(180.0f, location.bearingDegrees, 1e-4f) + assertEquals(12.25f, location.speedMetersPerSecond, 1e-4f) + assertEquals(1_728_500_000_000L, location.timestampMillis) + } + + @Test + fun fineLocationEventsEmitsUpdatesAndCleansUpDelegate() = runTest(UnconfinedTestDispatcher()) { + val manager = CLLocationManager() + val expectedFix = CLLocation(latitude = 37.4220, longitude = -122.0841) + + val deferred = async { + manager.fineLocationEvents(minDistanceM = 5f).first() + } + + val delegate = manager.delegate + assertNotNull(delegate, "Collecting fineLocationEvents should attach a CLLocationManagerDelegate") + assertEquals(kCLLocationAccuracyBest, manager.desiredAccuracy) + assertEquals(5.0, manager.distanceFilter) + + delegate.locationManager(manager, didUpdateLocations = listOf(expectedFix)) + + val emitted: Location = deferred.await() + assertEquals(37.4220, emitted.latLng.latitude, 1e-6) + assertEquals(-122.0841, emitted.latLng.longitude, 1e-6) + assertNull(manager.delegate, "Cancelling the flow collector should detach the delegate") + } + + @Test + fun coarseLocationEventsCompletesOnPermissionDeniedError() = runTest(UnconfinedTestDispatcher()) { + val manager = CLLocationManager() + val firstFix = CLLocation(latitude = 40.7128, longitude = -74.0060) + + val deferred = async { + manager.coarseLocationEvents(minDistanceM = 10f).toList() + } + + val delegate = manager.delegate + assertNotNull(delegate) + assertEquals(kCLLocationAccuracyHundredMeters, manager.desiredAccuracy) + assertEquals(10.0, manager.distanceFilter) + + delegate.locationManager(manager, didUpdateLocations = listOf(firstFix)) + val deniedError = NSError.errorWithDomain( + domain = kCLErrorDomain, + code = kCLErrorDenied, + userInfo = null + ) + delegate.locationManager(manager, didFailWithError = deniedError) + + val results = deferred.await() + assertEquals(1, results.size) + assertEquals(LatLng(40.7128, -74.0060), results.single().latLng) + assertNull(manager.delegate) + } + + @Test + fun multiplatformLocationSourceStreamsThroughCommonAbstraction() = runTest(UnconfinedTestDispatcher()) { + val manager = CLLocationManager() + val source: LocationSource = manager.asLocationSource() + val expectedFix = CLLocation(latitude = 48.8566, longitude = 2.3522) + + val deferred = async { + source.locationEvents( + intervalMs = 1_000L, + minUpdateDistanceM = 2f, + priority = LocationPriority.HIGH_ACCURACY + ).first() + } + + val delegate = assertNotNull(manager.delegate) + delegate.locationManager(manager, didUpdateLocations = listOf(expectedFix)) + + val emitted = deferred.await() + assertEquals(LatLng(48.8566, 2.3522), emitted.latLng) + } +} From ed571b549c6e628c69ad8f4d3dc3b2c472e76e78 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:42:21 -0600 Subject: [PATCH 2/5] fix(location): move FusedLocationProviderClient.asLocationSource to FusedLocationProvider.kt --- library/api/library.api | 4 ++-- .../android/location/FusedLocationProvider.kt | 21 +++++++++++++++++ .../maps/android/location/Location.android.kt | 23 ------------------- 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/library/api/library.api b/library/api/library.api index fb20ce6c3..195149e45 100644 --- a/library/api/library.api +++ b/library/api/library.api @@ -712,6 +712,8 @@ public final class com/google/maps/android/ktx/utils/location/LocationManagerKt } public final class com/google/maps/android/location/FusedLocationProviderKt { + public static final fun asLocationSource (Lcom/google/android/gms/location/FusedLocationProviderClient;Landroid/os/Looper;)Lcom/google/maps/android/location/LocationSource; + public static synthetic fun asLocationSource$default (Lcom/google/android/gms/location/FusedLocationProviderClient;Landroid/os/Looper;ILjava/lang/Object;)Lcom/google/maps/android/location/LocationSource; public static final fun fusedLocationEvents (Lcom/google/android/gms/location/FusedLocationProviderClient;JFILandroid/os/Looper;)Lkotlinx/coroutines/flow/Flow; public static synthetic fun fusedLocationEvents$default (Lcom/google/android/gms/location/FusedLocationProviderClient;JFILandroid/os/Looper;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; public static final fun locationEvents (Lcom/google/android/gms/location/FusedLocationProviderClient;Lcom/google/android/gms/location/LocationRequest;Landroid/os/Looper;)Lkotlinx/coroutines/flow/Flow; @@ -746,9 +748,7 @@ public abstract interface class com/google/maps/android/location/LocationSource public final class com/google/maps/android/location/Location_androidKt { public static final fun asLocationSource (Landroid/location/LocationManager;Landroid/os/Looper;)Lcom/google/maps/android/location/LocationSource; - public static final fun asLocationSource (Lcom/google/android/gms/location/FusedLocationProviderClient;Landroid/os/Looper;)Lcom/google/maps/android/location/LocationSource; public static synthetic fun asLocationSource$default (Landroid/location/LocationManager;Landroid/os/Looper;ILjava/lang/Object;)Lcom/google/maps/android/location/LocationSource; - public static synthetic fun asLocationSource$default (Lcom/google/android/gms/location/FusedLocationProviderClient;Landroid/os/Looper;ILjava/lang/Object;)Lcom/google/maps/android/location/LocationSource; public static final fun getAccuracyMeters (Landroid/location/Location;)F public static final fun getAltitudeMeters (Landroid/location/Location;)D public static final fun getBearingDegrees (Landroid/location/Location;)F diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt index 0dab48af6..e223f25ed 100644 --- a/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt @@ -85,3 +85,24 @@ public fun FusedLocationProviderClient.fusedLocationEvents( .build() return locationEvents(request, looper) } + +/** + * Adapts a Google Play Services [FusedLocationProviderClient] into a multiplatform + * [LocationSource] that can be consumed from shared `commonMain` code. + */ +@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) +public fun FusedLocationProviderClient.asLocationSource( + looper: Looper = Looper.getMainLooper() +): LocationSource = LocationSource { intervalMs, minUpdateDistanceM, priority -> + val playPriority = when (priority) { + LocationPriority.HIGH_ACCURACY -> Priority.PRIORITY_HIGH_ACCURACY + LocationPriority.BALANCED_POWER_ACCURACY -> Priority.PRIORITY_BALANCED_POWER_ACCURACY + LocationPriority.LOW_POWER -> Priority.PRIORITY_LOW_POWER + } + fusedLocationEvents( + intervalMs = intervalMs, + minUpdateDistanceM = minUpdateDistanceM, + priority = playPriority, + looper = looper + ) +} diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt index 874c0e85e..c8232ea43 100644 --- a/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt @@ -20,8 +20,6 @@ import android.Manifest import android.location.LocationManager import android.os.Looper import androidx.annotation.RequiresPermission -import com.google.android.gms.location.FusedLocationProviderClient -import com.google.android.gms.location.Priority /** * On Android, [Location] is a direct `typealias` for [android.location.Location]. @@ -104,24 +102,3 @@ public fun LocationManager.asLocationSource( ) } } - -/** - * Adapts a Google Play Services [FusedLocationProviderClient] into a multiplatform - * [LocationSource] that can be consumed from shared `commonMain` code. - */ -@RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) -public fun FusedLocationProviderClient.asLocationSource( - looper: Looper = Looper.getMainLooper() -): LocationSource = LocationSource { intervalMs, minUpdateDistanceM, priority -> - val playPriority = when (priority) { - LocationPriority.HIGH_ACCURACY -> Priority.PRIORITY_HIGH_ACCURACY - LocationPriority.BALANCED_POWER_ACCURACY -> Priority.PRIORITY_BALANCED_POWER_ACCURACY - LocationPriority.LOW_POWER -> Priority.PRIORITY_LOW_POWER - } - fusedLocationEvents( - intervalMs = intervalMs, - minUpdateDistanceM = minUpdateDistanceM, - priority = playPriority, - looper = looper - ) -} From d04495b6e95e3658e54099f14c8b88ae4dec8537 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:01:39 -0600 Subject: [PATCH 3/5] fix(location): close location Flows cleanly on SecurityException --- .../location/FusedLocationProviderTest.kt | 19 +++++++ .../android/location/LocationManagerTest.kt | 38 +++++++++++++ .../android/location/FusedLocationProvider.kt | 15 +++++- .../maps/android/location/LocationManager.kt | 54 ++++++++++++------- 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt index a9ac1a50c..0c242a14d 100644 --- a/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt +++ b/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt @@ -28,6 +28,7 @@ import com.google.android.gms.location.Priority import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -38,6 +39,7 @@ import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.eq import org.mockito.Captor import org.mockito.Mock +import org.mockito.Mockito.`when` import org.mockito.Mockito.verify import org.mockito.junit.MockitoJUnitRunner @@ -113,4 +115,21 @@ public class FusedLocationProviderTest { verify(fusedLocationClient).removeLocationUpdates(eq(locationCallbackCaptor.value)) } + + @SuppressLint("MissingPermission") + @Test + public fun testLocationEventsPermissionRevokedClosesCleanly(): Unit = runTest { + val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L).build() + `when`( + fusedLocationClient.requestLocationUpdates( + eq(request), + any(LocationCallback::class.java), + eq(looper) + ) + ).thenThrow(SecurityException("Location permission denied")) + + val events = fusedLocationClient.locationEvents(request, looper).toList() + + assertThat(events).isEmpty() + } } diff --git a/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt index f2dcd8ce2..70545c413 100644 --- a/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt +++ b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt @@ -166,4 +166,42 @@ public class LocationManagerTest { any() ) } + + @SuppressLint("MissingPermission") + @Test + public fun testCoarseLocationPermissionRevokedClosesCleanly(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.NETWORK_PROVIDER)) + `when`( + locationManager.requestLocationUpdates( + eq(LocationManager.NETWORK_PROVIDER), + anyLong(), + anyFloat(), + any(), + any() + ) + ).thenThrow(SecurityException("Location permission denied")) + + val events = locationManager.coarseLocationEvents(1_000L, 1f, looper).toList() + + assertThat(events).isEmpty() + } + + @SuppressLint("MissingPermission") + @Test + public fun testFineLocationPermissionRevokedClosesCleanly(): Unit = runTest { + `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.GPS_PROVIDER)) + `when`( + locationManager.requestLocationUpdates( + eq(LocationManager.GPS_PROVIDER), + anyLong(), + anyFloat(), + any(), + any() + ) + ).thenThrow(SecurityException("Location permission denied")) + + val events = locationManager.fineLocationEvents(2_000L, 2f, looper).toList() + + assertThat(events).isEmpty() + } } diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt index e223f25ed..e9377ce55 100644 --- a/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt @@ -58,10 +58,21 @@ public fun FusedLocationProviderClient.locationEvents( } } - requestLocationUpdates(locationRequest, callback, looper) + try { + requestLocationUpdates(locationRequest, callback, looper) + Unit + } catch (_: SecurityException) { + close() + return@callbackFlow + } awaitClose { - removeLocationUpdates(callback) + try { + removeLocationUpdates(callback) + Unit + } catch (_: SecurityException) { + // Ignore if location permission was revoked while the flow was active. + } } } diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt index ac8b8b8a2..8293d747a 100644 --- a/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt @@ -71,16 +71,25 @@ public fun LocationManager.coarseLocationEvents( } } - requestLocationUpdates( - LocationManager.NETWORK_PROVIDER, - minTimeMs, - minDistanceM, - listener, - looper - ) + try { + requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + minTimeMs, + minDistanceM, + listener, + looper + ) + } catch (_: SecurityException) { + close() + return@callbackFlow + } awaitClose { - removeUpdates(listener) + try { + removeUpdates(listener) + } catch (_: SecurityException) { + // Ignore if location permission was revoked while the flow was active. + } } } @@ -88,8 +97,8 @@ public fun LocationManager.coarseLocationEvents( * Returns a cold flow that emits the device's fine location updates using [LocationManager.GPS_PROVIDER]. * * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately - * when the collector cancels or closes the subscription. When the underlying location provider is unavailable - * or disabled, the flow completes normally. + * when the collector cancels or closes the subscription. When the underlying location provider is unavailable, + * disabled, or lacks runtime location permission, the flow completes normally. * * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing * multiple collectors will result in listener hijacking, and cancelling any observer will unregister @@ -127,15 +136,24 @@ public fun LocationManager.fineLocationEvents( } } - requestLocationUpdates( - LocationManager.GPS_PROVIDER, - minTimeMs, - minDistanceM, - listener, - looper - ) + try { + requestLocationUpdates( + LocationManager.GPS_PROVIDER, + minTimeMs, + minDistanceM, + listener, + looper + ) + } catch (_: SecurityException) { + close() + return@callbackFlow + } awaitClose { - removeUpdates(listener) + try { + removeUpdates(listener) + } catch (_: SecurityException) { + // Ignore if location permission was revoked while the flow was active. + } } } From 305749857aa1390209690733eb9fbda948b4a811 Mon Sep 17 00:00:00 2001 From: dkhawk <107309+dkhawk@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:06:23 -0600 Subject: [PATCH 4/5] docs(location): add TODOs referencing #1818 for unified LocationSource semantics --- .../com/google/maps/android/location/Location.android.kt | 3 +++ .../kotlin/com/google/maps/android/location/Location.ios.kt | 3 +++ 2 files changed, 6 insertions(+) diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt index c8232ea43..14e47e31d 100644 --- a/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/Location.android.kt @@ -84,6 +84,9 @@ public actual val Location.timestampMillis: Long * [LocationPriority.BALANCED_POWER_ACCURACY] and [LocationPriority.LOW_POWER] delegate to * [coarseLocationEvents] (`NETWORK_PROVIDER`). */ +// TODO(https://github.com/googlemaps/android-maps-utils/issues/1818): Emit the freshest cached +// lastKnownLocation on collection start and merge coarseLocationEvents as an indoor / coarse-only +// permission fallback for HIGH_ACCURACY so Android callers match iOS CLLocationManager semantics. @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) public fun LocationManager.asLocationSource( looper: Looper = Looper.getMainLooper() diff --git a/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt b/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt index 783bba1f9..3f2abcf3a 100644 --- a/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt +++ b/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt @@ -107,6 +107,9 @@ public fun CLLocationManager.locationEvents( desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest ): Flow = callbackFlow { val currentStatus = authorizationStatus + // TODO(https://github.com/googlemaps/android-maps-utils/issues/1818): Consider invoking + // requestWhenInUseAuthorization() automatically when currentStatus is + // kCLAuthorizationStatusNotDetermined so collecting the flow prompts for permission on iOS. if (currentStatus == kCLAuthorizationStatusDenied || currentStatus == kCLAuthorizationStatusRestricted ) { From 8cf7a5ab7a2662f03d11e16d7ab67ce48d2c99cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Mon, 12 Oct 2026 05:28:52 +0200 Subject: [PATCH 5/5] fix(location): keep SecurityException and the app's CLLocationManager delegate - Android location flows fail with SecurityException again when location permission is missing, as in 6.0.0, instead of completing silently. - iOS locationEvents restores the delegate the manager had before collection, and documents the single-collector limitation. - iOS altitudeMeters reads ellipsoidalAltitude (WGS 84), matching Android's getAltitude(); CLLocation.altitude is above mean sea level. --- .../location/FusedLocationProviderTest.kt | 9 ++++--- .../android/location/LocationManagerTest.kt | 17 ++++++------ .../android/location/FusedLocationProvider.kt | 7 ++--- .../maps/android/location/LocationManager.kt | 16 +++++++----- .../google/maps/android/location/Location.kt | 3 +++ .../maps/android/location/Location.ios.kt | 14 +++++++--- .../android/location/CLLocationManagerTest.kt | 26 ++++++++++++++++++- 7 files changed, 66 insertions(+), 26 deletions(-) diff --git a/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt index 0c242a14d..1e226ddba 100644 --- a/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt +++ b/library/src/androidHostTest/kotlin/com/google/maps/android/location/FusedLocationProviderTest.kt @@ -26,6 +26,7 @@ import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.Priority import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.toList @@ -118,7 +119,7 @@ public class FusedLocationProviderTest { @SuppressLint("MissingPermission") @Test - public fun testLocationEventsPermissionRevokedClosesCleanly(): Unit = runTest { + public fun testLocationEventsPermissionDeniedFailsWithSecurityException(): Unit = runTest { val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000L).build() `when`( fusedLocationClient.requestLocationUpdates( @@ -128,8 +129,8 @@ public class FusedLocationProviderTest { ) ).thenThrow(SecurityException("Location permission denied")) - val events = fusedLocationClient.locationEvents(request, looper).toList() - - assertThat(events).isEmpty() + assertFailsWith { + fusedLocationClient.locationEvents(request, looper).toList() + } } } diff --git a/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt index 70545c413..ea9a2aa0b 100644 --- a/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt +++ b/library/src/androidHostTest/kotlin/com/google/maps/android/location/LocationManagerTest.kt @@ -23,6 +23,7 @@ import android.location.LocationListener import android.location.LocationManager import android.os.Looper import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first @@ -169,7 +170,7 @@ public class LocationManagerTest { @SuppressLint("MissingPermission") @Test - public fun testCoarseLocationPermissionRevokedClosesCleanly(): Unit = runTest { + public fun testCoarseLocationPermissionDeniedFailsWithSecurityException(): Unit = runTest { `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.NETWORK_PROVIDER)) `when`( locationManager.requestLocationUpdates( @@ -181,14 +182,14 @@ public class LocationManagerTest { ) ).thenThrow(SecurityException("Location permission denied")) - val events = locationManager.coarseLocationEvents(1_000L, 1f, looper).toList() - - assertThat(events).isEmpty() + assertFailsWith { + locationManager.coarseLocationEvents(1_000L, 1f, looper).toList() + } } @SuppressLint("MissingPermission") @Test - public fun testFineLocationPermissionRevokedClosesCleanly(): Unit = runTest { + public fun testFineLocationPermissionDeniedFailsWithSecurityException(): Unit = runTest { `when`(locationManager.allProviders).thenReturn(listOf(LocationManager.GPS_PROVIDER)) `when`( locationManager.requestLocationUpdates( @@ -200,8 +201,8 @@ public class LocationManagerTest { ) ).thenThrow(SecurityException("Location permission denied")) - val events = locationManager.fineLocationEvents(2_000L, 2f, looper).toList() - - assertThat(events).isEmpty() + assertFailsWith { + locationManager.fineLocationEvents(2_000L, 2f, looper).toList() + } } } diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt index e9377ce55..3a2d12579 100644 --- a/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/FusedLocationProvider.kt @@ -34,7 +34,8 @@ import kotlinx.coroutines.flow.callbackFlow * Returns a cold flow that emits device location updates using [FusedLocationProviderClient.requestLocationUpdates]. * * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately - * when the collector cancels or closes the subscription. + * when the collector cancels or closes the subscription. Without location permission the flow fails + * with a [SecurityException]. * * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing * multiple collectors will result in listener hijacking, and cancelling any observer will unregister @@ -61,8 +62,8 @@ public fun FusedLocationProviderClient.locationEvents( try { requestLocationUpdates(locationRequest, callback, looper) Unit - } catch (_: SecurityException) { - close() + } catch (e: SecurityException) { + close(e) return@callbackFlow } diff --git a/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt b/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt index 8293d747a..6a86aea0c 100644 --- a/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt +++ b/library/src/androidMain/kotlin/com/google/maps/android/location/LocationManager.kt @@ -33,7 +33,8 @@ import kotlinx.coroutines.flow.callbackFlow * * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately * when the collector cancels or closes the subscription. When the underlying location provider is unavailable - * or disabled, the flow completes normally. + * or disabled, the flow completes normally. Without location permission the flow fails with a + * [SecurityException]. * * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing * multiple collectors will result in listener hijacking, and cancelling any observer will unregister @@ -79,8 +80,8 @@ public fun LocationManager.coarseLocationEvents( listener, looper ) - } catch (_: SecurityException) { - close() + } catch (e: SecurityException) { + close(e) return@callbackFlow } @@ -97,8 +98,9 @@ public fun LocationManager.coarseLocationEvents( * Returns a cold flow that emits the device's fine location updates using [LocationManager.GPS_PROVIDER]. * * The location updates start streaming ONLY when the flow is collected, and stop streaming immediately - * when the collector cancels or closes the subscription. When the underlying location provider is unavailable, - * disabled, or lacks runtime location permission, the flow completes normally. + * when the collector cancels or closes the subscription. When the underlying location provider is unavailable + * or disabled, the flow completes normally. Without location permission the flow fails with a + * [SecurityException]. * * **Warning**: This is a cold flow wrapping a single-listener SDK callback. Concurrently subscribing * multiple collectors will result in listener hijacking, and cancelling any observer will unregister @@ -144,8 +146,8 @@ public fun LocationManager.fineLocationEvents( listener, looper ) - } catch (_: SecurityException) { - close() + } catch (e: SecurityException) { + close(e) return@callbackFlow } diff --git a/library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt b/library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt index b2fd6f59d..6aa220eb0 100644 --- a/library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt +++ b/library/src/commonMain/kotlin/com/google/maps/android/location/Location.kt @@ -114,6 +114,9 @@ public fun interface LocationSource { * Returns a cold [Flow] that starts streaming [Location] updates when collected and * automatically unregisters the underlying platform listener when the collector cancels. * + * Without location permission, the Android sources fail with a `SecurityException`, while + * the iOS source completes without emitting. + * * @param intervalMs Desired interval between updates in milliseconds (used by Android providers; * iOS `CLLocationManager` filters primarily by [minUpdateDistanceM] and [priority]). * @param minUpdateDistanceM Minimum displacement in meters before a new update is emitted. diff --git a/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt b/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt index 3f2abcf3a..d0c70a83d 100644 --- a/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt +++ b/library/src/iosMain/kotlin/com/google/maps/android/location/Location.ios.kt @@ -64,10 +64,11 @@ public actual val Location.accuracyMeters: Float get() = horizontalAccuracy.toFloat() /** - * Returns `CLLocation.altitude` in meters above the WGS 84 reference ellipsoid. + * Returns `CLLocation.ellipsoidalAltitude` in meters above the WGS 84 reference ellipsoid, matching + * Android's `Location.getAltitude()`. (`CLLocation.altitude` is above mean sea level instead.) */ public actual val Location.altitudeMeters: Double - get() = altitude + get() = ellipsoidalAltitude /** * Returns `CLLocation.course` in degrees East of true North (negative when course is invalid). @@ -98,6 +99,12 @@ public actual val Location.timestampMillis: Long * the Kotlin `delegate` instance inside `awaitClose` ensures Kotlin/Native's garbage collector * keeps the delegate alive for the entire lifetime of the active flow subscription. * + * **Warning**: [CLLocationManager] has a single delegate. While the flow is collected it replaces + * any delegate already set on this manager, which gets no callbacks until the collector cancels + * and the previous delegate is restored. Concurrently collecting several flows from the same + * manager is not supported, and cancelling any of them stops location updates. Share the flow + * (e.g. using [kotlinx.coroutines.flow.shareIn]) for multi-observer configurations. + * * @param minDistanceM Minimum horizontal displacement in meters before a new fix is emitted. * @param desiredAccuracy Desired CoreLocation accuracy constant (for example, * [kCLLocationAccuracyBest] or [kCLLocationAccuracyHundredMeters]). @@ -143,13 +150,14 @@ public fun CLLocationManager.locationEvents( this@locationEvents.desiredAccuracy = desiredAccuracy this@locationEvents.distanceFilter = minDistanceM.toDouble() + val previousDelegate = this@locationEvents.delegate this@locationEvents.delegate = delegate startUpdatingLocation() awaitClose { stopUpdatingLocation() if (this@locationEvents.delegate === delegate) { - this@locationEvents.delegate = null + this@locationEvents.delegate = previousDelegate } } } diff --git a/library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt b/library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt index 1f72463cb..5faab8240 100644 --- a/library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt +++ b/library/src/iosTest/kotlin/com/google/maps/android/location/CLLocationManagerTest.kt @@ -24,7 +24,9 @@ import com.google.maps.android.model.longitude import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull +import kotlin.test.assertNotSame import kotlin.test.assertNull +import kotlin.test.assertSame import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async @@ -35,6 +37,7 @@ import kotlinx.coroutines.test.runTest import platform.CoreLocation.CLLocation import platform.CoreLocation.CLLocationCoordinate2DMake import platform.CoreLocation.CLLocationManager +import platform.CoreLocation.CLLocationManagerDelegateProtocol import platform.CoreLocation.kCLErrorDenied import platform.CoreLocation.kCLErrorDomain import platform.CoreLocation.kCLLocationAccuracyBest @@ -42,6 +45,7 @@ import platform.CoreLocation.kCLLocationAccuracyHundredMeters import platform.Foundation.NSDate import platform.Foundation.NSError import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.darwin.NSObject class CLLocationManagerTest { @@ -64,7 +68,9 @@ class CLLocationManagerTest { assertEquals(-0.1278, location.longitude, 1e-6) assertEquals(LatLng(51.5074, -0.1278), location.latLng) assertEquals(4.5f, location.accuracyMeters, 1e-4f) - assertEquals(35.5, location.altitudeMeters, 1e-6) + // CLLocation has no initializer for ellipsoidalAltitude, so a constructed fix reports 0. + // altitudeMeters must still read it rather than the mean sea level `altitude` (35.5). + assertEquals(clLocation.ellipsoidalAltitude, location.altitudeMeters) assertEquals(180.0f, location.bearingDegrees, 1e-4f) assertEquals(12.25f, location.speedMetersPerSecond, 1e-4f) assertEquals(1_728_500_000_000L, location.timestampMillis) @@ -92,6 +98,24 @@ class CLLocationManagerTest { assertNull(manager.delegate, "Cancelling the flow collector should detach the delegate") } + @Test + fun collectingRestoresThePreviousDelegate() = runTest(UnconfinedTestDispatcher()) { + val manager = CLLocationManager() + val appDelegate = object : NSObject(), CLLocationManagerDelegateProtocol {} + manager.delegate = appDelegate + + val deferred = async { + manager.fineLocationEvents().first() + } + + val flowDelegate = assertNotNull(manager.delegate) + assertNotSame(appDelegate, flowDelegate) + flowDelegate.locationManager(manager, didUpdateLocations = listOf(CLLocation(latitude = 1.0, longitude = 2.0))) + deferred.await() + + assertSame(appDelegate, manager.delegate, "Cancelling the flow should restore the app's delegate") + } + @Test fun coarseLocationEventsCompletesOnPermissionDeniedError() = runTest(UnconfinedTestDispatcher()) { val manager = CLLocationManager()