feat(amsat): 4.5.5 official AMSAT API + edge-to-edge status page

- Replace HTML parsing with the official AMSAT Satellite Status API v1
  (catalog.php + reports.php, JSON): AmSatApiClient (pure JVM, hand
  rolled ISO-8601/epoch parsing for minSdk 24) + rewritten
  AmSatRepository (satellite list from catalog, reports slotted into
  6 days x 12 two-hour slots, status colors per report value)
- Fix edge-to-edge: status bar / navigation bar insets on the status
  page (refresh button and update time were unreachable)
- Version 4.5.5 (455), What's new in all 5 locales
This commit is contained in:
mckero committed 2026-08-05 08:12:17 +00:00
1 parent c9cab8457d
commit 6221516893
10 files changed
+227 -51

No files matched your search

@@ -30,6 +30,7 @@ import com.rtbishop.look4sat.core.data.framework.Ic705Controller
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
import com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
@@ -82,7 +83,7 @@ class MainContainer(private val context: Context) : IMainContainer {
override val selectionRepo = provideSelectionRepo()
override val satelliteRepo = provideSatelliteRepo()
override val databaseRepo = provideDatabaseRepo()
override val amSatRepo = AmSatRepository(remoteSource)
override val amSatRepo = AmSatRepository(com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient())
override val radioTrackingService: IRadioTrackingService by lazy {
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
@@ -1,21 +1,91 @@
package com.rtbishop.look4sat.core.data.repository
import com.rtbishop.look4sat.core.data.source.AmSatParser
import com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient
import com.rtbishop.look4sat.core.domain.amsat.ApiReport
import com.rtbishop.look4sat.core.domain.model.SatDay
import com.rtbishop.look4sat.core.domain.model.SatReport
import com.rtbishop.look4sat.core.domain.model.SatSlot
import com.rtbishop.look4sat.core.domain.model.SatStatus
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import java.util.Calendar
import java.util.TimeZone
/** AMSAT status repository: fetch HTML -> parse -> SatStatusPage */
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
/** AMSAT status repository: official API v1 -> SatStatusPage (replaces the HTML parser). */
class AmSatRepository(private val apiClient: AmSatApiClient) : IAmSatRepository {
override suspend fun fetchStatus(): SatStatusPage? {
val html = remoteSource.getStatusHtml() ?: return null
if (html.isBlank()) return null
return try {
AmSatParser.parse(html, System.currentTimeMillis())
} catch (exception: Exception) {
println("AmSatRepository parse exception: $exception")
null
val nowSec = System.currentTimeMillis() / 1000
val names = apiClient.fetchCatalog()
val reports = apiClient.fetchAllReports(hours = 168)
if (names.isEmpty() && reports.isEmpty()) return null
val statuses = buildStatuses(names, reports, nowSec)
val reportMap = reports.associate { it.id to toSatReport(it) }
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
}
/** Build one SatStatus (6 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"))
val labels = (0 until 6).map { d ->
utc.timeInMillis = (nowSec - d * 86400L) * 1000
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
}
return names.map { name ->
val slots = (0 until 72).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 = NoReportGray, count = 0)
} else {
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
SatSlot(
statusColor = statusColorOf(newest.report),
count = inSlot.size,
reportIds = inSlot.map { it.id }
)
}
}
val days = (0 until 6).map { d ->
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
}
SatStatus(name = name, days = days)
}
}
private fun toSatReport(r: ApiReport): SatReport {
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
cal.timeInMillis = r.reportedTimeUtcSec * 1000
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
val y = cal.get(Calendar.YEAR)
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
return SatReport(
id = r.id,
statusText = r.report,
call = r.callsign,
grid = r.gridSquare,
dateUtc = "$y-$mo-$d",
timeUtc = "$hh:$mm UTC"
)
}
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
"heard", "crew active" -> ActiveBlue
"telemetry only" -> TlmOrange
"not heard" -> NotHeardPink
else -> ConflictDeepOrange
}
companion object {
private const val ActiveBlue = 0xFF648FFF
private const val TlmOrange = 0xFFFFB000
private const val NotHeardPink = 0xFFDC267F
private const val ConflictDeepOrange = 0xFFFE6100
private const val NoReportGray = 0xFFC0C0C0
}
}