diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt index 62aba9318c..73a86fd04e 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingService.kt @@ -205,6 +205,7 @@ class LocationSharingService : VectorService(), LocationTracker.Callback { private fun destroyMe() { locationTracker.removeCallback(this) + locationTracker.stop() timers.forEach { it.cancel() } timers.clear() stopSelf() diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt index 71f59c6fdf..f049a9400a 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt @@ -114,6 +114,7 @@ class LocationSharingViewModel @AssistedInject constructor( override fun onCleared() { super.onCleared() locationTracker.removeCallback(this) + locationTracker.stop() } override fun handle(action: LocationSharingAction) { diff --git a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt index c14ec214f0..3e0d60f2c8 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationTracker.kt @@ -21,6 +21,7 @@ import android.content.Context import android.location.Location import android.location.LocationManager import androidx.annotation.RequiresPermission +import androidx.annotation.VisibleForTesting import androidx.core.content.getSystemService import androidx.core.location.LocationListenerCompat import im.vector.app.BuildConfig @@ -52,10 +53,14 @@ class LocationTracker @Inject constructor( fun onNoLocationProviderAvailable() } - private val callbacks = mutableListOf() + @VisibleForTesting + val callbacks = mutableListOf() - private var hasLocationFromFusedProvider = false - private var hasLocationFromGPSProvider = false + @VisibleForTesting + var hasLocationFromFusedProvider = false + + @VisibleForTesting + var hasLocationFromGPSProvider = false private var lastLocation: LocationData? = null @@ -139,9 +144,6 @@ class LocationTracker @Inject constructor( fun removeCallback(callback: Callback) { callbacks.remove(callback) - if (callbacks.size == 0) { - stop() - } } override fun onLocationChanged(location: Location) { diff --git a/vector/src/test/java/im/vector/app/features/location/LocationTrackerTest.kt b/vector/src/test/java/im/vector/app/features/location/LocationTrackerTest.kt new file mode 100644 index 0000000000..409647a813 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/location/LocationTrackerTest.kt @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 im.vector.app.features.location + +import android.content.Context +import android.location.Location +import android.location.LocationManager +import im.vector.app.core.utils.Debouncer +import im.vector.app.core.utils.createBackgroundHandler +import im.vector.app.test.fakes.FakeContext +import im.vector.app.test.fakes.FakeHandler +import im.vector.app.test.fakes.FakeLocationManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.runs +import io.mockk.slot +import io.mockk.unmockkConstructor +import io.mockk.unmockkStatic +import io.mockk.verify +import io.mockk.verifyOrder +import org.amshove.kluent.shouldBeEqualTo +import org.junit.After +import org.junit.Before +import org.junit.Test + +private const val A_LATITUDE = 1.2 +private const val A_LONGITUDE = 44.0 +private const val AN_ACCURACY = 5.0f + +class LocationTrackerTest { + + private val fakeHandler = FakeHandler() + + init { + mockkConstructor(Debouncer::class) + every { anyConstructed().cancelAll() } just runs + val runnable = slot() + every { anyConstructed().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, capture(runnable)) } answers { + runnable.captured.run() + true + } + mockkStatic("im.vector.app.core.utils.HandlerKt") + every { createBackgroundHandler(any()) } returns fakeHandler.instance + } + + private val fakeLocationManager = FakeLocationManager() + private val fakeContext = FakeContext().also { + it.givenService(Context.LOCATION_SERVICE, android.location.LocationManager::class.java, fakeLocationManager.instance) + } + + private val locationTracker = LocationTracker(fakeContext.instance) + + @Before + fun setUp() { + fakeLocationManager.givenRemoveUpdates(locationTracker) + } + + @After + fun tearDown() { + unmockkStatic("im.vector.app.core.utils.HandlerKt") + unmockkConstructor(Debouncer::class) + } + + @Test + fun `given available list of providers when starting then location updates are requested in priority order`() { + val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER) + mockAvailableProviders(providers) + + locationTracker.start() + + verifyOrder { + fakeLocationManager.instance.requestLocationUpdates( + LocationManager.FUSED_PROVIDER, + MIN_TIME_TO_UPDATE_LOCATION_MILLIS, + MIN_DISTANCE_TO_UPDATE_LOCATION_METERS, + locationTracker + ) + fakeLocationManager.instance.requestLocationUpdates( + LocationManager.GPS_PROVIDER, + MIN_TIME_TO_UPDATE_LOCATION_MILLIS, + MIN_DISTANCE_TO_UPDATE_LOCATION_METERS, + locationTracker + ) + fakeLocationManager.instance.requestLocationUpdates( + LocationManager.NETWORK_PROVIDER, + MIN_TIME_TO_UPDATE_LOCATION_MILLIS, + MIN_DISTANCE_TO_UPDATE_LOCATION_METERS, + locationTracker + ) + } + } + + @Test + fun `given available list of providers when list is empty then callbacks are notified`() { + val providers = emptyList() + val callback = mockCallback() + + locationTracker.addCallback(callback) + fakeLocationManager.givenActiveProviders(providers) + + locationTracker.start() + + verify { callback.onNoLocationProviderAvailable() } + locationTracker.removeCallback(callback) + } + + @Test + fun `when adding or removing a callback then it is added into or removed from the list of callbacks`() { + val callback = mockCallback() + + locationTracker.addCallback(callback) + + locationTracker.callbacks.size shouldBeEqualTo 1 + locationTracker.callbacks.first() shouldBeEqualTo callback + + locationTracker.removeCallback(callback) + + locationTracker.callbacks.size shouldBeEqualTo 0 + } + + @Test + fun `when location updates are received from fused provider then fused locations are taken in priority`() { + val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER) + mockAvailableProviders(providers) + val callback = mockCallback() + locationTracker.addCallback(callback) + locationTracker.start() + + val fusedLocation = mockLocation( + provider = LocationManager.FUSED_PROVIDER, + latitude = 1.0, + longitude = 3.0, + accuracy = 4f + ) + val gpsLocation = mockLocation( + provider = LocationManager.GPS_PROVIDER + ) + + val networkLocation = mockLocation( + provider = LocationManager.NETWORK_PROVIDER + ) + locationTracker.onLocationChanged(fusedLocation) + locationTracker.onLocationChanged(gpsLocation) + locationTracker.onLocationChanged(networkLocation) + + val expectedLocationData = LocationData( + latitude = 1.0, + longitude = 3.0, + uncertainty = 4.0 + ) + verify { callback.onLocationUpdate(expectedLocationData) } + verify { anyConstructed().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, any()) } + locationTracker.hasLocationFromFusedProvider shouldBeEqualTo true + locationTracker.hasLocationFromGPSProvider shouldBeEqualTo false + } + + @Test + fun `when location updates are received from gps provider then gps locations are taken if none are received from fused provider`() { + val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER) + mockAvailableProviders(providers) + val callback = mockCallback() + locationTracker.addCallback(callback) + locationTracker.start() + + val gpsLocation = mockLocation( + provider = LocationManager.GPS_PROVIDER, + latitude = 1.0, + longitude = 3.0, + accuracy = 4f + ) + + val networkLocation = mockLocation( + provider = LocationManager.NETWORK_PROVIDER + ) + locationTracker.onLocationChanged(gpsLocation) + locationTracker.onLocationChanged(networkLocation) + + val expectedLocationData = LocationData( + latitude = 1.0, + longitude = 3.0, + uncertainty = 4.0 + ) + verify { callback.onLocationUpdate(expectedLocationData) } + verify { anyConstructed().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, any()) } + locationTracker.hasLocationFromFusedProvider shouldBeEqualTo false + locationTracker.hasLocationFromGPSProvider shouldBeEqualTo true + } + + @Test + fun `when location updates are received from network provider then network locations are taken if none are received from fused or gps provider`() { + val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.FUSED_PROVIDER, LocationManager.NETWORK_PROVIDER) + mockAvailableProviders(providers) + val callback = mockCallback() + locationTracker.addCallback(callback) + locationTracker.start() + + val networkLocation = mockLocation( + provider = LocationManager.NETWORK_PROVIDER, + latitude = 1.0, + longitude = 3.0, + accuracy = 4f + ) + locationTracker.onLocationChanged(networkLocation) + + val expectedLocationData = LocationData( + latitude = 1.0, + longitude = 3.0, + uncertainty = 4.0 + ) + verify { callback.onLocationUpdate(expectedLocationData) } + verify { anyConstructed().debounce(any(), MIN_TIME_TO_UPDATE_LOCATION_MILLIS, any()) } + locationTracker.hasLocationFromFusedProvider shouldBeEqualTo false + locationTracker.hasLocationFromGPSProvider shouldBeEqualTo false + } + + @Test + fun `when requesting the last location then last location is notified via callback`() { + val providers = listOf(LocationManager.GPS_PROVIDER) + fakeLocationManager.givenActiveProviders(providers) + val lastLocation = mockLocation(provider = LocationManager.GPS_PROVIDER) + fakeLocationManager.givenLastLocationForProvider(provider = LocationManager.GPS_PROVIDER, location = lastLocation) + fakeLocationManager.givenRequestUpdatesForProvider(provider = LocationManager.GPS_PROVIDER, listener = locationTracker) + val callback = mockCallback() + locationTracker.addCallback(callback) + locationTracker.start() + + locationTracker.requestLastKnownLocation() + + val expectedLocationData = LocationData( + latitude = A_LATITUDE, + longitude = A_LONGITUDE, + uncertainty = AN_ACCURACY.toDouble() + ) + verify { callback.onLocationUpdate(expectedLocationData) } + } + + @Test + fun `when stopping then location updates are stopped and callbacks are cleared`() { + locationTracker.stop() + + verify { fakeLocationManager.instance.removeUpdates(locationTracker) } + verify { anyConstructed().cancelAll() } + locationTracker.callbacks.isEmpty() shouldBeEqualTo true + locationTracker.hasLocationFromGPSProvider shouldBeEqualTo false + locationTracker.hasLocationFromFusedProvider shouldBeEqualTo false + } + + private fun mockAvailableProviders(providers: List) { + fakeLocationManager.givenActiveProviders(providers) + providers.forEach { provider -> + fakeLocationManager.givenLastLocationForProvider(provider = provider, location = null) + fakeLocationManager.givenRequestUpdatesForProvider(provider = provider, listener = locationTracker) + } + } + + private fun mockCallback(): LocationTracker.Callback { + return mockk().also { + every { it.onNoLocationProviderAvailable() } just runs + every { it.onLocationUpdate(any()) } just runs + } + } + + private fun mockLocation( + provider: String, + latitude: Double = A_LATITUDE, + longitude: Double = A_LONGITUDE, + accuracy: Float = AN_ACCURACY + ): Location { + return mockk().also { + every { it.time } returns 123 + every { it.latitude } returns latitude + every { it.longitude } returns longitude + every { it.accuracy } returns accuracy + every { it.provider } returns provider + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt index eb491c9e0c..226f6458de 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeContext.kt @@ -56,7 +56,7 @@ class FakeContext( givenService(Context.CONNECTIVITY_SERVICE, ConnectivityManager::class.java, connectivityManager.instance) } - private fun givenService(name: String, klass: Class, service: T) { + fun givenService(name: String, klass: Class, service: T) { every { instance.getSystemService(name) } returns service every { instance.getSystemService(klass) } returns service } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeHandler.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeHandler.kt new file mode 100644 index 0000000000..11340946ec --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeHandler.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 im.vector.app.test.fakes + +import android.os.Handler +import io.mockk.mockk + +class FakeHandler { + + val instance = mockk() +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeLocationManager.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationManager.kt new file mode 100644 index 0000000000..30c30e6b4a --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeLocationManager.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * 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 im.vector.app.test.fakes + +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs + +class FakeLocationManager { + val instance = mockk() + + fun givenActiveProviders(providers: List) { + every { instance.allProviders } returns providers + } + + fun givenRequestUpdatesForProvider( + provider: String, + listener: LocationListener + ) { + every { instance.requestLocationUpdates(provider, any(), any(), listener) } just runs + } + + fun givenRemoveUpdates(listener: LocationListener) { + every { instance.removeUpdates(listener) } just runs + } + + fun givenLastLocationForProvider(provider: String, location: Location?) { + every { instance.getLastKnownLocation(provider) } returns location + } +}