Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ maplibre-styleSpec = "26.4.2"
mobilityData = "0.4.0"
playServices-location = "21.4.0"
spatialk = "0.7.0"
robolectric = "4.16.1"

# Regular tools: keep as up to date as possible
gradle-dokka = "2.2.0"
Expand All @@ -81,6 +82,7 @@ gradle-kotlin = "2.4.10"
gradle-android = "9.1.1"

[libraries]
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
alchemist = { module = "io.github.kevincianfarini.alchemist:alchemist", version.ref = "alchemist" }
androidx-activity = { module = "androidx.activity:activity", version.ref = "androidx-activity" }
Expand Down
1 change: 1 addition & 0 deletions lib/location-runtime-gms/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ kotlin {
androidDeviceTest.dependencies { implementation(libs.androidx.test.runner) }

androidHostTest.dependencies {
implementation(libs.robolectric)
implementation(kotlin("test"))
implementation(libs.playServices.location)
implementation(libs.kotlinx.coroutines.test)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package org.maplibre.compose.gms

import android.location.Location
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationResult
import com.google.android.gms.tasks.Tasks
import java.lang.reflect.Proxy
import java.util.concurrent.Executor
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.runner.RunWith
import org.maplibre.compose.location.LocationAccuracyAuthorization
import org.maplibre.compose.location.LocationEvent
import org.maplibre.compose.location.LocationPermission
import org.maplibre.compose.location.LocationProvider
import org.maplibre.compose.location.LocationRequest
import org.maplibre.compose.location.LocationUnavailableReason
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36], manifest = Config.NONE)
class FusedLocationPermissionRecoveryTest {
@Test
fun securityFailureRecoversWithoutPermissionDelegate() = runTest {
val client = TestClient().apply { denied = true }
val provider = FusedLocationProvider(client.delegate, null, Executor { it.run() })
val events = mutableListOf<LocationEvent>()
val collection = backgroundScope.launch {
provider.updates(LocationRequest()).collect(events::add)
}
runCurrent()
assertEquals(
LocationUnavailableReason.PermissionDenied,
assertIs<LocationEvent.Unavailable>(events.last()).reason,
)
client.denied = false
client.lastLocation = Location("fused")
advanceTimeBy(1.seconds)
runCurrent()
assertIs<LocationEvent.Update>(events.last())
collection.cancelAndJoin()
assertTrue(client.callbacks.isEmpty())
}

@Test
fun collectorRecoversAfterPermissionChanges() = runTest {
val permission = MutableStateFlow<LocationPermission>(LocationPermission.NotGranted(false))
val delegate =
object : LocationProvider {
override val permission = permission

override fun updates(request: LocationRequest) =
error("Permission delegate must not receive locations")

override fun requestPermission() = error("Collection must not prompt")
}
val client = TestClient()
val provider = FusedLocationProvider(client.delegate, delegate, Executor { it.run() })
val events = mutableListOf<LocationEvent>()
val collection = backgroundScope.launch {
provider.updates(LocationRequest()).collect(events::add)
}
runCurrent()
assertEquals(
LocationUnavailableReason.PermissionDenied,
assertIs<LocationEvent.Unavailable>(events.last()).reason,
)
assertTrue(client.callbacks.isEmpty())
permission.value = LocationPermission.Granted(LocationAccuracyAuthorization.Precise)
runCurrent()
assertEquals(1, client.callbacks.size)
fun sendLocation() {
val result = LocationResult.create(listOf(Location("fused")))
client.callbacks.toList().forEach { it.onLocationResult(result) }
}
sendLocation()
runCurrent()
assertIs<LocationEvent.Update>(events.last())
permission.value = LocationPermission.NotGranted(false)
runCurrent()
assertEquals(
LocationUnavailableReason.PermissionDenied,
assertIs<LocationEvent.Unavailable>(events.last()).reason,
)
assertTrue(client.callbacks.isEmpty())
permission.value = LocationPermission.Granted(LocationAccuracyAuthorization.Approximate)
runCurrent()
assertEquals(1, client.callbacks.size)
sendLocation()
runCurrent()
assertIs<LocationEvent.Update>(events.last())
collection.cancelAndJoin()
assertTrue(client.callbacks.isEmpty())
provider.close()
}
}

private class TestClient {
var denied = false
var lastLocation: Location? = null
val callbacks = mutableSetOf<LocationCallback>()
val delegate =
Proxy.newProxyInstance(
FusedLocationProviderClient::class.java.classLoader,
arrayOf(FusedLocationProviderClient::class.java),
) { _, method, args ->
when (method.name) {
"getLastLocation" -> {
if (denied) throw SecurityException("denied")
Tasks.forResult(lastLocation)
}
"requestLocationUpdates" -> {
callbacks += args[2] as LocationCallback
Tasks.forResult<Void>(null)
}
"removeLocationUpdates" -> {
callbacks -= args[0] as LocationCallback
Tasks.forResult<Void>(null)
}
else -> error(method.name)
}
} as FusedLocationProviderClient
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package org.maplibre.compose.gms

import android.Manifest
import android.content.Context
import androidx.annotation.MainThread
import androidx.annotation.RequiresPermission
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.Granularity
import com.google.android.gms.location.LastLocationRequest
Expand All @@ -16,11 +14,17 @@ import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.Task
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.tasks.await
import org.maplibre.compose.location.AndroidLocationProvider
import org.maplibre.compose.location.LocationAccuracy
Expand Down Expand Up @@ -93,10 +97,23 @@ internal constructor(
permissionDelegate?.close()
}

@RequiresPermission(
anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION]
)
override fun updates(request: LocationRequest): Flow<LocationEvent> = callbackFlow {
@OptIn(ExperimentalCoroutinesApi::class)
override fun updates(request: LocationRequest): Flow<LocationEvent> =
permission.flatMapLatest { status ->
if (status is LocationPermission.Granted) {
locationUpdates(request).retryWhen { error, _ ->
if (error !is SecurityException) return@retryWhen false
emit(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied, error))
delay(1.seconds)
true
}
} else {
flowOf(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied))
}
}

@Suppress("MissingPermission")
private fun locationUpdates(request: LocationRequest): Flow<LocationEvent> = callbackFlow {
val callback =
object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
Expand All @@ -114,29 +131,24 @@ internal constructor(

var registration: Task<Void>? = null
try {
try {
locationClient
.getLastLocation(
LastLocationRequest.Builder()
.setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
.build()
)
.await()
?.let { location ->
trySend(location.asMapLibreLocationUpdate())
}
locationClient
.getLastLocation(
LastLocationRequest.Builder()
.setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL)
.build()
)
.await()
?.let { location ->
trySend(location.asMapLibreLocationUpdate())
}

registration =
locationClient.requestLocationUpdates(
request.asGmsLocationRequest(),
executor,
callback,
)
registration.await()
} catch (error: SecurityException) {
trySend(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied, error))
close()
}
registration =
locationClient.requestLocationUpdates(
request.asGmsLocationRequest(),
executor,
callback,
)
registration.await()

awaitClose()
} finally {
Expand Down
2 changes: 2 additions & 0 deletions lib/location-runtime-hms/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ kotlin {
androidDeviceTest.dependencies { implementation(libs.androidx.test.runner) }

androidHostTest.dependencies {
implementation(libs.robolectric)
implementation(libs.kotlinx.coroutines.test)
implementation(kotlin("test"))
implementation(libs.hms.location)
}
Expand Down
Loading
Loading