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 {