Skip to content

Commit

Permalink
Merge pull request #330 from PeriodPals/feat/geolocation/addr-from-coord
Browse files Browse the repository at this point in the history
Add search from coordinates to address
  • Loading branch information
lazarinibruno authored Dec 19, 2024
2 parents 3969efc + 44e3ce9 commit 86447db
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,23 @@ interface LocationModel {
* search. It takes an [Exception] as its parameter.
*/
fun search(query: String, onSuccess: (List<Location>) -> 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
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -57,23 +64,23 @@ class LocationModelNominatim(val client: OkHttpClient) : LocationModel {
override fun search(
query: String,
onSuccess: (List<Location>) -> 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(
Expand Down Expand Up @@ -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")
}
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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<String> = _query

private var _locationSuggestions = MutableStateFlow(emptyList<Location>())
val locationSuggestions: StateFlow<List<Location>> = _locationSuggestions
val locationSuggestions: StateFlow<List<Location>>
get() = _locationSuggestions

private val _address = MutableStateFlow("")
val address: StateFlow<String>
get() = _address

// create factory
companion object {
Expand All @@ -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)")
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Location>()))
assertThat(locationViewModel.address.value, `is`(""))
}

@Test
Expand Down Expand Up @@ -89,4 +104,36 @@ class LocationViewModelTest {

assertThat(locationViewModel.locationSuggestions.value, `is`(emptyList<Location>()))
}

@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`(""))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<okhttp3.Callback>(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<okhttp3.Callback>(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) })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 86447db

Please sign in to comment.