diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6958af600..f0df555eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" @@ -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" } diff --git a/lib/location-runtime-gms/build.gradle.kts b/lib/location-runtime-gms/build.gradle.kts index 40675eeec..33295db33 100644 --- a/lib/location-runtime-gms/build.gradle.kts +++ b/lib/location-runtime-gms/build.gradle.kts @@ -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) diff --git a/lib/location-runtime-gms/src/androidHostTest/kotlin/org/maplibre/compose/gms/FusedLocationPermissionRecoveryTest.kt b/lib/location-runtime-gms/src/androidHostTest/kotlin/org/maplibre/compose/gms/FusedLocationPermissionRecoveryTest.kt new file mode 100644 index 000000000..9ccbfc91f --- /dev/null +++ b/lib/location-runtime-gms/src/androidHostTest/kotlin/org/maplibre/compose/gms/FusedLocationPermissionRecoveryTest.kt @@ -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() + val collection = backgroundScope.launch { + provider.updates(LocationRequest()).collect(events::add) + } + runCurrent() + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(events.last()).reason, + ) + client.denied = false + client.lastLocation = Location("fused") + advanceTimeBy(1.seconds) + runCurrent() + assertIs(events.last()) + collection.cancelAndJoin() + assertTrue(client.callbacks.isEmpty()) + } + + @Test + fun collectorRecoversAfterPermissionChanges() = runTest { + val permission = MutableStateFlow(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() + val collection = backgroundScope.launch { + provider.updates(LocationRequest()).collect(events::add) + } + runCurrent() + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(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(events.last()) + permission.value = LocationPermission.NotGranted(false) + runCurrent() + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(events.last()).reason, + ) + assertTrue(client.callbacks.isEmpty()) + permission.value = LocationPermission.Granted(LocationAccuracyAuthorization.Approximate) + runCurrent() + assertEquals(1, client.callbacks.size) + sendLocation() + runCurrent() + assertIs(events.last()) + collection.cancelAndJoin() + assertTrue(client.callbacks.isEmpty()) + provider.close() + } +} + +private class TestClient { + var denied = false + var lastLocation: Location? = null + val callbacks = mutableSetOf() + 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(null) + } + "removeLocationUpdates" -> { + callbacks -= args[0] as LocationCallback + Tasks.forResult(null) + } + else -> error(method.name) + } + } as FusedLocationProviderClient +} diff --git a/lib/location-runtime-gms/src/androidMain/kotlin/org/maplibre/compose/gms/FusedLocationProvider.kt b/lib/location-runtime-gms/src/androidMain/kotlin/org/maplibre/compose/gms/FusedLocationProvider.kt index 4e063d2ce..0c4a463dd 100644 --- a/lib/location-runtime-gms/src/androidMain/kotlin/org/maplibre/compose/gms/FusedLocationProvider.kt +++ b/lib/location-runtime-gms/src/androidMain/kotlin/org/maplibre/compose/gms/FusedLocationProvider.kt @@ -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 @@ -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 @@ -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 = callbackFlow { + @OptIn(ExperimentalCoroutinesApi::class) + override fun updates(request: LocationRequest): Flow = + 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 = callbackFlow { val callback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { @@ -114,29 +131,24 @@ internal constructor( var registration: Task? = 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 { diff --git a/lib/location-runtime-hms/build.gradle.kts b/lib/location-runtime-hms/build.gradle.kts index 0446f0f8f..a8237b683 100644 --- a/lib/location-runtime-hms/build.gradle.kts +++ b/lib/location-runtime-hms/build.gradle.kts @@ -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) } diff --git a/lib/location-runtime-hms/src/androidHostTest/kotlin/org/maplibre/compose/hms/FusedLocationPermissionRecoveryTest.kt b/lib/location-runtime-hms/src/androidHostTest/kotlin/org/maplibre/compose/hms/FusedLocationPermissionRecoveryTest.kt new file mode 100644 index 000000000..d7e49fa0c --- /dev/null +++ b/lib/location-runtime-hms/src/androidHostTest/kotlin/org/maplibre/compose/hms/FusedLocationPermissionRecoveryTest.kt @@ -0,0 +1,142 @@ +package org.maplibre.compose.hms + +import android.location.Location +import android.os.Looper +import com.huawei.hmf.tasks.Task +import com.huawei.hmf.tasks.TaskCompletionSource +import com.huawei.hms.location.FusedLocationProviderClient +import com.huawei.hms.location.HWLocation +import com.huawei.hms.location.LocationCallback +import com.huawei.hms.location.LocationResult +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.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +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, null) + val events = mutableListOf() + val collection = backgroundScope.launch { + provider.updates(LocationRequest()).collect(events::add) + } + runCurrent() + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(events.last()).reason, + ) + client.denied = false + client.cachedLocation = Location("fused") + advanceTimeBy(1.seconds) + runCurrent() + shadowOf(Looper.getMainLooper()).idle() + runCurrent() + assertIs(events.last()) + collection.cancelAndJoin() + assertTrue(client.callbacks.isEmpty()) + } + + @Test + fun collectorRecoversAfterPermissionChanges() = runTest { + val permission = MutableStateFlow(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) + val events = mutableListOf() + val collection = backgroundScope.launch { + provider.updates(LocationRequest()).collect(events::add) + } + runCurrent() + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(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(HWLocation())) + client.callbacks.toList().forEach { it.onLocationResult(result) } + } + sendLocation() + runCurrent() + assertIs(events.last()) + permission.value = LocationPermission.NotGranted(false) + runCurrent() + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(events.last()).reason, + ) + assertTrue(client.callbacks.isEmpty()) + permission.value = LocationPermission.Granted(LocationAccuracyAuthorization.Approximate) + runCurrent() + assertEquals(1, client.callbacks.size) + sendLocation() + runCurrent() + assertIs(events.last()) + collection.cancelAndJoin() + assertTrue(client.callbacks.isEmpty()) + provider.close() + } +} + +private fun completed(value: T?): Task = + TaskCompletionSource().apply { setResult(value) }.task + +private class TestClient : FusedLocationProviderClient(RuntimeEnvironment.getApplication()) { + var denied = false + var cachedLocation: Location? = null + val callbacks = mutableSetOf() + + override fun getLastLocation(): Task { + if (denied) throw SecurityException("denied") + return completed(cachedLocation) + } + + override fun requestLocationUpdates( + request: com.huawei.hms.location.LocationRequest, + callback: LocationCallback, + looper: Looper, + ): Task { + callbacks += callback + return completed(null) + } + + override fun removeLocationUpdates(callback: LocationCallback): Task { + callbacks -= callback + return completed(null) + } +} diff --git a/lib/location-runtime-hms/src/androidMain/kotlin/org/maplibre/compose/hms/FusedLocationProvider.kt b/lib/location-runtime-hms/src/androidMain/kotlin/org/maplibre/compose/hms/FusedLocationProvider.kt index da8981138..8de84aaaa 100644 --- a/lib/location-runtime-hms/src/androidMain/kotlin/org/maplibre/compose/hms/FusedLocationProvider.kt +++ b/lib/location-runtime-hms/src/androidMain/kotlin/org/maplibre/compose/hms/FusedLocationProvider.kt @@ -1,10 +1,8 @@ package org.maplibre.compose.hms -import android.Manifest import android.content.Context import android.os.HandlerThread import androidx.annotation.MainThread -import androidx.annotation.RequiresPermission import com.huawei.hmf.tasks.Task import com.huawei.hmf.tasks.TaskExecutors import com.huawei.hms.location.FusedLocationProviderClient @@ -13,11 +11,16 @@ import com.huawei.hms.location.LocationCallback import com.huawei.hms.location.LocationRequest as HmsLocationRequest import com.huawei.hms.location.LocationResult import com.huawei.hms.location.LocationServices -import kotlinx.coroutines.channels.ProducerScope +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi 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 org.maplibre.compose.location.AndroidLocationProvider import org.maplibre.compose.location.LocationAccuracy import org.maplibre.compose.location.LocationEvent @@ -88,10 +91,23 @@ internal constructor( permissionDelegate?.close() } - @RequiresPermission( - anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION] - ) - override fun updates(request: LocationRequest): Flow = callbackFlow { + @OptIn(ExperimentalCoroutinesApi::class) + override fun updates(request: LocationRequest): Flow = + 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 = callbackFlow { val callback = object : LocationCallback() { override fun onLocationResult(result: LocationResult?) { @@ -113,13 +129,13 @@ internal constructor( .addOnSuccessListener { location -> location?.let { trySend(it.asMapLibreLocationUpdate()) } } - .addOnFailureListener { error -> handleFailure(error) } + .addOnFailureListener { error -> close(error) } locationClient .requestLocationUpdates(request.asHmsLocationRequest(), callback, callbackThread.looper) - .addOnFailureListener { error -> handleFailure(error) } + .addOnFailureListener { error -> close(error) } } catch (error: SecurityException) { - handleFailure(error) + close(error) null } @@ -132,15 +148,6 @@ internal constructor( } } - private fun ProducerScope.handleFailure(error: Exception) { - if (error is SecurityException) { - trySend(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied, error)) - close() - } else { - close(error) - } - } - private companion object { private val callbackThread by lazy { HandlerThread("HmsFusedLocationProvider").apply { start() } diff --git a/lib/location-runtime-macos/src/main/kotlin/org/maplibre/compose/location/desktop/macos/MacosLocationBackend.kt b/lib/location-runtime-macos/src/main/kotlin/org/maplibre/compose/location/desktop/macos/MacosLocationBackend.kt index ffe536301..3a65bd67d 100644 --- a/lib/location-runtime-macos/src/main/kotlin/org/maplibre/compose/location/desktop/macos/MacosLocationBackend.kt +++ b/lib/location-runtime-macos/src/main/kotlin/org/maplibre/compose/location/desktop/macos/MacosLocationBackend.kt @@ -3,23 +3,28 @@ package org.maplibre.compose.location.desktop.macos import java.util.Locale import java.util.concurrent.atomic.AtomicBoolean import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.maplibre.compose.location.DesktopLocationBackend @@ -82,15 +87,36 @@ internal constructor( override fun updates(request: LocationRequest): Flow = callbackFlow { check(job.isActive) { "The macOS location provider is closed" } - val collection = - scope.launch(start = CoroutineStart.UNDISPATCHED) { - try { - currentCoroutineContext().ensureActive() - collectUpdates(request).collect { send(it) } - } catch (error: Throwable) { - if (job.isActive) channel.close(error) + val collection = scope.launch { + try { + currentCoroutineContext().ensureActive() + check(backendAvailability == LocationBackendAvailability.Available) { + "Location updates require an available backend: $backendAvailability" + } + refreshPermission().collect { send(it) } + permission.collectLatest { status -> + when (status) { + is LocationPermission.Granted -> { + collectUpdates(request).collect { send(it) } + channel.close() + this@launch.cancel() + } + LocationPermission.Unknown -> refreshPermission().collect { send(it) } + is LocationPermission.NotGranted -> { + val enabled = withContext(ioDispatcher) { client.locationServicesEnabled } + send( + LocationEvent.Unavailable( + if (enabled) LocationUnavailableReason.PermissionDenied + else LocationUnavailableReason.ServicesDisabled + ) + ) + } + } } + } catch (error: Throwable) { + if (job.isActive) channel.close(error) } + } collection.invokeOnCompletion { channel.close() } try { awaitClose() @@ -99,10 +125,16 @@ internal constructor( } } + private fun refreshPermission(): Flow = + flow { requester.refreshPermission() } + .retryWhen { error, _ -> + if (error is CancellationException) return@retryWhen false + emit(LocationEvent.Unavailable(LocationUnavailableReason.UnexpectedFailure, error)) + delay(1.seconds) + true + } + private fun collectUpdates(request: LocationRequest): Flow = callbackFlow { - check(backendAvailability == LocationBackendAvailability.Available) { - "Location updates require an available backend: $backendAvailability" - } val locationServicesEnabled = withContext(ioDispatcher) { client.locationServicesEnabled } if (!locationServicesEnabled) { trySend(LocationEvent.Unavailable(LocationUnavailableReason.ServicesDisabled)) @@ -110,22 +142,6 @@ internal constructor( return@callbackFlow } - val permission = - try { - requester.refreshPermission() - } catch (error: Throwable) { - if (error is CancellationException) throw error - trySend(LocationEvent.Unavailable(LocationUnavailableReason.UnexpectedFailure, error)) - close() - return@callbackFlow - } - currentCoroutineContext().ensureActive() - if (permission !is LocationPermission.Granted) { - trySend(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied)) - close() - return@callbackFlow - } - val manager = try { client.createManager() diff --git a/lib/location-runtime-macos/src/test/kotlin/org/maplibre/compose/location/desktop/macos/MacosLocationProviderTest.kt b/lib/location-runtime-macos/src/test/kotlin/org/maplibre/compose/location/desktop/macos/MacosLocationProviderTest.kt index 579612be3..c390c572d 100644 --- a/lib/location-runtime-macos/src/test/kotlin/org/maplibre/compose/location/desktop/macos/MacosLocationProviderTest.kt +++ b/lib/location-runtime-macos/src/test/kotlin/org/maplibre/compose/location/desktop/macos/MacosLocationProviderTest.kt @@ -21,6 +21,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext @@ -39,6 +41,100 @@ import org.maplibre.spatialk.units.extensions.meters @OptIn(ExperimentalCoroutinesApi::class) class MacosLocationProviderTest { + @Test + fun collectorRecoversAfterPermissionChanges() = runTest { + val client = FakeCoreLocationClient(authorizationStatus = CL_AUTHORIZATION_DENIED) + val provider = MacosLocationProvider(client, Dispatchers.Unconfined, Dispatchers.Unconfined) + val permissionManager = client.managers.single() + val events = mutableListOf() + val collection = backgroundScope.launch { + provider.updates(LocationRequest()).collect(events::add) + } + runCurrent() + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(events.last()).reason, + ) + assertEquals(1, client.managers.size) + + permissionManager.authorizationStatus = CL_AUTHORIZATION_AUTHORIZED_WHEN_IN_USE + permissionManager.boundDelegate?.didChangeAuthorization() + runCurrent() + val firstManagers = client.managers.drop(1) + assertEquals(1, firstManagers.size) + firstManagers.forEach { it.boundDelegate?.didUpdateLocations(listOf(sampleMeasurement())) } + runCurrent() + assertIs(events.last()) + + permissionManager.authorizationStatus = CL_AUTHORIZATION_DENIED + permissionManager.boundDelegate?.didChangeAuthorization() + runCurrent() + assertTrue(firstManagers.all { it.closed }) + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(events.last()).reason, + ) + permissionManager.authorizationStatus = CL_AUTHORIZATION_AUTHORIZED_WHEN_IN_USE + permissionManager.boundDelegate?.didChangeAuthorization() + runCurrent() + assertEquals(3, client.managers.size) + client.managers.last().boundDelegate?.didUpdateLocations(listOf(sampleMeasurement())) + runCurrent() + assertIs(events.last()) + collection.cancel() + runCurrent() + assertTrue(client.managers.last().closed) + assertTrue(client.managers.all { it.whenInUseRequests == 0 }) + provider.close() + } + + @Test + fun collectorRetriesFailedPermissionReadsWithoutAnotherAuthorizationCallback() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val client = FakeCoreLocationClient().apply { nextLocation = sampleMeasurement() } + val provider = MacosLocationProvider(client, dispatcher, dispatcher) + val permissionManager = client.managers.single() + val events = mutableListOf() + val collection = backgroundScope.launch { provider.updates().collect(events::add) } + runCurrent() + assertIs(events.last()) + + val failure = IllegalStateException("permission read failed") + var failing = true + var reads = 0 + permissionManager.onAuthorizationRead = { + reads++ + if (failing) throw failure + } + permissionManager.boundDelegate?.didChangeAuthorization() + runCurrent() + val unavailable = assertIs(events.last()) + assertEquals(LocationUnavailableReason.UnexpectedFailure, unavailable.reason) + assertEquals(failure, unavailable.cause) + assertTrue(client.managers.last().closed) + advanceTimeBy(1.seconds) + runCurrent() + assertEquals(3, reads) + assertEquals(2, client.managers.size) + + failing = false + advanceTimeBy(1.seconds) + runCurrent() + assertIs(events.last()) + assertEquals(3, client.managers.size) + + failing = true + permissionManager.boundDelegate?.didChangeAuthorization() + runCurrent() + collection.cancel() + runCurrent() + val readsBeforeCancellation = reads + advanceTimeBy(2.seconds) + runCurrent() + assertEquals(readsBeforeCancellation, reads) + provider.close() + } + @Test fun serviceLoaderFindsMacosBackend() { assertTrue( @@ -260,14 +356,16 @@ class MacosLocationProviderTest { } @Test - fun missingLocationServicesEmitsServicesDisabled() = runTest { + fun missingLocationServicesReportsAndCompletes() = runTest { val client = FakeCoreLocationClient(locationServicesEnabled = false) val provider = MacosLocationProvider(client, Dispatchers.Unconfined) val managersBeforeUpdates = client.managers.size - val event = assertIs(provider.updates(LocationRequest()).first()) + val event = + assertIs(provider.updates(LocationRequest()).toList().single()) assertEquals(LocationUnavailableReason.ServicesDisabled, event.reason) assertEquals(managersBeforeUpdates, client.managers.size) + provider.close() } @Test @@ -362,22 +460,29 @@ class MacosLocationProviderTest { fun collectionRetriesPermissionInitializationWithoutPrompting() = runTest { val failure = IllegalStateException("native failed") val client = FakeCoreLocationClient().apply { createFailure = failure } - val provider = MacosLocationProvider(client, Dispatchers.Unconfined, Dispatchers.Unconfined) - - val failed = assertIs(provider.updates().first()) + val dispatcher = StandardTestDispatcher(testScheduler) + val provider = MacosLocationProvider(client, dispatcher, dispatcher) + val events = mutableListOf() + val collection = backgroundScope.launch { provider.updates().collect(events::add) } + runCurrent() + val failed = assertIs(events.single()) assertEquals(LocationUnavailableReason.UnexpectedFailure, failed.reason) assertEquals(failure, failed.cause) assertEquals(LocationPermission.Unknown, provider.permission.value) client.createFailure = null client.nextLocation = sampleMeasurement() - assertIs(provider.updates().first()) + advanceTimeBy(1.seconds) + runCurrent() + assertIs(events.last()) assertEquals( LocationPermission.Granted(LocationAccuracyAuthorization.Precise), provider.permission.value, ) assertEquals(2, client.managers.size) assertTrue(client.managers.all { it.whenInUseRequests == 0 }) + collection.cancel() + runCurrent() provider.close() assertTrue(client.managers.all { it.closeCount == 1 }) assertEquals(1, client.closeCount) diff --git a/lib/location/build.gradle.kts b/lib/location/build.gradle.kts index 8d6a330b2..742e40f36 100644 --- a/lib/location/build.gradle.kts +++ b/lib/location/build.gradle.kts @@ -57,6 +57,8 @@ kotlin { implementation(libs.kotlinx.coroutines.test) } + androidHostTest.dependencies { implementation(libs.robolectric) } + // The device test APK must package the instrumentation runner itself. androidDeviceTest.dependencies { implementation(libs.androidx.test.runner) } } diff --git a/lib/location/src/androidDeviceTest/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequestTest.kt b/lib/location/src/androidDeviceTest/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequestTest.kt index f07ecce7a..a3b256cb0 100644 --- a/lib/location/src/androidDeviceTest/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequestTest.kt +++ b/lib/location/src/androidDeviceTest/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequestTest.kt @@ -17,21 +17,23 @@ import kotlinx.coroutines.launch class AndroidLocationPermissionRequestTest { @Test fun reentrantCloseDuringPermissionRefreshPreventsLaunch() { - val registry = TestResultRegistry() - var rationale = false - val requester = AndroidLocationPermissionRequester(null, registry, { null }, { rationale }) - val observer = - CoroutineScope(Dispatchers.Unconfined).launch { - requester.status.drop(1).collect { requester.close() } + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val registry = TestResultRegistry() + var rationale = false + val requester = AndroidLocationPermissionRequester(null, registry, { null }, { rationale }) + val observer = + CoroutineScope(Dispatchers.Unconfined).launch { + requester.status.drop(1).collect { requester.close() } + } + try { + rationale = true + requester.requestForegroundPermission() + assertEquals(0, registry.launches) + assertFailsWith { requester.refresh() } + } finally { + observer.cancel() + requester.close() } - try { - rationale = true - requester.requestForegroundPermission() - assertEquals(0, registry.launches) - assertFailsWith { requester.refresh() } - } finally { - observer.cancel() - requester.close() } } diff --git a/lib/location/src/androidHostTest/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequesterTest.kt b/lib/location/src/androidHostTest/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequesterTest.kt index 9dd379f2e..29c1dcc27 100644 --- a/lib/location/src/androidHostTest/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequesterTest.kt +++ b/lib/location/src/androidHostTest/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequesterTest.kt @@ -3,11 +3,58 @@ package org.maplibre.compose.location import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +@OptIn(ExperimentalCoroutinesApi::class) class AndroidLocationPermissionRequesterTest { + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `close stops polling even while status is collected`() = runTest { + var reads = 0 + val requester = + AndroidLocationPermissionRequester( + null, + null, + { + reads++ + null + }, + { null }, + ) + backgroundScope.launch { requester.status.collect {} } + runCurrent() + advanceTimeBy(1.seconds) + runCurrent() + assertEquals(3, reads) + requester.close() + advanceTimeBy(2.seconds) + runCurrent() + assertEquals(3, reads) + } + @Test fun `close removes the observer and prevents later refreshes`() { val owner = TestLifecycleOwner() diff --git a/lib/location/src/androidHostTest/kotlin/org/maplibre/compose/location/AndroidLocationProviderTest.kt b/lib/location/src/androidHostTest/kotlin/org/maplibre/compose/location/AndroidLocationProviderTest.kt new file mode 100644 index 000000000..bcdca3873 --- /dev/null +++ b/lib/location/src/androidHostTest/kotlin/org/maplibre/compose/location/AndroidLocationProviderTest.kt @@ -0,0 +1,233 @@ +package org.maplibre.compose.location + +import android.Manifest.permission.ACCESS_COARSE_LOCATION +import android.Manifest.permission.ACCESS_FINE_LOCATION +import android.app.Application +import android.location.Location +import android.location.LocationManager +import androidx.activity.ComponentActivity +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +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.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowLocationManager + +@OptIn(ExperimentalCoroutinesApi::class) +@Suppress("DEPRECATION") // Inspect registrations to verify per-collector cleanup. +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [26, 36], manifest = Config.NONE) +class AndroidLocationProviderTest { + private val dispatcher = StandardTestDispatcher() + private lateinit var application: Application + private lateinit var manager: LocationManager + + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + application = RuntimeEnvironment.getApplication() + manager = application.getSystemService(LocationManager::class.java) + shadowOf(manager).setProviderEnabled(LocationManager.GPS_PROVIDER, true) + shadowOf(manager).setProviderEnabled(LocationManager.FUSED_PROVIDER, true) + deny() + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun applicationContextRecoversAcrossDenialAndRevocationWithoutRestartingCollectors() = + runTest(dispatcher) { + val provider = AndroidLocationProvider(application) + val events = mutableListOf() + val collection = backgroundScope.launch { + provider.updates(LocationRequest()).collect(events::add) + } + runCurrent() + assertDenied(events.last()) + assertTrue(shadowOf(manager).locationUpdateListeners.isEmpty()) + + grant() + advanceTimeBy(1.seconds) + runCurrent() + sendLocation() + runCurrent() + assertIs(events.last()) + + deny() + advanceTimeBy(1.seconds) + runCurrent() + assertDenied(events.last()) + assertTrue(shadowOf(manager).locationUpdateListeners.isEmpty()) + + grant() + advanceTimeBy(1.seconds) + runCurrent() + sendLocation() + runCurrent() + assertIs(events.last()) + assertTrue(collection.isActive) + + collection.cancelAndJoin() + assertTrue(shadowOf(manager).locationUpdateListeners.isEmpty()) + provider.close() + } + + @Test + fun collectionRefreshesStalePermissionBeforeEmittingFromAnotherDispatcher() = + runTest(dispatcher) { + val provider = AndroidLocationProvider(application) + grant() + val events = mutableListOf() + val collection = + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + provider.updates().collect(events::add) + } + // Collection can run now, but the main-thread refresh has not run yet. + assertTrue(events.isEmpty()) + runCurrent() + sendLocation() + runCurrent() + assertIs(events.single()) + collection.cancelAndJoin() + provider.close() + } + + @Test + @Config(shadows = [FailingLocationManager::class]) + fun invalidRegistrationReportsFailureAndCompletes() = + runTest(dispatcher) { + grant() + val provider = AndroidLocationProvider(application) + try { + val events = mutableListOf() + val collection = backgroundScope.launch { provider.updates().collect(events::add) } + runCurrent() + val event = assertIs(events.single()) + assertTrue(collection.isCompleted) + assertEquals(LocationUnavailableReason.UnexpectedFailure, event.reason) + assertTrue(shadowOf(manager).locationUpdateListeners.isEmpty()) + } finally { + provider.close() + } + } + + @Test + fun activityResumeRefreshesPermissionBeforeTheNextPoll() = + runTest(dispatcher) { + val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup() + val provider = AndroidLocationProvider(activity.get()) + val events = mutableListOf() + val collection = backgroundScope.launch { + provider.updates(LocationRequest()).collect(events::add) + } + runCurrent() + assertDenied(events.last()) + + activity.pause() + grant() + activity.resume() + runCurrent() + sendLocation() + runCurrent() + assertIs(events.last()) + + collection.cancelAndJoin() + provider.close() + activity.pause().stop().destroy() + } + + @Test + fun collectorsOwnIndependentRegistrationsAndCancellationWhileDeniedCannotRestart() = + runTest(dispatcher) { + val provider = AndroidLocationProvider(application) + val first = backgroundScope.launch { provider.updates(LocationRequest()).collect {} } + val second = backgroundScope.launch { provider.updates(LocationRequest()).collect {} } + runCurrent() + grant() + advanceTimeBy(1.seconds) + runCurrent() + assertEquals(2, shadowOf(manager).locationUpdateListeners.size) + first.cancelAndJoin() + assertEquals(1, shadowOf(manager).locationUpdateListeners.size) + deny() + advanceTimeBy(1.seconds) + runCurrent() + assertTrue(shadowOf(manager).locationUpdateListeners.isEmpty()) + second.cancelAndJoin() + runCurrent() + grant() + advanceTimeBy(2.seconds) + runCurrent() + assertTrue(shadowOf(manager).locationUpdateListeners.isEmpty()) + // No subscribers means no polling; collecting again refreshes the stale snapshot. + assertIs(provider.permission.value) + val third = backgroundScope.launch { provider.updates(LocationRequest()).collect {} } + runCurrent() + assertEquals(1, shadowOf(manager).locationUpdateListeners.size) + third.cancelAndJoin() + provider.close() + } + + private fun grant() { + shadowOf(application).grantPermissions(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION) + } + + private fun deny() { + shadowOf(application).denyPermissions(ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION) + } + + private fun assertDenied(event: LocationEvent) { + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(event).reason, + ) + } + + private fun sendLocation() { + val listeners = shadowOf(manager).locationUpdateListeners + assertEquals(1, listeners.size) + listeners + .single() + .onLocationChanged( + Location(LocationManager.GPS_PROVIDER).apply { + latitude = 52.0 + longitude = 13.0 + accuracy = 3f + time = System.currentTimeMillis() + elapsedRealtimeNanos = android.os.SystemClock.elapsedRealtimeNanos() + } + ) + } +} + +@Implements(LocationManager::class) +class FailingLocationManager : ShadowLocationManager() { + @Implementation + override fun getLastKnownLocation(provider: String): Location? { + throw IllegalArgumentException("Provider disappeared") + } +} diff --git a/lib/location/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequester.kt b/lib/location/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequester.kt index 953f57034..95465d1eb 100644 --- a/lib/location/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequester.kt +++ b/lib/location/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationPermissionRequester.kt @@ -14,8 +14,21 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * Observes and requests foreground location permission on Android. @@ -48,6 +61,7 @@ internal constructor( readRationale = { context.readRationale() }, ) + private val observationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) private var closed = false private var pendingLauncher: ActivityResultLauncher>? = null private val observer = LifecycleEventObserver { _, event -> @@ -76,23 +90,49 @@ internal constructor( * check. * - `canRequest = true` otherwise. * - * The value refreshes when the resolved activity resumes. + * Refreshes on activity resume and once per second while collected. */ - public val status: StateFlow = mutableStatus + @OptIn(ExperimentalForInheritanceCoroutinesApi::class) + public val status: StateFlow = + object : StateFlow by mutableStatus { + override suspend fun collect(collector: FlowCollector): Nothing { + withContext(Dispatchers.Main.immediate) { + if (!closed) refresh() + } + mutableStatus.collect(collector) + } + } init { if (lifecycle?.currentState == Lifecycle.State.DESTROYED) { closed = true + observationScope.cancel() } else { lifecycle?.addObserver(observer) + // Ordinary apps have no public callback for all runtime-permission changes. Observe only + // while needed; an activity lifecycle alone would leave application contexts stale. + observationScope.launch { + mutableStatus.subscriptionCount + .map { it > 0 } + .distinctUntilChanged() + .collectLatest { observed -> + if (observed) { + while (!closed) { + delay(1.seconds) + refresh() + } + } + } + } } } - /** Removes the activity observer and unregisters any pending permission callback. */ + /** Stops permission observation and unregisters any pending permission callback. */ override fun close() { if (closed) return lifecycle?.removeObserver(observer) closed = true + observationScope.cancel() pendingLauncher?.unregister() pendingLauncher = null } diff --git a/lib/location/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationProvider.kt b/lib/location/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationProvider.kt index 29b47eb42..523061a70 100644 --- a/lib/location/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationProvider.kt +++ b/lib/location/src/androidMain/kotlin/org/maplibre/compose/location/AndroidLocationProvider.kt @@ -5,7 +5,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.PackageManager import android.location.Criteria import android.location.Location as AndroidLocation import android.location.LocationListener @@ -17,10 +16,18 @@ import android.os.HandlerThread import androidx.annotation.MainThread import androidx.annotation.RequiresApi import androidx.annotation.RequiresPermission +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel 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.collectLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.retryWhen +import kotlinx.coroutines.launch import org.maplibre.spatialk.units.extensions.inMeters /** @@ -58,16 +65,30 @@ internal constructor(context: Context, private val requester: AndroidLocationPer @MainThread override fun close(): Unit = requester.close() - @RequiresPermission( - anyOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION] - ) override fun updates(request: LocationRequest): Flow = callbackFlow { - if (!context.hasLocationPermission()) { - trySend(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied)) - close() - return@callbackFlow + val collection = launch { + permission.collectLatest { 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 + } + .collect { send(it) } + close() + this@launch.cancel() + } else { + send(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied)) + } + } } + awaitClose { collection.cancel() } + } + @Suppress("MissingPermission") + private fun locationUpdates(request: LocationRequest): Flow = callbackFlow { val manager = context.getSystemService(LocationManager::class.java) val listener = object : LocationListener { @@ -123,8 +144,7 @@ internal constructor(context: Context, private val requester: AndroidLocationPer trySend(LocationEvent.Unavailable(LocationUnavailableReason.UnexpectedFailure, error)) close() } catch (error: SecurityException) { - trySend(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied, error)) - close() + close(error) } } } @@ -136,8 +156,7 @@ internal constructor(context: Context, private val requester: AndroidLocationPer trySend(LocationEvent.Unavailable(LocationUnavailableReason.UnexpectedFailure, error)) close() } catch (error: SecurityException) { - trySend(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied, error)) - close() + close(error) } awaitClose { @@ -145,6 +164,7 @@ internal constructor(context: Context, private val requester: AndroidLocationPer manager.removeUpdates(listener) } } + .flowOn(Dispatchers.Main.immediate) @Suppress("DEPRECATION") private fun selectProvider( @@ -237,12 +257,6 @@ private class IdentifiedLocationProvider( private val delegate: LocationProvider, ) : LocationProvider by delegate -private fun Context.hasLocationPermission(): Boolean = - checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED || - checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED - private fun Context.registerLocationSettingsReceiver(receiver: BroadcastReceiver) { val filter = IntentFilter().apply { diff --git a/lib/location/src/iosMain/kotlin/org/maplibre/compose/location/IosLocationProvider.kt b/lib/location/src/iosMain/kotlin/org/maplibre/compose/location/IosLocationProvider.kt index 4173caee6..9d25212d2 100644 --- a/lib/location/src/iosMain/kotlin/org/maplibre/compose/location/IosLocationProvider.kt +++ b/lib/location/src/iosMain/kotlin/org/maplibre/compose/location/IosLocationProvider.kt @@ -2,11 +2,14 @@ package org.maplibre.compose.location import kotlin.time.TimeSource import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.channels.awaitClose 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.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -41,7 +44,11 @@ import platform.darwin.NSObject * report [LocationUnavailableReason.UnexpectedFailure]. */ public class IosLocationProvider -internal constructor(private val requester: IosLocationPermissionRequester) : LocationProvider { +internal constructor( + private val requester: IosLocationPermissionRequester, + private val servicesEnabled: suspend () -> Boolean = ::locationServicesEnabled, + private val createManager: () -> CLLocationManager = { CLLocationManager() }, +) : LocationProvider { /** Creates a provider with its own permission requester. */ public constructor() : this(IosLocationPermissionRequester()) @@ -52,9 +59,24 @@ internal constructor(private val requester: IosLocationPermissionRequester) : Lo override fun close(): Unit = requester.close() + @OptIn(ExperimentalCoroutinesApi::class) override fun updates(request: LocationRequest): Flow = + permission.flatMapLatest { status -> + if (status is LocationPermission.Granted) { + locationUpdates(request) + } else { + flowOf( + LocationEvent.Unavailable( + if (servicesEnabled()) LocationUnavailableReason.PermissionDenied + else LocationUnavailableReason.ServicesDisabled + ) + ) + } + } + + private fun locationUpdates(request: LocationRequest): Flow = callbackFlow { - val manager = CLLocationManager() + val manager = createManager() val delegate = Delegate(channel) manager.delegate = delegate manager.desiredAccuracy = @@ -76,7 +98,7 @@ internal constructor(private val requester: IosLocationPermissionRequester) : Lo when (callback) { is IosLocationCallback.Update -> callback.event is IosLocationCallback.Failure -> - LocationEvent.Unavailable(callback.error.asUnavailableReason()) + LocationEvent.Unavailable(callback.error.asUnavailableReason(servicesEnabled)) } } diff --git a/lib/location/src/iosTest/kotlin/org/maplibre/compose/location/IosLocationProviderTest.kt b/lib/location/src/iosTest/kotlin/org/maplibre/compose/location/IosLocationProviderTest.kt index 4b649cb30..d22bd358e 100644 --- a/lib/location/src/iosTest/kotlin/org/maplibre/compose/location/IosLocationProviderTest.kt +++ b/lib/location/src/iosTest/kotlin/org/maplibre/compose/location/IosLocationProviderTest.kt @@ -4,13 +4,27 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withContext +import platform.CoreLocation.CLAuthorizationStatus +import platform.CoreLocation.CLLocation import platform.CoreLocation.CLLocationManager +import platform.CoreLocation.kCLAuthorizationStatusAuthorizedWhenInUse +import platform.CoreLocation.kCLAuthorizationStatusDenied import platform.CoreLocation.kCLErrorDenied import platform.CoreLocation.kCLErrorDomain import platform.CoreLocation.kCLErrorLocationUnknown @@ -18,7 +32,75 @@ import platform.CoreLocation.kCLErrorNetwork import platform.Foundation.NSError import platform.Foundation.NSThread +@OptIn(ExperimentalCoroutinesApi::class) class IosLocationProviderTest { + @Test + fun collectorRecoversAfterPermissionChanges() = runTest { + Dispatchers.setMain(UnconfinedTestDispatcher(testScheduler)) + val permissionManager = TestLocationManager() + val requester = IosLocationPermissionRequester(permissionManager) + val managers = mutableListOf() + val provider = + IosLocationProvider(requester, servicesEnabled = { true }) { + TestLocationManager().also { managers += it } + } + val events = Channel(Channel.UNLIMITED) + val collection = backgroundScope.launch { + provider.updates(LocationRequest()).collect { events.send(it) } + } + try { + assertEquals( + LocationUnavailableReason.PermissionDenied, + (events.receive() as LocationEvent.Unavailable).reason, + ) + assertTrue(managers.isEmpty()) + permissionManager.status = kCLAuthorizationStatusAuthorizedWhenInUse + permissionManager.delegate?.locationManagerDidChangeAuthorization(permissionManager) + runCurrent() + assertEquals(1, managers.size) + managers.first().sendLocation() + assertTrue(events.receive() is LocationEvent.Update) + permissionManager.status = kCLAuthorizationStatusDenied + permissionManager.delegate?.locationManagerDidChangeAuthorization(permissionManager) + assertEquals( + LocationUnavailableReason.PermissionDenied, + (events.receive() as LocationEvent.Unavailable).reason, + ) + assertTrue(managers.all { !it.updating && it.delegate == null }) + permissionManager.status = kCLAuthorizationStatusAuthorizedWhenInUse + permissionManager.delegate?.locationManagerDidChangeAuthorization(permissionManager) + runCurrent() + assertEquals(2, managers.size) + managers.last().sendLocation() + assertTrue(events.receive() is LocationEvent.Update) + collection.cancelAndJoin() + assertTrue(managers.all { !it.updating && it.delegate == null }) + assertEquals(0, permissionManager.requests) + } finally { + collection.cancelAndJoin() + provider.close() + Dispatchers.resetMain() + } + } + + @Test + fun deniedAuthorizationReportsGloballyDisabledServices() = runTest { + val manager = TestLocationManager() + val provider = + IosLocationProvider( + IosLocationPermissionRequester(manager), + servicesEnabled = { false }, + createManager = { error("Disabled services must not start location updates") }, + ) + try { + val event = assertIs(provider.updates().first()) + assertEquals(LocationUnavailableReason.ServicesDisabled, event.reason) + assertEquals(0, manager.requests) + } finally { + provider.close() + } + } + @Test fun exposesPermissionFromItsRequester() { IosLocationPermissionRequester().use { requester -> @@ -98,3 +180,27 @@ class IosLocationProviderTest { private fun coreLocationError(code: Long): NSError = NSError.errorWithDomain(kCLErrorDomain, code, null) } + +private class TestLocationManager : CLLocationManager() { + var status: CLAuthorizationStatus = kCLAuthorizationStatusDenied + var updating = false + var requests = 0 + + override fun authorizationStatus(): CLAuthorizationStatus = status + + override fun startUpdatingLocation() { + updating = true + } + + override fun stopUpdatingLocation() { + updating = false + } + + override fun requestWhenInUseAuthorization() { + requests++ + } + + fun sendLocation() { + delegate?.locationManager(this, didUpdateLocations = listOf(CLLocation(52.0, 13.0))) + } +} diff --git a/lib/location/src/jsMain/kotlin/org/maplibre/compose/location/BrowserLocationProvider.kt b/lib/location/src/jsMain/kotlin/org/maplibre/compose/location/BrowserLocationProvider.kt index 4665de6c6..d5956b60a 100644 --- a/lib/location/src/jsMain/kotlin/org/maplibre/compose/location/BrowserLocationProvider.kt +++ b/lib/location/src/jsMain/kotlin/org/maplibre/compose/location/BrowserLocationProvider.kt @@ -9,12 +9,14 @@ import kotlin.time.TimeSource import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import org.maplibre.spatialk.geojson.Position @@ -72,6 +74,24 @@ internal constructor( check(backendAvailability == LocationBackendAvailability.Available) { "Location updates require an available backend: $backendAvailability" } + val collection = launch { + permission.collectLatest { status -> + when (status) { + is LocationPermission.Granted -> { + locationUpdates(request).collect { send(it) } + close() + this@launch.cancel() + } + LocationPermission.Unknown -> Unit + is LocationPermission.NotGranted -> + send(LocationEvent.Unavailable(LocationUnavailableReason.PermissionDenied)) + } + } + } + awaitClose { collection.cancel() } + } + + private fun locationUpdates(request: LocationRequest): Flow = callbackFlow { var previous: BrowserPosition? = null fun publish(result: BrowserResult) { when (result) { @@ -91,10 +111,10 @@ internal constructor( is BrowserResult.Error -> { val reason = result.value.asUnavailableReason() previous = null - trySend(LocationEvent.Unavailable(reason)) if (reason == LocationUnavailableReason.PermissionDenied) { - boundary.permissionState.acceptDenial() - close() + requester.acceptDenial() + } else { + trySend(LocationEvent.Unavailable(reason)) } } } @@ -145,8 +165,16 @@ internal constructor( LocationBackendAvailability.Unsupported } + private val mutableStatus = MutableStateFlow(LocationPermission.Unknown) + /** Current foreground location permission. */ - public val status: StateFlow = boundary.permissionState.status + public val status: StateFlow = mutableStatus + + internal fun acceptDenial() { + if (mutableStatus.value !is LocationPermission.NotGranted) { + mutableStatus.value = LocationPermission.NotGranted(canRequest = null) + } + } private var requestPending = false @@ -155,7 +183,11 @@ internal constructor( boundary .permissionChanges() .catch { emit(BrowserPermission.Unknown) } - .collect { boundary.permissionState.accept(it.asLocationPermission()) } + .collect { + if (it != BrowserPermission.Unknown || status.value == LocationPermission.Unknown) { + mutableStatus.value = it.asLocationPermission() + } + } } } @@ -185,21 +217,18 @@ internal constructor( ) ) { is BrowserResult.Position -> - boundary.permissionState.accept( - LocationPermission.Granted(LocationAccuracyAuthorization.Unknown) - ) + mutableStatus.value = LocationPermission.Granted(LocationAccuracyAuthorization.Unknown) is BrowserResult.Error -> if (result.value == BrowserError.PermissionDenied) { - boundary.permissionState.acceptDenial() + acceptDenial() } else { - boundary.permissionState.accept( + mutableStatus.value = LocationPermission.Granted(LocationAccuracyAuthorization.Unknown) - ) } } } catch (error: Throwable) { if (error is CancellationException) throw error - boundary.permissionState.accept(LocationPermission.NotGranted(canRequest = null)) + mutableStatus.value = LocationPermission.NotGranted(canRequest = null) } finally { requestPending = false } @@ -251,25 +280,8 @@ internal sealed interface BrowserResult { data class Error(val value: BrowserError) : BrowserResult } -internal class BrowserLocationPermissionState { - private val mutableStatus = - MutableStateFlow(LocationPermission.NotGranted(canRequest = null)) - val status: StateFlow = mutableStatus - - fun accept(permission: LocationPermission) { - mutableStatus.value = permission - } - - fun acceptDenial() { - if (mutableStatus.value is LocationPermission.Granted) { - mutableStatus.value = LocationPermission.NotGranted(canRequest = null) - } - } -} - internal interface BrowserGeolocationBoundary { val supported: Boolean - val permissionState: BrowserLocationPermissionState fun permissionChanges(): Flow @@ -280,7 +292,6 @@ internal interface BrowserGeolocationBoundary { private object BrowserGeolocation : BrowserGeolocationBoundary { private val rawNavigator: dynamic = js("navigator") - override val permissionState = BrowserLocationPermissionState() override val supported: Boolean get() = rawNavigator.geolocation != null diff --git a/lib/location/src/jsTest/kotlin/org/maplibre/compose/location/BrowserLocationProviderTest.kt b/lib/location/src/jsTest/kotlin/org/maplibre/compose/location/BrowserLocationProviderTest.kt index 082d5392d..91f8989db 100644 --- a/lib/location/src/jsTest/kotlin/org/maplibre/compose/location/BrowserLocationProviderTest.kt +++ b/lib/location/src/jsTest/kotlin/org/maplibre/compose/location/BrowserLocationProviderTest.kt @@ -4,15 +4,22 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -239,6 +246,185 @@ class BrowserLocationProviderTest { ) } + @Test + fun collectionWaitsForPermissionInitialization() = runTest { + val initialized = CompletableDeferred() + val boundary = FakeBrowserGeolocationBoundary(permissionInitialized = initialized) + val provider = BrowserLocationProvider(boundary, backgroundScope) + val events = mutableListOf() + val collection = backgroundScope.launch { provider.updates().collect(events::add) } + runCurrent() + assertEquals(emptyList(), events) + assertEquals(emptyList(), boundary.watchedOptions) + assertEquals(emptyList(), boundary.requestedOptions) + + collection.cancel() + runCurrent() + val resumedCollection = backgroundScope.launch { provider.updates().collect(events::add) } + runCurrent() + initialized.complete(Unit) + runCurrent() + boundary.send(position(milliseconds = 0, longitude = 1.0)) + runCurrent() + assertIs(events.single()) + assertEquals(1, boundary.watchedOptions.size) + resumedCollection.cancel() + runCurrent() + assertEquals(1, boundary.stopCount) + } + + @Test + fun cancellingOneCollectorLeavesTheOtherWatchRunning() = runTest { + val boundary = FakeBrowserGeolocationBoundary() + val provider = BrowserLocationProvider(boundary, backgroundScope) + val firstEvents = mutableListOf() + val secondEvents = mutableListOf() + val first = backgroundScope.launch { provider.updates().collect(firstEvents::add) } + val second = backgroundScope.launch { provider.updates().collect(secondEvents::add) } + runCurrent() + assertEquals(2, boundary.watchedOptions.size) + boundary.send(position(milliseconds = 0, longitude = 1.0)) + runCurrent() + assertIs(firstEvents.single()) + assertIs(secondEvents.single()) + first.cancel() + runCurrent() + boundary.send(position(milliseconds = 2_000, longitude = 2.0)) + runCurrent() + assertEquals(1, firstEvents.size) + assertEquals(2, secondEvents.size) + assertEquals(1, boundary.stopCount) + second.cancel() + runCurrent() + assertEquals(2, boundary.stopCount) + assertNull(boundary.callback) + } + + @Test + fun failedWatchStartupReportsFailureAndCompletes() = runTest { + val failure = IllegalStateException("watch unavailable") + val boundary = FakeBrowserGeolocationBoundary().apply { startFailure = failure } + val provider = BrowserLocationProvider(boundary, backgroundScope) + val events = mutableListOf() + val collection = backgroundScope.launch { provider.updates().collect(events::add) } + runCurrent() + val event = assertIs(events.single()) + assertTrue(collection.isCompleted) + assertEquals(LocationUnavailableReason.UnexpectedFailure, event.reason) + assertEquals(failure, event.cause) + assertEquals(0, boundary.stopCount) + } + + @Test + fun explicitRequestResultsWhilePermissionQueryIsPendingArePublished() = runTest { + val initialized = CompletableDeferred() + val boundary = FakeBrowserGeolocationBoundary(permissionInitialized = initialized) + boundary.requestPositionAction = { BrowserResult.Error(BrowserError.PermissionDenied) } + val provider = BrowserLocationProvider(boundary, backgroundScope) + val events = mutableListOf() + backgroundScope.launch { provider.updates().collect(events::add) } + runCurrent() + provider.requestPermission() + runCurrent() + assertEquals(LocationPermission.NotGranted(canRequest = null), provider.permission.value) + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(events.single()).reason, + ) + assertEquals(emptyList(), boundary.watchedOptions) + + boundary.requestPositionAction = { position(milliseconds = 0, longitude = 1.0) } + provider.requestPermission() + runCurrent() + boundary.permission.value = BrowserPermission.Unknown + initialized.complete(Unit) + runCurrent() + assertIs(provider.permission.value) + assertEquals(1, boundary.watchedOptions.size) + } + + @Test + fun newRequesterDoesNotReusePermissionFromACancelledScope() = runTest { + val boundary = FakeBrowserGeolocationBoundary() + boundary.permission.value = BrowserPermission.Denied + val oldScope = CoroutineScope(coroutineContext + Job()) + val oldProvider = BrowserLocationProvider(boundary, oldScope) + runCurrent() + assertEquals(LocationPermission.NotGranted(false), oldProvider.permission.value) + oldScope.cancel() + runCurrent() + + val initialized = CompletableDeferred() + boundary.permissionInitialized = initialized + boundary.permission.value = BrowserPermission.Granted + val provider = BrowserLocationProvider(boundary, backgroundScope) + val events = mutableListOf() + backgroundScope.launch { provider.updates().collect(events::add) } + runCurrent() + assertEquals(emptyList(), events) + assertEquals(emptyList(), boundary.watchedOptions) + initialized.complete(Unit) + runCurrent() + boundary.send(position(milliseconds = 0, longitude = 1.0)) + runCurrent() + assertIs(events.single()) + } + + @Test + fun collectorRecoversAfterPermissionChangesWithoutPrompting() = runTest { + val boundary = FakeBrowserGeolocationBoundary() + boundary.permission.value = BrowserPermission.Denied + val provider = BrowserLocationProvider(boundary, backgroundScope) + val events = mutableListOf() + val collection = backgroundScope.launch { + provider.updates(LocationRequest()).collect(events::add) + } + runCurrent() + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(events.last()).reason, + ) + assertNull(boundary.callback) + + boundary.permission.value = BrowserPermission.Granted + runCurrent() + boundary.send(position(milliseconds = 0, longitude = 1.0)) + runCurrent() + assertIs(events.last()) + boundary.permission.value = BrowserPermission.Denied + runCurrent() + assertEquals( + LocationUnavailableReason.PermissionDenied, + assertIs(events.last()).reason, + ) + assertNull(boundary.callback) + + boundary.permission.value = BrowserPermission.Granted + runCurrent() + boundary.send(position(milliseconds = 500, longitude = 2.0)) + runCurrent() + assertEquals(2.0, assertIs(events.last()).measurement.position.longitude) + collection.cancel() + runCurrent() + assertNull(boundary.callback) + assertEquals(emptyList(), boundary.requestedOptions) + } + + @Test + fun unknownPermissionWaitsForExplicitRequestWithoutStartingAWatch() = runTest { + val boundary = FakeBrowserGeolocationBoundary() + boundary.permission.value = BrowserPermission.Unknown + val provider = BrowserLocationProvider(boundary, backgroundScope) + backgroundScope.launch { provider.updates(LocationRequest()).collect {} } + runCurrent() + assertNull(boundary.callback) + assertEquals(emptyList(), boundary.requestedOptions) + boundary.requestPositionAction = { position(milliseconds = 0, longitude = 1.0) } + provider.requestPermission() + runCurrent() + assertNotNull(boundary.callback) + } + private fun position( milliseconds: Long, longitude: Double, @@ -259,17 +445,25 @@ class BrowserLocationProviderTest { ) } -private class FakeBrowserGeolocationBoundary(override val supported: Boolean = true) : - BrowserGeolocationBoundary { - override val permissionState = BrowserLocationPermissionState() +private class FakeBrowserGeolocationBoundary( + override val supported: Boolean = true, + var permissionInitialized: CompletableDeferred? = null, +) : BrowserGeolocationBoundary { val permission = MutableStateFlow(BrowserPermission.Granted) var requestPositionAction: suspend (BrowserOptions) -> BrowserResult = { awaitCancellation() } val requestedOptions = mutableListOf() val watchedOptions = mutableListOf() - var callback: ((BrowserResult) -> Unit)? = null + private val callbacks = mutableSetOf<(BrowserResult) -> Unit>() + val callback: ((BrowserResult) -> Unit)? + get() = callbacks.firstOrNull() + var stopCount = 0 + var startFailure: Throwable? = null - override fun permissionChanges(): Flow = permission + override fun permissionChanges(): Flow = flow { + permissionInitialized?.await() + emitAll(permission) + } override suspend fun requestPosition(options: BrowserOptions): BrowserResult { requestedOptions += options @@ -280,15 +474,16 @@ private class FakeBrowserGeolocationBoundary(override val supported: Boolean = t options: BrowserOptions, onResult: (BrowserResult) -> Unit, ): () -> Unit { + startFailure?.let { throw it } watchedOptions += options - callback = onResult + callbacks += onResult return { stopCount += 1 - callback = null + callbacks -= onResult } } fun send(result: BrowserResult) { - callback?.invoke(result) + callbacks.toList().forEach { it(result) } } }