From 37edc96d50f22786af006d282f1de70cdf1e13fe Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Thu, 9 Jul 2026 21:46:47 +0000 Subject: [PATCH] =?UTF-8?q?Replace=202GIS=20MapKit=20with=20OpenStreetMap?= =?UTF-8?q?=20(osmdroid)=20=E2=80=94=20no=20key=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Getting a working mobile-SDK key from 2GIS turned out to require a sales-mediated B2B process (Platform Manager subscription, demo keys explicitly excluded from mobile SDK use per their own docs), not a quick self-serve signup. Swapped to osmdroid instead: free, no API key or account, works immediately. CityMapView (replacing DgisMapView/DgisSdkProvider) wraps osmdroid's View-based MapView via AndroidView, forwarding lifecycle events, with markers for the user's location and each nearby place. Configured required OSM tile-usage-policy user agent + app-private tile cache in GuideCityApp. Removed the now-unused DGIS_API_KEY plumbing and the arm64-only ABI filter (osmdroid has no heavy native libs, so it's not needed) — APK is back down to ~19.6MB from ~69MB. Verified: testDebugUnitTest passes, assembleDebug produces a working APK, sent via the Telegram bot (previously blocked by its 50MB limit with the 2GIS build). Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 - README.md | 43 ++----- android/app/build.gradle.kts | 21 +--- .../java/com/guidecity/app/GuideCityApp.kt | 17 +++ .../java/com/guidecity/app/map/CityMapView.kt | 113 +++++++++++++++++ .../java/com/guidecity/app/map/DgisMapView.kt | 114 ------------------ .../com/guidecity/app/map/DgisSdkProvider.kt | 39 ------ .../com/guidecity/app/ui/map/MapScreen.kt | 5 +- .../com/guidecity/app/ui/map/MapViewModel.kt | 5 - android/gradle/libs.versions.toml | 5 +- android/local.properties.example | 3 - android/settings.gradle.kts | 1 - 12 files changed, 147 insertions(+), 220 deletions(-) create mode 100644 android/app/src/main/java/com/guidecity/app/map/CityMapView.kt delete mode 100644 android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt delete mode 100644 android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt diff --git a/.gitignore b/.gitignore index ec83ee6..8a6bf9c 100644 --- a/.gitignore +++ b/.gitignore @@ -27,7 +27,6 @@ android/captures/ android/.externalNativeBuild/ *.keystore *.jks -android/app/src/main/assets/dgissdk.key # IDE .vscode/ diff --git a/README.md b/README.md index 4c18852..c4a564b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ voice while showing a swipeable card stack. Starting city: Moscow. ``` ┌────────────────────┐ │ Android app │ Kotlin + Jetpack Compose - │ (android/) │ 2GIS map, on-device TTS, + │ (android/) │ OSM map, on-device TTS, └─────────┬──────────┘ location, Room favorites │ HTTPS / REST (JSON) ┌─────────▼──────────┐ @@ -25,7 +25,7 @@ voice while showing a swipeable card stack. Starting city: Moscow. - `backend/` — FastAPI service exposing city/place/nearby-search endpoints, backed by PostgreSQL+PostGIS. See `backend/` for details. - `android/` — Kotlin/Compose app skeleton (MVVM, Hilt, Retrofit, Room, - DataStore, 2GIS MapKit, on-device TextToSpeech). + DataStore, OpenStreetMap via osmdroid, on-device TextToSpeech). ## Backend quickstart @@ -56,7 +56,7 @@ which works from anywhere (not just the local network) and is the preferred ```bash cp android/local.properties.example android/local.properties -# fill in sdk.dir and DGIS_API_KEY (test key: b4df01a8-61db-4cb9-8286-7e069495987d) +# fill in sdk.dir (and API_BASE_URL if not using the default) cd android && ./gradlew :app:assembleDebug ``` @@ -68,39 +68,18 @@ Wi-Fi/LAN. Run unit tests before building — `./gradlew testDebugUnitTest`, then `./gradlew :app:assembleDebug`. -## 2GIS map SDK +## Map -The app depends on the real 2GIS MapKit SDK (`ru.dgis.sdk:sdk-map` + -`compose-map`, from `https://artifactory.2gis.dev/sdk-maven-release`, wired -up in `settings.gradle.kts`/`app/build.gradle.kts`). It needs a **separate** -key from the `DGIS_API_KEY` above: - -- `DGIS_API_KEY` (the `b4df01a...` value) is a 2GIS **public REST/JS API** - key — not used by the native SDK at all currently. -- The native MapKit SDK instead needs a **`dgissdk.key` file**, issued - per-app (tied to the package name `com.guidecity.app`) from - https://dev.2gis.com/. Place it at `android/app/src/main/assets/dgissdk.key` - (gitignored — don't commit it). - -Without that key file, `DgisSdkProvider` fails to initialize (logged, not -crashed) and `DgisMapView` falls back to a placeholder canvas rendering -(user location + nearby place dots, no real map tiles). Once a real key is -in place, the real map should render automatically — no code changes needed. - -Also note: the SDK bundles native libraries per CPU architecture, which -balloons APK size a lot (~19MB → ~190MB unfiltered). `app/build.gradle.kts` -restricts `ndk.abiFilters` to `arm64-v8a` only (~69MB) since that covers -virtually all real devices today — remove that filter if you need to test -on an x86 emulator or 32-bit device. +Uses OpenStreetMap tiles via [osmdroid](https://github.com/osmdroid/osmdroid) +(`map/CityMapView.kt`) — free, no API key or account needed, works out of +the box. (An earlier iteration tried the native 2GIS MapKit SDK instead; +dropped because getting a working mobile-SDK key from 2GIS turned out to be +a sales-mediated B2B process, not a quick self-serve signup — see git +history if that's ever worth revisiting.) ## Notes / current scope This is iteration 1: Moscow only (Gorky Park, Red Square area, Moscow-City), no user accounts (favorites are local/Room-only), no speed/heading-aware auto content-length selection yet (reserved API params exist, unused). -Nearby-search radius caps at 10km. Place markers aren't yet plotted on the -real 2GIS map (only the placeholder does that) — follow-up work. - -The 2GIS REST/JS API key above is used directly via -`local.properties`/`BuildConfig` for development convenience — don't ship it -as-is in a public repo. Same goes for `dgissdk.key` once you have one. +Nearby-search radius caps at 10km. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 12d819b..4d60840 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -32,24 +32,11 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - buildConfigField( - "String", - "DGIS_API_KEY", - "\"${localProp("DGIS_API_KEY", "")}\"", - ) buildConfigField( "String", "API_BASE_URL", "\"${localProp("API_BASE_URL", "http://10.0.2.2:8000/")}\"", ) - - // The 2GIS SDK ships native libs for every ABI, which balloons the APK - // (~19MB -> ~190MB unfiltered). Almost all real devices are arm64 - // today, and this keeps debug builds small enough to send anywhere - // (e.g. under Telegram bot's 50MB upload limit). - ndk { - abiFilters += "arm64-v8a" - } } buildTypes { @@ -118,12 +105,8 @@ dependencies { implementation(libs.play.services.location) implementation(libs.kotlinx.coroutines.play.services) - // 2GIS MapKit SDK. Requires a real dgissdk.key in app/src/main/assets/, - // obtained per-app from dev.2gis.com — see README for details. Without - // it, DgisSdkProvider's init fails gracefully and DgisMapView falls back - // to its placeholder rendering. - implementation(libs.dgis.sdk.map) - implementation(libs.dgis.compose.map) + // OpenStreetMap tiles via osmdroid — free, no API key/registration needed. + implementation(libs.osmdroid.android) testImplementation(libs.junit) testImplementation(libs.mockk) diff --git a/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt b/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt index 1c3c302..42adf8c 100644 --- a/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt +++ b/android/app/src/main/java/com/guidecity/app/GuideCityApp.kt @@ -3,6 +3,8 @@ package com.guidecity.app import android.app.Application import android.util.Log import dagger.hilt.android.HiltAndroidApp +import org.osmdroid.config.Configuration +import java.io.File private const val TAG = "GuideCityApp" @@ -11,5 +13,20 @@ class GuideCityApp : Application() { override fun onCreate() { super.onCreate() Log.i(TAG, "Application created (debug=${BuildConfig.DEBUG}, apiBaseUrl=${BuildConfig.API_BASE_URL})") + configureOsmdroid() + } + + /** + * OpenStreetMap's tile usage policy requires a distinct user agent per + * app. Cache paths point at app-private storage so no storage + * permission is needed. + */ + private fun configureOsmdroid() { + val osmdroidDir = File(cacheDir, "osmdroid") + Configuration.getInstance().apply { + userAgentValue = BuildConfig.APPLICATION_ID + osmdroidBasePath = osmdroidDir + osmdroidTileCache = File(osmdroidDir, "tiles") + } } } diff --git a/android/app/src/main/java/com/guidecity/app/map/CityMapView.kt b/android/app/src/main/java/com/guidecity/app/map/CityMapView.kt new file mode 100644 index 0000000..cbb8e04 --- /dev/null +++ b/android/app/src/main/java/com/guidecity/app/map/CityMapView.kt @@ -0,0 +1,113 @@ +package com.guidecity.app.map + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import com.guidecity.app.data.remote.dto.NearbyPlaceDto +import com.guidecity.app.location.LatLon +import com.guidecity.app.theme.Mocha +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.MapView +import org.osmdroid.views.overlay.Marker + +/** + * OpenStreetMap tiles via osmdroid — free, no API key or registration + * needed (unlike the 2GIS native MapKit SDK, which requires a paid/sales + * process for a mobile SDK key; see git history if that's ever revisited). + * + * Wraps osmdroid's View-based [MapView] via [AndroidView] since it has no + * native Compose API, forwarding lifecycle events so tile loading/caching + * behaves correctly. + */ +@Composable +fun CityMapView( + userLocation: LatLon?, + places: List, + onPlaceClick: (placeId: Int) -> Unit, + modifier: Modifier = Modifier, +) { + if (userLocation == null) { + Box( + modifier = modifier + .fillMaxSize() + .background(Mocha.Mantle), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Waiting for location…", + color = Mocha.Subtext1, + style = MaterialTheme.typography.bodyLarge, + ) + } + return + } + + val lifecycleOwner = LocalLifecycleOwner.current + var mapViewRef by remember { mutableStateOf(null) } + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> mapViewRef?.onResume() + Lifecycle.Event.ON_PAUSE -> mapViewRef?.onPause() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + mapViewRef?.onDetach() + } + } + + AndroidView( + modifier = modifier.fillMaxSize(), + factory = { context -> + MapView(context).apply { + setMultiTouchControls(true) + controller.setZoom(15.0) + mapViewRef = this + } + }, + update = { mapView -> + mapView.controller.setCenter(GeoPoint(userLocation.lat, userLocation.lon)) + mapView.overlays.clear() + + mapView.overlays.add( + Marker(mapView).apply { + position = GeoPoint(userLocation.lat, userLocation.lon) + title = "Вы здесь" + }, + ) + + places.forEach { place -> + mapView.overlays.add( + Marker(mapView).apply { + position = GeoPoint(place.location.lat, place.location.lon) + title = place.content.title + setOnMarkerClickListener { _, _ -> + onPlaceClick(place.id) + true + } + }, + ) + } + + mapView.invalidate() + }, + ) +} diff --git a/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt b/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt deleted file mode 100644 index 12040a6..0000000 --- a/android/app/src/main/java/com/guidecity/app/map/DgisMapView.kt +++ /dev/null @@ -1,114 +0,0 @@ -package com.guidecity.app.map - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.drawscope.Stroke -import com.guidecity.app.data.remote.dto.NearbyPlaceDto -import com.guidecity.app.location.LatLon -import com.guidecity.app.theme.Mocha -import ru.dgis.sdk.Context as DgisContext -import ru.dgis.sdk.coordinates.Bearing -import ru.dgis.sdk.coordinates.GeoPoint -import ru.dgis.sdk.coordinates.Latitude -import ru.dgis.sdk.coordinates.Longitude -import ru.dgis.sdk.compose.map.MapComposable -import ru.dgis.sdk.compose.map.MapComposableState -import ru.dgis.sdk.map.CameraPosition -import ru.dgis.sdk.map.MapOptions -import ru.dgis.sdk.map.Tilt -import ru.dgis.sdk.map.Zoom -import kotlin.math.cos - -/** - * Renders the real 2GIS MapKit map when the SDK initialized successfully - * (i.e. a valid `dgissdk.key` is present — see [DgisSdkProvider]); otherwise - * falls back to a lightweight placeholder (user location centered, nearby - * places plotted by relative lat/lon offset) so the rest of the app — - * permission flow, nearby fetch, navigation to place detail — stays fully - * functional and demoable without a real key. - * - * Follow-up not yet implemented: plotting [places] as markers on the real - * map (needs the SDK's MapObjectManager/Marker API) — for now the real map - * only centers on [userLocation]; the placeholder still shows place dots. - */ -@Composable -fun DgisMapView( - sdkContext: DgisContext?, - userLocation: LatLon?, - places: List, - onPlaceClick: (placeId: Int) -> Unit, - modifier: Modifier = Modifier, -) { - if (sdkContext != null && userLocation != null) { - val mapState = remember(userLocation) { - MapComposableState( - MapOptions().apply { - position = CameraPosition( - point = GeoPoint(Latitude(userLocation.lat), Longitude(userLocation.lon)), - zoom = Zoom(15f), - tilt = Tilt(), - bearing = Bearing(), - ) - }, - ) - } - MapComposable(modifier = modifier.fillMaxSize(), state = mapState) - return - } - - Box( - modifier = modifier - .fillMaxSize() - .background(Mocha.Mantle), - contentAlignment = Alignment.Center, - ) { - if (userLocation == null) { - Text( - text = "Map placeholder — waiting for location", - color = Mocha.Subtext1, - style = MaterialTheme.typography.bodyLarge, - ) - return@Box - } - - Canvas(modifier = Modifier.fillMaxSize()) { - val centerX = size.width / 2f - val centerY = size.height / 2f - val metersPerDegreeLat = 111_320.0 - val metersPerDegreeLon = 111_320.0 * cos(Math.toRadians(userLocation.lat)) - val pixelsPerMeter = 0.6f - - fun offsetFor(lat: Double, lon: Double): Offset { - val dyMeters = (lat - userLocation.lat) * metersPerDegreeLat - val dxMeters = (lon - userLocation.lon) * metersPerDegreeLon - return Offset( - x = centerX + (dxMeters * pixelsPerMeter).toFloat(), - y = centerY - (dyMeters * pixelsPerMeter).toFloat(), - ) - } - - // User location marker. - drawCircle(color = Mocha.Blue, radius = 14f, center = Offset(centerX, centerY)) - drawCircle( - color = Mocha.Blue, - radius = 22f, - center = Offset(centerX, centerY), - style = Stroke(width = 3f), - ) - - places.forEach { place -> - val point = offsetFor(place.location.lat, place.location.lon) - drawCircle(color = Mocha.Peach, radius = 10f, center = point) - } - } - } -} diff --git a/android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt b/android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt deleted file mode 100644 index e2f67b4..0000000 --- a/android/app/src/main/java/com/guidecity/app/map/DgisSdkProvider.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.guidecity.app.map - -import android.util.Log -import dagger.hilt.android.qualifiers.ApplicationContext -import ru.dgis.sdk.Context as DgisContext -import ru.dgis.sdk.DGis -import javax.inject.Inject -import javax.inject.Singleton - -private const val TAG = "DgisSdkProvider" - -/** - * Lazily initializes the 2GIS MapKit SDK once per process. - * - * Requires a real `dgissdk.key` file in `app/src/main/assets/`, issued - * per-app (tied to the package name) from https://dev.2gis.com/ — the - * `DGIS_API_KEY` used elsewhere in this app (2GIS's public REST/JS API key - * format) is a *different* product and will not work here. Without a valid - * key file, [sdkContext] is null and [DgisMapView] falls back to its - * placeholder rendering instead of crashing. - */ -@Singleton -class DgisSdkProvider @Inject constructor( - @ApplicationContext private val androidContext: android.content.Context, -) { - val sdkContext: DgisContext? by lazy { - runCatching { DGis.initialize(androidContext) } - .onSuccess { Log.i(TAG, "2GIS SDK initialized") } - .onFailure { - Log.e( - TAG, - "2GIS SDK initialization failed — is a valid dgissdk.key present in " + - "app/src/main/assets/? (see README)", - it, - ) - } - .getOrNull() - } -} diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt index 8457c3a..27e1f88 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapScreen.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.guidecity.app.R -import com.guidecity.app.map.DgisMapView +import com.guidecity.app.map.CityMapView @Composable fun MapScreen( @@ -84,8 +84,7 @@ fun MapScreen( .fillMaxSize() .padding(padding), ) { - DgisMapView( - sdkContext = viewModel.dgisSdkContext, + CityMapView( userLocation = resolvedLocation, places = nearbyPlaces, onPlaceClick = {}, diff --git a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt index 52cdb4b..f478c58 100644 --- a/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt +++ b/android/app/src/main/java/com/guidecity/app/ui/map/MapViewModel.kt @@ -9,7 +9,6 @@ import com.guidecity.app.data.repository.PlacesRepository import com.guidecity.app.location.LatLon import com.guidecity.app.location.LocationProvider import com.guidecity.app.location.SelectedLocationHolder -import com.guidecity.app.map.DgisSdkProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -35,12 +34,8 @@ class MapViewModel @Inject constructor( private val locationProvider: LocationProvider, private val selectedLocationHolder: SelectedLocationHolder, private val placesRepository: PlacesRepository, - dgisSdkProvider: DgisSdkProvider, ) : ViewModel() { - /** Null until a valid `dgissdk.key` is present — see [DgisSdkProvider]. */ - val dgisSdkContext = dgisSdkProvider.sdkContext - private val _uiState = MutableStateFlow(MapUiState.CheckingPermission) val uiState: StateFlow = _uiState.asStateFlow() diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 3fafd63..8a3a9a6 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -21,7 +21,7 @@ androidxTestExtJunit = "1.2.1" espressoCore = "3.6.1" mockk = "1.13.12" kotlinxCoroutinesTest = "1.8.1" -dgisSdk = "13.5.0" +osmdroid = "6.1.20" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -63,8 +63,7 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } -dgis-sdk-map = { group = "ru.dgis.sdk", name = "sdk-map", version.ref = "dgisSdk" } -dgis-compose-map = { group = "ru.dgis.sdk", name = "compose-map", version.ref = "dgisSdk" } +osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/android/local.properties.example b/android/local.properties.example index 6432a12..c81b7a7 100644 --- a/android/local.properties.example +++ b/android/local.properties.example @@ -2,9 +2,6 @@ sdk.dir=/path/to/your/Android/Sdk -# Test key provided for development; do not ship this as-is in a public repo. -DGIS_API_KEY=b4df01a8-61db-4cb9-8286-7e069495987d - # Base URL of the guideCity backend API. Options, in order of preference: # 1. Nginx-fronted HTTPS domain (works from anywhere, not just the LAN): API_BASE_URL=https://guidetest.vrubel.xyz/ diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 7558f1e..493fcee 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -11,7 +11,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() - maven { url = uri("https://artifactory.2gis.dev/sdk-maven-release") } } }