v4.4.7 - Data import overhaul, AMSAT reports, Map tweaks

This commit is contained in:
Arty Bishop committed 2026-09-13 20:26:27 +01:00
1 parent 9699d142fa
commit d471f02370
6 files changed
+176 -49

No files matched your search

@@ -56,13 +56,14 @@
\n\nPlease update the database at least weekly to get accurate predictions.</string> \n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Added AMSAT status tracking page by MCKero6423 (#234) * Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241)
\n* Tweaked Radar track on reposition by atsunatsu (#235) \n\n* Overhauled sources import, added HTTP status by wty2019wty (#242)
\n* Tweaked linear transponder calc by atsunatsu (#236) \nSources now have priority - the top ones define satellite names and transceiver data.
\n* Added saving per-sat doppler offset by atsunatsu (#237) \nYou can enable/disable sources and restore defaults via the Satellite data import dialog.
\n* Added custom freq offset setting to network reporting \n\n* Localized AMSAT status screen for Chinese by atsunatsu (#245)
\n* Added ability to customize data sources via import \n\n* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
\n* Fixed star chart in README by PingouinFerreux (#240) \n\n* Added AMSAT satellite status reports submission by atsunatsu (#248)
\n\n* Slightly optimized map performance to reduce ANRs and memory consumption. Zoom in to see satellite labels.
</string> </string>
<!-- AMSAT Status screen --> <!-- AMSAT Status screen -->
@@ -1,7 +1,5 @@
* Added AMSAT status tracking page by MCKero6423 (#234) * Tweaked sun calculations, added tests by atsunatsu (#241)
* Tweaked Radar track on reposition by atsunatsu (#235) * Overhauled sources import, added HTTP status by wty2019wty (#242)
* Tweaked linear transponder calc by atsunatsu (#236) * Localized AMSAT status screen for Chinese by atsunatsu (#245)
* Added saving per-sat doppler offset by atsunatsu (#237) * Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
* Added custom freq offset setting to network reporting * Added AMSAT satellite status reports submission by atsunatsu (#248)
* Added ability to customize data sources via import
* Fixed star chart in README by PingouinFerreux (#240)
@@ -21,6 +21,7 @@ import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RectF import android.graphics.RectF
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Overlay import org.osmdroid.views.overlay.Overlay
import kotlin.math.cos import kotlin.math.cos
@@ -39,9 +40,11 @@ import kotlin.math.sin
* exceeds 90°, i.e. the dot product of the two unit vectors is negative: * exceeds 90°, i.e. the dot product of the two unit vectors is negative:
* dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0 * dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
* *
* Performance: we sample one column per [stepPx] pixels (default 4) and draw * Performance: we sample one column per stepPx pixels (default 4) and draw
* filled vertical rectangles. On a 1080-wide screen this means ~270 trig * filled vertical rectangles. On a 1080-wide screen this means ~270 trig
* evaluations per row, which is imperceptible. * evaluations per row, which is imperceptible. draw() is called on every
* frame, so it must stay allocation-free — all fromPixels() calls reuse a
* single GeoPoint instance.
*/ */
class MapNightOverlay : Overlay() { class MapNightOverlay : Overlay() {
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
private val rect = RectF() private val rect = RectF()
/** Reused across every fromPixels() call — draw() runs on every frame, so it must not allocate */
private val reusableGeoPoint = GeoPoint(0.0, 0.0)
override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) { override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
if (shadow) return if (shadow) return
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
val h = mapView.height val h = mapView.height
val stepPx = 4 // sample every N pixels — balance quality vs CPU val stepPx = 4 // sample every N pixels — balance quality vs CPU
// The map is never rotated, so latitude depends only on y and longitude only on x.
// Resolve the top/bottom latitudes once instead of once per column.
val latTopRad = Math.toRadians((proj.fromPixels(0, 0, reusableGeoPoint) ?: return).latitude)
val latBotRad = Math.toRadians((proj.fromPixels(0, h - 1, reusableGeoPoint) ?: return).latitude)
val sinLatTop = sin(latTopRad)
val cosLatTop = cos(latTopRad)
val sinLatBot = sin(latBotRad)
val cosLatBot = cos(latBotRad)
// We scan column by column. For each column we determine the longitude, // We scan column by column. For each column we determine the longitude,
// then find the latitude range that is in night and shade it. // then find the latitude range that is in night and shade it.
// Since longitude is constant along a vertical strip and the day/night // Since longitude is constant along a vertical strip and the day/night
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
var x = 0 var x = 0
while (x < w) { while (x < w) {
// Get the geographic coordinate at the top and bottom of this column. val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
val lonRad = Math.toRadians(geoTop.longitude) val lonRad = Math.toRadians(geoTop.longitude)
val cosLonDiff = cos(lonRad - sunLonRad) val cosLonDiff = cos(lonRad - sunLonRad)
// Top pixel geographic lat
val latTopRad = Math.toRadians(geoTop.latitude)
// Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
val latBotRad = Math.toRadians(geoBot.latitude)
// dot(sunVec, pointVec) < 0 → night // dot(sunVec, pointVec) < 0 → night
// dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff // dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
when { when {
dotTop < 0 && dotBot < 0 -> { dotTop < 0 && dotBot < 0 -> {
@@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() {
var hi = yBot var hi = yBot
while (hi - lo > 1) { while (hi - lo > 1) {
val mid = (lo + hi) / 2 val mid = (lo + hi) / 2
val geo = proj.fromPixels(x, mid) ?: return mid val geo = proj.fromPixels(x, mid, reusableGeoPoint) ?: return mid
val latRad = Math.toRadians(geo.latitude) val latRad = Math.toRadians(geo.latitude)
val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
if (dot < 0) hi = mid else lo = mid if (dot < 0) hi = mid else lo = mid
@@ -62,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
import com.rtbishop.look4sat.core.presentation.IconCard import com.rtbishop.look4sat.core.presentation.IconCard
import com.rtbishop.look4sat.core.presentation.NextPassRow import com.rtbishop.look4sat.core.presentation.NextPassRow
@@ -71,6 +70,10 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import org.osmdroid.events.DelayedMapListener
import org.osmdroid.events.MapListener
import org.osmdroid.events.ScrollEvent
import org.osmdroid.events.ZoomEvent
import org.osmdroid.tileprovider.tilesource.XYTileSource import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.CustomZoomButtonsController
@@ -126,6 +129,21 @@ fun MapDestination() {
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container)) val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mapView = rememberMapViewWithLifecycle() val mapView = rememberMapViewWithLifecycle()
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_START -> viewModel.onAction(MapAction.SetVisible(true))
Lifecycle.Event.ON_STOP -> viewModel.onAction(MapAction.SetVisible(false))
else -> {}
}
}
lifecycle.addObserver(observer)
onDispose {
lifecycle.removeObserver(observer)
viewModel.onAction(MapAction.SetVisible(false))
}
}
MapScreen(uiState, viewModel::onAction, mapView) MapScreen(uiState, viewModel::onAction, mapView)
} }
@@ -283,19 +301,49 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
private val markerPool = HashMap<String, Marker>() private val markerPool = HashMap<String, Marker>()
private var lastMapView: MapView? = null private var lastMapView: MapView? = null
/**
* Above this many satellites inside the viewport labels are dropped in favor of a single
* shared dot icon. Per-satellite label bitmaps cost ~90KB each, so drawing thousands of them
* exhausts memory and stalls the UI thread — and overlapping labels are unreadable anyway.
*/
private const val LABEL_LIMIT = 128
/** Degrees of space around the viewport so markers don't pop in at the edges */
private const val VIEWPORT_MARGIN = 8.0
/** Debounce for viewport-driven marker refreshes, in milliseconds */
private const val MAP_LISTENER_DELAY = 128L
/** Shared dot icon used when too many satellites are visible to label them */
private var dotIcon: Drawable? = null
/** Scratch list reused every frame to avoid per-tick allocation */
private val visibleSats = ArrayList<Pair<OrbitalObject, GeoPos>>()
/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
private var lastPositions: Map<OrbitalObject, GeoPos>? = null
private var lastAction: ((OrbitalObject) -> Unit)? = null
private fun setPositions( private fun setPositions(
posMap: Map<OrbitalObject, GeoPos>, posMap: Map<OrbitalObject, GeoPos>,
mapView: MapView, mapView: MapView,
action: (OrbitalObject) -> Unit action: (OrbitalObject) -> Unit
) { ) {
try { try {
lastPositions = posMap
lastAction = action
// Clear caches when the MapView instance changes (e.g. config change) // Clear caches when the MapView instance changes (e.g. config change)
if (lastMapView !== mapView) { if (lastMapView !== mapView) {
lastMapView = mapView lastMapView = mapView
markerPool.clear() markerPool.clear()
iconCache.evictAll() iconCache.evictAll()
dotIcon = null
footprintPolyline = null footprintPolyline = null
footprintPoints = null footprintPoints = null
mapView.addMapListener(DelayedMapListener(object : MapListener {
override fun onScroll(event: ScrollEvent?) = refreshPositions(mapView)
override fun onZoom(event: ZoomEvent?) = refreshPositions(mapView)
}, MAP_LISTENER_DELAY))
} }
// Reuse the existing FolderOverlay — creating a new one and replacing it // Reuse the existing FolderOverlay — creating a new one and replacing it
// causes osmdroid to detach shared Marker objects, making them invisible. // causes osmdroid to detach shared Marker objects, making them invisible.
@@ -304,17 +352,48 @@ private fun setPositions(
} }
folder.items.clear() folder.items.clear()
val activeNames = HashSet<String>(posMap.size) // Cull satellites outside the viewport: only meaningful once zoomed in, but that is
posMap.forEach { (satellite, geoPos) -> // exactly when marker labels are shown and drawing is most expensive.
visibleSats.clear()
if (mapView.width > 0 && mapView.height > 0) {
val box = mapView.boundingBox
val latNorth = box.latNorth + VIEWPORT_MARGIN
val latSouth = box.latSouth - VIEWPORT_MARGIN
val lonWest = box.lonWest - VIEWPORT_MARGIN
val lonEast = box.lonEast + VIEWPORT_MARGIN
val wrapsDateLine = box.lonWest > box.lonEast
for ((satellite, geoPos) in posMap) {
val lat = geoPos.latitude
if (lat !in latSouth..latNorth) continue
val lon = geoPos.longitude
val isLonVisible = if (wrapsDateLine) lon >= lonWest || lon <= lonEast
else lon in lonWest..lonEast
if (isLonVisible) visibleSats.add(satellite to geoPos)
}
} else {
for (entry in posMap) visibleSats.add(entry.key to entry.value)
}
val showLabels = visibleSats.size <= LABEL_LIMIT
val activeNames = HashSet<String>(visibleSats.size)
for ((satellite, geoPos) in visibleSats) {
val name = satellite.data.name val name = satellite.data.name
activeNames.add(name) activeNames.add(name)
val marker = markerPool.getOrPut(name) { val marker = markerPool.getOrPut(name) {
Marker(mapView).apply { Marker(mapView).apply {
setInfoWindow(null) setInfoWindow(null)
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
icon = getCachedTextIcon(name, mapView) // Resolve the satellite via relatedObject so the listener is allocated
// once per marker instead of once per satellite per tick
setOnMarkerClickListener { clicked, _ ->
(clicked.relatedObject as? OrbitalObject)?.let(action)
true
}
} }
} }
marker.relatedObject = satellite
val icon = if (showLabels) getCachedTextIcon(name, mapView) else getDotIcon(mapView)
if (marker.icon !== icon) marker.icon = icon
// Update position in-place — reuse existing GeoPoint if available // Update position in-place — reuse existing GeoPoint if available
val pos = marker.position val pos = marker.position
if (pos != null) { if (pos != null) {
@@ -323,22 +402,32 @@ private fun setPositions(
} else { } else {
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude) marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
} }
marker.setOnMarkerClickListener { _, _ ->
action(satellite)
true
}
folder.add(marker) folder.add(marker)
} }
// Evict markers for satellites no longer tracked // Evict markers that are no longer tracked or no longer visible
val iter = markerPool.keys.iterator() markerPool.keys.retainAll(activeNames)
while (iter.hasNext()) { visibleSats.clear()
if (iter.next() !in activeNames) iter.remove()
}
} catch (e: Exception) { } catch (e: Exception) {
println(e) println(e)
} }
} }
/** Re-applies the last known positions against the new viewport after a pan or zoom */
private fun refreshPositions(mapView: MapView): Boolean {
val posMap = lastPositions ?: return false
val action = lastAction ?: return false
setPositions(posMap, mapView, action)
mapView.invalidate()
return true
}
private fun getDotIcon(mapView: MapView): Drawable = dotIcon ?: run {
val size = 20
val bitmap = createBitmap(size, size)
Canvas(bitmap).drawCircle(size / 2f, size / 2f, size / 2f - 2f, textPaint)
bitmap.toDrawable(mapView.context.resources).also { dotIcon = it }
}
private fun getCachedTextIcon(name: String, mapView: MapView): Drawable { private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
iconCache[name]?.let { return it } iconCache[name]?.let { return it }
val labelRect = Rect() val labelRect = Rect()
@@ -376,9 +465,8 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
private var footprintPolyline: Polyline? = null private var footprintPolyline: Polyline? = null
private var footprintPoints: ArrayList<GeoPoint>? = null private var footprintPoints: ArrayList<GeoPoint>? = null
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) { private fun setFootprint(rangeCircle: List<GeoPos>, mapView: MapView) {
try { try {
val rangeCircle = orbitalPos.getRangeCircle()
var pts = footprintPoints var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) { if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size) pts = ArrayList(rangeCircle.size)
@@ -508,9 +596,28 @@ private fun rememberMapViewWithLifecycle(): MapView {
lifecycle.addObserver(lifecycleObserver) lifecycle.addObserver(lifecycleObserver)
onDispose { lifecycle.removeObserver(lifecycleObserver) } onDispose { lifecycle.removeObserver(lifecycleObserver) }
} }
// The overlay caches below are file-level (shared across MapView instances), so they must be
// released with the MapView or they keep the Activity and its bitmaps alive after disposal.
DisposableEffect(mapView) {
onDispose {
clearMapCaches()
mapView.onDetach()
}
}
return mapView return mapView
} }
private fun clearMapCaches() {
markerPool.clear()
iconCache.evictAll()
dotIcon = null
footprintPolyline = null
footprintPoints = null
lastPositions = null
lastAction = null
lastMapView = null
}
@Composable @Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) { private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
LifecycleEventObserver { _, event -> LifecycleEventObserver { _, event ->
@@ -20,7 +20,6 @@ package com.rtbishop.look4sat.feature.map
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class MapState( data class MapState(
val mapData: MapData? = null, val mapData: MapData? = null,
@@ -29,7 +28,7 @@ data class MapState(
val stationPosition: GeoPos? = null, val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass, val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null, val track: List<List<GeoPos>>? = null,
val footprint: OrbitalPos? = null, val footprint: List<GeoPos>? = null,
val positions: Map<OrbitalObject, GeoPos>? = null, val positions: Map<OrbitalObject, GeoPos>? = null,
val sunLatDeg: Double = 0.0, val sunLatDeg: Double = 0.0,
val sunLonDeg: Double = 0.0, val sunLonDeg: Double = 0.0,
@@ -42,6 +41,7 @@ sealed interface MapAction {
data object SelectNext : MapAction data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction data class SelectDefaultItem(val catnum: Int) : MapAction
data class SetVisible(val isVisible: Boolean) : MapAction
} }
data class MapData( data class MapData(
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.domain.utility.toMapGeoPos
import com.rtbishop.look4sat.core.domain.utility.toDegrees import com.rtbishop.look4sat.core.domain.utility.toDegrees
import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.domain.utility.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass import com.rtbishop.look4sat.core.presentation.getDefaultPass
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -45,10 +46,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
import kotlin.time.Duration.Companion.milliseconds
class MapViewModel( class MapViewModel(
private val satelliteRepo: ISatelliteRepo, private val satelliteRepo: ISatelliteRepo,
@@ -69,6 +72,9 @@ class MapViewModel(
private var dataUpdateJob: Job? = null private var dataUpdateJob: Job? = null
private var dataUpdateRate = 1000L private var dataUpdateRate = 1000L
private var selectedOrbitalObject: OrbitalObject? = null private var selectedOrbitalObject: OrbitalObject? = null
/** Gates the prediction loop so it doesn't burn CPU while the map isn't on screen */
private val isScreenVisible = MutableStateFlow(true)
val uiState: StateFlow<MapState> = _uiState val uiState: StateFlow<MapState> = _uiState
init { init {
@@ -87,6 +93,7 @@ class MapViewModel(
MapAction.SelectNext -> scrollSelection(false) MapAction.SelectNext -> scrollSelection(false)
is MapAction.SelectItem -> selectSatellite(action.item) is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum) is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
} }
} }
@@ -124,7 +131,11 @@ class MapViewModel(
selectedOrbitalObject = orbitalObject selectedOrbitalObject = orbitalObject
viewModelScope.launch { viewModelScope.launch {
dataUpdateJob?.cancelAndJoin() dataUpdateJob?.cancelAndJoin()
dataUpdateJob = launch { // Default dispatcher is mandatory: viewModelScope is Main.immediate, and every
// satelliteRepo call internally hops to Default and resumes back on the caller's
// dispatcher. On Main that posts one continuation per satellite per tick, which
// floods the looper and ANRs for users tracking thousands of objects.
dataUpdateJob = launch(Dispatchers.Default) {
val dateNow = Date() val dateNow = Date()
getStationPosition() getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow) getSatTrack(orbitalObject, stationPos, dateNow)
@@ -135,9 +146,11 @@ class MapViewModel(
else -> updateFreq else -> updateFreq
} }
while (isActive) { while (isActive) {
// Suspends while the map is off-screen instead of predicting into the void
isScreenVisible.first { it }
dateNow.time = System.currentTimeMillis() dateNow.time = System.currentTimeMillis()
updateMapState(orbitalObject, allSatellites, stationPos, dateNow) updateMapState(orbitalObject, allSatellites, stationPos, dateNow)
delay(effectiveRate) delay(effectiveRate.milliseconds)
} }
} }
} }
@@ -187,7 +200,8 @@ class MapViewModel(
// 2. Derive footprint, info data, sun and moon position from already-computed state // 2. Derive footprint, info data, sun and moon position from already-computed state
val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time) val satPos = selectedSatPos ?: satelliteRepo.getPosition(selected, pos, date.time)
val footprint = satPos // Range circle is 721 trig-heavy points — keep it on this background dispatcher
val footprint = satPos.getRangeCircle()
val mapData = buildMapData(selected, satPos, date) val mapData = buildMapData(selected, satPos, date)
val sunPos = CelestialComputer.getSunPosition(stationPos, date.time) val sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time) val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
@@ -294,7 +308,7 @@ class MapViewModel(
companion object { companion object {
/** Number of parallel chunks for satellite position computation */ /** Number of parallel chunks for satellite position computation */
private const val PARALLEL_CHUNKS = 4 private val PARALLEL_CHUNKS = Runtime.getRuntime().availableProcessors().coerceIn(2, 8)
fun factory(container: IMainContainer) = viewModelFactory { fun factory(container: IMainContainer) = viewModelFactory {
initializer { initializer {