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:
parent
7c4d5727e4
commit
37edc96d50
12 changed files with 147 additions and 220 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
113
android/app/src/main/java/com/guidecity/app/map/CityMapView.kt
Normal file
113
android/app/src/main/java/com/guidecity/app/map/CityMapView.kt
Normal 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()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ dependencyResolutionManagement {
|
|||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url = uri("https://artifactory.2gis.dev/sdk-maven-release") }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue