package infra import org.json.JSONObject import org.json.JSONArray import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder data class POI( val id: Long, val lat: Double, val lon: Double, val name: String, val category: String, val tags: Map ) private val CATEGORY_TAG_MAP: Map = mapOf( "restaurant" to "amenity=restaurant", "cafe" to "amenity=cafe", "bar" to "amenity=bar", "museum" to "tourism=museum", "monument" to "historic=monument", "park" to "leisure=park", "library" to "amenity=library", "theatre" to "amenity=theatre", "cinema" to "amenity=cinema", "gallery" to "tourism=gallery", "historic" to "historic", "tourism" to "tourism", "shop" to "shop", "hotel" to "tourism=hotel", "pharmacy" to "amenity=pharmacy", "hospital" to "amenity=hospital" ) /** Builds the Overpass QL node clause for a single category tag expression. */ private fun nodeClause(tagExpr: String, radiusM: Int, lat: Double, lon: Double): String { return if (tagExpr.contains('=')) { val (key, value) = tagExpr.split('=', limit = 2) """node["$key"="$value"](around:$radiusM,$lat,$lon);""" } else { // Wildcard: match any value for the key """node["$tagExpr"](around:$radiusM,$lat,$lon);""" } } /** * OverpassNearbyPois queries the Overpass API (OpenStreetMap) to retrieve POIs * near the given coordinates within the specified radius. * * Uses only the Android SDK (java.net.HttpURLConnection + org.json) — no external * dependencies. Throws RuntimeException on HTTP or parse errors. * * @param lat Latitude of the center point (WGS84) * @param lon Longitude of the center point (WGS84) * @param radiusM Search radius in metres (default: 500) * @param categories Subset of supported categories to filter by, or null for all * @return List of POIs found, may be empty */ fun overpassNearbyPois( lat: Double, lon: Double, radiusM: Int = 500, categories: List? = null ): List { val resolved: Map = if (categories == null) { CATEGORY_TAG_MAP } else { CATEGORY_TAG_MAP.filterKeys { it in categories } } if (resolved.isEmpty()) { throw RuntimeException("No valid categories resolved from: $categories") } val nodeClauses = resolved.values.joinToString("\n ") { tagExpr -> nodeClause(tagExpr, radiusM, lat, lon) } val query = "[timeout:10][out:json];\n(\n $nodeClauses\n);\nout body;" val endpoint = "https://overpass-api.de/api/interpreter" val encodedData = "data=" + URLEncoder.encode(query, "UTF-8") val conn = (URL(endpoint).openConnection() as HttpURLConnection).apply { requestMethod = "POST" connectTimeout = 10_000 readTimeout = 10_000 doOutput = true setRequestProperty("Content-Type", "application/x-www-form-urlencoded") setRequestProperty("Accept", "application/json") } try { conn.outputStream.use { it.write(encodedData.toByteArray(Charsets.UTF_8)) } val code = conn.responseCode if (code != 200) { val err = conn.errorStream?.bufferedReader()?.readText() ?: "" throw RuntimeException("Overpass API returned HTTP $code: $err") } val body = conn.inputStream.bufferedReader().readText() val root = JSONObject(body) val elements: JSONArray = root.optJSONArray("elements") ?: return emptyList() val tagExprByCategory: Map = resolved.entries .associate { (cat, tagExpr) -> tagExpr to cat } val pois = mutableListOf() for (i in 0 until elements.length()) { val el = elements.getJSONObject(i) if (el.optString("type") != "node") continue val id = el.getLong("id") val eLat = el.getDouble("lat") val eLon = el.getDouble("lon") val tagsObj: JSONObject = el.optJSONObject("tags") ?: JSONObject() // Build tags map val tagsMap = mutableMapOf() for (key in tagsObj.keys()) { tagsMap[key] = tagsObj.getString(key) } // Determine category from the first matching tag expression val category = tagExprByCategory.entries.firstOrNull { (tagExpr, _) -> if (tagExpr.contains('=')) { val (k, v) = tagExpr.split('=', limit = 2) tagsMap[k] == v } else { tagsMap.containsKey(tagExpr) } }?.value ?: "unknown" val name = tagsMap["name"] ?: category.takeIf { it != "unknown" } ?: "Unknown" pois.add(POI(id = id, lat = eLat, lon = eLon, name = name, category = category, tags = tagsMap)) } return pois } finally { conn.disconnect() } }