From be96602c761001181b8e476254c7e02067dc6a02 Mon Sep 17 00:00:00 2001 From: Arty Bishop Date: Sat, 4 Apr 2026 18:42:32 +0100 Subject: [PATCH] Added normalized time for AOS/LOS, fixed passes refresh --- .../core/data/repository/SatelliteRepo.kt | 49 ++-------- .../core/domain/predict/OrbitalPass.kt | 2 +- .../core/domain/repository/ISatelliteRepo.kt | 26 ++++-- .../look4sat/feature/passes/PassesScreen.kt | 11 ++- .../feature/passes/PassesViewModel.kt | 93 +++++++++++++------ .../look4sat/feature/radar/RadarViewModel.kt | 28 +++--- 6 files changed, 118 insertions(+), 91 deletions(-) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt index 1de7451b..9a04d0bc 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SatelliteRepo.kt @@ -54,8 +54,7 @@ class SatelliteRepo( settingsRepo.selectedIds.collect { selectedIds -> _satellites.update { localStorage.getEntriesWithIds(selectedIds) } val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value - val timeNow = 1000 * ((System.currentTimeMillis() + 500) / 1000) - calculatePasses(timeNow, hoursAhead, minElevation, modes) + calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) } } @@ -95,27 +94,10 @@ class SatelliteRepo( } } - override suspend fun processPasses(passList: List, time: Long): List { - return withContext(dispatcher) { - val result = ArrayList(passList.size) - for (pass in passList) { - if (!pass.isDeepSpace) { - val timeStart = pass.aosTime - if (time > timeStart) { - val deltaNow = time.minus(timeStart).toFloat() - val deltaTotal = pass.losTime.minus(timeStart).toFloat() - pass.progress = (deltaNow / deltaTotal).round(2) - } - } - if (pass.progress < 1.0f) { - result.add(pass.copy()) - } - } - result - } - } - override suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List) { + // Normalize to the start of the current minute so that coarse 60-second stepping + // in getLeoPass always begins from the same phase, producing stable AOS/LOS times + val normalizedTime = time / 60_000L * 60_000L val currentSatellites = _satellites.value if (currentSatellites.isNotEmpty()) { withContext(dispatcher) { @@ -129,13 +111,11 @@ class SatelliteRepo( // Compute passes for each satellite in parallel val passLists = coroutineScope { filteredSatellites.map { satellite -> - async { - satellite.getPasses(stationPos, time, hoursAhead) - } + async { satellite.getPasses(stationPos, normalizedTime, hoursAhead) } }.awaitAll() } // Flatten and filter in a single pass - val timeFuture = time + (hoursAhead * 60L * 60L * 1000L) + val timeFuture = normalizedTime + (hoursAhead * 60L * 60L * 1000L) val newPasses = ArrayList() for (list in passLists) { for (pass in list) { @@ -177,7 +157,6 @@ class SatelliteRepo( return passes } - private fun getGeoPass(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPass { val satPos = sat.getPosition(pos, time) val aos = time - 24 * 60L * 60L * 1000L @@ -210,9 +189,7 @@ class SatelliteRepo( do { calendarTimeMillis += 60L * 1000L elevation = sat.getElevation(pos, calendarTimeMillis) - if (elevation > maxElevation) { - maxElevation = elevation - } + if (elevation > maxElevation) maxElevation = elevation } while (elevation < 0.0) // refine AOS to ~500ms precision @@ -220,9 +197,7 @@ class SatelliteRepo( do { calendarTimeMillis += 500L elevation = sat.getElevation(pos, calendarTimeMillis) - if (elevation > maxElevation) { - maxElevation = elevation - } + if (elevation > maxElevation) maxElevation = elevation } while (elevation < 0.0) // Get full position for AOS data (azimuth, altitude) @@ -234,9 +209,7 @@ class SatelliteRepo( do { calendarTimeMillis += 30L * 1000L elevation = sat.getElevation(pos, calendarTimeMillis) - if (elevation > maxElevation) { - maxElevation = elevation - } + if (elevation > maxElevation) maxElevation = elevation } while (elevation > 0.0) // refine LOS to ~500ms precision @@ -244,9 +217,7 @@ class SatelliteRepo( do { calendarTimeMillis += 500L elevation = sat.getElevation(pos, calendarTimeMillis) - if (elevation > maxElevation) { - maxElevation = elevation - } + if (elevation > maxElevation) maxElevation = elevation } while (elevation > 0.0) // Get full position for LOS data (azimuth, altitude) diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt index fd678a5f..571b342e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/predict/OrbitalPass.kt @@ -25,7 +25,7 @@ data class OrbitalPass( val altitude: Int = 1000, val maxElevation: Double = 75.0, val orbitalObject: OrbitalObject, - var progress: Float = 0.0f + val progress: Float = 0.0f ) { val catNum: Int = orbitalObject.data.catnum val name: String = orbitalObject.data.name diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt index a1c0f50a..e50e8b57 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/ISatelliteRepo.kt @@ -25,13 +25,27 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import kotlinx.coroutines.flow.StateFlow interface ISatelliteRepo { - val passes: StateFlow> + /** All selected OrbitalObjects, updated when the selection changes. */ val satellites: StateFlow> - suspend fun getRadiosWithId(id: Int): List + + /** Raw calculated passes (without live progress). Updated on selection/filter change. */ + val passes: StateFlow> + + /** Load satellite objects from DB based on the current selection. */ suspend fun initRepository() - suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos - suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List - suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List, time: Long): List - suspend fun processPasses(passList: List, time: Long): List + + /** Recalculate passes with the given filter parameters. */ suspend fun calculatePasses(time: Long, hoursAhead: Int, minElevation: Double, modes: List) + + /** Get the current position of a single satellite. */ + suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos + + /** Get the ground track for a satellite over a time range. */ + suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List + + /** Get Doppler-shifted radio frequencies for a satellite at the given time. */ + suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List, time: Long): List + + /** Fetch radio transceivers for a satellite by its catalog number. */ + suspend fun getRadiosWithId(id: Int): List } diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt index 615c292b..b789d714 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -223,6 +224,10 @@ private fun NearEarthPass( ) { val passSatId = stringResource(id = R.string.pass_satId, pass.catNum) val horizontalPadding = if (isVerticalLayout) 6.dp else 10.dp + // Cache formatted date/time strings — aosTime/losTime never change for a given pass + val aosDateStr = remember(pass.aosTime) { sdfDate.format(Date(pass.aosTime)) } + val aosTimeStr = remember(pass.aosTime) { sdfTime.format(Date(pass.aosTime)) } + val losTimeStr = remember(pass.losTime) { sdfTime.format(Date(pass.losTime)) } Surface(color = MaterialTheme.colorScheme.background, modifier = modifier) { Surface(modifier = Modifier .padding(bottom = 2.dp) @@ -274,7 +279,7 @@ private fun NearEarthPass( Text(text = stringResource(R.string.pass_deep_space), fontSize = 15.sp) } else { Text( - text = sdfDate.format(Date(pass.aosTime)), + text = aosDateStr, fontSize = 15.sp ) } @@ -317,7 +322,7 @@ private fun NearEarthPass( ) { val defaultTime = " - - : - - " Text( - text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.aosTime)), + text = if (pass.isDeepSpace) defaultTime else aosTimeStr, fontSize = 15.sp ) LinearProgressIndicator( @@ -326,7 +331,7 @@ private fun NearEarthPass( modifier = modifier.fillMaxWidth(0.75f) ) Text( - text = if (pass.isDeepSpace) defaultTime else sdfTime.format(Date(pass.losTime)), + text = if (pass.isDeepSpace) defaultTime else losTimeStr, fontSize = 15.sp ) } diff --git a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt index c902594b..6ac89421 100644 --- a/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt +++ b/feature/passes/src/main/java/com/rtbishop/look4sat/feature/passes/PassesViewModel.kt @@ -27,10 +27,9 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo +import com.rtbishop.look4sat.core.domain.utility.round import com.rtbishop.look4sat.core.domain.utility.toTimerString import com.rtbishop.look4sat.core.presentation.getDefaultPass -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -44,6 +43,7 @@ class PassesViewModel( private val settingsRepo: ISettingsRepo ) : ViewModel() { + private val defaultPass = getDefaultPass() private val _uiState = MutableStateFlow( PassesState( isPassesDialogShown = false, @@ -52,7 +52,7 @@ class PassesViewModel( isUtc = settingsRepo.otherSettings.value.stateOfUtc, nextTime = "00:00:00", isNextTimeAos = true, - nextPass = getDefaultPass(), + nextPass = defaultPass, hours = settingsRepo.passesSettings.value.hoursAhead, elevation = settingsRepo.passesSettings.value.minElevation, modes = settingsRepo.passesSettings.value.selectedModes, @@ -61,25 +61,36 @@ class PassesViewModel( takeAction = ::handleAction ) ) - private var processing: Job? = null val uiState: StateFlow = _uiState init { + // React to raw pass list changes (initial load, recalculation, filter change) viewModelScope.launch { - delay(1000) + var initialLoadDone = false satelliteRepo.passes.collectLatest { passes -> - processing?.cancelAndJoin() - processing = viewModelScope.launch { - while (isActive) { - val timeNow = System.currentTimeMillis() - val newPasses = satelliteRepo.processPasses(passes, timeNow) - setPassInfo(newPasses, timeNow) - _uiState.update { it.copy(isRefreshing = false, itemsList = newPasses) } - delay(1000) - } + if (!initialLoadDone && passes.isNotEmpty()) { + initialLoadDone = true + _uiState.update { it.copy(isRefreshing = false) } } } } + // Local tick loop — computes pass progress and countdown timer every second + viewModelScope.launch { + while (isActive) { + val timeNow = System.currentTimeMillis() + val processed = computePassProgress(satelliteRepo.passes.value, timeNow) + val nextInfo = resolveNextPass(processed, timeNow) + _uiState.update { + it.copy( + itemsList = processed, + nextPass = nextInfo.first, + nextTime = nextInfo.second, + isNextTimeAos = nextInfo.third + ) + } + delay(1000) + } + } viewModelScope.launch { settingsRepo.otherSettings.collectLatest { settings -> _uiState.update { it.copy(shouldSeeWhatsNew = settings.shouldSeeWhatsNew) } @@ -87,6 +98,43 @@ class PassesViewModel( } } + /** Computes live progress for each pass, filtering out expired ones. */ + private fun computePassProgress(passList: List, time: Long): List { + val result = ArrayList(passList.size) + for (pass in passList) { + if (!pass.isDeepSpace && time > pass.aosTime) { + val deltaNow = time.minus(pass.aosTime).toFloat() + val deltaTotal = pass.losTime.minus(pass.aosTime).toFloat() + val newProgress = (deltaNow / deltaTotal).round(2) + if (newProgress >= 1.0f) continue + if (newProgress != pass.progress) { + result.add(pass.copy(progress = newProgress)) + } else { + result.add(pass) + } + } else { + result.add(pass) + } + } + return result + } + + /** Resolves the next upcoming or active pass and its countdown timer. */ + private fun resolveNextPass( + passes: List, + timeNow: Long + ): Triple { + val upcoming = passes.firstOrNull { it.aosTime.minus(timeNow) > 0 } + if (upcoming != null) { + return Triple(upcoming, upcoming.aosTime.minus(timeNow).toTimerString(), true) + } + if (passes.isNotEmpty()) { + val lastPass = passes.last() + return Triple(lastPass, lastPass.losTime.minus(timeNow).toTimerString(), false) + } + return Triple(defaultPass, "00:00:00", true) + } + private fun handleAction(action: PassesAction) { when (action) { PassesAction.DismissWhatsNew -> settingsRepo.setWhatsNewDismissed() @@ -100,30 +148,17 @@ class PassesViewModel( private fun applyFilter(hoursAhead: Int, minElevation: Double, modes: List) = viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true) } - processing?.cancelAndJoin() settingsRepo.setPassesSettings(PassesSettings(hoursAhead, minElevation, modes)) _uiState.update { it.copy(hours = hoursAhead, elevation = minElevation, modes = modes) } satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) + _uiState.update { it.copy(isRefreshing = false) } } private fun refreshPasses() = viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true) } - processing?.cancelAndJoin() val (hoursAhead, minElevation, modes) = settingsRepo.passesSettings.value satelliteRepo.calculatePasses(System.currentTimeMillis(), hoursAhead, minElevation, modes) - } - - private fun setPassInfo(passes: List, timeNow: Long) { - if (passes.isEmpty()) return - try { - val nextPass = passes.first { it.aosTime.minus(timeNow) > 0 } - val time = nextPass.aosTime.minus(timeNow).toTimerString() - _uiState.update { it.copy(nextPass = nextPass, nextTime = time, isNextTimeAos = true) } - } catch (_: NoSuchElementException) { - val lastPass = passes.last() - val time = lastPass.losTime.minus(timeNow).toTimerString() - _uiState.update { it.copy(nextPass = lastPass, nextTime = time, isNextTimeAos = false) } - } + _uiState.update { it.copy(isRefreshing = false) } } private fun toggleFilterDialog() { diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt index 0bae67c7..ea88383c 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarViewModel.kt @@ -26,7 +26,6 @@ import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.predict.OrbitalObject -import com.rtbishop.look4sat.core.domain.predict.OrbitalPass import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.IReporter @@ -75,6 +74,7 @@ class RadarViewModel( val uiState: StateFlow = _uiState init { + // Compass sensor collection if (settingsRepo.otherSettings.value.stateOfSensors) { viewModelScope.launch { sensorsRepo.enableSensor() @@ -84,6 +84,7 @@ class RadarViewModel( } } } + // Resolve which pass we're tracking viewModelScope.launch { val catNum = savedStateHandle.get("catNum") ?: 0 val aosTime = savedStateHandle.get("aosTime") ?: 0L @@ -93,25 +94,33 @@ class RadarViewModel( currentPass?.let { satPass -> _uiState.update { it.copy(currentPass = satPass) } val transmitters = satelliteRepo.getRadiosWithId(satPass.catNum) + // Compute track once (it doesn't change) + if (!satPass.isDeepSpace) { + val track = satelliteRepo.getTrack( + satPass.orbitalObject, stationPos, satPass.aosTime, satPass.losTime + ) + _uiState.update { it.copy(satTrack = track) } + } + // Local tick loop — computes position only while the radar screen is alive while (isActive) { val timeNow = System.currentTimeMillis() val pos = satelliteRepo.getPosition(satPass.orbitalObject, stationPos, timeNow) when { satPass.isDeepSpace -> { val time = 0L.toTimerString() - _uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) } + _uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false, orbitalPos = pos) } } satPass.aosTime > timeNow -> { val time = satPass.aosTime.minus(timeNow).toTimerString() - _uiState.update { it.copy(currentTime = time, isCurrentTimeAos = true) } + _uiState.update { it.copy(currentTime = time, isCurrentTimeAos = true, orbitalPos = pos) } } else -> { val time = satPass.losTime.minus(timeNow).toTimerString() - _uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false) } + _uiState.update { it.copy(currentTime = time, isCurrentTimeAos = false, orbitalPos = pos) } } } processRadios(transmitters, satPass.orbitalObject, timeNow) - sendPassData(satPass, pos, satPass.orbitalObject) + sendPassData(pos) sendPassDataBT(pos) delay(1000) } @@ -131,14 +140,7 @@ class RadarViewModel( } } - private suspend fun sendPassData(orbitalPass: OrbitalPass, orbitalPos: OrbitalPos, orbitalObject: OrbitalObject) { - var track: List = emptyList() - if (!orbitalPass.isDeepSpace) { - track = satelliteRepo.getTrack( - orbitalObject, stationPos, orbitalPass.aosTime, orbitalPass.losTime - ) - } - _uiState.update { it.copy(orbitalPos = orbitalPos, satTrack = track) } + private fun sendPassData(orbitalPos: OrbitalPos) { viewModelScope.launch { val rc = settingsRepo.rcSettings.value if (rc.rotatorState) {