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

@@ -16,6 +16,11 @@ alwaysApply: true
- When referencing legacy implementation details, copy behavior intentionally into the new project rather than editing old files. - When referencing legacy implementation details, copy behavior intentionally into the new project rather than editing old files.
- If a task appears to require modifying the old project, stop and propose an equivalent change in `airmq-android-2026` instead. - If a task appears to require modifying the old project, stop and propose an equivalent change in `airmq-android-2026` instead.
## Naming convention
- Use `AirMQ` everywhere for product naming.
- Never use mixed-case variants in text, docs, comments, identifiers, filenames, or symbols.
## Baseline architecture and platform stack ## Baseline architecture and platform stack
Use this stack as the default foundation for all implementation work in `airmq-android-2026`: Use this stack as the default foundation for all implementation work in `airmq-android-2026`:
@@ -23,7 +28,7 @@ Use this stack as the default foundation for all implementation work in `airmq-a
1. Jetpack Compose for UI. 1. Jetpack Compose for UI.
2. Compose Navigation 3 as the in-app navigation system. 2. Compose Navigation 3 as the in-app navigation system.
3. Firebase for mobile backend capabilities (for example auth, analytics, crash reporting, messaging, or remote config as needed). 3. Firebase for mobile backend capabilities (for example auth, analytics, crash reporting, messaging, or remote config as needed).
4. Google Maps platform for map rendering and map-related interactions. 4. OpenStreetMap platform for map rendering and map-related interactions.
5. Apollo GraphQL for GraphQL schema integration, client generation, and network operations. 5. Apollo GraphQL for GraphQL schema integration, client generation, and network operations.
If a requested change conflicts with this baseline, ask for explicit approval before introducing an alternative. If a requested change conflicts with this baseline, ask for explicit approval before introducing an alternative.

4
.gitignore vendored
View File

@@ -7,9 +7,13 @@
/.idea/workspace.xml /.idea/workspace.xml
/.idea/navEditor.xml /.idea/navEditor.xml
/.idea/assetWizardSettings.xml /.idea/assetWizardSettings.xml
.idea/inspectionProfiles/
.DS_Store .DS_Store
/build /build
/captures /captures
.externalNativeBuild .externalNativeBuild
.cxx .cxx
local.properties local.properties
**/build/
.kotlin/
/.idea/**/workspace.xml

1
.idea/gradle.xml generated
View File

@@ -9,6 +9,7 @@
<set> <set>
<option value="$PROJECT_DIR$" /> <option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" /> <option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/sdk" />
</set> </set>
</option> </option>
</GradleProjectSettings> </GradleProjectSettings>

2
.idea/misc.xml generated
View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" /> <output url="file://$PROJECT_DIR$/build/classes" />
</component> </component>
<component name="ProjectType"> <component name="ProjectType">

View File

@@ -1,5 +1,7 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.hilt.android)
alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
} }
@@ -40,8 +42,15 @@ android {
} }
dependencies { dependencies {
// AirMQ SDK
implementation(project(":sdk"))
// Android
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
@@ -49,13 +58,18 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.hilt.android)
implementation(libs.androidx.hilt.navigation.compose)
ksp(libs.hilt.compiler)
// Firebase
implementation(platform(libs.firebase.bom)) implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics) implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics) implementation(libs.firebase.crashlytics)
implementation(libs.firebase.messaging) implementation(libs.firebase.messaging)
implementation(libs.google.maps.compose) implementation(libs.osmdroid.android)
implementation(libs.play.services.maps)
implementation(libs.apollo.runtime) // Tests
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)

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" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:name=".AirMQApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_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.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier 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 import org.db3.airmq.ui.theme.AirMQTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
AirMQTheme { 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,5 +1,6 @@
package org.db3.airmq.features.map package org.db3.airmq.features.map
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -24,8 +26,9 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner 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.features.common.AirMqContainedButton
import org.db3.airmq.sdk.map.domain.MapItem
import org.osmdroid.config.Configuration import org.osmdroid.config.Configuration
import org.osmdroid.tileprovider.tilesource.TileSourceFactory import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
@@ -34,9 +37,42 @@ import org.osmdroid.views.overlay.Marker
@Composable @Composable
fun MapScreen( fun MapScreen(
viewModel: MapViewModel = viewModel() viewModel: MapViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsState() 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 context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
@@ -65,47 +101,27 @@ fun MapScreen(
} }
} }
Box(modifier = Modifier.fillMaxSize()) { AndroidView(
AndroidView( modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(), factory = { mapView },
factory = { mapView }, update = { map ->
update = { map -> map.overlays.removeAll { it is Marker }
map.overlays.removeAll { it is Marker } items.forEach { item ->
uiState.items.forEach { item -> val marker = Marker(map).apply {
val marker = Marker(map).apply { position = GeoPoint(item.latitude, item.longitude)
position = GeoPoint(item.latitude, item.longitude) title = listOfNotNull(item.title, item.city).joinToString(" - ")
title = listOfNotNull(item.title, item.city).joinToString(" - ") subDescription = if (item.isOnline) "Online" else "Offline"
subDescription = if (item.isOnline) "Online" else "Offline" setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
}
map.overlays.add(marker)
} }
map.overlays.add(marker)
uiState.items.firstOrNull()?.let { first ->
map.controller.animateTo(GeoPoint(first.latitude, first.longitude))
}
map.invalidate()
} }
)
if (uiState.isLoading) { items.firstOrNull()?.let { first ->
Box( map.controller.animateTo(GeoPoint(first.latitude, first.longitude))
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(40.dp))
} }
map.invalidate()
} }
)
if (uiState.errorMessage != null) {
ErrorOverlay(
message = uiState.errorMessage ?: "Unknown error",
onRetry = viewModel::refresh
)
}
}
} }
@Composable @Composable

View File

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

View File

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

View File

@@ -1,5 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.apollo) apply false
alias(libs.plugins.hilt.android) apply false
} }

View File

@@ -20,4 +20,6 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the # Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.disallowKotlinSourceSets=false
android.newDsl=false

View File

@@ -5,14 +5,19 @@ junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
lifecycleViewmodel = "2.10.0"
activityCompose = "1.12.4" activityCompose = "1.12.4"
kotlin = "2.0.21" kotlin = "2.0.21"
composeBom = "2024.09.00" composeBom = "2024.09.00"
navigationCompose = "2.9.3" navigationCompose = "2.9.3"
firebaseBom = "34.4.0" firebaseBom = "34.4.0"
mapsCompose = "6.12.1" mapsCompose = "6.12.1"
playServicesMaps = "19.2.0" osmdroid = "6.1.20"
apollo = "4.3.3" apollo = "5.0.0-alpha.4"
hilt = "2.57.2"
hiltNavigationCompose = "1.2.0"
okhttpLogging = "4.12.0"
ksp = "2.0.21-1.0.27"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -20,6 +25,9 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodel" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodel" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
@@ -34,12 +42,20 @@ firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.r
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
google-maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "mapsCompose" } osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
play-services-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "playServicesMaps" }
apollo-runtime = { group = "com.apollographql.apollo", name = "apollo-runtime", version.ref = "apollo" } apollo-runtime = { group = "com.apollographql.apollo", name = "apollo-runtime", version.ref = "apollo" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttpLogging" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
apollo = { id = "com.apollographql.apollo", version.ref = "apollo" }
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

49
sdk/build.gradle.kts Normal file
View File

@@ -0,0 +1,49 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.apollo)
}
android {
namespace = "org.db3.airmq.sdk"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
minSdk = 24
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
buildConfig = true
}
}
apollo {
service("service") {
packageName.set("org.db3.airmq.sdk")
schemaFiles.from(file("../app/src/main/graphql/schema.graphqls"))
}
}
dependencies {
implementation(libs.apollo.runtime)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
}

1
sdk/consumer-rules.pro Normal file
View File

@@ -0,0 +1 @@
# Consumer ProGuard rules for sdk module.

1
sdk/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# Add project specific ProGuard rules here.

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@@ -0,0 +1,9 @@
query MapMarkers {
getMarkers {
_id
name
latitude
longitude
text
}
}

View File

@@ -0,0 +1,33 @@
package org.db3.airmq.sdk.di
import com.apollographql.apollo.ApolloClient
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.db3.airmq.sdk.map.ApolloMapService
import org.db3.airmq.sdk.map.MapService
@Module
@InstallIn(SingletonComponent::class)
object SDKModule {
private const val API_URL = "https://api.airmq.cc"
@Provides
@Singleton
fun provideApolloClient(): ApolloClient {
return ApolloClient.Builder()
.serverUrl(API_URL)
.build()
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class SDKBindModule {
@Binds
@Singleton
abstract fun bindMapService(impl: ApolloMapService): MapService
}

View File

@@ -0,0 +1,21 @@
package org.db3.airmq.sdk.map
import javax.inject.Inject
import org.db3.airmq.sdk.MapMarkersQuery
import org.db3.airmq.sdk.map.domain.MapItem
class ApolloMapItemMapper @Inject constructor() {
fun toDomain(marker: MapMarkersQuery.GetMarker, index: Int): MapItem? {
val latitude = marker.latitude ?: return null
val longitude = marker.longitude ?: return null
val id = marker._id?.ifBlank { null } ?: "marker-$index"
return MapItem(
id = id,
title = marker.name?.ifBlank { null } ?: marker.text?.ifBlank { null } ?: id,
city = null,
latitude = latitude,
longitude = longitude,
isOnline = false
)
}
}

View File

@@ -0,0 +1,33 @@
package org.db3.airmq.sdk.map
import android.util.Log
import com.apollographql.apollo.ApolloClient
import javax.inject.Inject
import org.db3.airmq.sdk.MapMarkersQuery
import org.db3.airmq.sdk.map.domain.MapItem
class ApolloMapService @Inject constructor(
private val apolloClient: ApolloClient,
private val mapper: ApolloMapItemMapper
) : MapService {
override suspend fun fetchMapItems(): List<MapItem> {
Log.d(TAG, "Executing MapMarkers Apollo query")
val response = apolloClient.query(MapMarkersQuery()).execute()
response.errors?.firstOrNull()?.let { gqlError ->
Log.e(TAG, "MapMarkers Apollo query failed: ${gqlError.message}")
throw IllegalStateException(gqlError.message)
}
val mappedItems = response.data?.getMarkers
.orEmpty()
.filterNotNull()
.mapIndexedNotNull { index, marker -> mapper.toDomain(marker, index) }
Log.d(TAG, "MapMarkers Apollo query returned ${mappedItems.size} items")
return mappedItems
}
private companion object {
private const val TAG = "ApolloMapService"
}
}

View File

@@ -0,0 +1,7 @@
package org.db3.airmq.sdk.map
import org.db3.airmq.sdk.map.domain.MapItem
interface MapService {
suspend fun fetchMapItems(): List<MapItem>
}

View File

@@ -1,4 +1,4 @@
package org.db3.airmq.features.map package org.db3.airmq.sdk.map.domain
data class MapItem( data class MapItem(
val id: String, val id: String,

View File

@@ -24,4 +24,5 @@ dependencyResolutionManagement {
rootProject.name = "AirMQ" rootProject.name = "AirMQ"
include(":app") include(":app")
include(":sdk")