diff --git a/.cursor/rules/app-recreation-core.mdc b/.cursor/rules/app-recreation-core.mdc
index 2cf8f76..ef3aea7 100644
--- a/.cursor/rules/app-recreation-core.mdc
+++ b/.cursor/rules/app-recreation-core.mdc
@@ -16,6 +16,11 @@ alwaysApply: true
- 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.
+## 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
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.
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).
-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.
If a requested change conflicts with this baseline, ask for explicit approval before introducing an alternative.
diff --git a/.gitignore b/.gitignore
index aa724b7..2ef3244 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,9 +7,13 @@
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
+.idea/inspectionProfiles/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+**/build/
+.kotlin/
+/.idea/**/workspace.xml
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index cdbc250..250d958 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -9,6 +9,7 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 74dd639..991a888 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -1,7 +1,7 @@
-
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e9827ba..513c737 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,5 +1,7 @@
plugins {
alias(libs.plugins.android.application)
+ alias(libs.plugins.hilt.android)
+ alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.compose)
}
@@ -40,8 +42,15 @@ android {
}
dependencies {
+ // AirMQ SDK
+ implementation(project(":sdk"))
+
+ // Android
implementation(libs.androidx.core.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(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
@@ -49,13 +58,18 @@ dependencies {
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
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(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
implementation(libs.firebase.messaging)
- implementation(libs.google.maps.compose)
- implementation(libs.play.services.maps)
- implementation(libs.apollo.runtime)
+ implementation(libs.osmdroid.android)
+
+ // Tests
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/src/androidTest/java/org/db3/airmq/ExampleInstrumentedTest.kt b/app/src/androidTest/java/org/db3/airmq/ExampleInstrumentedTest.kt
deleted file mode 100644
index 6353ae9..0000000
--- a/app/src/androidTest/java/org/db3/airmq/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -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)
- }
-}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b310721..18fcee4 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,10 @@
+
+
{
- 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 {
- 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
- }
- }
- """
- }
-}
diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt
index 1d431e2..8d8fc00 100644
--- a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt
+++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt
@@ -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
+) {
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
diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapUiState.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapUiState.kt
index 6550b8f..bc69cef 100644
--- a/app/src/main/kotlin/org/db3/airmq/features/map/MapUiState.kt
+++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapUiState.kt
@@ -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 = emptyList(),
diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt
index 74475e8..3f337b2 100644
--- a/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt
+++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt
@@ -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(
diff --git a/app/src/test/java/org/db3/airmq/ExampleUnitTest.kt b/app/src/test/java/org/db3/airmq/ExampleUnitTest.kt
deleted file mode 100644
index 6177fe1..0000000
--- a/app/src/test/java/org/db3/airmq/ExampleUnitTest.kt
+++ /dev/null
@@ -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)
- }
-}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 18318be..456f85c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,5 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
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.ksp) apply false
+ alias(libs.plugins.apollo) apply false
+ alias(libs.plugins.hilt.android) apply false
}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 20e2a01..427b103 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -20,4 +20,6 @@ kotlin.code.style=official
# 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,
# thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
+android.disallowKotlinSourceSets=false
+android.newDsl=false
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 0a73ec1..345efbc 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,14 +5,19 @@ junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.10.0"
+lifecycleViewmodel = "2.10.0"
activityCompose = "1.12.4"
kotlin = "2.0.21"
composeBom = "2024.09.00"
navigationCompose = "2.9.3"
firebaseBom = "34.4.0"
mapsCompose = "6.12.1"
-playServicesMaps = "19.2.0"
-apollo = "4.3.3"
+osmdroid = "6.1.20"
+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]
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-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-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-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
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-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
-google-maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "mapsCompose" }
-play-services-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "playServicesMaps" }
+osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
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]
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-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" }
diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts
new file mode 100644
index 0000000..bc9d44f
--- /dev/null
+++ b/sdk/build.gradle.kts
@@ -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)
+}
diff --git a/sdk/consumer-rules.pro b/sdk/consumer-rules.pro
new file mode 100644
index 0000000..736bb01
--- /dev/null
+++ b/sdk/consumer-rules.pro
@@ -0,0 +1 @@
+# Consumer ProGuard rules for sdk module.
diff --git a/sdk/proguard-rules.pro b/sdk/proguard-rules.pro
new file mode 100644
index 0000000..fb164d6
--- /dev/null
+++ b/sdk/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8072ee0
--- /dev/null
+++ b/sdk/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/sdk/src/main/graphql/MapLocations.graphql b/sdk/src/main/graphql/MapLocations.graphql
new file mode 100644
index 0000000..811e503
--- /dev/null
+++ b/sdk/src/main/graphql/MapLocations.graphql
@@ -0,0 +1,9 @@
+query MapMarkers {
+ getMarkers {
+ _id
+ name
+ latitude
+ longitude
+ text
+ }
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt
new file mode 100644
index 0000000..60af50e
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt
@@ -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
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapItemMapper.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapItemMapper.kt
new file mode 100644
index 0000000..7f6da1b
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapItemMapper.kt
@@ -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
+ )
+ }
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapService.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapService.kt
new file mode 100644
index 0000000..e9507bc
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapService.kt
@@ -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 {
+ 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"
+ }
+}
diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapService.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapService.kt
new file mode 100644
index 0000000..d32646e
--- /dev/null
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapService.kt
@@ -0,0 +1,7 @@
+package org.db3.airmq.sdk.map
+
+import org.db3.airmq.sdk.map.domain.MapItem
+
+interface MapService {
+ suspend fun fetchMapItems(): List
+}
diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapItem.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/domain/MapItem.kt
similarity index 81%
rename from app/src/main/kotlin/org/db3/airmq/features/map/MapItem.kt
rename to sdk/src/main/kotlin/org/db3/airmq/sdk/map/domain/MapItem.kt
index 5dbad6b..848ec8d 100644
--- a/app/src/main/kotlin/org/db3/airmq/features/map/MapItem.kt
+++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/domain/MapItem.kt
@@ -1,4 +1,4 @@
-package org.db3.airmq.features.map
+package org.db3.airmq.sdk.map.domain
data class MapItem(
val id: String,
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 2b2d93f..f4a83f4 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -24,4 +24,5 @@ dependencyResolutionManagement {
rootProject.name = "AirMQ"
include(":app")
+include(":sdk")
\ No newline at end of file