diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index c458cb30..ed1b585f 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -56,13 +56,14 @@
\n\nPlease update the database at least weekly to get accurate predictions.
What\'s new in Look4Sat
- * Added AMSAT status tracking page by MCKero6423 (#234)
- \n* Tweaked Radar track on reposition by atsunatsu (#235)
- \n* Tweaked linear transponder calc by atsunatsu (#236)
- \n* Added saving per-sat doppler offset by atsunatsu (#237)
- \n* Added custom freq offset setting to network reporting
- \n* Added ability to customize data sources via import
- \n* Fixed star chart in README by PingouinFerreux (#240)
+ * Tweaked sunrise/sunset calculations, added tests by atsunatsu (#241)
+ \n\n* Overhauled sources import, added HTTP status by wty2019wty (#242)
+ \nSources now have priority - the top ones define satellite names and transceiver data.
+ \nYou can enable/disable sources and restore defaults via the Satellite data import dialog.
+ \n\n* Localized AMSAT status screen for Chinese by atsunatsu (#245)
+ \n\n* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
+ \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.
diff --git a/fastlane/metadata/android/en-US/changelogs/default.txt b/fastlane/metadata/android/en-US/changelogs/default.txt
index 88bd5615..259eeef9 100644
--- a/fastlane/metadata/android/en-US/changelogs/default.txt
+++ b/fastlane/metadata/android/en-US/changelogs/default.txt
@@ -1,7 +1,5 @@
-* Added AMSAT status tracking page by MCKero6423 (#234)
-* Tweaked Radar track on reposition by atsunatsu (#235)
-* Tweaked linear transponder calc by atsunatsu (#236)
-* Added saving per-sat doppler offset by atsunatsu (#237)
-* Added custom freq offset setting to network reporting
-* Added ability to customize data sources via import
-* Fixed star chart in README by PingouinFerreux (#240)
+* Tweaked sun calculations, added tests by atsunatsu (#241)
+* Overhauled sources import, added HTTP status by wty2019wty (#242)
+* Localized AMSAT status screen for Chinese by atsunatsu (#245)
+* Extracted hardcoded UI strings to resources by TianhengZhuang (#247)
+* Added AMSAT satellite status reports submission by atsunatsu (#248)
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt
index db31dfc1..33e17381 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt
@@ -21,6 +21,7 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
+import org.osmdroid.util.GeoPoint
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.Overlay
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:
* 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
- * 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() {
@@ -58,6 +61,9 @@ class MapNightOverlay : Overlay() {
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) {
if (shadow) return
@@ -71,6 +77,15 @@ class MapNightOverlay : Overlay() {
val h = mapView.height
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,
// then find the latitude range that is in night and shade it.
// Since longitude is constant along a vertical strip and the day/night
@@ -79,22 +94,14 @@ class MapNightOverlay : Overlay() {
var x = 0
while (x < w) {
- // Get the geographic coordinate at the top and bottom of this column.
- val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
- val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
-
+ val geoTop = proj.fromPixels(x, 0, reusableGeoPoint) ?: run { x += stepPx; continue }
val lonRad = Math.toRadians(geoTop.longitude)
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 = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
- val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
- val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
+ val dotTop = sinLatTop * sinSunLat + cosLatTop * cosSunLat * cosLonDiff
+ val dotBot = sinLatBot * sinSunLat + cosLatBot * cosSunLat * cosLonDiff
when {
dotTop < 0 && dotBot < 0 -> {
@@ -142,7 +149,7 @@ class MapNightOverlay : Overlay() {
var hi = yBot
while (hi - lo > 1) {
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 dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
if (dot < 0) hi = mid else lo = mid
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt
index 3ed234d4..fec5a37d 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt
@@ -62,7 +62,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rtbishop.look4sat.core.domain.predict.GeoPos
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.presentation.IconCard
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.isVerticalLayout
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.util.GeoPoint
import org.osmdroid.views.CustomZoomButtonsController
@@ -126,6 +129,21 @@ fun MapDestination() {
val viewModel: MapViewModel = viewModel(factory = MapViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
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)
}
@@ -283,19 +301,49 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
private val markerPool = HashMap()
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>()
+
+/** Last emitted positions, replayed on scroll/zoom so culled markers appear without waiting for a tick */
+private var lastPositions: Map? = null
+private var lastAction: ((OrbitalObject) -> Unit)? = null
+
private fun setPositions(
posMap: Map,
mapView: MapView,
action: (OrbitalObject) -> Unit
) {
try {
+ lastPositions = posMap
+ lastAction = action
// Clear caches when the MapView instance changes (e.g. config change)
if (lastMapView !== mapView) {
lastMapView = mapView
markerPool.clear()
iconCache.evictAll()
+ dotIcon = null
footprintPolyline = 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
// causes osmdroid to detach shared Marker objects, making them invisible.
@@ -304,17 +352,48 @@ private fun setPositions(
}
folder.items.clear()
- val activeNames = HashSet(posMap.size)
- posMap.forEach { (satellite, geoPos) ->
+ // Cull satellites outside the viewport: only meaningful once zoomed in, but that is
+ // 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(visibleSats.size)
+ for ((satellite, geoPos) in visibleSats) {
val name = satellite.data.name
activeNames.add(name)
val marker = markerPool.getOrPut(name) {
Marker(mapView).apply {
setInfoWindow(null)
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
val pos = marker.position
if (pos != null) {
@@ -323,22 +402,32 @@ private fun setPositions(
} else {
marker.position = GeoPoint(geoPos.latitude, geoPos.longitude)
}
- marker.setOnMarkerClickListener { _, _ ->
- action(satellite)
- true
- }
folder.add(marker)
}
- // Evict markers for satellites no longer tracked
- val iter = markerPool.keys.iterator()
- while (iter.hasNext()) {
- if (iter.next() !in activeNames) iter.remove()
- }
+ // Evict markers that are no longer tracked or no longer visible
+ markerPool.keys.retainAll(activeNames)
+ visibleSats.clear()
} catch (e: Exception) {
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 {
iconCache[name]?.let { return it }
val labelRect = Rect()
@@ -376,9 +465,8 @@ private fun setSatelliteTrack(satTrack: List>, mapView: MapView) {
private var footprintPolyline: Polyline? = null
private var footprintPoints: ArrayList? = null
-private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
+private fun setFootprint(rangeCircle: List, mapView: MapView) {
try {
- val rangeCircle = orbitalPos.getRangeCircle()
var pts = footprintPoints
if (pts == null || pts.size != rangeCircle.size) {
pts = ArrayList(rangeCircle.size)
@@ -508,9 +596,28 @@ private fun rememberMapViewWithLifecycle(): MapView {
lifecycle.addObserver(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
}
+private fun clearMapCaches() {
+ markerPool.clear()
+ iconCache.evictAll()
+ dotIcon = null
+ footprintPolyline = null
+ footprintPoints = null
+ lastPositions = null
+ lastAction = null
+ lastMapView = null
+}
+
@Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
LifecycleEventObserver { _, event ->
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt
index e1274618..7664c3e0 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt
@@ -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.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
-import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
data class MapState(
val mapData: MapData? = null,
@@ -29,7 +28,7 @@ data class MapState(
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List>? = null,
- val footprint: OrbitalPos? = null,
+ val footprint: List? = null,
val positions: Map? = null,
val sunLatDeg: Double = 0.0,
val sunLonDeg: Double = 0.0,
@@ -42,6 +41,7 @@ sealed interface MapAction {
data object SelectNext : MapAction
data class SelectItem(val item: OrbitalObject) : MapAction
data class SelectDefaultItem(val catnum: Int) : MapAction
+ data class SetVisible(val isVisible: Boolean) : MapAction
}
data class MapData(
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
index 118a089d..59407aa4 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
@@ -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.toTimerString
import com.rtbishop.look4sat.core.presentation.getDefaultPass
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -45,10 +46,12 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.util.Date
+import kotlin.time.Duration.Companion.milliseconds
class MapViewModel(
private val satelliteRepo: ISatelliteRepo,
@@ -69,6 +72,9 @@ class MapViewModel(
private var dataUpdateJob: Job? = null
private var dataUpdateRate = 1000L
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 = _uiState
init {
@@ -87,6 +93,7 @@ class MapViewModel(
MapAction.SelectNext -> scrollSelection(false)
is MapAction.SelectItem -> selectSatellite(action.item)
is MapAction.SelectDefaultItem -> selectDefaultSatellite(action.catnum)
+ is MapAction.SetVisible -> isScreenVisible.value = action.isVisible
}
}
@@ -124,7 +131,11 @@ class MapViewModel(
selectedOrbitalObject = orbitalObject
viewModelScope.launch {
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()
getStationPosition()
getSatTrack(orbitalObject, stationPos, dateNow)
@@ -135,9 +146,11 @@ class MapViewModel(
else -> updateFreq
}
while (isActive) {
+ // Suspends while the map is off-screen instead of predicting into the void
+ isScreenVisible.first { it }
dateNow.time = System.currentTimeMillis()
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
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 sunPos = CelestialComputer.getSunPosition(stationPos, date.time)
val moonPos = CelestialComputer.getMoonPosition(stationPos, date.time)
@@ -294,7 +308,7 @@ class MapViewModel(
companion object {
/** 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 {
initializer {