Replace 2GIS MapKit with OpenStreetMap (osmdroid) — no key needed

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 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-09 21:46:47 +00:00
parent 7c4d5727e4
commit 37edc96d50
12 changed files with 147 additions and 220 deletions

1
.gitignore vendored
View file

@ -27,7 +27,6 @@ android/captures/
android/.externalNativeBuild/
*.keystore
*.jks
android/app/src/main/assets/dgissdk.key
# IDE
.vscode/

View file

@ -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.

View file

@ -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)

View file

@ -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")
}
}
}

View file

@ -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<NearbyPlaceDto>,
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<MapView?>(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()
},
)
}

View file

@ -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<NearbyPlaceDto>,
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)
}
}
}
}

View file

@ -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()
}
}

View file

@ -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 = {},

View file

@ -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>(MapUiState.CheckingPermission)
val uiState: StateFlow<MapUiState> = _uiState.asStateFlow()

View file

@ -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" }

View file

@ -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/

View file

@ -11,7 +11,6 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven { url = uri("https://artifactory.2gis.dev/sdk-maven-release") }
}
}