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 22aed236..dfa84c2a 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,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) 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 52c131d6..3b92bdbb 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,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, reports: List, nowSec: Long): List { + 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 + } } 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 new file mode 100644 index 00000000..787e73ec --- /dev/null +++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/amsat/AmSatApiClient.kt @@ -0,0 +1,98 @@ +/* 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/presentation/src/main/res/values-id/strings.xml b/core/presentation/src/main/res/values-id/strings.xml index 096d2c29..93acfc05 100644 --- a/core/presentation/src/main/res/values-id/strings.xml +++ b/core/presentation/src/main/res/values-id/strings.xml @@ -91,13 +91,12 @@ \n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat. Apa yang baru di Look4Sat - * BARU: Halaman status satelit AMSAT - tabel status langsung (warna resmi), jumlah laporan, laporan detail per satelit/hari - * Log dikelompokkan per lintasan satelit (sesi = satelit + waktu AOS), garis pemisah antar sesi - * GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin) - * Pesan kegagalan jelas saat database satelit gagal diunduh - * Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan - * Perbaikan bahasa Indonesia (values-id) - * BARU: Pelaporan APRS (jaringan APRS-IS, layanan latar depan dengan notifikasi, interval dapat diatur) + * BARU: Status satelit AMSAT kini menggunakan API resmi AMSAT (v1 JSON, lebih cepat dan andal) + * Halaman status AMSAT kini mendukung layar penuh (tombol refresh dan waktu selalu dapat diakses) + * WaveLog: pencarian grid lawan QRZ (dialog ⚙ tempel Cookie + uji query) + * WaveLog: tombol perbarui daftar satelit LoTW (112 satelit bawaan) + * WaveLog: laporan sinyal RST 59/59 untuk QSO (v1 dan v2) + Kembali Beri tahu Azimuth diff --git a/core/presentation/src/main/res/values-in/strings.xml b/core/presentation/src/main/res/values-in/strings.xml index beb2715e..bedaa6c5 100644 --- a/core/presentation/src/main/res/values-in/strings.xml +++ b/core/presentation/src/main/res/values-in/strings.xml @@ -90,13 +90,12 @@ \n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat. Apa yang baru di Look4Sat - * BARU: Halaman status satelit AMSAT - tabel status langsung (warna resmi), jumlah laporan, laporan detail per satelit/hari - * Log dikelompokkan per lintasan satelit (sesi = satelit + waktu AOS), garis pemisah antar sesi - * GPS kini hanya menampilkan sukses saat posisi benar-benar didapat (timeout 15 dtk, cek izin) - * Pesan kegagalan jelas saat database satelit gagal diunduh - * Frekuensi log mengikuti panel Doppler secara real-time; visual hapus geser dipulihkan - * Perbaikan bahasa Indonesia (values-id) - * BARU: Pelaporan APRS (jaringan APRS-IS, layanan latar depan dengan notifikasi, interval dapat diatur) + * BARU: Status satelit AMSAT kini menggunakan API resmi AMSAT (v1 JSON, lebih cepat dan andal) + * Halaman status AMSAT kini mendukung layar penuh (tombol refresh dan waktu selalu dapat diakses) + * WaveLog: pencarian grid lawan QRZ (dialog ⚙ tempel Cookie + uji query) + * WaveLog: tombol perbarui daftar satelit LoTW (112 satelit bawaan) + * WaveLog: laporan sinyal RST 59/59 untuk QSO (v1 dan v2) + Kembali Beri tahu Azimuth diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml index 1e53690f..93aa4127 100644 --- a/core/presentation/src/main/res/values-tr/strings.xml +++ b/core/presentation/src/main/res/values-tr/strings.xml @@ -95,13 +95,12 @@ \n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin. What\'s new in Look4Sat - * YENİ: AMSAT uydu durum sayfası - canlı durum tablosu (resmi renkler), rapor sayıları, uydu/gün bazında detaylı raporlar - * Günlükler uydu geçişine göre gruplanır (oturum = uydu + AOS zamanı), oturumlar arasında ayırıcı çizgi - * GPS artık yalnızca gerçek konum alındığında başarı gösterir (15 sn zaman aşımı, izin kontrolü) - * Uydu veritabanı indirilemediğinde net hata mesajı gösterilir - * Günlük frekansları Doppler panelini gerçek zamanlı takip eder; kaydırarak silme görselleri düzeltildi - * Endonezya dili düzeltmesi (values-id) - * YENİ: APRS raporlama (APRS-IS ağı, bildirimli ön plan hizmeti, ayarlanabilir aralık) + * YENİ: AMSAT uydu durumu resmi AMSAT API kullanıyor (v1 JSON, daha hızlı ve güvenilir) + * AMSAT durum sayfası tam ekran cihazlara uyumlu (yenileme düğmesi ve saat her zaman erişilebilir) + * WaveLog: QRZ karşı grid sorgulama (⚙ diyalogda Cookie yapıştır + test sorgusu) + * WaveLog: LoTW uydu listesi güncelleme düğmesi (112 uydu yerleşik) + * WaveLog: RST 59/59 sinyal raporu (v1 ve v2) + Geri diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml index fd2caeb3..5a19f412 100644 --- a/core/presentation/src/main/res/values-zh/strings.xml +++ b/core/presentation/src/main/res/values-zh/strings.xml @@ -85,13 +85,12 @@ 请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性 Look4Sat Pro 更新内容 - * 新增: AMSAT 卫星状态页 - 实时状态表格(官网配色)、报告数量、逐卫星/逐日详细报告 - * 日志按场次分组(场次 = 卫星 + AOS 时间), 组间横线分隔 - * GPS 真正定位成功才会提示成功(15 秒超时, 权限检查) - * 卫星数据库下载失败时有明确失败提示 - * 日志频率实时跟随多普勒面板; 左滑删除视觉恢复 - * 印尼语识别修复(values-id) - * 新增: APRS 网络上报(APRS-IS,前台服务+通知保活,周期可调) + * 新: AMSAT 卫星状态改用官方 AMSAT API(v1 JSON,更快更稳) + * AMSAT 状态页适配全面屏(刷新按钮和更新时间始终可点) + * WaveLog: QRZ 对方网格查询(⚙ 弹窗粘贴 Cookie + 测试查询) + * WaveLog: LoTW 卫星列表更新按钮(内置 112 颗卫星) + * WaveLog: 信号报告 59/59(v1 和 v2) + 后退 diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml index 80d774eb..adb749cd 100644 --- a/core/presentation/src/main/res/values/strings.xml +++ b/core/presentation/src/main/res/values/strings.xml @@ -97,13 +97,12 @@ \n\nPlease update the database at least weekly to get accurate predictions. What\'s new in Look4Sat - * NEW: AMSAT satellite status page - live status grid (official colors), report counts, detailed reports per satellite/day - * Log entries grouped by satellite pass (session ID = satellite + AOS time), divider lines between sessions - * GPS now only reports success when a real fix is acquired (15s timeout, permission check) - * Data update shows a clear failure message when the satellite database cannot be downloaded - * Log frequencies follow the Doppler panel in real time; swipe-to-delete visuals restored - * Indonesian locale fix (values-id) - * NEW: APRS reporting (APRS-IS network, foreground service with notification, configurable interval) + * NEW: AMSAT satellite status now uses the official AMSAT API (v1 JSON, faster and more reliable) + * AMSAT status page fits edge-to-edge screens (refresh button and update time always reachable) + * WaveLog: QRZ counterpart grid lookup (gear dialog > paste QRZ cookies > test query) + * WaveLog: LoTW satellite list refresh button (112 satellites built in) + * WaveLog: RST 59/59 signal reports for QSOs (v1 and v2) + Back 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 e0b6dc07..71bd74b6 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 @@ -15,6 +15,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -72,7 +75,13 @@ fun SatStatusScreen(container: IMainContainer) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() var selectedDay by remember { mutableStateOf?>(null) } - Column(modifier = Modifier.fillMaxSize().padding(horizontal = 8.dp)) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .padding(horizontal = 8.dp) + ) { // Top: legend + refresh StatusHeader( fetchedAtUtcMs = uiState.fetchedAtUtcMs, @@ -100,7 +109,10 @@ fun SatStatusScreen(container: IMainContainer) { HeaderRow(statuses = uiState.statuses) HorizontalDivider(thickness = 1.dp) // Row - LazyColumn(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 16.dp) + ) { items(uiState.statuses, key = { it.name }) { status -> StatusRow( status = status, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index afe393a3..687c5ecd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] #noinspection UnusedVersionCatalogEntry -appVersionCode = "454" +appVersionCode = "455" #noinspection UnusedVersionCatalogEntry -appVersionName = "4.5.4" +appVersionName = "4.5.5" #noinspection UnusedVersionCatalogEntry compileSdk = "37" #noinspection UnusedVersionCatalogEntry