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

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