From 29b6de2731e00393e244c1bba7cea51588c8cb4c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:55:38 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20location=20sharing=20without=20gms=20whe?= =?UTF-8?q?n=20not=20moving=20[WPB-9724]=20=F0=9F=8D=92=20=F0=9F=8D=92=20(?= =?UTF-8?q?#3145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: MichaƂ Saleniuk Co-authored-by: Mojtaba Chenani --- .../location/LocationPickerHelperFlavor.kt | 11 +- .../kotlin/com/wire/android/di/AppModule.kt | 8 + .../location/GeocoderHelper.kt | 35 +++ .../location/LocationPickerHelper.kt | 81 ++++++- .../location/LocationPickerHelperFlavor.kt | 17 +- .../wire/android/mapper/UserTypeMapperTest.kt | 2 +- .../EditGuestAccessViewModelTest.kt | 8 +- .../location/GeocoderHelperTest.kt | 97 ++++++++ .../LocationPickerHelperFlavorTest.kt | 184 +++++++++++++++ .../location/LocationPickerHelperTest.kt | 219 ++++++++++++++---- .../gradle/KotlinAndroidConfiguration.kt | 2 +- gradle/libs.versions.toml | 2 +- 12 files changed, 584 insertions(+), 82 deletions(-) create mode 100644 app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/GeocoderHelper.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/GeocoderHelperTest.kt create mode 100644 app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavorTest.kt diff --git a/app/src/foss/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt b/app/src/foss/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt index f50538d7437..793acb075fe 100644 --- a/app/src/foss/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt +++ b/app/src/foss/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt @@ -17,16 +17,15 @@ */ package com.wire.android.ui.home.messagecomposer.location -import android.content.Context import javax.inject.Inject -import javax.inject.Singleton -@Singleton -class LocationPickerHelperFlavor @Inject constructor(context: Context) : LocationPickerHelper(context) { +class LocationPickerHelperFlavor @Inject constructor( + private val locationPickerHelper: LocationPickerHelper, +) { suspend fun getLocation(onSuccess: (GeoLocatedAddress) -> Unit, onError: () -> Unit) { - getLocationWithoutGms( + locationPickerHelper.getLocationWithoutGms( onSuccess = onSuccess, - onError = onError + onError = onError, ) } } diff --git a/app/src/main/kotlin/com/wire/android/di/AppModule.kt b/app/src/main/kotlin/com/wire/android/di/AppModule.kt index c73e8ecf7e9..f778646e16a 100644 --- a/app/src/main/kotlin/com/wire/android/di/AppModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/AppModule.kt @@ -20,12 +20,14 @@ package com.wire.android.di import android.app.NotificationManager import android.content.Context +import android.location.Geocoder import android.media.AudioAttributes import android.media.MediaPlayer import androidx.core.app.NotificationManagerCompat import com.wire.android.BuildConfig import com.wire.android.mapper.MessageResourceProvider import com.wire.android.ui.home.appLock.CurrentTimestampProvider +import com.wire.android.ui.home.messagecomposer.location.LocationPickerParameters import com.wire.android.util.dispatchers.DefaultDispatcherProvider import com.wire.android.util.dispatchers.DispatcherProvider import dagger.Module @@ -82,4 +84,10 @@ object AppModule { @Singleton @Provides fun provideCurrentTimestampProvider(): CurrentTimestampProvider = { System.currentTimeMillis() } + + @Provides + fun provideGeocoder(appContext: Context): Geocoder = Geocoder(appContext) + + @Provides + fun provideLocationPickerParameters(): LocationPickerParameters = LocationPickerParameters() } diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/GeocoderHelper.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/GeocoderHelper.kt new file mode 100644 index 00000000000..78fe7939e2f --- /dev/null +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/GeocoderHelper.kt @@ -0,0 +1,35 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.messagecomposer.location + +import android.location.Geocoder +import android.location.Location +import javax.inject.Inject + +class GeocoderHelper @Inject constructor(private val geocoder: Geocoder) { + + @Suppress("TooGenericExceptionCaught") + fun getGeoLocatedAddress(location: Location): GeoLocatedAddress = + try { + geocoder.getFromLocation(location.latitude, location.longitude, 1).orEmpty() + } catch (e: Exception) { + emptyList() + }.let { addressList -> + GeoLocatedAddress(addressList.firstOrNull(), location) + } +} diff --git a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelper.kt b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelper.kt index f66fa5aec8a..880191b1281 100644 --- a/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelper.kt +++ b/app/src/main/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelper.kt @@ -19,20 +19,39 @@ package com.wire.android.ui.home.messagecomposer.location import android.annotation.SuppressLint import android.content.Context -import android.location.Geocoder import android.location.Location import android.location.LocationListener import android.location.LocationManager +import android.os.Build +import android.os.CancellationSignal +import androidx.annotation.VisibleForTesting import androidx.core.location.LocationManagerCompat import com.wire.android.AppJsonStyledLogger +import com.wire.android.di.ApplicationScope +import com.wire.android.ui.home.appLock.CurrentTimestampProvider import com.wire.kalium.logger.KaliumLogLevel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.function.Consumer import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds -open class LocationPickerHelper @Inject constructor(@ApplicationContext val context: Context) { +@SuppressLint("MissingPermission") +class LocationPickerHelper @Inject constructor( + @ApplicationContext private val context: Context, + @ApplicationScope private val scope: CoroutineScope, + private val currentTimestampProvider: CurrentTimestampProvider, + private val geocoderHelper: GeocoderHelper, + private val parameters: LocationPickerParameters, +) { - @SuppressLint("MissingPermission") - protected fun getLocationWithoutGms(onSuccess: (GeoLocatedAddress) -> Unit, onError: () -> Unit) { + @VisibleForTesting + fun getLocationWithoutGms(onSuccess: (GeoLocatedAddress) -> Unit, onError: () -> Unit) { if (isLocationServicesEnabled()) { AppJsonStyledLogger.log( level = KaliumLogLevel.INFO, @@ -40,14 +59,17 @@ open class LocationPickerHelper @Inject constructor(@ApplicationContext val cont jsonStringKeyValues = mapOf("isUsingGms" to false) ) val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager - val networkLocationListener: LocationListener = object : LocationListener { - override fun onLocationChanged(location: Location) { - val address = Geocoder(context).getFromLocation(location.latitude, location.longitude, 1).orEmpty() - onSuccess(GeoLocatedAddress(address.firstOrNull(), location)) - locationManager.removeUpdates(this) // important step, otherwise it will keep listening for location changes + locationManager.getLastKnownLocation(LocationManager.FUSED_PROVIDER).let { lastLocation -> + if ( + lastLocation != null + && currentTimestampProvider() - lastLocation.time <= parameters.lastLocationTimeLimit.inWholeMilliseconds + ) { + // use last known location if present and not older than given limit + onSuccess(geocoderHelper.getGeoLocatedAddress(lastLocation)) + } else { + locationManager.requestCurrentLocationWithoutGms(onSuccess, onError) } } - locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0f, networkLocationListener) } else { AppJsonStyledLogger.log( level = KaliumLogLevel.WARN, @@ -61,8 +83,45 @@ open class LocationPickerHelper @Inject constructor(@ApplicationContext val cont } } - protected fun isLocationServicesEnabled(): Boolean { + private fun LocationManager.requestCurrentLocationWithoutGms(onSuccess: (GeoLocatedAddress) -> Unit, onError: () -> Unit) { + val cancellationSignal = CancellationSignal() + val timeoutJob = scope.launch(start = CoroutineStart.LAZY) { + delay(parameters.requestLocationTimeout) + cancellationSignal.cancel() + onError() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val executor = context.mainExecutor + val consumer: Consumer = Consumer { location -> + timeoutJob.cancel() + if (location != null) { + onSuccess(geocoderHelper.getGeoLocatedAddress(location)) + } else { + onError() + } + } + this.getCurrentLocation(LocationManager.FUSED_PROVIDER, cancellationSignal, executor, consumer) + } else { + val listener = LocationListener { location -> + timeoutJob.cancel() + onSuccess(geocoderHelper.getGeoLocatedAddress(location)) + } + cancellationSignal.setOnCancelListener { + this.removeUpdates(listener) + } + this.requestSingleUpdate(LocationManager.FUSED_PROVIDER, listener, null) + } + timeoutJob.start() + } + + internal fun isLocationServicesEnabled(): Boolean { val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager return LocationManagerCompat.isLocationEnabled(locationManager) } } + +data class LocationPickerParameters( + val lastLocationTimeLimit: Duration = 1.minutes, + val requestLocationTimeout: Duration = 10.seconds, +) diff --git a/app/src/nonfree/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt b/app/src/nonfree/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt index 5cf954d7d99..305ad565a34 100644 --- a/app/src/nonfree/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt +++ b/app/src/nonfree/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavor.kt @@ -19,7 +19,6 @@ package com.wire.android.ui.home.messagecomposer.location import android.annotation.SuppressLint import android.content.Context -import android.location.Geocoder import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import com.google.android.gms.tasks.CancellationTokenSource @@ -28,11 +27,12 @@ import com.wire.android.util.extension.isGoogleServicesAvailable import com.wire.kalium.logger.KaliumLogLevel import kotlinx.coroutines.tasks.await import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class LocationPickerHelperFlavor @Inject constructor(context: Context) : LocationPickerHelper(context) { +class LocationPickerHelperFlavor @Inject constructor( + private val context: Context, + private val geocoderHelper: GeocoderHelper, + private val locationPickerHelper: LocationPickerHelper, +) { suspend fun getLocation(onSuccess: (GeoLocatedAddress) -> Unit, onError: () -> Unit) { if (context.isGoogleServicesAvailable()) { getLocationWithGms( @@ -40,7 +40,7 @@ class LocationPickerHelperFlavor @Inject constructor(context: Context) : Locatio onError = onError ) } else { - getLocationWithoutGms( + locationPickerHelper.getLocationWithoutGms( onSuccess = onSuccess, onError = onError ) @@ -53,7 +53,7 @@ class LocationPickerHelperFlavor @Inject constructor(context: Context) : Locatio */ @SuppressLint("MissingPermission") private suspend fun getLocationWithGms(onSuccess: (GeoLocatedAddress) -> Unit, onError: () -> Unit) { - if (isLocationServicesEnabled()) { + if (locationPickerHelper.isLocationServicesEnabled()) { AppJsonStyledLogger.log( level = KaliumLogLevel.INFO, leadingMessage = "GetLocation", @@ -62,8 +62,7 @@ class LocationPickerHelperFlavor @Inject constructor(context: Context) : Locatio val locationProvider = LocationServices.getFusedLocationProviderClient(context) val currentLocation = locationProvider.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, CancellationTokenSource().token).await() - val address = Geocoder(context).getFromLocation(currentLocation.latitude, currentLocation.longitude, 1).orEmpty() - onSuccess(GeoLocatedAddress(address.firstOrNull(), currentLocation)) + onSuccess(geocoderHelper.getGeoLocatedAddress(currentLocation)) } else { AppJsonStyledLogger.log( level = KaliumLogLevel.WARN, diff --git a/app/src/test/kotlin/com/wire/android/mapper/UserTypeMapperTest.kt b/app/src/test/kotlin/com/wire/android/mapper/UserTypeMapperTest.kt index 09108ce3fb1..54718bc081c 100644 --- a/app/src/test/kotlin/com/wire/android/mapper/UserTypeMapperTest.kt +++ b/app/src/test/kotlin/com/wire/android/mapper/UserTypeMapperTest.kt @@ -46,7 +46,7 @@ class UserTypeMapperTest { } @Test - fun `given internal as a user type correctly map to none as membership`() { + fun `given internal as a user type correctly map to standard as membership`() { val result = userTypeMapper.toMembership(UserType.INTERNAL) assertEquals(Membership.Standard, result) } diff --git a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModelTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModelTest.kt index f4f61c2392a..daac0ce0ad4 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModelTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/conversations/details/editguestaccess/EditGuestAccessViewModelTest.kt @@ -55,8 +55,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith @OptIn(ExperimentalCoroutinesApi::class) -@ExtendWith(CoroutineTestExtension::class) -@ExtendWith(NavigationTestExtension::class) +@ExtendWith(CoroutineTestExtension::class, NavigationTestExtension::class) class EditGuestAccessViewModelTest { private val dispatcher = TestDispatcherProvider() @@ -108,7 +107,7 @@ class EditGuestAccessViewModelTest { editGuestAccessViewModel.updateGuestAccess(false) // then - coVerify(inverse = true) { arrangement.updateConversationAccessRoleUseCase(any(), any(), any()) } + coVerify(inverse = true) { arrangement.updateConversationAccessRole(any(), any(), any()) } assertEquals(true, editGuestAccessViewModel.editGuestAccessState.shouldShowGuestAccessChangeConfirmationDialog) } @@ -218,9 +217,6 @@ class EditGuestAccessViewModelTest { @MockK lateinit var savedStateHandle: SavedStateHandle - @MockK - lateinit var updateConversationAccessRoleUseCase: UpdateConversationAccessRoleUseCase - @MockK lateinit var observeConversationDetails: ObserveConversationDetailsUseCase diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/GeocoderHelperTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/GeocoderHelperTest.kt new file mode 100644 index 00000000000..23cd5603817 --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/GeocoderHelperTest.kt @@ -0,0 +1,97 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.messagecomposer.location + +import android.location.Address +import android.location.Geocoder +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import okio.IOException +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class GeocoderHelperTest { + + @Test + fun `given non-null result, when getting geocoder address, then return result with address`() = runTest { + // given + val location = mockLocation(latitude = 1.0, longitude = 1.0) + val address = mockAddress(addressFirstLine = "address") + val (_, geocoderHelper) = Arrangement() + .withGetFromLocation(1.0, 1.0, address) + .arrange() + + // when + val result = geocoderHelper.getGeoLocatedAddress(location) + + // then + assertEquals(address, result.address) + } + + @Test + fun `given empty result, when getting geocoder address, then return result without address`() = runTest { + // given + val location = mockLocation(latitude = 1.0, longitude = 1.0) + val (_, geocoderHelper) = Arrangement() + .withGetFromLocation(1.0, 1.0, null) + .arrange() + + // when + val result = geocoderHelper.getGeoLocatedAddress(location) + + // then + assertEquals(null, result.address) + } + + @Test + fun `given failure, when getting geocoder address, then return result without address`() = runTest { + // given + val location = mockLocation(latitude = 1.0, longitude = 1.0) + val (_, geocoderHelper) = Arrangement() + .withGetFromLocationFailure() + .arrange() + + // when + val result = geocoderHelper.getGeoLocatedAddress(location) + + // then + assertEquals(null, result.address) + } + + inner class Arrangement { + + @MockK + lateinit var geocoder: Geocoder + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + } + + fun withGetFromLocation(latitude: Double, longitude: Double, result: Address?) = apply { + coEvery { geocoder.getFromLocation(latitude, longitude, 1) } returns listOfNotNull(result) + } + + fun withGetFromLocationFailure() = apply { + coEvery { geocoder.getFromLocation(any(), any(), any()) } throws IOException() + } + + fun arrange() = this to GeocoderHelper(geocoder) + } +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavorTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavorTest.kt new file mode 100644 index 00000000000..467b8ad282a --- /dev/null +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperFlavorTest.kt @@ -0,0 +1,184 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.ui.home.messagecomposer.location + +import android.content.Context +import android.location.Address +import android.location.Location +import android.location.LocationManager +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +import com.google.android.gms.tasks.CancellationToken +import com.google.android.gms.tasks.CancellationTokenSource +import com.google.android.gms.tasks.Task +import com.wire.android.config.CoroutineTestExtension +import com.wire.android.util.extension.isGoogleServicesAvailable +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class LocationPickerHelperFlavorTest { + + private val dispatcher = StandardTestDispatcher() + + @Test + fun `given GMS not available, when getting location, then execute getLocationWithoutGms`() = + runTest(dispatcher) { + // given + val (arrangement, locationPickerHelperFlavor) = Arrangement() + .withIsGoogleServicesAvailable(false) + .arrange() + + // when + locationPickerHelperFlavor.getLocation(onSuccess = arrangement.onSuccess, onError = arrangement.onError) + + // then + coVerify(exactly = 1) { + arrangement.locationPickerHelper.getLocationWithoutGms(any(), any()) + } + } + + @Test + fun `given GMS available and location service disabled, when getting location, then execute onError`() = + runTest(dispatcher) { + // given + val (arrangement, locationPickerHelperFlavor) = Arrangement() + .withIsGoogleServicesAvailable(true) + .withIsLocationServiceEnabled(false) + .arrange() + + // when + locationPickerHelperFlavor.getLocation(onSuccess = arrangement.onSuccess, onError = arrangement.onError) + + // then + coVerify(exactly = 0) { + arrangement.onSuccess(any()) + } + coVerify(exactly = 1) { + arrangement.onError() + } + } + + @Test + fun `given GMS available and location service enabled, when getting location, then execute onSuccess with location`() = + runTest(dispatcher) { + // given + val location = mockLocation(latitude = 1.0, longitude = 1.0) + val address = mockAddress(addressFirstLine = "address") + val (arrangement, locationPickerHelperFlavor) = Arrangement() + .withIsGoogleServicesAvailable(true) + .withIsLocationServiceEnabled(true) + .withGetCurrentLocation(location) + .withGetGeoLocatedAddress(location, address) + .arrange() + + // when + locationPickerHelperFlavor.getLocation(onSuccess = arrangement.onSuccess, onError = arrangement.onError) + + // then + coVerify(exactly = 1) { + arrangement.onSuccess(match { it.location == location && it.address == address }) + } + coVerify(exactly = 0) { + arrangement.onError() + } + } + + inner class Arrangement { + + @MockK + private lateinit var context: Context + + @MockK + private lateinit var locationManager: LocationManager + + @MockK + private lateinit var fusedLocationProviderClient: FusedLocationProviderClient + + @MockK + private lateinit var geocoderHelper: GeocoderHelper + + @MockK + lateinit var locationPickerHelper: LocationPickerHelper + + val onSuccess: (GeoLocatedAddress) -> Unit = mockk() + val onError: () -> Unit = mockk() + + private val locationPickerHelperFlavor by lazy { + LocationPickerHelperFlavor( + context = context, + geocoderHelper = geocoderHelper, + locationPickerHelper = locationPickerHelper, + ) + } + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + mockkStatic(LocationServices::getFusedLocationProviderClient) + coEvery { LocationServices.getFusedLocationProviderClient(context) } returns fusedLocationProviderClient + coEvery { context.getSystemService(Context.LOCATION_SERVICE) } returns locationManager + coEvery { onSuccess(any()) } returns Unit + coEvery { onError() } returns Unit + coEvery { locationPickerHelper.getLocationWithoutGms(any(), any()) } returns Unit + } + + fun withIsGoogleServicesAvailable(isAvailable: Boolean) = apply { + mockkStatic("com.wire.android.util.extension.GoogleServicesKt") + coEvery { context.isGoogleServicesAvailable() } returns isAvailable + } + + fun withIsLocationServiceEnabled(isEnabled: Boolean) = apply { + coEvery { locationPickerHelper.isLocationServicesEnabled() } returns isEnabled + } + + fun withGetCurrentLocation(location: Location) = apply { + val task: Task = mockk() + mockkStatic("kotlinx.coroutines.tasks.TasksKt") + coEvery { task.await() } returns location + mockkConstructor(CancellationTokenSource::class) + coEvery { anyConstructed().token } returns mockk() + coEvery { fusedLocationProviderClient.getCurrentLocation(any(), any()) } returns task + } + + fun withGetGeoLocatedAddress(location: Location, result: Address) = apply { + coEvery { geocoderHelper.getGeoLocatedAddress(location) } returns GeoLocatedAddress(result, location) + } + + fun arrange() = this to locationPickerHelperFlavor + } +} + +fun mockLocation(latitude: Double, longitude: Double) = mockk().let { + coEvery { it.latitude } returns latitude + coEvery { it.longitude } returns longitude + it +} + +fun mockAddress(addressFirstLine: String) = mockk
().also { + coEvery { it.getAddressLine(0) } returns addressFirstLine +} diff --git a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperTest.kt b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperTest.kt index 7d25c187e27..0253262da6a 100644 --- a/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperTest.kt +++ b/app/src/test/kotlin/com/wire/android/ui/home/messagecomposer/location/LocationPickerHelperTest.kt @@ -19,82 +19,207 @@ package com.wire.android.ui.home.messagecomposer.location import android.app.Application import android.content.Context +import android.location.Address +import android.location.Geocoder +import android.location.Location import android.location.LocationManager import android.os.Build +import android.os.Looper import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest -import org.junit.Assert.assertTrue +import org.amshove.kluent.internal.assertEquals import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowSystemClock +import java.util.Locale +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.AtomicInteger +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaDuration @RunWith(RobolectricTestRunner::class) -@Config( - sdk = [Build.VERSION_CODES.TIRAMISU], - /* - * Run tests in isolation, use basic Application class instead of initializing WireApplication. - * It won't work with WireApplication because of Datadog - for each test new WireApplication instance is created but Datadog uses - * singleton and initializes itself only once for the first instance of WireApplication which then crashes for other instances. - */ - application = Application::class, -) +/* + * Run tests in isolation, use basic Application class instead of initializing WireApplication. + * It won't work with WireApplication because of Datadog - for each test new WireApplication instance is created but Datadog uses + * singleton and initializes itself only once for the first instance of WireApplication which then crashes for other instances. + */ +@Config(application = Application::class) class LocationPickerHelperTest { + private val dispatcher = StandardTestDispatcher() + @Test - fun `given user has device location disabled, when sharing location, then error lambda is called`() = runTest { + @Config(sdk = [Build.VERSION_CODES.P, Build.VERSION_CODES.R]) + fun `given last location not too old, then emit last location`() = runTest(dispatcher) { // given - val (arrangement, locationHelper) = Arrangement() - .withLocationEnabled(false) - .arrange() + val resultHandler = ResultHandler() + val (arrangement, locationPickerHelper) = Arrangement().arrange() + val location = Location(LocationManager.FUSED_PROVIDER).apply { + latitude = 1.0 + longitude = 1.0 + elapsedRealtimeNanos = arrangement.lastLocationTimeLimit.inWholeNanoseconds - 1.seconds.inWholeNanoseconds + time = dispatcher.scheduler.currentTime - elapsedRealtimeNanos.nanoseconds.inWholeMilliseconds + bearing = 0f + } + arrangement.updateLocation(location) + shadowOf(Looper.getMainLooper()).idle() - // when - then - locationHelper.getLocation( - onSuccess = { - assertTrue(false) // this should not be called, so it will fail the test otherwise. - }, - onError = { assertTrue(true) } - ) + // when + locationPickerHelper.getLocationWithoutGms(resultHandler::onSuccess, resultHandler::onError) + + // then + resultHandler.assert(expectedErrorCount = 0, expectedLocations = listOf(GeoLocatedAddress(arrangement.address, location))) } @Test - fun `given user has device location enabled, when sharing location, then on success lambda is called`() = runTest { + @Config(sdk = [Build.VERSION_CODES.P, Build.VERSION_CODES.R]) + fun `given last location too old, when new location comes before timeout, then emit new location`() = runTest(dispatcher) { // given - val (arrangement, locationHelper) = Arrangement() - .withLocationEnabled(true) - .arrange() + val resultHandler = ResultHandler() + val (arrangement, locationPickerHelper) = Arrangement().arrange() + val lastLocation = Location(LocationManager.FUSED_PROVIDER).apply { + latitude = 1.0 + longitude = 1.0 + elapsedRealtimeNanos = arrangement.lastLocationTimeLimit.inWholeNanoseconds + 1.seconds.inWholeNanoseconds + time = dispatcher.scheduler.currentTime - elapsedRealtimeNanos.nanoseconds.inWholeMilliseconds + } + arrangement.updateLocation(lastLocation) + shadowOf(Looper.getMainLooper()).idle() - // when - then - locationHelper.getLocation( - onSuccess = { - assertTrue(true) - }, - onError = { - assertTrue(false) // this should not be called, so it will fail the test otherwise. - } + // when + locationPickerHelper.getLocationWithoutGms(resultHandler::onSuccess, resultHandler::onError) + advanceTimeBy(arrangement.requestLocationTimeout - 1.seconds) + + val newLocation = Location(LocationManager.FUSED_PROVIDER).apply { + latitude = 2.0 + longitude = 2.0 + elapsedRealtimeNanos = 0 + time = dispatcher.scheduler.currentTime + } + arrangement.updateLocation(newLocation) + shadowOf(Looper.getMainLooper()).idle() + + // then + resultHandler.assert(expectedErrorCount = 0, expectedLocations = listOf(GeoLocatedAddress(arrangement.address, newLocation))) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.P, Build.VERSION_CODES.R]) + fun `given last location too old, when new location times out, then emit error`() = runTest(dispatcher) { + // given + val resultHandler = ResultHandler() + val (arrangement, locationPickerHelper) = Arrangement().arrange() + val lastLocation = Location(LocationManager.FUSED_PROVIDER).apply { + latitude = 1.0 + longitude = 1.0 + elapsedRealtimeNanos = arrangement.lastLocationTimeLimit.inWholeNanoseconds + 1.seconds.inWholeNanoseconds + time = dispatcher.scheduler.currentTime - elapsedRealtimeNanos.nanoseconds.inWholeMilliseconds + } + arrangement.updateLocation(lastLocation) + shadowOf(Looper.getMainLooper()).idle() + + // when + locationPickerHelper.getLocationWithoutGms(resultHandler::onSuccess, resultHandler::onError) + advanceTimeBy(arrangement.requestLocationTimeout + 1.seconds) + + // then + resultHandler.assert(expectedErrorCount = 1, expectedLocations = emptyList()) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.R]) // null location can happen only for R and above after some timeout + fun `given no last location, when new location is null, then emit error`() = runTest(dispatcher) { + // given + val resultHandler = ResultHandler() + val (arrangement, locationPickerHelper) = Arrangement( + requestLocationTimeout = 1.minutes ) + .arrange() + + // when + locationPickerHelper.getLocationWithoutGms(resultHandler::onSuccess, resultHandler::onError) + + shadowOf(Looper.getMainLooper()) + .idleFor(shadowOf(Looper.getMainLooper()).lastScheduledTaskTime) // this is how the timeout is simulated + + // then + resultHandler.assert(expectedErrorCount = 1, expectedLocations = emptyList()) } - private class Arrangement { - val context: Context = ApplicationProvider.getApplicationContext() - val locationManager: LocationManager = context.getSystemService(Application.LOCATION_SERVICE) as LocationManager + inner class Arrangement( + val lastLocationTimeLimit: Duration = 1.minutes, + val requestLocationTimeout: Duration = 10.seconds + ) { + private val context: Context = ApplicationProvider.getApplicationContext() + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + private val geocoder: Geocoder = Geocoder(context) + private val geocoderHelper: GeocoderHelper = GeocoderHelper(geocoder) + private val locationManager: LocationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + val address = Address(Locale.getDefault()).apply { + setAddressLine(0, "address") + } + + private val locationPickerHelper by lazy { + LocationPickerHelper( + context = context, + scope = scope, + currentTimestampProvider = dispatcher.scheduler::currentTime, + geocoderHelper = geocoderHelper, + parameters = LocationPickerParameters( + lastLocationTimeLimit = lastLocationTimeLimit, + requestLocationTimeout = requestLocationTimeout + ), + ) + } init { - shadowOf(locationManager).apply { - setProviderEnabled(LocationManager.GPS_PROVIDER, true) - setProviderEnabled(LocationManager.NETWORK_PROVIDER, true) - } + shadowOf(geocoder).setFromLocation(listOf(address)) + shadowOf(locationManager).setProviderEnabled(LocationManager.FUSED_PROVIDER, true) + + // update the system clock to not start with 0 and prevent from having negative time for some locations + dispatcher.scheduler.advanceTimeBy(1.hours) + ShadowSystemClock.advanceBy(1.hours.toJavaDuration()) } - fun withLocationEnabled(enabled: Boolean) = apply { - locationManager.apply { - shadowOf(this).apply { - setLocationEnabled(enabled) - } - } + fun updateLocation(location: Location) = apply { + shadowOf(locationManager).simulateLocation(location) + } + + fun arrange() = this to locationPickerHelper + } + + class ResultHandler { + private val locations = ConcurrentLinkedQueue() + private val errorCount = AtomicInteger(0) + + fun onSuccess(geoLocatedAddress: GeoLocatedAddress) { + locations.add(geoLocatedAddress) + } + + fun onError() { + errorCount.incrementAndGet() } - fun arrange() = this to LocationPickerHelperFlavor(context) + fun assert(expectedErrorCount: Int = 0, expectedLocations: List = emptyList()) { + assertEquals(expectedErrorCount, errorCount.get()) + assertEquals(expectedLocations.size, locations.size) + locations.forEachIndexed { index, geoLocatedAddress -> + assertEquals(expectedLocations[index].address, geoLocatedAddress.address) + assertEquals(expectedLocations[index].location.latitude, geoLocatedAddress.location.latitude) + assertEquals(expectedLocations[index].location.longitude, geoLocatedAddress.location.longitude) + assertEquals(expectedLocations[index].location.time, geoLocatedAddress.location.time) + } + } } } diff --git a/build-logic/plugins/src/main/kotlin/com/wire/android/gradle/KotlinAndroidConfiguration.kt b/build-logic/plugins/src/main/kotlin/com/wire/android/gradle/KotlinAndroidConfiguration.kt index 82e19ebba39..08d5c8ec33b 100644 --- a/build-logic/plugins/src/main/kotlin/com/wire/android/gradle/KotlinAndroidConfiguration.kt +++ b/build-logic/plugins/src/main/kotlin/com/wire/android/gradle/KotlinAndroidConfiguration.kt @@ -88,7 +88,7 @@ private fun CommonExtension<*, *, *, *, *, *>.configureLint(project: Project) { disable.add("IconDensities") // For testing purpose. This is safe to remove. disable.add("IconMissingDensityFolder") // For testing purpose. This is safe to remove. disable.add("ComposePreviewPublic") // Needed for screenshot testing. - disable.add("MissingTranslation") // translations are added asynchronously + disable.add("MissingTranslation") // We don't want to hardcode translations in English for other languages. baseline = project.file("lint-baseline.xml") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dcd820e675a..2a20f1af1e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -270,7 +270,7 @@ androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents" junit4 = { module = "junit:junit", version.ref = "junit4" } junit5-core = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" } junit5-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } -junit5-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } +junit5-vintage-engine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit5" } # needed for tests that use Robolectric because it doesn't yet support JUnit5 kluent-android = { module = "org.amshove.kluent:kluent-android", version.ref = "kluent" } kluent-core = { module = "org.amshove.kluent:kluent", version.ref = "kluent" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }