Files
Look4Sat-mckero/AGENTS.md
T
mckero 1a3c94f1e0 refactor(domain): make core:domain multiplatform so iOS can reuse it
The orbital maths, the satellite models and the repository contracts sat in a Kotlin/JVM
module, so an iOS target could not share a single line of them: java.lang.String.format,
InputStream, System.currentTimeMillis, java.util.Locale and org.json are all JVM-only, and
the tests that covered them used JUnit4. core:domain now declares jvm, iosArm64 and
iosSimulatorArm64 targets, its sources moved to commonMain/commonTest, and the JVM-only
pieces were replaced with multiplatform equivalents: java.lang.String.format by a shared
printf implementation, System.currentTimeMillis by kotlin.time.Clock, InputStream by
ByteArray, org.json by kotlinx-serialization, Locale by nothing at all. Tests that read
classpath resources (javaClass.classLoader) moved to jvmTest, because that is JVM-only
behaviour rather than a JVM-only API.

Auditing the migration against the old module turned up four things that were wrong rather
than merely ported:

- java.lang.String.format rounds the shortest decimal representation of a double half-up,
  not the binary value: "%.3f" of 0.5005 is "0.501", because the stored double is
  0.50049999999999994493. The shared implementation scaled in binary first and printed
  "0.500", which would have changed APRS position packets and the Wavelog frequency fields
  against the released Android app. It now takes the digits from the decimal representation
  and rounds them with integer arithmetic, and jvmTest compares it against
  String.format(Locale.ROOT, ...) over 40 000 sampled doubles plus the boundary cases, while
  commonTest pins literals so the iOS run checks the same digits.

- The queue mutators lost the kotlin.jvm.Synchronized monitor each of them had. It is not a
  JVM-only annotation - it is an optional expectation, so it still compiles in common code -
  but the stdlib deprecated it for common use in 1.8 and made it an error in 2.1. The monitor
  is a platform actual now: the JVM keeps the real monitor, since Compose and the upload
  coroutine both reach the queue there, and iOS carries a documented placeholder until the
  iOS side has a second thread to protect against.

- 107 assertions in DataParserTest and QthConverterTest were bare kotlin.assert calls, which
  a build without -ea skips silently: they are assertTrue now, so the iOS run cannot pass
  vacuously. The three Locale.setDefault cases (ar-EG, bn-BD, fa-IR) that used to guard APRS
  output against Eastern Arabic digits moved to jvmTest instead of being deleted with the
  Locale dependency - APRS-IS is an ASCII protocol, and Locale.setDefault does not exist on
  iOS.

- @Volatile on the LoTW name cache would not have compiled for iOS either: kotlin.jvm's
  variant is an error in common code since 2.1. kotlin.concurrent.Volatile is the
  multiplatform annotation, and it is the stronger form: it takes effect on Kotlin/Native
  rather than being ignored.

A second audit pass over the files the first one could not reach - the HTTP client, the
parsers, the queue and the injection - found three more:

- OkHttpHttpClient built its Request outside the try, so a URL OkHttp refuses to parse left
  postQso/testToken/getStation as an exception, and neither caller catches one. The client it
  replaced reported HTTP -1 and let the caller treat it as a failure; building the request
  inside the try restores that, and a transport failure reports -1 again rather than 0.

- WavelogQueue's readers were stricter than the org.json ones they replaced. A timestamp
  stored as 1234.0 (or "1234.0") read back as 0L instead of 1234 - a QSO uploaded as 1970 -
  and a field holding an object or array threw the whole list away instead of falling back.
  The readers coerce decimals, keep the old defaults and no longer throw, matching optLong,
  optInt, optString and optBoolean.

- The ADIF dates went through the JVM default locale before, so a device set to Arabic wrote
  Eastern Arabic digits into the QSO date. The shared formatter only ever produces ASCII,
  which the locale cases in AprsPacketDefaultLocaleTest pin down.

