merge: upstream rt-bishop main (18 commits) with conflict resolution

Merges rt-bishop/Look4Sat main (a42a5f1f, 18 commits: AMSAT status page,
customizable data sources, Doppler calculator, radar compass offset, per-sat
offset memory, localized date formats) into the fork's 30-commit audit
baseline.

Conflict resolution policy (user-directed):
- AMSAT feature (AmSatRepository, SatStatusScreen/ViewModel, SatStatus model):
  upstream version, which the user judged better built. MainScreen routes
  Screen.AmSat through SatStatusDestination().
- Localization: our values-zh/values-tr restored (upstream's merge dropped the
  fork-only strings); new upstream strings (sat_group counts, compass offset,
  frequency offset help) added in EN + ZH.
- fork-only features kept (CW decoder, Mutual/Roaming, WaveLog, APRS, Log tab,
  custom TLE/transceiver source switches): ours.
- Both sides' additions merged where independent: radar compass offset fields
  (Settings/SettingsRepo/RadarState), calculatorOffsetKHz action, wider linear
  transponder detection, deduplicateTransponders + its tests, sunrise/sunset
  tests merged with the moon hour-angle test.
- Sources kept as the fork's map structure (DatabaseRepo depends on it);
  satelliteModes list re-added for SelectionRepo. getSatelliteTypesIds /
  setSatelliteTypeIds re-added to ISettingsRepo+SettingsRepo; SharedDialog
  re-added to Components; providePairedBluetoothDevices added to
  IMainContainer/MainContainer.
- Icons renamed upstream (ic_satellites->ic_sputnik, ic_radar->ic_satellite)
  applied to Navigation/MutualScreen.

Build verified: all modules compileReleaseKotlin + unit tests
(core:domain, core:data, feature:map, feature:roaming) green.
This commit is contained in:
mckero committed 2026-08-20 12:35:01 +00:00
commit a654735337
68 files changed
+2206 -1038

No files matched your search

