diff --git a/app/src/main/java/com/android/periodpals/model/location/LocationModel.kt b/app/src/main/java/com/android/periodpals/model/location/LocationModel.kt index d3514264c..71161e8f3 100644 --- a/app/src/main/java/com/android/periodpals/model/location/LocationModel.kt +++ b/app/src/main/java/com/android/periodpals/model/location/LocationModel.kt @@ -19,4 +19,23 @@ interface LocationModel { * search. It takes an [Exception] as its parameter. */ fun search(query: String, onSuccess: (List) -> Unit, onFailure: (Exception) -> Unit) + + /** + * Performs a "reverse location search". + * + * In other words, the function returns the closest address to the specified latitude and + * longitude. + * + * @param lat Latitude of the location. + * @param lon Longitude of the location. + * @param onSuccess Callback function to handle the succesful retrieval of the address. + * @param onFailure Callback function to handle any errors or exceptions encountered during the + * search. + */ + fun reverseSearch( + lat: Double, + lon: Double, + onSuccess: (String) -> Unit, + onFailure: (Exception) -> Unit + ) } diff --git a/app/src/main/java/com/android/periodpals/model/location/LocationModelNominatim.kt b/app/src/main/java/com/android/periodpals/model/location/LocationModelNominatim.kt index 04ec99b06..577e695ad 100644 --- a/app/src/main/java/com/android/periodpals/model/location/LocationModelNominatim.kt +++ b/app/src/main/java/com/android/periodpals/model/location/LocationModelNominatim.kt @@ -12,6 +12,13 @@ import org.json.JSONArray private const val TAG = "LocationModelNominatim" +private const val SCHEME = "https" +private const val HOST = "nominatim.openstreetmap.org" +private const val FORMAT_NAME = "format" +private const val FORMAT_VAL = "json" +private const val HEADER_NAME = "User-Agent" +private const val HEADER_VALUE = "PeriodPals" + /** * A concrete implementation of the [LocationModel] interface that uses the Nominatim API from * OpenStreetMap to search for locations. @@ -57,23 +64,23 @@ class LocationModelNominatim(val client: OkHttpClient) : LocationModel { override fun search( query: String, onSuccess: (List) -> Unit, - onFailure: (Exception) -> Unit + onFailure: (Exception) -> Unit, ) { // Using HttpUrl.Builder to properly construct the URL with query parameters. val url = HttpUrl.Builder() - .scheme("https") - .host("nominatim.openstreetmap.org") + .scheme(SCHEME) + .host(HOST) .addPathSegment("search") .addQueryParameter("q", query) - .addQueryParameter("format", "json") + .addQueryParameter(FORMAT_NAME, FORMAT_VAL) .build() // Log the URL to Logcat for inspection Log.d(TAG, "Request URL: $url") // Create the request with a custom User-Agent and optional Referer - val request = Request.Builder().url(url).header("User-Agent", "PeriodPals").build() + val request = Request.Builder().url(url).header(HEADER_NAME, HEADER_VALUE).build() client .newCall(request) .enqueue( @@ -103,4 +110,53 @@ class LocationModelNominatim(val client: OkHttpClient) : LocationModel { } }) } + + override fun reverseSearch( + lat: Double, + lon: Double, + onSuccess: (String) -> Unit, + onFailure: (Exception) -> Unit + ) { + + val url = + HttpUrl.Builder() + .scheme(SCHEME) + .host(HOST) + .addPathSegment("reverse") + .addQueryParameter("lat", lat.toString()) + .addQueryParameter("lon", lon.toString()) + .addQueryParameter(FORMAT_NAME, FORMAT_VAL) + .build() + + val request = Request.Builder().url(url).header(HEADER_NAME, HEADER_VALUE).build() + Log.d(TAG, "Request url: $url") + + client + .newCall(request) + .enqueue( + object : Callback { + + override fun onFailure(call: Call, e: IOException) { + Log.e(TAG, "Failed to execute request", e) + onFailure(e) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!response.isSuccessful) { + Log.e(TAG, "Unexpected code: $response") + onFailure(Exception("Unexpected code: $response")) + return + } + + val responseBody = response.body?.string() + responseBody?.let { + val jsonObject = org.json.JSONObject(it) + val displayName = jsonObject.getString("display_name") + onSuccess(displayName) + } ?: onSuccess("Unknown address") + } + } + }) + } } diff --git a/app/src/main/java/com/android/periodpals/model/location/LocationViewModel.kt b/app/src/main/java/com/android/periodpals/model/location/LocationViewModel.kt index 3b0d15f0d..5e8b70ecd 100644 --- a/app/src/main/java/com/android/periodpals/model/location/LocationViewModel.kt +++ b/app/src/main/java/com/android/periodpals/model/location/LocationViewModel.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import okhttp3.OkHttpClient +private const val TAG = "LocationViewModel" + /** * ViewModel responsible for managing and providing location data for UI components. * @@ -16,13 +18,18 @@ import okhttp3.OkHttpClient * query input. It exposes StateFlows to observe query changes and location suggestions in a * reactive way. */ -class LocationViewModel(val repository: LocationModel) : ViewModel() { +class LocationViewModel(private val repository: LocationModel) : ViewModel() { private val _query = MutableStateFlow("") val query: StateFlow = _query private var _locationSuggestions = MutableStateFlow(emptyList()) - val locationSuggestions: StateFlow> = _locationSuggestions + val locationSuggestions: StateFlow> + get() = _locationSuggestions + + private val _address = MutableStateFlow("") + val address: StateFlow + get() = _address // create factory companion object { @@ -49,9 +56,29 @@ class LocationViewModel(val repository: LocationModel) : ViewModel() { query, { _locationSuggestions.value = it - Log.d("SearchSuccess", "Successfully fetched location suggestions for query: $query") + Log.d(TAG, "Successfully fetched location suggestions for query: $query") }, - { Log.d("SearchError", "Failed to fetch location suggestions for query: $query") }) + { Log.d(TAG, "Failed to fetch location suggestions for query: $query") }) } } + + /** + * Finds the address closest to the specified latitude and longitude and assigns it to the + * [address] state flow. + * + * @param lat Latitude of the location. + * @param lon Longitude of the location. + */ + fun getAddressFromCoordinates(lat: Double, lon: Double) { + repository.reverseSearch( + lat = lat, + lon = lon, + onSuccess = { resultAddress -> + Log.d(TAG, "Successfully fetched address related to coordinates: ($lat, $lon") + _address.value = resultAddress + }, + onFailure = { + Log.d(TAG, "Failed to fetch address related to the coordinates: ($lat, $lon)") + }) + } } diff --git a/app/src/main/java/com/android/periodpals/services/GPSServiceImpl.kt b/app/src/main/java/com/android/periodpals/services/GPSServiceImpl.kt index 97fa91b58..1ab6fe93a 100644 --- a/app/src/main/java/com/android/periodpals/services/GPSServiceImpl.kt +++ b/app/src/main/java/com/android/periodpals/services/GPSServiceImpl.kt @@ -249,13 +249,13 @@ class GPSServiceImpl( result.lastLocation?.let { location -> val lat = location.latitude val long = location.longitude + _location.value = Location( lat, long, Location.CURRENT_LOCATION_NAME, - ) // TODO change CURRENT_LOCATION_NAME to actual - // location based on the coordinates + ) _accuracy.value = location.accuracy diff --git a/app/src/main/java/com/android/periodpals/ui/components/AlertComponents.kt b/app/src/main/java/com/android/periodpals/ui/components/AlertComponents.kt index a0fc31e52..0770e13ee 100644 --- a/app/src/main/java/com/android/periodpals/ui/components/AlertComponents.kt +++ b/app/src/main/java/com/android/periodpals/ui/components/AlertComponents.kt @@ -142,6 +142,11 @@ fun LocationField( var name by remember { mutableStateOf(location?.name ?: "") } val gpsLocation by gpsService.location.collectAsState() + LaunchedEffect(Unit) { + locationViewModel.getAddressFromCoordinates( + lat = gpsLocation.latitude, lon = gpsLocation.longitude) + } + // State for dropdown visibility var showDropdown by remember { mutableStateOf(false) } @@ -189,7 +194,11 @@ fun LocationField( "Selected current location: ${gpsLocation.name} at (${gpsLocation.latitude}, ${gpsLocation.longitude})", ) name = Location.CURRENT_LOCATION_NAME - onLocationSelected(gpsLocation) + onLocationSelected( + Location( + latitude = gpsLocation.latitude, + longitude = gpsLocation.longitude, + name = locationViewModel.address.value)) showDropdown = false }, modifier = diff --git a/app/src/test/java/com/android/periodpals/model/location/LocationViewModelTest.kt b/app/src/test/java/com/android/periodpals/model/location/LocationViewModelTest.kt index 030efa0a1..6f829d46a 100644 --- a/app/src/test/java/com/android/periodpals/model/location/LocationViewModelTest.kt +++ b/app/src/test/java/com/android/periodpals/model/location/LocationViewModelTest.kt @@ -23,9 +23,15 @@ class LocationViewModelTest { private lateinit var locationRepository: LocationModel private lateinit var locationViewModel: LocationViewModel - val testQuery = "EPFL" + private val testQuery = "EPFL" private val mockLocations = listOf(Location(46.5197, 6.5662, "EPFL")) + private val mockAddressName = + "1, Avenue de Praz-Rodet, Morges, District de Morges, Vaud, 1110, Switzerland" + + private val mockLat = 46.509858 + private val mockLon = 6.485742 + private val testDispatcher = StandardTestDispatcher() @Before @@ -46,10 +52,19 @@ class LocationViewModelTest { verify(locationRepository).search(eq(testQuery), any(), any()) } + @Test + fun getAddressFromCoordinatesCallsRepository() = runTest { + locationViewModel.getAddressFromCoordinates(lat = mockLat, lon = mockLon) + verify(locationRepository) + .reverseSearch( + eq(mockLat), eq(mockLon), any<(String) -> Unit>(), any<(Exception) -> Unit>()) + } + @Test fun initialStateIsCorrect() { assertThat(locationViewModel.query.value, `is`("")) assertThat(locationViewModel.locationSuggestions.value, `is`(emptyList())) + assertThat(locationViewModel.address.value, `is`("")) } @Test @@ -89,4 +104,36 @@ class LocationViewModelTest { assertThat(locationViewModel.locationSuggestions.value, `is`(emptyList())) } + + @Test + fun reverseSearchSuccessfulUpdatesAddress() = runTest { + // Simulate successful repository call + doAnswer { + val successCallback = it.getArgument<(String) -> Unit>(2) + successCallback(mockAddressName) + } + .whenever(locationRepository) + .reverseSearch(any(), any(), any(), any()) + + locationViewModel.getAddressFromCoordinates(mockLat, mockLon) + testDispatcher.scheduler.advanceUntilIdle() // Ensure all coroutines complete + + assertThat(locationViewModel.address.value, `is`(mockAddressName)) + } + + @Test + fun reverseSearchFailureDoesNotCrash() = runTest { + // Simulate failure in the repository call + doAnswer { + val failureCallback = it.getArgument<(Exception) -> Unit>(3) + failureCallback(RuntimeException("Network error")) + } + .whenever(locationRepository) + .reverseSearch(any(), any(), any(), any()) + + locationViewModel.getAddressFromCoordinates(mockLat, mockLon) + testDispatcher.scheduler.advanceUntilIdle() // Ensure all coroutines complete + + assertThat(locationViewModel.address.value, `is`("")) + } } diff --git a/app/src/test/java/com/android/periodpals/model/location/NominatimLocationRepositoryTest.kt b/app/src/test/java/com/android/periodpals/model/location/NominatimLocationRepositoryTest.kt index edef889ba..f954ca787 100644 --- a/app/src/test/java/com/android/periodpals/model/location/NominatimLocationRepositoryTest.kt +++ b/app/src/test/java/com/android/periodpals/model/location/NominatimLocationRepositoryTest.kt @@ -103,4 +103,77 @@ class LocationModelNominatimTest { onSuccess = { assert(false) { "Expected failure, but got success" } }, onFailure = { exception -> assert(exception.message?.contains("Server Error") == true) }) } + + @Test + fun reverse_search_successful_response() { + val jsonResponse = + """ + { + "place_id": 83566034, + "lat": "46.5098584", + "lon": "6.4857429", + "display_name": "1, Avenue de Praz-Rodet, Morges, District de Morges, Vaud, 1110, Switzerland" + } + """ + .trimIndent() + + // Create a basic Request object + val mockRequest = Request.Builder().url("https://mockurl.com").build() + + val response = + Response.Builder() + .request(mockRequest) + .protocol(okhttp3.Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(jsonResponse.toResponseBody("application/json".toMediaType())) + .build() + + whenever(mockHttpClient.newCall(any())).thenReturn(mockCall) + doAnswer { invocation -> + val callback = invocation.getArgument(0) + callback.onResponse(mockCall, response) + } + .whenever(mockCall) + .enqueue(any()) + + locationRepository.reverseSearch( + lat = 46.509858, + lon = 6.485742, + onSuccess = { address -> + assert(address.isNotEmpty()) + assert( + address == + "1, Avenue de Praz-Rodet, Morges, District de Morges, Vaud, 1110, Switzerland") + }, + onFailure = { assert(false) { "Expected success, but got failure" } }) + } + + @Test + fun reverse_search_unsuccessful_response() { + val mockRequest = Request.Builder().url("https://mockurl.com").build() + + val response = + Response.Builder() + .request(mockRequest) + .protocol(okhttp3.Protocol.HTTP_1_1) + .code(500) + .message("Server Error") + .body("Internal Server Error".toResponseBody("text/plain".toMediaType())) + .build() + + whenever(mockHttpClient.newCall(any())).thenReturn(mockCall) + doAnswer { invocation -> + val callback = invocation.getArgument(0) + callback.onResponse(mockCall, response) + } + .whenever(mockCall) + .enqueue(any()) + + locationRepository.reverseSearch( + lon = 0.0, + lat = 0.0, + onSuccess = { assert(false) { "Expected failure, but got success" } }, + onFailure = { exception -> assert(exception.message?.contains("Server Error") == true) }) + } } diff --git a/app/src/test/java/com/android/periodpals/ui/alert/CreateAlertScreenTest.kt b/app/src/test/java/com/android/periodpals/ui/alert/CreateAlertScreenTest.kt index 2853e3abd..7595dce1e 100644 --- a/app/src/test/java/com/android/periodpals/ui/alert/CreateAlertScreenTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/alert/CreateAlertScreenTest.kt @@ -60,6 +60,7 @@ class CreateAlertScreenTest { private lateinit var navigationActions: NavigationActions private lateinit var locationViewModel: LocationViewModel + private val mockAddress = MutableStateFlow("Some address") private lateinit var gpsService: GPSServiceImpl private val mockLocationFLow = MutableStateFlow(Location.DEFAULT_LOCATION) private lateinit var authenticationViewModel: AuthenticationViewModel @@ -169,6 +170,7 @@ class CreateAlertScreenTest { `when`(userViewModel.user).thenReturn(userState) `when`(authenticationViewModel.authUserData).thenReturn(authUserData) `when`(navigationActions.currentRoute()).thenReturn(Route.ALERT) + `when`(locationViewModel.address).thenReturn(mockAddress) } @Test diff --git a/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt b/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt index 70214452a..1cbbc4576 100644 --- a/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt +++ b/app/src/test/java/com/android/periodpals/ui/alert/EditAlertScreenTest.kt @@ -59,6 +59,7 @@ import org.robolectric.RobolectricTestRunner class EditAlertScreenTest { private lateinit var navigationActions: NavigationActions private lateinit var locationViewModel: LocationViewModel + private val mockAddress = MutableStateFlow("Some address") private lateinit var gpsService: GPSServiceImpl private val mockLocationFLow = MutableStateFlow(Location.DEFAULT_LOCATION) private lateinit var authenticationViewModel: AuthenticationViewModel @@ -184,6 +185,8 @@ class EditAlertScreenTest { MutableStateFlow( listOf(LOCATION_SUGGESTION1, LOCATION_SUGGESTION2, LOCATION_SUGGESTION3))) `when`(locationViewModel.query).thenReturn(MutableStateFlow(LOCATION_SUGGESTION1.name)) + + `when`(locationViewModel.address).thenReturn(mockAddress) } @Test