Verified locally with ./gradlew jvmTest (343 tests, 0 failures) and the multiplatform gate
in check-multiplatform.sh, which now also refuses JVM-only stdlib APIs that resolve in common
code but fail to compile for iOS: @Synchronized, kotlin.jvm.Volatile, synchronized(),
toUpperCase/toLowerCase/capitalize, BigDecimal. The iOS targets themselves need the macOS
runner in .github/workflows/ios-kmp.yml.
2026-09-27 08:55:49 +01:00

8.4 KiB

Look4Sat AI Agent Instructions

This is the canonical project guide for all AI assistants working on Look4Sat. All assistant-specific files (CLAUDE.md, .github/copilot-instructions.md) point here.


Project Overview

Look4Sat is an open-source, fully offline Android satellite tracker and pass predictor. It tracks 9000+ active satellites using Celestrak/SatNOGS orbital data, calculates positions via SGP4/SDP4, and predicts passes relative to the user's location. Features include polar radar visualization, SSTV image decoding, and ground track mapping. No ads, no tracking, no network required after initial data download.

Architecture & Design

MVI (Model-View-Intent) with unidirectional data flow:

  • State data class (named <Feature>State) exposed via StateFlow from ViewModel
  • Action sealed interface (named <Feature>Action) dispatched to ViewModel's onAction()
  • Jetpack Compose UI observes state and recomposes reactively

Clean Architecture layers:

Module Responsibility
app Entry point. Aggregates all modules
core:data Android library. Room DB, OkHttp networking, repo implementations
core:domain Multiplatform: JVM + iOS. Orbital math (SGP4/SDP4), models, contracts
core:presentation Android library. Compose theme, shared UI components, NavKeys
feature:map OSMDroid map with ground tracks
feature:passes Pass predictions and upcoming events
feature:radar Polar radar view of satellite positions, SSTV image decoding
feature:satellites Satellite list, filtering, selection
feature:settings User preferences

Feature isolation:

  • feature:* modules depend only on core:domain and core:presentation.
  • No feature-to-feature dependencies; cross-feature communication goes through core layers.

Build & Platform

# Debug build
./gradlew assembleDebug

# Release build (minified, shrunk resources)
./gradlew assembleRelease

# Run tests
./gradlew test
  • Min SDK: 24 | Target SDK: 36 | JDK: 21 (jdkVersion in the version catalog)
  • Gradle: Version catalog in gradle/libs.versions.toml + convention plugins in build-logic/

Tech Stack

  • Compose (BOM 2026.05.01) + Material3 Adaptive
  • Navigation3: Type-safe navigation with @Serializable nav keys
  • Room (KSP code generation) for local satellite/orbital storage
  • OkHttp 5.x for data downloads
  • OSMDroid for map rendering
  • Kotlin Serialization for navigation args and parsing
  • Coroutines + StateFlow for async/reactive patterns
  • Localization: 7 languages (en, es, ru, si, tr, uk, zh)

Data Formats & Migration

Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:

  • TLE format: Legacy 3-line element format limited by 5-digit NORAD IDs
  • OMM/CSV format: Successor format with ISO 8601 timestamps and larger NORAD ID support
  • New 5-digit NORAD IDs are exhausted; TLE is officially deprecated and OMM/CSV is the clear default
  • DataParser.kt supports both via parseTLE() and parseCSV(), each taking the file text
  • Downloads auto-detect format; both produce identical OrbitalData objects
  • Existing code already supports transparent source transition without feature changes
  • Refresh orbital data weekly for accurate pass prediction (orbital decay)

Engineering Heuristics (Lazy = Efficient)

  • Treat "lazy" as efficient, not careless: the best code is the code never written.
  • First understand the task and trace the real flow end-to-end, then climb this ladder:
    1. Does this need to be built now? (YAGNI)
    2. Does it already exist in this codebase? Reuse helpers/patterns before rewriting.
    3. Does Kotlin/Java stdlib already solve it?
    4. Does the Android/platform API already solve it?
    5. Does an already-installed dependency solve it?
    6. Can this be simpler (including one-liner simple)?
    7. Only then: write the minimum code that works.
  • Prefer deletion to addition, boring over clever, and the fewest touched files.
  • Avoid new abstractions, dependencies, and boilerplate unless explicitly requested.
  • Manual DI only: ViewModels use companion factory() methods with IMainContainer.
  • Release builds use ProGuard: avoid reflection-heavy libraries unless explicitly approved.
  • When two options are similar in size, choose the edge-case-correct one.
  • If you keep a deliberate simplification (for example O(n^2) scan or global lock), leave a short comment with the ceiling and upgrade path.
  • For complex asks, challenge scope when appropriate: "Do you need X, or does Y already cover it?"

Bug-Fix Policy

  • Fix root cause, not just the reported symptom.
  • If touching a shared function, inspect callers and prefer one shared fix over per-caller patches.
  • The smallest correct diff wins only after behavior is understood.

Roadmap

  • KMP migration: core:domain is now a Kotlin Multiplatform module (jvm + iosArm64/iosSimulatorArm64), so the orbital math, models and repository contracts are compiled once and shared with the iOS app; Android modules consume its jvm target. commonMain must stay free of JVM-only APIs (no java.*, org.json, String.format, Locale, InputStream) - formatString in utility/CommonFormat.kt covers printf.
  • iOS app: next step - an iOS shell that consumes the Look4SatCore framework plus the expect/actual platform pieces (map, location, sensors, notifications).

Gotchas

  • Orbital math lives in core:domain/predict/ — dense vector math (SGP4/SDP4). Tread carefully.
  • core:domain is compiled for iOS too: anything added to its commonMain must exist in Kotlin/Native. .github/workflows/ios-kmp.yml compiles it for iOS and runs the shared tests on an iOS simulator.
  • Kotlin/JVM-only declarations still resolve in commonMain and only fail when the iOS target compiles: @Synchronized and @Volatile (the kotlin.jvm ones) are errors in common code since Kotlin 2.1, as are toUpperCase/toLowerCase/capitalize and BigDecimal. Use kotlin.concurrent.Volatile, and utility/SynchronizedOn.kt (a platform actual) when a monitor is needed. check-multiplatform.sh in the working copy's parent directory flags the rest.
  • Source sets: commonTest runs on both jvm and iOS, so no JUnit4, no javaClass.classLoader and no bare assert() there - a build without -ea skips those silently, and -ea is a JVM flag. Use kotlin.test. JVM-only tests (classpath resources, Locale.setDefault) belong in jvmTest; platform code in jvmMain/iosMain.
  • formatString has to match java.lang.String.format exactly, and that rounds the shortest decimal representation of a double half-up: "%.3f" of 0.5005 is "0.501", even though the stored double is 0.50049999999999994493. CommonFormatOracleTest (jvmTest) compares against real String.format over sampled doubles; CommonFormatRoundingTest (commonTest) pins literals so iOS checks the same digits.
  • SSTV decoding in feature:radar is experimental; image quality depends on signal strength during satellite pass.
  • build-logic/convention/ contains shared Gradle configuration — edit there, not in individual modules.
  • AMSAT status colours are ARGB literals in core:data (AmSatRepository.statusColorOf) and duplicated in core:presentation/MainTheme.kt, so the data layer currently decides how the UI looks. Known debt, left as upstream shipped it: the fix is a status enum in core:domain with the colour mapping in core:presentation. Anything needing themeable, dark-mode-aware or colour-blind-safe status colours has to do that first.

Copilot Working Mode: Code-Only

  • Default to code changes only. Provide explanations in chat only.
  • If documentation seems useful, ask first before creating files.
  • Do NOT create any .md documentation files unless explicitly requested.
  • Do NOT add README, guides, summaries, migration notes, or how-to files unless asked.
  • Prefer minimal diffs focused on requested implementation.
  • Default validation is static checks (get_errors). Do NOT run Gradle compile/test tasks unless explicitly requested.