@@ -45,7 +45,12 @@ interface Look4SatDao {
@Query("DELETE FROM entries")
suspend fun deleteEntries()
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
@Query(
"""
SELECT DISTINCT catnum FROM radios WHERE isAlive = 1
AND (downlinkMode IN (:modes) OR uplinkMode IN (:modes))
"""
)
suspend fun getIdsWithModes(modes: List<String>): List<Int>
@Query("SELECT COUNT(*) FROM radios")
@@ -97,6 +97,13 @@ class MainContainer(private val context: Context) : IMainContainer {
override fun provideAddToCalendar(): IAddToCalendar = AddToCalendar(context)
override fun providePairedBluetoothDevices(): List<Pair<String, String>> = buildList {
try {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
manager.adapter?.bondedDevices?.forEach { add(Pair(it.name ?: "Unknown", it.address ?: "")) }
} catch (_: SecurityException) {}
}
override fun provideShowToast(): IShowToast = ShowToast(context)
override fun provideAudioCapture(): IAudioCapture = AudioCapture()
@@ -35,14 +35,15 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
val nowSec = System.currentTimeMillis() / 1000
val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null
val reportsJson = remoteSource.getAmSatReports(hours = REPORTS_HOURS, limit = REPORTS_LIMIT)
?: return@withContext null
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return@withContext null
val names = parseCatalog(catalogJson)
val reports = parseReports(reportsJson)
if (names.isEmpty() && reports.isEmpty()) return@withContext null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
@@ -53,7 +54,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
return try {
val arr = JSONObject(json).getJSONArray("data")
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
} catch (e: Exception) {
} catch (_: Exception) {
emptyList()
}
}
@@ -74,7 +75,7 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
reportedTimeUtcSec = parseIsoUtcSec(iso)
)
}
} catch (e: Exception) {
} catch (_: Exception) {
emptyList()
}
}
@@ -83,60 +84,38 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
private fun parseIsoUtcSec(iso: String): Long {
return try {
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
} catch (e: Exception) {
} catch (_: Exception) {
0L
}
}
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
/** Build one SatStatus (5 days x 12 slots) per catalog satellite, slotting reports by age. */
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
val byName = reports.groupBy { it.name }
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
// Anchor the columns on UTC midnight. Slicing a rolling "N x 2 hours ago"
// window into six groups of twelve does not line up with calendar days
// unless the fetch happens just before 24:00 UTC: at 00:00 UTC every one
// of the 72 slots sat under the wrong date label, and at 12:00 UTC half of
// them did, so the column headed "today" showed yesterday's reports.
val todayStartSec = (nowSec / 86400L) * 86400L
// Only show days the API actually covered. Its reports endpoint caps the
// response at 500 rows regardless of the requested limit and fills them
// newest-first, so with ~90 catalogued satellites the oldest days come
// back empty. Rendering a fixed six columns turned "not fetched" into an
// all-gray "nobody reported" column, which is a different claim.
val oldestReportSec = reports.minOfOrNull { it.reportedTimeUtcSec }
val coveredDays = if (oldestReportSec == null) {
1
} else {
val oldestDayStart = (oldestReportSec / 86400L) * 86400L
(((todayStartSec - oldestDayStart) / 86400L) + 1).toInt().coerceIn(1, MAX_DAYS)
}
val labels = (0 until coveredDays).map { d ->
utc.timeInMillis = (todayStartSec - d * 86400L) * 1000
val labels = (0 until 3).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
return names.map { name ->
val reportsForSat = byName[name].orEmpty()
val days = (0 until coveredDays).map { d ->
val dayStart = todayStartSec - d * 86400L
// Newest slot first: the UI picks the first non-gray slot of a day
// as that day's status.
val slots = (0 until 12).map { slotInDay ->
val slotStart = dayStart + (11 - slotInDay) * 7200L
val slotEnd = slotStart + 7200L
val inSlot = reportsForSat.filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
val slots = (0 until 36).map { slotIdx ->
val slotStart = nowSec - (slotIdx + 1) * 7200L
val slotEnd = nowSec - slotIdx * 7200L
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
if (inSlot.isEmpty()) {
SatSlot(statusColor = NO_REPORT_GRAY, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
SatDay(dateLabel = labels[d], slots = slots)
}
val days = (0 until 3).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
}
@@ -169,20 +148,6 @@ class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepositor
}
companion object {
/** Longest history the status grid will render. */
private const val MAX_DAYS = 6
/**
* The reports endpoint clamps its response to 500 rows and ignores a
* larger limit (measured: limit=1000 still answers with
* `meta.count = 500`), so asking for more is pointless. Rows arrive
* newest-first, which is why the oldest days can be missing entirely.
*/
private const val REPORTS_LIMIT = 500
/** Request a full week so the six rendered days have data when available. */
private const val REPORTS_HOURS = 168
// AMSAT official status colors (from amsat.org/status)
private const val ACTIVE_BLUE = 0xFF648FFF
private const val TLM_ORANGE = 0xFFFFB000
@@ -34,11 +34,13 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.util.TimeZone
import kotlin.time.Duration.Companion.milliseconds
class SatelliteRepo(
private val dispatcher: CoroutineDispatcher,
@@ -69,19 +71,23 @@ class SatelliteRepo(
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
override suspend fun initRepository() = withContext(dispatcher) {
settingsRepo.selectedIds.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settings.selectedModes
)
}
combine(
settingsRepo.selectedIds,
settingsRepo.stationPosition
) { selectedIds, _ -> selectedIds }
.collect { selectedIds ->
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
val settings = settingsRepo.passesSettings.value
calculatePasses(
time = System.currentTimeMillis(),
hoursAhead = settings.hoursAhead,
minElevation = settings.minElevation,
aosStartMinute = settings.aosStartMinute,
aosEndMinute = settings.aosEndMinute,
invertAosTimeWindow = settings.invertAosTimeWindow,
modes = settingsRepo.selectedSatModes.value
)
}
}
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
@@ -186,7 +192,7 @@ class SatelliteRepo(
val inRange = if (aosStartMinute <= aosEndMinute) {
aosMinute in aosStartMinute..aosEndMinute
} else {
aosMinute >= aosStartMinute || aosMinute <= aosEndMinute
aosMinute !in (aosEndMinute + 1)..<aosStartMinute
}
return if (invertAosTimeWindow) !inRange else inRange
}
@@ -38,16 +38,16 @@ class SelectionRepo(
) : ISelectionRepo {
private val currentItems = MutableStateFlow<List<SatItem>>(emptyList())
private val currentTypes = MutableStateFlow(settingsRepo.selectedTypes.value)
private val currentQuery = MutableStateFlow("")
// Resolve type IDs once when types change, then filter items reactively.
// Resolve sat IDs once when modes change, then filter items reactively.
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
val catnumSet: Set<Int>? = if (types.isEmpty()) {
// Directly observe settingsRepo.selectedSatModes to ensure real-time sync across screens.
private val itemsWithModes = settingsRepo.selectedSatModes.flatMapLatest { list: List<String> ->
val catnumSet: Set<Int>? = if (list.isEmpty()) {
null // null = no filtering
} else {
val ids = settingsRepo.getSatelliteTypesIds(types)
val ids = localSource.getIdsWithModes(list)
if (ids.isEmpty()) null else ids.toHashSet()
}
currentItems.map { items ->
@@ -56,14 +56,18 @@ class SelectionRepo(
}
private val itemsWithQuery = currentQuery.flatMapLatest { query ->
itemsWithTypes.map { items -> filterByQuery(items, query) }
itemsWithModes.map { items ->
filterByQuery(items, query).sortedWith(
compareByDescending<SatItem> { it.isSelected }
.thenBy { it.name }
.thenBy { it.catnum }
)
}
}
override fun getCurrentTypes() = currentTypes.value
override fun getCurrentModes() = settingsRepo.selectedSatModes.value
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
removeAt(0)
}
override fun getModesList() = Sources.satelliteModes
override suspend fun getEntriesFlow() = withContext(dispatcher) {
val selectedIds = settingsRepo.selectedIds.value.toHashSet()
@@ -73,9 +77,8 @@ class SelectionRepo(
return@withContext itemsWithQuery
}
override suspend fun setTypes(types: List<String>) {
currentTypes.value = types
settingsRepo.setSelectedTypes(types)
override suspend fun setModes(modes: List<String>) {
settingsRepo.setSelectedSatModes(modes)
}
override suspend fun setQuery(query: String) {
@@ -29,15 +29,18 @@ import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.model.Constants
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.Sources
import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.round
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.json.JSONObject
class SettingsRepo(
private val context: android.content.Context,
@@ -69,9 +72,9 @@ class SettingsRepo(
private val keyFrequencyAddress = "frequencyAddress"
private val keyFrequencyPort = "frequencyPort"
private val keyFrequencyFormat = "frequencyFormat"
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
private val keySelectedIds = "selectedIds"
private val keySelectedTypes = "selectedTypes"
private val keySelectedModes = "selectedModes"
private val keySelectedSatModes = "selectedSatModes"
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
private val keyStateOfSensors = "stateOfSensors"
private val keyStateOfSweep = "stateOfSweep"
@@ -100,13 +103,19 @@ class SettingsRepo(
private val keyWavelogApiKey = "wavelogApiKey"
private val keyWavelogStationId = "wavelogStationId"
private val keyWavelogAutoUpload = "wavelogAutoUpload"
private val keyRadarCompassOffset = "radarCompassOffset"
private val keyRadarCompassOffsetElev = "radarCompassOffsetElev"
private val keySatelliteUrls = "satelliteUrls"
private val keyTransceiversUrls = "transceiversUrls"
private val separatorComma = ","
private val separatorUrl = "\n"
//region # Satellites selection settings
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
private val _typesSelection = MutableStateFlow(getSelectedTypes())
private val _satelliteModeSelection = MutableStateFlow(getSelectedSatModes())
override val selectedIds: StateFlow<List<Int>> = _satelliteSelection
override val selectedTypes: StateFlow<List<String>> = _typesSelection
override val selectedSatModes: StateFlow<List<String>> = _satelliteModeSelection
override fun setSelectedIds(ids: List<Int>) {
val selectionString = ids.joinToString(separatorComma)
@@ -114,10 +123,10 @@ class SettingsRepo(
_satelliteSelection.value = ids
}
override fun setSelectedTypes(types: List<String>) {
val typesString = types.joinToString(separatorComma)
preferences.edit { putString(keySelectedTypes, typesString) }
_typesSelection.value = types
override fun setSelectedSatModes(modes: List<String>) {
val modesString = modes.joinToString(separatorComma)
preferences.edit { putString(keySelectedSatModes, modesString) }
_satelliteModeSelection.value = modes
}
private fun getSelectedIds(): List<Int> {
@@ -126,10 +135,10 @@ class SettingsRepo(
return selectionString.split(separatorComma).map { it.toInt() }
}
private fun getSelectedTypes(): List<String> {
val typesString = preferences.getString(keySelectedTypes, "Amateur")
if (typesString.isNullOrEmpty()) return emptyList()
return typesString.split(separatorComma)
private fun getSelectedSatModes(): List<String> {
val modesString = preferences.getString(keySelectedSatModes, null)
if (modesString.isNullOrEmpty()) return emptyList()
return modesString.split(separatorComma).sorted()
}
//endregion
@@ -144,7 +153,6 @@ class SettingsRepo(
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
putBoolean(keyFilterAosInvert, settings.invertAosTimeWindow)
putString(keySelectedModes, settings.selectedModes.joinToString(separatorComma))
_passesSettings.value = settings
}
@@ -155,16 +163,13 @@ class SettingsRepo(
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
val invertAosTimeWindow = preferences.getBoolean(keyFilterAosInvert, false)
val selectedModesString = preferences.getString(keySelectedModes, null)
val selectedModes = selectedModesString?.split(separatorComma)?.sorted() ?: emptyList()
return PassesSettings(
showDeepSpace,
hoursAhead,
minElevation,
aosStartMinute,
aosEndMinute,
invertAosTimeWindow,
selectedModes
invertAosTimeWindow
)
}
//endregion
@@ -265,6 +270,7 @@ class SettingsRepo(
private val _databaseState = MutableStateFlow(getDatabaseState())
override val databaseState: StateFlow<DatabaseState> = _databaseState
override fun getSatelliteTypesIds(types: List<String>): List<Int> {
val idsSet = mutableSetOf<Int>()
types.forEach { type ->
@@ -328,6 +334,10 @@ class SettingsRepo(
override val rcSettings: StateFlow<RCSettings> = _rcSettings
override fun updateRCSettings(settings: RCSettings) {
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
Constants.FREQ_OFFSET_MIN_HZ,
Constants.FREQ_OFFSET_MAX_HZ
)
preferences.edit {
putBoolean(keyRotatorState, settings.rotatorState)
putString(keyRotatorAddress, settings.rotatorAddress)
@@ -337,6 +347,7 @@ class SettingsRepo(
putString(keyFrequencyAddress, settings.frequencyAddress)
putString(keyFrequencyPort, settings.frequencyPort)
putString(keyFrequencyFormat, settings.frequencyFormat)
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
@@ -345,7 +356,7 @@ class SettingsRepo(
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
}
_rcSettings.value = settings
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
}
private fun getRCSettings(): RCSettings = RCSettings(
@@ -357,6 +368,8 @@ class SettingsRepo(
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
@@ -393,6 +406,9 @@ class SettingsRepo(
putString(keyWavelogApiKey, new.wavelogApiKey)
putString(keyWavelogStationId, new.wavelogStationId)
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
putFloat(keyRadarCompassOffset, new.radarCompassOffset)
putFloat(keyRadarCompassOffsetElev, new.radarCompassOffsetElev)
}
new
}
@@ -416,7 +432,9 @@ class SettingsRepo(
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false),
radarCompassOffset = preferences.getFloat(keyRadarCompassOffset, 0f),
radarCompassOffsetElev = preferences.getFloat(keyRadarCompassOffsetElev, 0f)
)
//endregion
@@ -448,6 +466,7 @@ class SettingsRepo(
transceiversUrl = txUrl
)
}
//endregion
//region # Radio control settings
@@ -487,5 +506,27 @@ class SettingsRepo(
baudRate = preferences.getInt(keyRadioBaudRate, 4800),
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
)
//endregion
private val keySatelliteOffsets = "satelliteOffsets"
override fun getSatelliteOffset(catnum: Int): String {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
return try {
JSONObject(json).optString(catnum.toString(), "")
} catch (_: Exception) {
""
}
}
override fun setSatelliteOffset(catnum: Int, offset: String) {
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
val updated = try {
val obj = JSONObject(json)
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
obj.toString()
} catch (_: Exception) {
"""{"$catnum": "$offset"}"""
}
preferences.edit { putString(keySatelliteOffsets, updated) }
}
}
@@ -25,7 +25,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayInputStream
import java.io.InputStream
class RemoteSource(
@@ -67,10 +66,14 @@ class RemoteSource(
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
httpClient.newCall(networkRequest).execute().use { response ->
if (!response.isSuccessful) return@withContext null
ByteArrayInputStream(response.body.bytes())
val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
response.close()
return@withContext null
}
// Return the body stream directly as the caller is responsible for closing it
// That returns the connection to OkHttp's pool
response.body.byteStream().buffered()
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
@@ -93,6 +96,7 @@ class RemoteSource(
throw exception
} catch (exception: Exception) {
println("RemoteSource amsat catalog exception: $exception")
null
}
}
@@ -111,6 +115,7 @@ class RemoteSource(
throw exception
} catch (exception: Exception) {
println("RemoteSource amsat reports exception: $exception")
null
}
}
@@ -172,10 +172,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val selectedSatModes: StateFlow<List<String>> = MutableStateFlow(emptyList())
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
@@ -183,7 +183,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
@@ -200,7 +200,7 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedTypes(types: List<String>) = Unit
override fun setSelectedSatModes(modes: List<String>) = Unit
override fun setPassesSettings(settings: PassesSettings) = Unit
@@ -229,6 +229,10 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
}
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
}
private fun defaultDataSourcesSettings(): DataSourcesSettings {
@@ -0,0 +1,181 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
import com.rtbishop.look4sat.core.domain.model.SatItem
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import com.rtbishop.look4sat.core.domain.source.ILocalSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SelectionRepoTest {
private val dispatcher = StandardTestDispatcher()
@Test
fun `unknown mode values do not crash and do not filter out entries`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(25544, "ISS (ZARYA)", false),
SatItem(40967, "TIANGONG", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = listOf("REMOVED_MODE"))
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setModes(listOf("REMOVED_MODE"))
val items = flow.first()
assertEquals(listOf(25544, 40967), items.map { it.catnum })
assertEquals(listOf("REMOVED_MODE"), repository.getCurrentModes())
}
@Test
fun `selected satellites are shown first`() = runTest(dispatcher) {
val localSource = FakeLocalSource(
entries = listOf(
SatItem(44444, "Zeta", false),
SatItem(25544, "Alpha", false),
SatItem(40967, "Beta", false)
)
)
val settingsRepo = FakeSettingsRepo(selectedModes = emptyList())
val repository = SelectionRepo(dispatcher, localSource, settingsRepo)
val flow = repository.getEntriesFlow()
repository.setSelection(listOf(40967), true)
val items = flow.first()
assertEquals(listOf(40967, 25544, 44444), items.map { it.catnum })
assertEquals(listOf(true, false, false), items.map { it.isSelected })
}
private class FakeLocalSource(
private val entries: List<SatItem>
) : ILocalSource {
override suspend fun getEntriesTotal(): Int = entries.size
override suspend fun getEntriesList(): List<SatItem> = entries
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
override suspend fun insertEntries(entries: List<com.rtbishop.look4sat.core.domain.predict.OrbitalData>) = Unit
override suspend fun deleteEntries() = Unit
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
override suspend fun getRadiosTotal(): Int = 0
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
override suspend fun insertRadios(radios: List<SatRadio>) = Unit
override suspend fun deleteRadios() = Unit
}
private class FakeSettingsRepo(
selectedModes: List<String>
) : ISettingsRepo {
override val appVersionName: String = "test"
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
override val selectedSatModes: MutableStateFlow<List<String>> = MutableStateFlow(selectedModes)
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
PassesSettings(hoursAhead = 24, minElevation = 0.0)
)
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
)
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
OtherSettings(false, false, false, false, false, false, false, false)
)
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
DataSourcesSettings(false, false, "", "")
)
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
)
override fun setSelectedIds(ids: List<Int>) = Unit
override fun setSelectedSatModes(modes: List<String>) {
selectedSatModes.value = modes
}
override fun setPassesSettings(settings: PassesSettings) = Unit
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
override suspend fun setStationPosition(): Boolean = true
override fun setStationPosition(locator: String): Boolean = true
override fun getSatelliteTypesIds(types: List<String>): List<Int> = emptyList()
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = Unit
override fun updateDatabaseState(state: DatabaseState) {
databaseState.value = state
}
override fun updateRCSettings(settings: RCSettings) = Unit
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
override fun getSatelliteOffset(catnum: Int): String = ""
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
}
}