Skip to content

Commit b7ef5a5

Browse files
authored
feat: add first implementation of OSM Hiking route API (#22)
* fix: bump osmdroid version * feat: implement basic datastructures for hike routes * feat: add support for hike route fetching with OSM Added a repository interface for providing routes, and an implementation of this interface based on the Overpass API, which is an API for reading and extracting data from OSM * chore: move the ListOfHikeRoutesViewModel The ListOfHikeRoutesViewModel is more suitable to be in model.route than model.map, since it doesn't provide any data for the map itself, just data to show on top of it * chore: add missing okhttp dependency * fix: change incorrect latitude mapping In Overpass implementation of HikeRoutesRepository, a longitude was set as a latitude * test: add initial tests to HikeRoutesRepositoryOverpass * fix: add bounds check for Bounds data class * fix: change overpass variables to constants * test: fix incorrect bounds in Overpass repository tests
1 parent 37e81bd commit b7ef5a5

File tree

8 files changed

+569
-3
lines changed

8 files changed

+569
-3
lines changed

app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ dependencies {
193193
androidTestImplementation(libs.mockito.android.v3124)
194194
androidTestImplementation(libs.mockito.kotlin)
195195

196+
implementation(libs.okhttp)
197+
196198
// Robolectric (for unit tests that require Android framework)
197199
testImplementation(libs.robolectric)
198200
// To fix an issue with Firebase and the Protobuf library
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package ch.hikemate.app.model.route
2+
3+
/** A class representing a bounding box */
4+
data class Bounds(val minLat: Double, val minLon: Double, val maxLat: Double, val maxLon: Double) {
5+
6+
init {
7+
require(!(minLat > maxLat || minLon > maxLon)) { "Invalid bounds" }
8+
}
9+
10+
class Builder {
11+
private var minLat = 0.0
12+
private var minLon = 0.0
13+
private var maxLat = 0.0
14+
private var maxLon = 0.0
15+
16+
fun setMinLat(minLat: Double): Builder {
17+
this.minLat = minLat
18+
return this
19+
}
20+
21+
fun setMinLon(minLon: Double): Builder {
22+
this.minLon = minLon
23+
return this
24+
}
25+
26+
fun setMaxLat(maxLat: Double): Builder {
27+
this.maxLat = maxLat
28+
return this
29+
}
30+
31+
fun setMaxLon(maxLon: Double): Builder {
32+
this.maxLon = maxLon
33+
return this
34+
}
35+
36+
fun build(): Bounds {
37+
return Bounds(minLat, minLon, maxLat, maxLon)
38+
}
39+
}
40+
}
41+
42+
/** A simple data class to represent a latitude and longitude */
43+
data class LatLong(val lat: Double, val lon: Double)
44+
45+
/**
46+
* Represents a hike route
47+
*
48+
* @param id The id of the route, depending of the client
49+
* @param bounds The bounding box of the route
50+
* @param ways The points of the route
51+
*/
52+
data class HikeRoute(val id: String, val bounds: Bounds, val ways: List<LatLong>)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package ch.hikemate.app.model.route
2+
3+
/** Interface for the hiking route provider repository. */
4+
fun interface HikeRoutesRepository {
5+
/**
6+
* Returns the routes inside the given bounding box and zoom level.
7+
*
8+
* @param bounds The bounds in which to search for routes.
9+
* @param onSuccess The callback to be called when the routes are successfully fetched.
10+
* @param onFailure The callback to be called when the routes could not be fetched.
11+
*/
12+
fun getRoutes(
13+
bounds: Bounds,
14+
onSuccess: (List<HikeRoute>) -> Unit,
15+
onFailure: (Exception) -> Unit
16+
)
17+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
package ch.hikemate.app.model.route
2+
3+
import android.util.JsonReader
4+
import java.io.Reader
5+
import okhttp3.OkHttpClient
6+
import okhttp3.Request
7+
8+
/** The URL of the Overpass API interpreter. */
9+
private const val OVERPASS_API_URL: String = "https://overpass-api.de/api/interpreter"
10+
/** The type of format to request from the Overpass API, written in OverpassQL. */
11+
private const val JSON_OVERPASS_FORMAT_TAG = "[out:json]"
12+
13+
/**
14+
* Overpass implementation of the hiking route provider repository.
15+
*
16+
* This implementation uses the Overpass API to fetch the hiking routes from OSM.
17+
*
18+
* @see <a href="https://dev.overpass-api.de/overpass-doc/">Overpass API documentation</a>
19+
*/
20+
class HikeRoutesRepositoryOverpass(val client: OkHttpClient) : HikeRoutesRepository {
21+
22+
override fun getRoutes(
23+
bounds: Bounds,
24+
onSuccess: (List<HikeRoute>) -> Unit,
25+
onFailure: (Exception) -> Unit
26+
) {
27+
val boundingBoxOverpass =
28+
"(${bounds.minLat},${bounds.minLon},${bounds.maxLat},${bounds.maxLon})"
29+
30+
// See OverpassQL documentation for more information on the query format.
31+
val overpassRequestData =
32+
"""
33+
${JSON_OVERPASS_FORMAT_TAG};
34+
nwr[route="hiking"]${boundingBoxOverpass};
35+
out geom;
36+
"""
37+
.trimIndent()
38+
39+
val requestBuilder = Request.Builder().url("$OVERPASS_API_URL?data=$overpassRequestData").get()
40+
41+
setRequestHeaders(requestBuilder)
42+
43+
client.newCall(requestBuilder.build()).enqueue(OverpassResponseHandler(onSuccess, onFailure))
44+
}
45+
46+
/**
47+
* Sets the headers for the request. Especially the user-agent.
48+
*
49+
* @param request The request builder to set the headers on.
50+
*/
51+
private fun setRequestHeaders(request: Request.Builder) {
52+
request.header("User-Agent", "Hikemate/1.0")
53+
}
54+
55+
/**
56+
* The response handler for the Overpass API request.
57+
*
58+
* @param onSuccess The callback to be called when the routes are successfully fetched.
59+
* @param onFailure The callback to be called when the routes could not be fetched.
60+
*/
61+
private inner class OverpassResponseHandler(
62+
val onSuccess: (List<HikeRoute>) -> Unit,
63+
val onFailure: (Exception) -> Unit
64+
) : okhttp3.Callback {
65+
override fun onFailure(call: okhttp3.Call, e: java.io.IOException) {
66+
onFailure(e)
67+
}
68+
69+
override fun onResponse(call: okhttp3.Call, response: okhttp3.Response) {
70+
if (!response.isSuccessful) {
71+
onFailure(
72+
Exception("Failed to fetch routes from Overpass API. Response code: ${response.code}"))
73+
return
74+
}
75+
76+
val responseBody = response.body?.charStream() ?: throw Exception("Response body is null")
77+
val routes = parseRoutes(responseBody)
78+
79+
onSuccess(routes)
80+
}
81+
82+
/**
83+
* Parses the routes from the Overpass API response.
84+
*
85+
* @param responseReader The reader for the response body.
86+
* @return The list of parsed routes.
87+
*/
88+
private fun parseRoutes(responseReader: Reader): List<HikeRoute> {
89+
val routes = mutableListOf<HikeRoute>()
90+
val jsonReader = JsonReader(responseReader)
91+
jsonReader.beginObject() // We're in the root object
92+
while (jsonReader.hasNext()) {
93+
val name = jsonReader.nextName()
94+
if (name == "elements") {
95+
// Parse the elements array
96+
jsonReader.beginArray()
97+
while (jsonReader.hasNext()) {
98+
jsonReader.beginObject() // We're in an element object
99+
routes.add(parseElement(jsonReader))
100+
jsonReader.endObject()
101+
}
102+
jsonReader.endArray()
103+
} else {
104+
jsonReader.skipValue()
105+
}
106+
}
107+
jsonReader.endObject()
108+
return routes
109+
}
110+
111+
/**
112+
* Parses a single element from the Overpass API response. The reader is supposed to already be
113+
* in the element object.
114+
*
115+
* @param elementReader The reader for the element object.
116+
* @return The parsed route.
117+
*/
118+
private fun parseElement(elementReader: JsonReader): HikeRoute {
119+
var id = ""
120+
val boundsBuilder = Bounds.Builder()
121+
val points = mutableListOf<LatLong>()
122+
while (elementReader.hasNext()) {
123+
val name = elementReader.nextName()
124+
when (name) {
125+
"id" -> id = elementReader.nextInt().toString()
126+
"bounds" -> {
127+
elementReader.beginObject() // We're in the bounds object of the element
128+
while (elementReader.hasNext()) {
129+
when (elementReader.nextName()) {
130+
"minlat" -> boundsBuilder.setMinLat(elementReader.nextDouble())
131+
"minlon" -> boundsBuilder.setMinLon(elementReader.nextDouble())
132+
"maxlat" -> boundsBuilder.setMaxLat(elementReader.nextDouble())
133+
"maxlon" -> boundsBuilder.setMaxLon(elementReader.nextDouble())
134+
else -> elementReader.skipValue()
135+
}
136+
}
137+
elementReader.endObject()
138+
}
139+
"members" -> {
140+
elementReader.beginArray() // We're in the members array of the element
141+
while (elementReader.hasNext()) {
142+
elementReader.beginObject() // We're in a member object
143+
points.addAll(parseMember(elementReader))
144+
elementReader.endObject()
145+
}
146+
elementReader.endArray()
147+
}
148+
else -> elementReader.skipValue()
149+
}
150+
}
151+
return HikeRoute(id, boundsBuilder.build(), points)
152+
}
153+
154+
/**
155+
* Parses a member from the Overpass API response. The reader is supposed to already be in the
156+
* member object.
157+
*
158+
* @param memberReader The reader for the member object.
159+
* @return The list of parsed points for this member.
160+
*/
161+
private fun parseMember(memberReader: JsonReader): List<LatLong> {
162+
val points = mutableListOf<LatLong>()
163+
while (memberReader.hasNext()) {
164+
val name = memberReader.nextName()
165+
if (name == "type") {
166+
val type = memberReader.nextString()
167+
when (type) {
168+
// Lat and Long are in the object, no need to change the reader
169+
"node" -> points.add(parseLatLong(memberReader))
170+
"way" -> points.addAll(parseWay(memberReader))
171+
else -> memberReader.skipValue()
172+
}
173+
} else {
174+
memberReader.skipValue()
175+
}
176+
}
177+
178+
return points
179+
}
180+
181+
/**
182+
* Parses a way from the Overpass API response. The reader is supposed to already be in the way
183+
* object.
184+
*
185+
* @param wayReader The reader for the way object.
186+
* @return The list of parsed points for this way.
187+
*/
188+
private fun parseWay(wayReader: JsonReader): List<LatLong> {
189+
val points = mutableListOf<LatLong>()
190+
while (wayReader.hasNext()) {
191+
if (wayReader.nextName() != "geometry") {
192+
wayReader.skipValue()
193+
continue
194+
}
195+
wayReader.beginArray()
196+
while (wayReader.hasNext()) {
197+
wayReader.beginObject()
198+
points.add(parseLatLong(wayReader))
199+
wayReader.endObject()
200+
}
201+
wayReader.endArray()
202+
}
203+
return points
204+
}
205+
206+
/**
207+
* Parses a latitude and longitude from the Overpass API response. The reader is supposed to
208+
* already be in the object to parse.
209+
*
210+
* @param reader The reader for the latlong object.
211+
* @return The parsed latitude and longitude.
212+
*/
213+
private fun parseLatLong(reader: JsonReader): LatLong {
214+
var lat = 0.0
215+
var lon = 0.0
216+
while (reader.hasNext()) {
217+
when (reader.nextName()) {
218+
"lat" -> lat = reader.nextDouble()
219+
"lon" -> lon = reader.nextDouble()
220+
else -> reader.skipValue()
221+
}
222+
}
223+
return LatLong(lat, lon)
224+
}
225+
}
226+
}

app/src/main/java/ch/hikemate/app/model/map/ListOfHikeRoutesViewModel.kt renamed to app/src/main/java/ch/hikemate/app/model/route/ListOfHikeRoutesViewModel.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package ch.hikemate.app.model.map
1+
package ch.hikemate.app.model.route
22

33
import androidx.lifecycle.ViewModel
44
import androidx.lifecycle.ViewModelProvider

0 commit comments

Comments
 (0)