MapView.kt
| 1 | package net.lexcom.opentracker.ui |
| 2 | |
| 3 | import android.content.Context |
| 4 | import androidx.compose.runtime.Composable |
| 5 | import androidx.compose.runtime.DisposableEffect |
| 6 | import androidx.compose.runtime.remember |
| 7 | import androidx.compose.ui.Modifier |
| 8 | import androidx.compose.ui.platform.LocalContext |
| 9 | import androidx.compose.ui.viewinterop.AndroidView |
| 10 | import androidx.lifecycle.Lifecycle |
| 11 | import androidx.lifecycle.LifecycleEventObserver |
| 12 | import androidx.lifecycle.compose.LocalLifecycleOwner |
| 13 | import net.lexcom.opentracker.wire.Point |
| 14 | import org.osmdroid.config.Configuration |
| 15 | import org.osmdroid.tileprovider.tilesource.TileSourceFactory |
| 16 | import org.osmdroid.util.GeoPoint |
| 17 | import org.osmdroid.views.CustomZoomButtonsController |
| 18 | import org.osmdroid.views.overlay.CopyrightOverlay |
| 19 | import org.osmdroid.views.overlay.Marker |
| 20 | import java.io.File |
| 21 | import java.util.concurrent.atomic.AtomicBoolean |
| 22 | import org.osmdroid.views.MapView as OsmMapView |
| 23 | |
| 24 | /** |
| 25 | * The live map: an osmdroid [OsmMapView] hosted in Compose. |
| 26 | * |
| 27 | * osmdroid is a plain Android View, so there is no Compose-native alternative |
| 28 | * that does not pull in Play Services or a vector tile renderer. [AndroidView] |
| 29 | * is the whole integration. |
| 30 | * |
| 31 | * The one rule that is easy to get wrong: osmdroid must be configured *before* |
| 32 | * its first view is constructed. The view reads the global [Configuration] in |
| 33 | * its constructor to build the tile downloader, so configuring afterwards |
| 34 | * silently leaves the old values in place and tiles never load. |
| 35 | */ |
| 36 | |
| 37 | /** Zoom used once a real position is known. Roughly a few streets across. */ |
| 38 | private const val FOLLOW_ZOOM = 16.0 |
| 39 | |
| 40 | /** Zoom used while there is no position at all. A continent, not the ocean. */ |
| 41 | private const val DEFAULT_ZOOM = 3.0 |
| 42 | |
| 43 | private const val TILE_CACHE_DIR = "osmdroid-tiles" |
| 44 | |
| 45 | @Composable |
| 46 | fun MapView(point: Point?, modifier: Modifier = Modifier) { |
| 47 | val context = LocalContext.current |
| 48 | val map = remember { createMapView(context) } |
| 49 | val marker = remember { Marker(map) } |
| 50 | |
| 51 | // Not Compose state on purpose. The update block below reads it, and a |
| 52 | // snapshot state read there would make the block re-run when it flips. |
| 53 | val centred = remember { AtomicBoolean(false) } |
| 54 | |
| 55 | val lifecycleOwner = LocalLifecycleOwner.current |
| 56 | DisposableEffect(lifecycleOwner) { |
| 57 | // osmdroid's onResume/onPause start and stop the tile downloader and the |
| 58 | // (optional) compass sensor. Skipping them keeps threads running while |
| 59 | // the app is in the background, which on a tracker is exactly the wrong |
| 60 | // place to burn battery. |
| 61 | val observer = LifecycleEventObserver { _, event -> |
| 62 | when (event) { |
| 63 | Lifecycle.Event.ON_RESUME -> map.onResume() |
| 64 | Lifecycle.Event.ON_PAUSE -> map.onPause() |
| 65 | else -> Unit |
| 66 | } |
| 67 | } |
| 68 | lifecycleOwner.lifecycle.addObserver(observer) |
| 69 | onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } |
| 70 | } |
| 71 | |
| 72 | AndroidView( |
| 73 | factory = { map }, |
| 74 | modifier = modifier, |
| 75 | update = { |
| 76 | if (point == null) { |
| 77 | map.overlays.remove(marker) |
| 78 | } else { |
| 79 | // latE7/lonE7 are degrees scaled by 1e7, the wire representation. |
| 80 | val here = GeoPoint(point.latE7 / 1e7, point.lonE7 / 1e7) |
| 81 | marker.position = here |
| 82 | if (!map.overlays.contains(marker)) map.overlays.add(marker) |
| 83 | // First fix jumps and zooms in; after that the map follows. |
| 84 | if (centred.compareAndSet(false, true)) map.controller.setZoom(FOLLOW_ZOOM) |
| 85 | map.controller.setCenter(here) |
| 86 | } |
| 87 | map.invalidate() |
| 88 | }, |
| 89 | // osmdroid holds a tile downloader thread pool, a tile cache and overlay |
| 90 | // references. onDetach() is the only thing that frees them; without it |
| 91 | // every visit to this screen leaks a pool. |
| 92 | onRelease = { it.onDetach() }, |
| 93 | ) |
| 94 | } |
| 95 | |
| 96 | private fun createMapView(context: Context): OsmMapView { |
| 97 | val config = Configuration.getInstance() |
| 98 | // OSM's tile servers block the library's default "osmdroid" user agent |
| 99 | // outright, so an unset UA means every tile request returns 403. |
| 100 | config.userAgentValue = context.packageName |
| 101 | // Deliberately not Configuration.load(context, PreferenceManager...): that |
| 102 | // form needs the androidx.preference dependency and defaults the cache to |
| 103 | // external storage, which this app holds no permission for. Setting the two |
| 104 | // paths by hand keeps everything in the app's own cache directory, which |
| 105 | // the system may also reclaim under storage pressure. |
| 106 | config.osmdroidBasePath = context.cacheDir |
| 107 | config.osmdroidTileCache = File(context.cacheDir, TILE_CACHE_DIR) |
| 108 | |
| 109 | return OsmMapView(context).apply { |
| 110 | // ponytail: step 14 repoints this at the server's own /tiles proxy, so |
| 111 | // the map does not leak the user's viewport to a third party. Hitting |
| 112 | // OSM's public servers directly is acceptable for development only. |
| 113 | setTileSource(TileSourceFactory.MAPNIK) |
| 114 | setMultiTouchControls(true) |
| 115 | // Pinch covers zooming, so the overlaid +/- buttons only cost screen. |
| 116 | // This is what setBuiltInZoomControls(false) does; that call is |
| 117 | // deprecated and only forwards here. |
| 118 | zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) |
| 119 | controller.setZoom(DEFAULT_ZOOM) |
| 120 | // Not decoration. The OSM tile usage policy requires visible |
| 121 | // attribution, and it stays required in step 14: proxying the tiles |
| 122 | // through our own server changes who fetches them, not who made them. |
| 123 | // The overlay reads the credit line from the tile source, so it also |
| 124 | // stays correct if that source changes. |
| 125 | overlays.add(CopyrightOverlay(context)) |
| 126 | } |
| 127 | } |
| 128 |