MapView.kt
⎇
Raw
1package net.lexcom.opentracker.ui
2
3import android.content.Context
4import androidx.compose.runtime.Composable
5import androidx.compose.runtime.DisposableEffect
6import androidx.compose.runtime.remember
7import androidx.compose.ui.Modifier
8import androidx.compose.ui.platform.LocalContext
9import androidx.compose.ui.viewinterop.AndroidView
10import androidx.lifecycle.Lifecycle
11import androidx.lifecycle.LifecycleEventObserver
12import androidx.lifecycle.compose.LocalLifecycleOwner
13import net.lexcom.opentracker.wire.Point
14import org.osmdroid.config.Configuration
15import org.osmdroid.tileprovider.tilesource.TileSourceFactory
16import org.osmdroid.util.GeoPoint
17import org.osmdroid.views.CustomZoomButtonsController
18import org.osmdroid.views.overlay.CopyrightOverlay
19import org.osmdroid.views.overlay.Marker
20import java.io.File
21import java.util.concurrent.atomic.AtomicBoolean
22import 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. */
38private const val FOLLOW_ZOOM = 16.0
39
40/** Zoom used while there is no position at all. A continent, not the ocean. */
41private const val DEFAULT_ZOOM = 3.0
42
43private const val TILE_CACHE_DIR = "osmdroid-tiles"
44
45@Composable
46fun 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
96private 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