From eb66a77cae452b8de289b011efb14d2d938a8254 Mon Sep 17 00:00:00 2001 From: QIU Date: Tue, 25 Aug 2026 15:18:40 +0000 Subject: [PATCH] refactor(qrz): move the grid lookup out of the composable LogTab read the QRZ cookie straight out of SharedPreferences through LocalContext, inside composition, on every submission - disk access in a composable, around the repository layer, with the client referenced by fully-qualified name inline. And it did `if (grid != null)`, so a lookup that failed for any reason left the QSO without a grid and told the operator nothing. IQrzGridLookup lives in core:domain, QrzGridLookup in core:data owns the cookie read, and the view model exposes lookupGrid. The composable now takes a callback and handles each outcome: a locator is attached, no locator on file passes quietly because nothing is wrong, an expired cookie says to paste a fresh one, and an unreachable QRZ says so. That is what the four-outcome QrzGrid type from ce68f487 was for - until now nothing consumed it and the old nullable client was still the one being called. Note for anyone extending RadarScreen: the local holding the view model cannot be referenced as `viewModel` inside a lambda, because that name also resolves to the composable factory function. Hence the explicitly typed local. The old QrzGridClient is now unused here but left in place; removing it belongs with the settings screen, which still calls it to validate a pasted cookie. --- .../core/data/injection/MainContainer.kt | 18 ++++++ .../look4sat/core/data/qrz/QrzGridLookup.kt | 55 +++++++++++++++++++ .../core/domain/repository/IMainContainer.kt | 3 + .../rtbishop/look4sat/feature/radar/LogTab.kt | 29 ++++++---- .../look4sat/feature/radar/RadarScreen.kt | 18 ++++-- .../look4sat/feature/radar/RadarViewModel.kt | 24 +++++++- 6 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridLookup.kt diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt index 787192a8..16864446 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/injection/MainContainer.kt @@ -69,6 +69,8 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import com.rtbishop.look4sat.core.data.qrz.QrzGridLookup +import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup import okhttp3.OkHttpClient import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo @@ -134,6 +136,22 @@ class MainContainer(private val context: Context) : IMainContainer { override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo + /** + * QRZ grid lookup. Holds the cookie read so no composable has to: the log screen used to pull + * it out of SharedPreferences through LocalContext, putting disk access inside composition. + */ + private val qrzGridLookup: QrzGridLookup by lazy { + QrzGridLookup( + context.getSharedPreferences(QrzGridLookup.PREFS_NAME, Context.MODE_PRIVATE), + OkHttpClient.Builder() + .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(20, java.util.concurrent.TimeUnit.SECONDS) + .build() + ) + } + + override fun provideQrzGridLookup(): IQrzGridLookup = qrzGridLookup + override fun provideBluetoothReporter(): IReporter { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val rc = settingsRepo.rcSettings.value diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridLookup.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridLookup.kt new file mode 100644 index 00000000..9010e023 --- /dev/null +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/qrz/QrzGridLookup.kt @@ -0,0 +1,55 @@ +/* + * Look4Sat. Amateur radio satellite tracker and pass predictor. + * Copyright (C) 2019-2026 Arty Bishop and contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.rtbishop.look4sat.core.data.qrz + +import android.content.SharedPreferences +import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup +import com.rtbishop.look4sat.core.domain.qrz.QrzGrid +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import okhttp3.OkHttpClient + +/** + * Grid lookup backed by the cookie the operator pasted into settings. + * + * Owns the cookie read so the log screen does not: a composable used to pull it out of + * SharedPreferences through LocalContext on every submission, which put disk access inside + * composition and went around the repository layer. + */ +class QrzGridLookup( + private val preferences: SharedPreferences, + httpClient: OkHttpClient, + dispatcher: CoroutineDispatcher = Dispatchers.IO +) : IQrzGridLookup { + + private val source = QrzGridSource(httpClient, dispatcher) + + override suspend fun lookup(callsign: String): QrzGrid { + val cookie = preferences.getString(COOKIE_KEY, "").orEmpty() + // No cookie and an expired one call for the same thing from the operator, so they report + // the same way rather than adding a fourth outcome nobody could act on differently. + if (cookie.isBlank()) return QrzGrid.SignedOut + return source.lookupGrid(callsign, cookie) + } + + companion object { + /** Where the settings screen stores what the operator pasted. */ + const val PREFS_NAME = "qrz_cookie" + const val COOKIE_KEY = "cookie" + } +} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt index e4504b37..1dff8113 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/repository/IMainContainer.kt @@ -50,6 +50,9 @@ interface IMainContainer { fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo + + /** QRZ grid lookup, so the log screen never touches the stored cookie itself. */ + fun provideQrzGridLookup(): com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup } data class MutualPassData( diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt index 9cee80a5..af61d353 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/LogTab.kt @@ -71,6 +71,7 @@ import androidx.compose.ui.unit.sp import com.rtbishop.look4sat.core.domain.model.SatRadio import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator +import com.rtbishop.look4sat.core.domain.qrz.QrzGrid import com.rtbishop.look4sat.core.domain.wavelog.CallsignEntry import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue @@ -99,6 +100,7 @@ fun LogTab( showToast: (String) -> Unit, txBaseFrequencyHz: Long? = null, aosTimeMs: Long = 0L, + onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit, modifier: Modifier = Modifier ) { val context = androidx.compose.ui.platform.LocalContext.current @@ -174,6 +176,7 @@ fun LogTab( showToast = showToast, txBaseFrequencyHz = txBaseFrequencyHz, aosTimeMs = aosTimeMs, + onLookupGrid = onLookupGrid, onSaved = { refreshTick++ } ) } @@ -261,6 +264,7 @@ private fun ExpandedLogInput( showToast: (String) -> Unit, txBaseFrequencyHz: Long? = null, aosTimeMs: Long = 0L, + onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit, onSaved: () -> Unit ) { val context = androidx.compose.ui.platform.LocalContext.current @@ -280,6 +284,8 @@ private fun ExpandedLogInput( val illegalMsg = stringResource(id = R.string.log_call_illegal) val notACallMsg = stringResource(id = R.string.log_call_not_a_call) val tooLongMsg = stringResource(id = R.string.log_call_too_long) + val gridSignedOutMsg = stringResource(id = R.string.log_grid_signed_out) + val gridUnreachableMsg = stringResource(id = R.string.log_grid_unreachable) fun rejectionMessage(reason: CallsignEntry.Reason): String = when (reason) { CallsignEntry.Reason.EMPTY, CallsignEntry.Reason.TOO_SHORT -> tooShortMsg @@ -326,18 +332,19 @@ private fun ExpandedLogInput( null -> savedMsg } ) - // QRZ counterpart grid async backfill (4.5.5): only queried when Cookie is set; silent on failure - scope.launch { - val prefs = context.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE) - val rawCookie = prefs.getString("cookie", "") ?: "" - if (rawCookie.isNotBlank()) { - val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid( - call, com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(rawCookie) - ) - if (grid != null) { - queue.updateGridsquare(qsoId, grid) - onSaved() // 触发 refreshTick++ 刷新列表 + // Grid backfill. The view model owns the cookie and the request; this used to read + // SharedPreferences through LocalContext right here, inside composition. Failures now say + // something: an expired cookie was indistinguishable from a station with no grid filed. + onLookupGrid(call) { outcome -> + when (outcome) { + is QrzGrid.Found -> { + queue.updateGridsquare(qsoId, outcome.locator) + onSaved() } + // Nothing to fetch and nothing wrong: the station has no locator on file. + QrzGrid.NotOnFile -> Unit + QrzGrid.SignedOut -> showToast(gridSignedOutMsg) + is QrzGrid.Unreachable -> showToast(gridUnreachableMsg) } } } diff --git a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt index a0d3b389..fcac2636 100644 --- a/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt +++ b/feature/radar/src/main/java/com/rtbishop/look4sat/feature/radar/RadarScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.rtbishop.look4sat.core.domain.qrz.QrzGrid import com.rtbishop.look4sat.core.domain.predict.OrbitalPos import com.rtbishop.look4sat.core.domain.repository.IContainerProvider import com.rtbishop.look4sat.core.domain.repository.MutualPassData @@ -92,6 +93,9 @@ fun RadarDestination(navigateUp: () -> Unit) { val container = (context.applicationContext as IContainerProvider).getMainContainer() val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val uiState by viewModel.uiState.collectAsStateWithLifecycle() + // Captured as a value because `viewModel` also names the composable factory function, so + // referring to it inside a lambda resolves to that instead of this local. + val lookupGrid: (String, (QrzGrid) -> Unit) -> Unit = viewModel::lookupGrid val mutualData by container.mutualPassData.collectAsStateWithLifecycle() val navigateUpAndClearMutual = { if (container.mutualPassData.value.endTime > 0L) { @@ -149,7 +153,8 @@ fun RadarDestination(navigateUp: () -> Unit) { wavelogConfigured = container.settingsRepo.otherSettings.value.let { it.wavelogUrl.isNotBlank() && it.wavelogApiKey.isNotBlank() && it.wavelogStationId.isNotBlank() }, - showToast = { msg -> container.provideShowToast()(msg) } + showToast = { msg -> container.provideShowToast()(msg) }, + onLookupGrid = lookupGrid ) } @@ -162,7 +167,8 @@ private fun RadarScreen( requestMicPermission: () -> Unit, wavelogQueue: WavelogQueue, wavelogConfigured: Boolean, - showToast: (String) -> Unit + showToast: (String) -> Unit, + onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit ) { val upcomingPass = uiState.currentPass ?: getDefaultPass() val addToCalendar: () -> Unit = { @@ -216,11 +222,11 @@ private fun RadarScreen( } if (isVertical) { RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) - PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f)) + PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, onLookupGrid, Modifier.weight(1f)) } else { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f)) - PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f)) + PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, onLookupGrid, Modifier.weight(1f)) } } } @@ -234,6 +240,7 @@ private fun PagerCard( wavelogQueue: WavelogQueue, wavelogConfigured: Boolean, showToast: (String) -> Unit, + onLookupGrid: (String, (QrzGrid) -> Unit) -> Unit, modifier: Modifier = Modifier ) { val hasCalculatorPage = remember(uiState.transceivers.transmitters) { @@ -302,7 +309,8 @@ private fun PagerCard( wavelogConfigured = wavelogConfigured, showToast = showToast, txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz, - aosTimeMs = uiState.currentPass?.aosTime ?: 0L + aosTimeMs = uiState.currentPass?.aosTime ?: 0L, + onLookupGrid = onLookupGrid ) } } 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 b32a440b..96f70856 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,6 +26,8 @@ import com.rtbishop.look4sat.core.domain.predict.CelestialComputer 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.qrz.IQrzGridLookup +import com.rtbishop.look4sat.core.domain.qrz.QrzGrid import com.rtbishop.look4sat.core.domain.repository.IMainContainer import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService import com.rtbishop.look4sat.core.domain.repository.IReporter @@ -64,7 +66,8 @@ class RadarViewModel( private val audioCapture: IAudioCapture, private val cwDecoderFactory: () -> ICwDecoder, private val saveImage: ISaveImage, - private val showToast: IShowToast + private val showToast: IShowToast, + private val qrzGridLookup: IQrzGridLookup ) : ViewModel() { private var stationPos = settingsRepo.stationPosition.value @@ -544,6 +547,22 @@ class RadarViewModel( _uiState.update { it.copy(cw = it.cw.copy(status = CwStatus.Idle)) } } + /** + * Look up a station's grid and hand the outcome back. + * + * Lives here rather than in the composable, which read the QRZ cookie straight out of + * SharedPreferences through LocalContext - disk access inside composition, around the + * repository layer. Every outcome is returned, because a lookup that failed used to be + * indistinguishable from a station that simply has no grid on file. + */ + fun lookupGrid(callsign: String, onResult: (QrzGrid) -> Unit) { + viewModelScope.launch { + val outcome = runCatching { qrzGridLookup.lookup(callsign) } + .getOrElse { QrzGrid.Unreachable(1) } + onResult(outcome) + } + } + companion object { // SSTV Decoder Tuning Parameters // ============================== @@ -599,7 +618,8 @@ class RadarViewModel( audioCapture = container.provideAudioCapture(), cwDecoderFactory = { container.provideCwDecoder() }, saveImage = container.provideSaveImage(), - showToast = container.provideShowToast() + showToast = container.provideShowToast(), + qrzGridLookup = container.provideQrzGridLookup() ) } }