diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c4dc507c..b94946061 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -110,7 +110,10 @@ dependencies { // Map Libre implementation(libs.android.sdk) implementation(libs.ramani.maplibre) - + + // Location + implementation(libs.play.services.location) + // Hilt implementation(libs.hilt.android) implementation(libs.androidx.hilt.navigation.compose) diff --git a/app/src/androidTest/java/com/github/swent/echo/connectivity/GPSServiceImplTest.kt b/app/src/androidTest/java/com/github/swent/echo/connectivity/GPSServiceImplTest.kt new file mode 100644 index 000000000..52f284319 --- /dev/null +++ b/app/src/androidTest/java/com/github/swent/echo/connectivity/GPSServiceImplTest.kt @@ -0,0 +1,39 @@ +package com.github.swent.echo.connectivity + +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.mapbox.mapboxsdk.geometry.LatLng +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class GPSServiceImplTest { + + companion object { + const val LOCATION_DELAY_MILLIS = 3000 + } + + private lateinit var gpsService: GPSService + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun shouldRefreshLocation() { + lateinit var location: State + composeTestRule.setContent { + gpsService = GPSServiceImpl(LocalContext.current) + location = gpsService.userLocation.collectAsState() + } + runBlocking { delay(LOCATION_DELAY_MILLIS.toDuration(DurationUnit.MILLISECONDS)) } + assertNotNull(location.value) + } +} diff --git a/app/src/main/java/com/github/swent/echo/connectivity/GPSService.kt b/app/src/main/java/com/github/swent/echo/connectivity/GPSService.kt new file mode 100644 index 000000000..9cb1bc169 --- /dev/null +++ b/app/src/main/java/com/github/swent/echo/connectivity/GPSService.kt @@ -0,0 +1,17 @@ +package com.github.swent.echo.connectivity + +import com.mapbox.mapboxsdk.geometry.LatLng +import kotlinx.coroutines.flow.StateFlow + +/** A service that provides the user's current location if it's accessible. */ +interface GPSService { + + /** The user location as a state flow. */ + val userLocation: StateFlow + + /** The current user location. */ + fun currentUserLocation(): LatLng? + + /** Force the service to refresh the user location */ + fun refreshUserLocation() +} diff --git a/app/src/main/java/com/github/swent/echo/connectivity/GPSServiceImpl.kt b/app/src/main/java/com/github/swent/echo/connectivity/GPSServiceImpl.kt new file mode 100644 index 000000000..8e6c77b96 --- /dev/null +++ b/app/src/main/java/com/github/swent/echo/connectivity/GPSServiceImpl.kt @@ -0,0 +1,67 @@ +package com.github.swent.echo.connectivity + +import android.content.Context +import android.content.pm.PackageManager +import android.location.LocationManager +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat.getSystemService +import com.github.swent.echo.compose.navigation.LOCATION_PERMISSIONS +import com.google.android.gms.location.LocationServices +import com.mapbox.mapboxsdk.geometry.LatLng +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * A simple implementation of a [GPSService] + * + * @param context The context of the application that's using this service. + */ +class GPSServiceImpl(val context: Context) : GPSService { + private val locationProviderClient = LocationServices.getFusedLocationProviderClient(context) + + // This manager allows to check whether the location is disabled in settings. + private val locationManager: LocationManager? = + getSystemService(context, LocationManager::class.java) + + // User's last known location + private var _location = MutableStateFlow(null) + + /** Updates the last known location. */ + private fun getLocation() { + if ( + LOCATION_PERMISSIONS.any { + ActivityCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED + } + ) { + locationProviderClient.lastLocation.addOnSuccessListener { + it?.apply { + val old = _location.value + _location.compareAndSet( + old, + this.let { location -> LatLng(location.latitude, location.longitude) } + ) + } + // Request again if null, unless location is turned off in settings. + // Even if this process has permissions to access the location, as + // long as it's disabled in settings, the listener will return null. + locationManager?.apply { + // TODO maybe also check for NETWORK_PROVIDER + if (isProviderEnabled(LocationManager.GPS_PROVIDER)) { + it ?: getLocation() + } + } + } + } + } + + init { + getLocation() + } + + override val userLocation: StateFlow = _location.asStateFlow() + + override fun currentUserLocation(): LatLng? = _location.value + + override fun refreshUserLocation() = getLocation() +} diff --git a/app/src/main/java/com/github/swent/echo/di/GPSServiceModule.kt b/app/src/main/java/com/github/swent/echo/di/GPSServiceModule.kt new file mode 100644 index 000000000..a998484b2 --- /dev/null +++ b/app/src/main/java/com/github/swent/echo/di/GPSServiceModule.kt @@ -0,0 +1,22 @@ +package com.github.swent.echo.di + +import android.app.Application +import com.github.swent.echo.connectivity.GPSService +import com.github.swent.echo.connectivity.GPSServiceImpl +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object GPSServiceModule { + + @Singleton + @Provides + fun provideGPSService(application: Application): GPSService { + val context = application.applicationContext + return GPSServiceImpl(context) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d3e34d77..f27c24ea7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ androidSdk = "10.2.0" ##################### kotlinxSerializationJson = "1.6.3" ktorClientAndroid = "2.3.9" +playServicesLocation = "21.3.0" ramaniMaplibre = "0.3.0" robolectric = "4.12" roomRuntime = "2.6.1" @@ -47,6 +48,7 @@ hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", vers kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktorClientAndroid" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" } ramani-maplibre = { module = "org.ramani-maps:ramani-maplibre", version.ref = "ramaniMaplibre" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4jSimple" }