Add SDK map module and migrate map fetching to Apollo with Hilt wiring.

This includes the new sdk module, MapService + mapper implementation, Apollo provider module, Hilt app integration, and the project .gitignore update.

Made-with: Cursor
This commit is contained in:
2026-02-28 16:38:52 +01:00
parent 54092f184f
commit 6dedaf0e8b
29 changed files with 292 additions and 215 deletions

View File

@@ -1,24 +0,0 @@
package org.db3.airmq
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("org.db3.airmq", appContext.packageName)
}
}

View File

@@ -2,7 +2,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".AirMQApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

View File

@@ -1,10 +0,0 @@
query MapLocations {
locations {
_id
name
city
latitude
longitude
isOnline
}
}

View File

@@ -0,0 +1,7 @@
package org.db3.airmq
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class AirMQApplication : Application()

View File

@@ -6,16 +6,18 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import org.db3.airmq.features.navigation.AirMqNavGraph
import dagger.hilt.android.AndroidEntryPoint
import org.db3.airmq.features.navigation.AirMQNavGraph
import org.db3.airmq.ui.theme.AirMQTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AirMQTheme {
AirMqNavGraph(modifier = Modifier.fillMaxSize())
AirMQNavGraph(modifier = Modifier.fillMaxSize())
}
}
}

View File

@@ -1,110 +0,0 @@
package org.db3.airmq.features.map
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
class AirMqMapApi {
fun fetchMapItems(): List<MapItem> {
val payload = JSONObject()
.put("query", LOCATIONS_QUERY)
.put("variables", JSONObject())
val connection = (URL(API_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 15_000
readTimeout = 15_000
setRequestProperty("Content-Type", "application/json")
doInput = true
doOutput = true
}
return try {
OutputStreamWriter(connection.outputStream).use { writer ->
writer.write(payload.toString())
writer.flush()
}
val responseCode = connection.responseCode
val body = readResponseBody(connection, responseCode)
if (responseCode !in 200..299) {
throw IllegalStateException("Map API request failed: HTTP $responseCode")
}
parseMapItems(body)
} finally {
connection.disconnect()
}
}
private fun parseMapItems(rawJson: String): List<MapItem> {
val root = JSONObject(rawJson)
val errors = root.optJSONArray("errors")
if (errors != null && errors.length() > 0) {
throw IllegalStateException(firstGraphQlError(errors))
}
val data = root.optJSONObject("data")
?: throw IllegalStateException("Map API response is missing data")
val locations = data.optJSONArray("locations") ?: JSONArray()
return buildList {
for (index in 0 until locations.length()) {
val location = locations.optJSONObject(index) ?: continue
val id = location.optString("_id")
val latitude = location.optDouble("latitude", Double.NaN)
val longitude = location.optDouble("longitude", Double.NaN)
if (id.isBlank() || latitude.isNaN() || longitude.isNaN()) {
continue
}
add(
MapItem(
id = id,
title = location.optString("name").ifBlank { id },
city = location.optString("city").ifBlank { null },
latitude = latitude,
longitude = longitude,
isOnline = location.optBoolean("isOnline", false)
)
)
}
}
}
private fun readResponseBody(connection: HttpURLConnection, responseCode: Int): String {
val source = if (responseCode in 200..299) {
connection.inputStream
} else {
connection.errorStream ?: connection.inputStream
}
return source.bufferedReader().use(BufferedReader::readText)
}
private fun firstGraphQlError(errors: JSONArray): String {
val firstError = errors.optJSONObject(0)
return firstError?.optString("message").orEmpty().ifBlank {
"Map API returned an unknown GraphQL error"
}
}
companion object {
private const val API_URL = "https://api.airmq.cc"
private const val LOCATIONS_QUERY = """
query MapLocations {
locations {
_id
name
city
latitude
longitude
isOnline
}
}
"""
}
}

View File

@@ -1,10 +0,0 @@
package org.db3.airmq.features.map
data class MapItem(
val id: String,
val title: String,
val city: String?,
val latitude: Double,
val longitude: Double,
val isOnline: Boolean
)

View File

@@ -1,5 +1,6 @@
package org.db3.airmq.features.map
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -24,8 +26,9 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.hilt.navigation.compose.hiltViewModel
import org.db3.airmq.features.common.AirMqContainedButton
import org.db3.airmq.sdk.map.domain.MapItem
import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
@@ -34,9 +37,42 @@ import org.osmdroid.views.overlay.Marker
@Composable
fun MapScreen(
viewModel: MapViewModel = viewModel()
viewModel: MapViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
LaunchedEffect(uiState) {
Toast.makeText(context, uiState.items.count().toString(), Toast.LENGTH_LONG).show()
}
Box(modifier = Modifier.fillMaxSize()) {
AirMQMap(uiState.items)
if (uiState.isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(40.dp))
}
}
if (uiState.errorMessage != null) {
ErrorOverlay(
message = uiState.errorMessage ?: "Unknown error",
onRetry = viewModel::refresh
)
}
}
}
@Composable
private fun AirMQMap(
items: List<MapItem>
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
@@ -65,47 +101,27 @@ fun MapScreen(
}
}
Box(modifier = Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { mapView },
update = { map ->
map.overlays.removeAll { it is Marker }
uiState.items.forEach { item ->
val marker = Marker(map).apply {
position = GeoPoint(item.latitude, item.longitude)
title = listOfNotNull(item.title, item.city).joinToString(" - ")
subDescription = if (item.isOnline) "Online" else "Offline"
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
}
map.overlays.add(marker)
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { mapView },
update = { map ->
map.overlays.removeAll { it is Marker }
items.forEach { item ->
val marker = Marker(map).apply {
position = GeoPoint(item.latitude, item.longitude)
title = listOfNotNull(item.title, item.city).joinToString(" - ")
subDescription = if (item.isOnline) "Online" else "Offline"
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
}
uiState.items.firstOrNull()?.let { first ->
map.controller.animateTo(GeoPoint(first.latitude, first.longitude))
}
map.invalidate()
map.overlays.add(marker)
}
)
if (uiState.isLoading) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(40.dp))
items.firstOrNull()?.let { first ->
map.controller.animateTo(GeoPoint(first.latitude, first.longitude))
}
map.invalidate()
}
if (uiState.errorMessage != null) {
ErrorOverlay(
message = uiState.errorMessage ?: "Unknown error",
onRetry = viewModel::refresh
)
}
}
)
}
@Composable

View File

@@ -1,5 +1,7 @@
package org.db3.airmq.features.map
import org.db3.airmq.sdk.map.domain.MapItem
data class MapUiState(
val isLoading: Boolean = false,
val items: List<MapItem> = emptyList(),

View File

@@ -2,14 +2,18 @@ package org.db3.airmq.features.map
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.db3.airmq.sdk.map.MapService
class MapViewModel(
private val mapApi: AirMqMapApi = AirMqMapApi()
@HiltViewModel
class MapViewModel @Inject constructor(
private val mapService: MapService
) : ViewModel() {
private val _uiState = MutableStateFlow(MapUiState(isLoading = true))
@@ -22,7 +26,7 @@ class MapViewModel(
fun refresh() {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
viewModelScope.launch(Dispatchers.IO) {
val result = runCatching { mapApi.fetchMapItems() }
val result = runCatching { mapService.fetchMapItems() }
_uiState.value = result.fold(
onSuccess = { items ->
MapUiState(

View File

@@ -1,17 +0,0 @@
package org.db3.airmq
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}