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 dfa84c2a..797b44a2 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 @@ -30,7 +30,6 @@ 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 @@ -83,7 +82,7 @@ class MainContainer(private val context: Context) : IMainContainer { override val selectionRepo = provideSelectionRepo() override val satelliteRepo = provideSatelliteRepo() override val databaseRepo = provideDatabaseRepo() - override val amSatRepo = AmSatRepository(com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient()) + override val amSatRepo by lazy { AmSatRepository(remoteSource) } override val radioTrackingService: IRadioTrackingService by lazy { val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo) diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt index 2686c87b..d45d880b 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/AmSatRepository.kt @@ -1,31 +1,95 @@ package com.rtbishop.look4sat.core.data.repository -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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONObject import java.util.Calendar import java.util.TimeZone -/** AMSAT status repository: official API v1 -> SatStatusPage (replaces the HTML parser). */ -class AmSatRepository(private val apiClient: AmSatApiClient) : IAmSatRepository { +/** One report from the AMSAT API (data layer model). */ +private data class ApiReport( + val id: String, + val name: String, + val callsign: String, + val report: String, + val gridSquare: String, + val reportedTimeUtcSec: Long +) + +/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */ +class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository { override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) { val nowSec = System.currentTimeMillis() / 1000 - val names = apiClient.fetchCatalog() - val reports = apiClient.fetchAllReports(hours = 168) + val catalogJson = remoteSource.getAmSatCatalog() ?: return@withContext null + val reportsJson = remoteSource.getAmSatReports(hours = 168, 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) } + /** Parse catalog JSON to list of satellite names */ + private fun parseCatalog(json: String): List { + return try { + val arr = JSONObject(json).getJSONArray("data") + (0 until arr.length()).map { arr.getJSONObject(it).getString("name") } + } catch (e: Exception) { + emptyList() + } + } + + /** Parse reports JSON to list of ApiReport domain objects */ + private fun parseReports(json: String): List { + return try { + val arr = JSONObject(json).getJSONArray("data") + (0 until arr.length()).mapNotNull { i -> + val o = arr.getJSONObject(i) + val iso = o.optString("reported_time", "") + if (iso.isEmpty()) null else ApiReport( + id = o.optString("id", ""), + name = o.optString("name", ""), + callsign = o.optString("callsign", ""), + report = o.optString("report", ""), + gridSquare = o.optString("grid_square", ""), + reportedTimeUtcSec = parseIsoUtcSec(iso) + ) + } + } catch (e: Exception) { + emptyList() + } + } + + /** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */ + private fun parseIsoUtcSec(iso: String): Long { + val m = Regex("""(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})""").find(iso) ?: return 0L + val (y, mo, d, h, mi, s) = m.destructured + val days = daysFromCivil(y.toInt(), mo.toInt(), d.toInt()) + return days * 86400L + h.toInt() * 3600L + mi.toInt() * 60L + s.toInt() + } + + /** Days since 1970-01-01 (civil calendar, proleptic Gregorian) */ + private fun daysFromCivil(year: Int, month: Int, day: Int): Long { + val y = if (month <= 2) year - 1 else year + val era = (if (y >= 0) y else y - 399) / 400 + val yoe = y - era * 400 + val doy = (153 * (if (month > 2) month - 3 else month + 9) + 2) / 5 + day - 1 + val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy + return era * 146097L + doe - 719468 + } + /** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */ private fun buildStatuses(names: List, reports: List, nowSec: Long): List { val byName = reports.groupBy { it.name } @@ -41,7 +105,7 @@ class AmSatRepository(private val apiClient: AmSatApiClient) : IAmSatRepository 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) + SatSlot(statusColor = NO_REPORT_GRAY, count = 0) } else { val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!! SatSlot( @@ -76,18 +140,20 @@ class AmSatRepository(private val apiClient: AmSatApiClient) : IAmSatRepository ) } + /** Map status text to color value (for UI rendering). */ private fun statusColorOf(report: String): Long = when (report.lowercase()) { - "heard", "crew active" -> ActiveBlue - "telemetry only" -> TlmOrange - "not heard" -> NotHeardPink - else -> ConflictDeepOrange + "heard", "crew active" -> ACTIVE_BLUE + "telemetry only" -> TLM_ORANGE + "not heard" -> NOT_HEARD_PINK + else -> CONFLICT_DEEP_ORANGE } 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 + // AMSAT official status colors (from amsat.org/status) + private const val ACTIVE_BLUE = 0xFF648FFF + private const val TLM_ORANGE = 0xFFFFB000 + private const val NOT_HEARD_PINK = 0xFFDC267F + private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100 + private const val NO_REPORT_GRAY = 0xFFC0C0C0 } } diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt index b65cfb8e..985d0731 100644 --- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt +++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/source/RemoteSource.kt @@ -71,4 +71,36 @@ class RemoteSource( null } } + + override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) { + try { + val request = Request.Builder() + .url("https://www.amsat.org/status/api/v1/catalog.php") + .header("User-Agent", "Look4Sat/4.5.5") + .build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + response.body?.string() + } + } catch (exception: Exception) { + println("RemoteSource amsat catalog exception: $exception") + null + } + } + + override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) { + try { + val request = Request.Builder() + .url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit") + .header("User-Agent", "Look4Sat/4.5.5") + .build() + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + response.body?.string() + } + } catch (exception: Exception) { + println("RemoteSource amsat reports exception: $exception") + null + } + } } diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/amsat/AmSatApiClient.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/amsat/AmSatApiClient.kt deleted file mode 100644 index 787e73ec..00000000 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/amsat/AmSatApiClient.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* AmSatApiClient.kt - AMSAT official status API v1 client (pure JVM). - * Endpoints (verified 2026-08): - * GET https://www.amsat.org/status/api/v1/catalog.php -> satellite list - * GET https://www.amsat.org/status/api/v1/reports.php?hours=N&limit=500 -> reports - * Report fields: id, name ("SO-50_[FM]"), callsign, report, grid_square, reported_time (ISO 8601 UTC). - * Status values: Heard / Telemetry Only / Not Heard / Crew Active. - */ -package com.rtbishop.look4sat.core.domain.amsat - -import org.json.JSONObject -import org.json.JSONArray -import java.io.BufferedReader -import java.net.HttpURLConnection -import java.net.URL - -/** One report from the AMSAT API. */ -data class ApiReport( - val id: String, - val name: String, // "SO-50_[FM]" (API name, includes mode suffix) - val callsign: String, - val report: String, // Heard / Telemetry Only / Not Heard / Crew Active - val gridSquare: String, - val reportedTimeUtcSec: Long -) - -/** AMSAT official satellite status API v1 client. */ -class AmSatApiClient(private val baseUrl: String = "https://www.amsat.org/status/api/v1") { - - /** Fetch the full satellite catalog. Returns API names (e.g. "SO-50_[FM]"). */ - fun fetchCatalog(): List { - val body = httpGet("$baseUrl/catalog.php") ?: return emptyList() - return try { - val arr = JSONObject(body).getJSONArray("data") - (0 until arr.length()).map { arr.getJSONObject(it).getString("name") } - } catch (e: Exception) { - emptyList() - } - } - - /** Fetch reports for a rolling UTC window. Empty on failure. */ - fun fetchAllReports(hours: Int = 168): List { - val body = httpGet("$baseUrl/reports.php?hours=$hours&limit=500") ?: return emptyList() - return try { - val arr = JSONObject(body).getJSONArray("data") - (0 until arr.length()).mapNotNull { i -> - val o = arr.getJSONObject(i) - val iso = o.optString("reported_time", "") - if (iso.isEmpty()) null else ApiReport( - id = o.optString("id", ""), - name = o.optString("name", ""), - callsign = o.optString("callsign", ""), - report = o.optString("report", ""), - gridSquare = o.optString("grid_square", ""), - reportedTimeUtcSec = parseIsoUtcSec(iso) - ) - } - } catch (e: Exception) { - emptyList() - } - } - - /** Parse "2026-08-05T07:30:00Z" to epoch seconds (minSdk 24: no java.time). */ - fun parseIsoUtcSec(iso: String): Long { - val m = Regex("""(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})""").find(iso) ?: return 0L - val (y, mo, d, h, mi, s) = m.destructured - val days = daysFromCivil(y.toInt(), mo.toInt(), d.toInt()) - return days * 86400L + h.toInt() * 3600L + mi.toInt() * 60L + s.toInt() - } - - /** Days since 1970-01-01 (civil calendar, proleptic Gregorian). */ - private fun daysFromCivil(year: Int, month: Int, day: Int): Long { - val y = if (month <= 2) year - 1 else year - val era = (if (y >= 0) y else y - 399) / 400 - val yoe = y - era * 400 - val doy = (153 * (if (month > 2) month - 3 else month + 9) + 2) / 5 + day - 1 - val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy - return era * 146097L + doe - 719468 - } - - private fun httpGet(url: String): String? { - return try { - val conn = URL(url).openConnection() as HttpURLConnection - conn.requestMethod = "GET" - conn.connectTimeout = 15000 - conn.readTimeout = 20000 - conn.setRequestProperty("User-Agent", "Look4Sat/4.5.5") - if (conn.responseCode !in 200..299) { - conn.disconnect() - return null - } - val text = conn.inputStream.bufferedReader().use(BufferedReader::readText) - conn.disconnect() - text - } catch (e: Exception) { - null - } - } -} diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt index c20cefde..8eab563e 100644 --- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/source/IRemoteSource.kt @@ -25,4 +25,10 @@ interface IRemoteSource { /** Fetch AMSAT status page HTML (with UA; null = failure) */ suspend fun getStatusHtml(): String? + + /** Fetch AMSAT API catalog (JSON string; null on failure) */ + suspend fun getAmSatCatalog(): String? + + /** Fetch AMSAT API reports for the past N hours (JSON string; null on failure) */ + suspend fun getAmSatReports(hours: Int, limit: Int): String? } diff --git a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt index f0864b41..80f4ade9 100644 --- a/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt +++ b/core/presentation/src/main/java/com/rtbishop/look4sat/core/presentation/MainTheme.kt @@ -79,6 +79,10 @@ private val lightScheme = lightColorScheme( onSecondary = Color(0xFFFFFFFF), secondaryContainer = Color(0xFFF1E1BB), onSecondaryContainer = Color(0xFF221B04), + tertiary = Color(0xFF3C6FE0), // AMSAT Active (darker for light theme) + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFE09800), // AMSAT Telemetry (darker) + onTertiaryContainer = Color(0xFF000000), background = Color(0xFFFFF8F0), onBackground = Color(0xFF1E1B13), surface = Color(0xFFFFF8F0), @@ -101,10 +105,10 @@ private val darkScheme = darkColorScheme( onSecondary = Color(0xFF000000), secondaryContainer = Color(0xFF404040), // navBar indicator, onSecondaryContainer = Color(0xFFE0E0E0), // navBar active icon -// tertiary = Color(0xFF121212), -// onTertiary = Color(0xFF121212), -// tertiaryContainer = Color(0xFF121212), -// onTertiaryContainer = Color(0xFF121212), + tertiary = Color(0xFF648FFF), // AMSAT Active (from amsat.org/status) + onTertiary = Color(0xFF000000), + tertiaryContainer = Color(0xFFFFB000), // AMSAT Telemetry + onTertiaryContainer = Color(0xFF000000), background = Color(0xFF121212), onBackground = Color(0xFFE0E0E0), surface = Color(0xFF202020), // card background diff --git a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt index c22fa7d2..96f003c9 100644 --- a/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt +++ b/feature/status/src/main/java/com/rtbishop/look4sat/feature/status/SatStatusScreen.kt @@ -62,12 +62,25 @@ import com.rtbishop.look4sat.core.presentation.R import com.rtbishop.look4sat.feature.status.R as StatusR import java.util.Calendar -// ========== Official status colors (amsat.org/status originals) ========== -val ActiveBlue = Color(0xFF648FFF) -val TlmOrange = Color(0xFFFFB000) -val NotHeardPink = Color(0xFFDC267F) -val ConflictDeepOrange = Color(0xFFFE6100) -val NoReportGray = Color(0xFFC0C0C0) +/** + * Map AMSAT status text to Material3 colorScheme colors. + * Addresses PR #233 review: use colorScheme instead of hardcoded Color() constants. + */ +@Composable +private fun statusColorOf(statusText: String): Color { + return when { + statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> + MaterialTheme.colorScheme.tertiary // Active + statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> + MaterialTheme.colorScheme.tertiaryContainer // Telemetry + statusText.contains("Not Heard", ignoreCase = true) -> + Color(0xFFDC267F) // NotHeard pink (no semantic slot) + else -> + MaterialTheme.colorScheme.error // Conflict + } +} + +private val NoReportGray = Color(0xFFC0C0C0) // Neutral state @Composable fun SatStatusScreen(container: IMainContainer) { @@ -197,10 +210,10 @@ private fun StatusHeader( @Composable private fun LegendRow() { val legend = listOf( - stringResource(id = R.string.amsat_active) to ActiveBlue, - stringResource(id = R.string.amsat_tlm) to TlmOrange, - stringResource(id = R.string.amsat_not_heard) to NotHeardPink, - stringResource(id = R.string.amsat_conflict) to ConflictDeepOrange + stringResource(id = R.string.amsat_active) to MaterialTheme.colorScheme.tertiary, + stringResource(id = R.string.amsat_tlm) to MaterialTheme.colorScheme.tertiaryContainer, + stringResource(id = R.string.amsat_not_heard) to Color(0xFFDC267F), + stringResource(id = R.string.amsat_conflict) to MaterialTheme.colorScheme.error ) Row( modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), @@ -249,9 +262,10 @@ private fun HeaderRow(statuses: List) { } } -/** Satellite row: name + 6 day color blocks (official colors + report counts) */ +/** Satellite row: name + 6 day color blocks (displays the newest non-gray status) */ @Composable private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { + val noReportGray = 0xFFC0C0C0L Row( modifier = Modifier .fillMaxWidth() @@ -266,7 +280,7 @@ private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) { modifier = Modifier.weight(2f).padding(start = 4.dp) ) status.days.forEach { day -> - val slot = day.slots.firstOrNull { it.statusColor != NoReportGray.value.toInt().toLong() } ?: day.slots.first() + val slot = day.slots.firstOrNull { it.statusColor != noReportGray } ?: day.slots.first() DayCell( slot = slot, modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp), @@ -351,12 +365,6 @@ private fun ReportDialog( ) } -private fun statusColorOf(statusText: String): Color = when { - statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> ActiveBlue - statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> TlmOrange - statusText.contains("Not Heard", ignoreCase = true) -> NotHeardPink - else -> ConflictDeepOrange -} private fun formatFetchedAt(utcMs: Long): String { val cal = Calendar.getInstance()