Compare commits

..
11 Commits
Author SHA1 Message Date
mckero 9ca122a734 test(kmp): make commonTest sources compile on kotlin/native
With the main source sets now compiling on both platforms the native
test compilation finally ran, and it rejected a handful of test-side
forms the jvm toolchain silently accepts.

Backtick test names are mapped onto native symbols, where a comma is
an illegal character, so three names drop the comma; the wording keeps
the same meaning. java.lang.Math.PI has no common analogue and becomes
kotlin.math.PI, matching the already-qualified kotlin.math.sin call on
the same line. String.toByteArray() is jvm-only, and the byte-length
assertion for the APRS line budget switches to encodeToByteArray(),
the same replacement the production sources went through.
2026-09-27 10:54:16 +01:00
mckero 904d1ffbea fix(kmp): replace remaining jvm-only encoding calls in common sources
The previous round fixed the convention plugin and the native expect
declarations, which let both platforms compile far enough to reveal the
next layer: a handful of jvm-only call forms that survived the original
kotlin/native sweep because they look like plain kotlin.

String.toByteArray() and Charsets.UTF_8 live in java.nio.charset and do
not exist on kotlin/native; the stdlib equivalents encodeToByteArray()
compile everywhere and are byte-identical for utf-8, so the APRS packet
length budget and the ADIF field length calculation keep their exact
arithmetic. The DatabaseRepoTest helpers kept an InputStream return type
after their bodies were moved to ByteArray sources, and the java.io
import was gone with the sweep, so the android unit test task could not
resolve them; the fake source maps were already typed () -> ByteArray,
so the return type simply follows the data it now feeds.
2026-09-27 10:44:48 +01:00
mckero 9deb517874 fix(build): configure core domain source sets through container members
The second CI round got past the missing configure import but still
failed in the same file: bare name accessors inside sourceSets { }
(commonMain.dependencies { ... }, commonTest, jvmTest) are kotlin-dsl
script syntax generated for .kts files. Plugin source compiled as plain
Kotlin has no such accessors on its classpath, so the four dependency
blocks failed with receiver type mismatches while every real member call
around them - jvmToolchain, jvm(), the ios targets, binaries.framework -
already resolved.

Configure the source sets through the container API instead:
sourceSets.getByName("commonMain").dependencies { ... }. getByName,
dependencies and implementation are all members on types that ship with
KGP 2.4.10, verified against the gradle plugin jars byte for byte.
getByName is safe at this point because jvm() above has just created the
jvm source sets synchronously; commonMain and commonTest exist as soon
as the multiplatform plugin is applied.
2026-09-27 10:28:37 +01:00
mckero fa91e89024 fix(build): add missing gradle-kotlin-dsl import to the core domain convention plugin
The iOS CI run failed in both jobs before reaching any product code: the
convention plugin itself did not compile. CoreDomainPlugin.kt used the
reified extensions.configure<KotlinMultiplatformExtension> { } form, but
unlike every other plugin in this directory it was missing the
org.gradle.kotlin.dsl.configure import. Without it only the member
overloads taking an explicit type parameter resolve, so the extension
receiver cannot be inferred and all twenty subsequent unresolved
references - jvmToolchain, jvm(), iosArm64(), binaries.framework,
sourceSets with the commonMain/commonTest/jvmTest accessors - are one
cascading failure, not twenty bugs.

build-logic is the first thing both CI jobs compile, and it has never
been compiled anywhere before this run, so the workflow is doing exactly
what it was added for: catching what no local machine can check.
2026-09-27 10:07:08 +01:00
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
mckero abb73ffef7 build: bump to 4.6.2 (versionCode 469)
Carries the CW record fixes: the record no longer deletes its own text while decoding,
a drifting tone no longer wipes it instead of filling it, the archive path no longer
races itself when capture stops, and the decoded text can finally be copied off the
screen.

Also stops the APRS version string drifting, which the comment on it had predicted and
which had already happened again: it still read 4.6.0 while the app shipped 4.6.1, so
every station on the network was told the wrong version. AprsReporter now takes the
version as a parameter and the app module passes BuildConfig.VERSION_NAME, which
required turning on the buildConfig feature - AGP 8 does not generate the class
otherwise. The literal cannot fall out of step with the build again.
2026-08-27 15:41:07 +00:00
mckero cc4f156c83 fix(cw): a drifting tone wiped the record instead of filling it
The record could go from a screenful of text to less, and then to nothing at all, while
decoding was still running. The cause is dropBufferedAudio(), which runs on every change
of shift - and the shift tracks the detected tone, which drifts across a pass.

The live window is the only route into the archive: audio gets there by being pushed out
by newer audio. Clearing the window therefore did not merely discard 20 s, it reset the
progress towards ever archiving anything. Modelled at 18 WPM, a drift every 20 s meant
five minutes of listening archived not one character, however long the operator waited -
the window was always wiped before the first sample could be evicted. With the record
still concatenating the live decode at the time, each wipe also cut the visible
transcript short, which is the text going backwards and then never accumulating.

Retiring the window instead of dropping it fixes both. Those samples cannot stay - one
spectrogram over two shift amounts smears the tone - but they were shifted consistently
and they are complete, so they decode fine on their own. They are queued and archived by
the normal path, keeping dropBufferedAudio() non-suspending: both callers sit on the
synchronous capture path, and decoding there would put an inference inside the tone
scan. Modelled over 300 s, a drift every 5 s goes from 0 characters archived to 449.

An earlier attempt at this - keeping archiveSize instead of zeroing it - was wrong and
the probe rejected it: with the window wiped before it ever overflowed, that buffer was
empty, so preserving it preserved nothing.

The queue is synchronised (written from capture, drained from capture and flush) and
capped at four windows, dropping the oldest when full: a tone drifting on every
detection scan would otherwise queue faster than the decoder can drain.

Also from an audit of the previous two commits:

- The copy button was gated on the record, which is empty for the opening half-minute
  while the first batch accumulates. That greyed out the one control that rescues the
  text over exactly the short exchange most likely to be lost. It now copies the live
  window too, and the empty state says which surface updates sooner.
- cw_copy, cw_copied, cw_record_label and cw_record_empty were missing from values-id,
  values-in and values-tr, which carry the full UI strings, so those users saw English.
- Dropped a KDoc block left dangling by 8445c170, which had removed the constant it
  documented.
2026-08-27 15:24:01 +00:00
mckero 3810e76ea2 fix(cw): the archive path raced itself when capture stopped
flush() runs on a different coroutine from processBuffer - on pause from the screen's
own effect, and from the app scope as the screen leaves - so both were appending to
committedText concurrently. That append is a read, an inference lasting hundreds of
milliseconds, and only then a write, so the window for interleaving is the whole
inference: the later write wins and an entire batch of text is gone. Modelled over 40
trials the unlocked version lost 160 characters, averaging a full batch each time.

Worse, both paths touch archiveBuffer and archiveSize. flush() zeroes the index after
copying out a snapshot; the capture coroutine then writes from zero into slots that
snapshot already covered, so the same audio decodes twice and the text appears twice.

Both failures land at the moment the operator stops listening and starts reading,
which is the worst possible time for the record to be wrong.

archiveLock now serialises the archive path. It is a separate mutex from
inferenceLock, which is a tryLock that drops work when contended - right for the live
window, where another decode is 1.5 s away, and wrong here, where dropping a batch
discards the audio for good. Mutex is not reentrant, so archiveDecode is split into a
locking shell and archiveDecodeLocked for callers already holding it.

reset() is left unsynchronised and now says so: an archive decode in flight can land
after it returns, leaving a few characters behind. Making it suspend to close that
window would push suspension onto every caller including a button handler, and the
operator who asked to clear can ask again.
2026-08-27 15:09:24 +00:00
mckero 5ed76dba31 fix(cw): the record deleted its own text while decoding
The record pane concatenated the live decode onto the archived text. The live decode
is the 20 s window, replaced wholesale every 1.5 s because DeepCW is a whole-segment
CTC model that rewrites earlier characters as more context arrives. So the tail of
the record kept changing and could get shorter - text vanishing from under the
operator while the decoder was still running.

A previous attempt (828fd0fb) added decodePending() to cover the gap while audio
waits to be archived, but the call site was never wired up. The function had no
callers and pendingText was only ever cleared, never assigned, so that fix has never
once run and the gap it targeted stayed open. Both are deleted here.

The record now binds to archived text only, which is append-only, so it cannot
shrink. That moves the whole problem to latency, which was 20 s window + 15 s batch:
nothing at all in the record for the first 35 s of a session, and thereafter a stall
of up to 15 s each cycle. The batch is now 4 s, holding the stall under the ~4.7 s a
seven-character call sign takes at 18 WPM, while the archive path still fires less
than half as often as the live redecode.

Neither holding place drains on its own: the live window only reaches the archive by
being pushed out by newer audio, and the pending batch only by filling up. So pausing
or leaving the screen discarded whatever was in flight - the end of every
transmission, the part with the call sign in it. flush() archives both, pending batch
first so the text is not transposed, and is called on pause and before close(). On
the way out it runs on appScope, because the screen's own scope is cancelled as it
leaves and would abort the decode.

Also: the record was an unlabelled grey box showing a bare ellipsis, which reads as a
disabled text field. It now has a label, an empty state that says what it is for, and
a copy button - until now there was no way to get the decoded text off the screen at
all, so an operator who had just copied a call sign by ear had to transcribe it a
second time by hand.

CwArchiveTimingTest covers the timing against the real constants rather than copies;
it caught a 5 s batch exceeding the call-sign bound during this change. The archive
path had no test coverage before.
2026-08-27 15:01:04 +00:00
mckero 78a6f270bf fix(cw): filter before decimating, so high tones stop smearing across the band
The operator reported that any tone leaked across the whole display - "even 3 kHz spreads
over the entire band, like taking a piss". The tone shifter was the suspect, since it had
been changed recently. It turned out to be innocent: the audio reaching it was already
ruined.

Capture runs at 44100 Hz and the model needs 3200 Hz, so resampleLinear decimates by a
factor of nearly 14. It interpolates between samples and nothing removes the content above
the new Nyquist of 1600 Hz first, which is the one thing decimation cannot skip. Measured on
44100 Hz input:

    3000 Hz tone  ->  ghost at  200 Hz, 119x the spectral mean
    2400 Hz tone  ->  ghost at  800 Hz
    1800 Hz tone  ->  ghost at 1400 Hz
    5000 Hz tone  ->  ghost at 1400 Hz

Each ghost is as strong as a real signal, so a tone nothing is transmitting on looks
entirely convincing. Worse for actually copying anything: the whole 1600-22050 Hz band of
hiss folds down on top of the signal and lifts the noise floor across the display. That is
the smearing.

CwAntiAlias is a 127-tap windowed-sinc low-pass, Blackman-windowed because sidelobe level is
what decides how much of the folded band survives, cut off at 92% of the target Nyquist so
the transition lands inside the discarded region. Measured suppression at the fold
frequency: 2400 Hz down 69 dB, 3000 Hz down 81 dB, 5000 Hz down 96 dB. 1800 Hz only makes
16 dB - it sits just past the 1472 Hz cut-off and 127 taps cannot be steeper without costing
more time than a phone has during a pass. The tests assert the measured numbers rather than
the ones I hoped for.

resampleLinear itself is untouched. Its comment notes it matches the reference implementation
DeepCW was trained against, so changing its arithmetic would move the spectrogram away from
what the model expects.

The streaming path holds output back by the group delay. A first attempt let the lookahead
taps read zeros at the end of each chunk, which diverged from whole-buffer filtering by
0.134 across the last 44 samples of every chunk - a click at each boundary. Holding output
back makes the two identical to within 1e-8. The cost is 63 samples, 1.4 ms, against a 20 WPM
dot of about 60 ms.

Both the decoder and the waterfall filter now. The waterfall mattered as much as the
decoder: it was showing the folded spectrum, which is what the operator was looking at.
2026-08-27 14:03:30 +00:00
mckero 8445c17033 fix: remove the receive-only notice, and stop the CW record scrolling itself
Two things the operator asked for after running 4.6.1.

The receive-only notice named a state this app does not have. APRS-IS lets an unverified
station connect and then discards its packets, which is what "receive-only" means at the
protocol level - but this app only reports its own position. There is no receiving side to
it, and none intended, so telling the operator they are in receive-only mode described a
mode that does not exist here. Without a passcode the packet does not arrive, and the
unverified notice already says exactly that. The string is gone from all five locales,
along with the AprsReport.receiveOnly field, which had no remaining consumer.

AprsPasscode.classify stays: loginValue still uses it, and its tests hold the distinction
between a deliberate -1 and a typo, which is a separate defect worth keeping fixed.

The CW history pane no longer follows the decode. Its whole purpose is to be read back, and
a record that scrolls itself is worse than paper - as the operator put it, if it scrolls
away then why use a decoder instead of listening and writing it down, since paper does not
erase itself. The single line above it is where new characters appear; that still scrolls,
because that is its job. A down arrow in the toolbar jumps to the newest text when wanted.

Not fixed here: logged times in the log page look wrong and inconsistent. I proposed a
timezone explanation and wrote a probe, and the probe disproved it - on a real JVM both the
session header and the row times are stable and both resolve to local time. That reverted
attempt is not in this commit. The cause is still unknown.
2026-08-27 07:31:20 +00:00
137 changed files with 2706 additions and 1027 deletions

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# First step of the Kotlin Multiplatform port: core:domain becomes shareable code that compiles
# and runs on iOS as well as Android. This workflow is the evidence for that claim - the same
# orbital math (SGP4/SDP4), models and repository contracts are compiled by the Kotlin/Native
# compiler for iOS and their unit tests run on an iOS simulator. It deliberately does not touch
# the Android build rules: the second job only proves the existing app still builds.
name: ios-kmp
on:
workflow_dispatch:
push:
branches:
- feat/ios-kmp
- ios-kmp
jobs:
ios:
name: iOS shared module
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v4
- name: Compile shared module for iOS
run: ./gradlew :core:domain:compileKotlinIosSimulatorArm64 --console=plain
- name: Unit tests on the iOS simulator
run: ./gradlew :core:domain:iosSimulatorArm64Test --console=plain
- name: Upload iOS test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: ios-domain-test-report
path: core/domain/build/reports/tests/
android:
name: Android regression
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- uses: gradle/actions/setup-gradle@v4
- name: JVM unit tests (domain and data)
run: ./gradlew :core:domain:jvmTest :core:data:testDebugUnitTest --console=plain
- name: Assemble debug APK
run: ./gradlew :app:assembleDebug --console=plain
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: android-reports
path: |
core/domain/build/reports/tests/
core/data/build/reports/tests/
+24 -4
View File
@@ -25,7 +25,7 @@ no tracking, no network required after initial data download.
|----------------------|---------------------------------------------------------------------|
| `app` | Entry point. Aggregates all modules |
| `core:data` | Android library. Room DB, OkHttp networking, repo implementations |
| `core:domain` | Pure Kotlin (JVM). Orbital math (SGP4/SDP4), models, repo contracts |
| `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 |
@@ -50,7 +50,7 @@ no tracking, no network required after initial data download.
./gradlew test
```
- **Min SDK**: 24 | **Target SDK**: 36 | **JDK**: 17
- **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
@@ -71,7 +71,7 @@ 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 `parseTLEStream()` and `parseCSVStream()`
- `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)
@@ -103,11 +103,31 @@ Look4Sat supports both TLE and OMM (Orbit Mean-Elements Message) CSV formats:
## Roadmap
- **KMP migration**: `core:domain` is to become a fully shareable KMM module. Keep it pure Kotlin/JVM.
- **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
@@ -15,6 +15,7 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.IBinder
import com.rtbishop.look4sat.BuildConfig
import com.rtbishop.look4sat.MainApplication
import com.rtbishop.look4sat.core.presentation.R
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
@@ -109,6 +110,8 @@ class AprsForegroundService : Service() {
startForegroundWithNotification(cfg)
val rep = AprsReporter(
configProvider = { AprsStore.loadConfig(this) },
// The real version, so the login line cannot drift from the build again.
appVersion = BuildConfig.VERSION_NAME,
positionProvider = { stationPosition() },
onState = { lastState = it },
onReport = { report ->
@@ -123,13 +126,13 @@ class AprsForegroundService : Service() {
// An unverified login needs its own message: the write succeeded, so a bare
// failure notice would send the operator looking at their network when the
// problem is the passcode - and APRS-IS is dropping every packet meanwhile.
// No receive-only notice. APRS-IS lets an unverified station connect and then
// discards its packets, but this app only reports its own position - there is no
// receiving side to it - so telling the operator they are "in receive-only mode"
// named a state that does not exist here. Without a passcode the packet does not
// arrive, and that is what the failure notice says.
val msg = when {
report.ok -> getString(R.string.aprs_toast_ok)
// Receive-only first: it logs in with -1 exactly as a wrong passcode does and
// the server answers "unverified" to both, so without this branch the one safe
// way to test a setup reported itself as a configuration error.
!report.verified && report.receiveOnly ->
getString(R.string.aprs_toast_receive_only)
!report.verified -> getString(R.string.aprs_toast_unverified)
else -> getString(R.string.aprs_toast_fail, report.detail)
}
@@ -19,17 +19,50 @@ package com.rtbishop.look4sat.convention
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
/**
* core:domain is the one module shared by every platform: it holds the orbital math, the data
* models and the repository contracts, and none of it touches Android APIs. It is a Kotlin
* Multiplatform module (JVM for Android, Kotlin/Native for iOS) rather than a JVM one so the
* same compiled logic runs on both platforms instead of being reimplemented.
*
* Android modules consume the jvm target; the iOS app consumes the framework built from the
* ios targets. Anything JVM-only - org.json, java.net, java.io, java.util.Locale,
* String.format - cannot live in commonMain, because Kotlin/Native has none of them.
*/
@Suppress("Unused")
internal class CoreDomainPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
applyPlugin(libs.plugins.kotlin.jvm)
applyPlugin(libs.plugins.kotlin.multiplatform)
applyPlugin(libs.plugins.kotlin.serialization)
setupKotlin()
dependencies {
implementation(libs.kotlin.coroutines)
implementation(libs.kotlin.serialization)
extensions.configure<KotlinMultiplatformExtension> {
jvmToolchain(libs.versions.jdkVersion.get().toInt())
jvm()
listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "Look4SatCore"
isStatic = true
}
}
// The bare name accessors (commonMain, jvmTest, ...) are script-only syntax;
// plugin source has to resolve through the container members, so configure each
// source set by name. getByName is safe here: jvm() above has just created the
// jvm source sets, the same pattern the local probe build script relies on.
sourceSets.getByName("commonMain").dependencies {
implementation(libs.kotlin.coroutines)
implementation(libs.kotlin.serialization)
}
sourceSets.getByName("commonTest").dependencies {
implementation(libs.kotlin.test)
implementation(libs.test.coroutines)
}
// JVM-only tests live here: the AndroidManifest check reads the file system, and
// the formatter oracle tests compare against java.lang.String.format.
sourceSets.getByName("jvmTest").dependencies {
implementation(libs.test.junit4)
}
}
}
}
@@ -62,6 +62,12 @@ internal fun Project.setupAndroidApp() {
versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get()
}
// The APRS login line reports the app version to every station on the network. It used
// to be a literal in core:data and drifted twice, so the app module now reads
// BuildConfig.VERSION_NAME - which AGP 8 only generates when asked.
buildFeatures {
buildConfig = true
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
@@ -52,12 +52,20 @@ data class AprsReport(
* test a setup without putting anything on the network - was reported as a wrong passcode
* and sent the operator to fix something they had set on purpose.
*/
val receiveOnly: Boolean = false
)
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
class AprsReporter(
private val configProvider: () -> AprsConfig,
/**
* The app's own version, reported to APRS-IS in the login line.
*
* Passed in because core:data has no BuildConfig. It used to be a literal here and drifted
* exactly as predicted: it still read 4.6.0 two releases later, so every station on the
* network was told the wrong version. A caller in the app module can read the real one.
*/
private val appVersion: String,
private val positionProvider: () -> Pair<Double, Double>? = { null },
private val onState: (AprsState) -> Unit = {},
private val onReport: (AprsReport) -> Unit = {}
@@ -127,13 +135,6 @@ class AprsReporter(
}
val packetLine = (beacon as AprsBeacon.Result.Line).text
// Receive-only is a deliberate choice and has to be told apart from a typo, because
// both log in with -1 and the server answers "unverified" to each. Classifying rather
// than asking canTransmit: that collapses ReceiveOnly and Mismatch into one boolean,
// so a mistyped passcode would be told it is in receive-only mode - the same
// mis-diagnosis as before, pointing the other way.
val wantsReceiveOnly =
AprsPasscode.classify(cfg.callsign, cfg.passcode) is AprsPasscode.Entry.ReceiveOnly
val c = client ?: AprsIsClient(
host = cfg.server,
port = cfg.port,
@@ -143,11 +144,8 @@ class AprsReporter(
// transmitting under a passcode the app invented for an unchecked licence.
passcode = AprsPasscode.loginValue(cfg.callsign, cfg.passcode),
// Two fields, because APRS-IS wants `vers <name> <version>` as separate tokens.
// The version is hardcoded and drifts - it read 4.5.4 while the app was 4.6.0.
// core:data has no BuildConfig, so fixing that properly means passing it in from
// the app module; noting rather than doing it here to keep this change small.
softwareName = "Look4Sat",
version = "4.6.0"
version = appVersion
).also { client = it }
if (!c.isConnected) c.connect()
onState(AprsState.Connected)
@@ -166,14 +164,13 @@ class AprsReporter(
// report, say that instead.
val reported = when {
ok -> detail
refused && wantsReceiveOnly -> "receive-only, not forwarded"
refused -> "login not verified"
else -> detail
}
onReport(
AprsReport(
System.currentTimeMillis(), packetLine, ok, reported,
verified = !refused, receiveOnly = wantsReceiveOnly
verified = !refused
)
)
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
@@ -24,6 +24,7 @@ import android.content.Context
import android.util.Log
import com.rtbishop.look4sat.core.domain.cw.CwCtcDecoder
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwAntiAlias
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import com.rtbishop.look4sat.core.domain.cw.CwDetectionPool
import com.rtbishop.look4sat.core.domain.cw.CwShiftDecider
@@ -35,6 +36,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.nio.FloatBuffer
@@ -64,13 +66,28 @@ class CwDeepDecoder(
private val isToneShiftEnabled: () -> Boolean = { false }
) : ICwDecoder {
private companion object {
// internal, not private: the archive timing is user-visible behaviour and its test asserts
// against these constants directly rather than a copy that could silently drift.
internal companion object {
const val TAG = "CwDeepDecoder"
const val MODEL_ASSET = "deepcw/model.onnx"
const val METADATA_ASSET = "deepcw/model.onnx.json"
/** Evicted audio is decoded into permanent history once this much accumulates. */
const val ARCHIVE_SECONDS = 15.0
/**
* Evicted audio is decoded into permanent history once this much accumulates.
*
* This is the delay before decoded text reaches the record, and it is additive with
* the 20 s live window: at the old 15 s the record showed nothing for the first 35 s
* of a session, and thereafter text that had scrolled out of the live window sat
* invisible for up to 15 s before landing - the record appeared to stall and, when
* it was still concatenating the live window, to delete what it had just shown.
*
* Each batch is one full inference, so this trades CPU for latency. 4 s holds the gap
* under the ~4.7 s a seven-character call sign takes at 18 WPM - the record must not
* stall for longer than the one thing an operator most needs to read back - while the
* archive path still fires less than half as often as the 1.5 s live redecode cycle.
*/
const val ARCHIVE_SECONDS = 4.0
val ARCHIVE_THRESHOLD: Int = (CwDeepSpectrogram.SAMPLE_RATE * ARCHIVE_SECONDS).toInt()
/**
@@ -104,13 +121,12 @@ class CwDeepDecoder(
override val decodedText: StateFlow<String> = _decodedText.asStateFlow()
/**
* Archived text, plus a provisional decode of audio not yet archived.
* Archived text: only appended to, so a pane bound to it never loses what it showed.
*
* Kept as one flow of the two parts concatenated. Without the provisional part the
* transcript visibly shrank: audio leaving the 20 s window waits for a full
* [ARCHIVE_SECONDS] batch before it is decoded into the archive, so for up to 15 s its
* characters were in neither place - measured, up to 30 characters at 20 WPM would
* vanish and reappear later, which reads as the box deleting text.
* This once carried a provisional tail meant to cover the gap while audio waited to be
* archived, but the decode feeding that tail was never wired up, so the tail was always
* empty and the gap stayed. It is closed instead by archiving in [ARCHIVE_SECONDS]
* batches, which no longer concatenate anything that gets rewritten.
*/
private val _historyText = MutableStateFlow("")
override val historyText: StateFlow<String> = _historyText.asStateFlow()
@@ -118,9 +134,6 @@ class CwDeepDecoder(
/** Permanently archived text; the provisional tail is appended to this for display. */
private var committedText = ""
/** Provisional decode of the pending archive batch, replaced when it is archived. */
private var pendingText = ""
private val _estimatedPitch = MutableStateFlow<Float?>(null)
override val estimatedPitch: StateFlow<Float?> = _estimatedPitch.asStateFlow()
@@ -142,18 +155,54 @@ class CwDeepDecoder(
private val buffer = CwDeepBuffer()
/**
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then
* is decoded once and appended to [historyText]. Archiving in ~15 s chunks
* keeps the extra inference cheap (short window) while long enough to be
* decoded accurately — the content has already been through the 20 s window
* many times, so a slightly shorter archive decode loses almost nothing.
* Evicted audio accumulates here until it reaches [ARCHIVE_SECONDS], then is decoded
* once and appended to [historyText]. The batch stays short enough that the record does
* not visibly stall, and accuracy barely suffers: this content has already been through
* the 20 s window many times, so the archive decode is a confirmation, not a first look.
*
* Sized to the full window rather than the batch: [flush] hands over whatever the live
* window holds, which can be the whole 20 s.
*/
private val archiveBuffer = FloatArray(CwDeepBuffer.DEFAULT_MAX_SECONDS.toInt() * CwDeepSpectrogram.SAMPLE_RATE)
private var archiveSize = 0
/**
* Window contents retired by a change of shift, waiting to be archived.
*
* The live window is the only route into the archive - audio gets there by being pushed
* out - so clearing the window used to mean its audio was never decoded at all. When the
* shift changed more often than the window took to fill, that was every sample: modelled
* at 18 WPM with a drift every 20 s, five minutes of listening archived nothing whatever.
* Synchronised on itself: written from the capture path and drained from both there and
* [flush], which run on different coroutines. Capped, because a signal drifting on every
* detection scan would otherwise queue windows faster than they can be decoded and grow
* without bound; past the cap the oldest goes, since newer audio is what is being read.
*/
private val retiredAudio = ArrayDeque<FloatArray>()
/** Windows held awaiting archival before the oldest is dropped. */
private val retiredAudioLimit = 4
/** Held while inference runs so slow devices skip work instead of queuing it. */
private val inferenceLock = Mutex()
/**
* Serialises the archive path, which mutates state the capture coroutine also touches.
*
* [flush] runs on a different coroutine from [processBuffer] - on pause, and from the app
* scope as the screen leaves - and both append to [committedText], which is a read, an
* inference lasting hundreds of milliseconds, and only then a write. Interleaved, the
* later write wins and a whole batch of text is lost, precisely at the moment the
* operator stops listening and starts reading. They also both touch [archiveBuffer] and
* [archiveSize]: a reset of the index under a snapshot that already covered those slots
* makes the same audio decode twice.
*
* Not [inferenceLock]: that one is a tryLock, dropping work when contended, which is
* right for the live window (another decode is 1.5 s away) and wrong here (dropping an
* archive batch discards the audio for good).
*/
private val archiveLock = Mutex()
/** Decides what shift to apply from successive tone estimates. */
private val shiftDecider = CwShiftDecider(SHIFT_HYSTERESIS_HZ)
@@ -172,6 +221,17 @@ class CwDeepDecoder(
/** Carries Hilbert filter history and mixer phase across capture chunks. */
private val streamingShifter = CwToneShifter.Streaming()
/**
* Anti-alias filter for the decimation to [CwDeepSpectrogram.SAMPLE_RATE].
*
* Built on the first chunk because the capture rate is not known until then. Without
* it everything above 1600 Hz folds into the window: a 3000 Hz tone reappeared at
* 200 Hz at 119 times the spectral mean, and the whole 1600-22050 Hz band of hiss
* folded down on top of the signal.
*/
private var antiAlias: CwAntiAlias.Streaming? = null
private var antiAliasRate = 0
/**
* Previous value of the setting, so a toggle can invalidate buffered audio.
* Null until the first chunk: a decoder created while the setting is already on
@@ -255,10 +315,35 @@ class CwDeepDecoder(
if (samples.isEmpty()) return
if (!ensureLoaded()) return
// Filter before decimating. resampleLinear interpolates without removing anything
// above the new Nyquist, so this has to happen first or the fold is already baked in.
if (antiAlias == null || antiAliasRate != sampleRate) {
antiAlias = CwAntiAlias.Streaming(sampleRate, CwDeepSpectrogram.SAMPLE_RATE)
antiAliasRate = sampleRate
}
val bandLimited = antiAlias?.process(samples) ?: samples
// The filter holds back its group delay, so the first call returns nothing.
if (bandLimited.isEmpty()) return
val resampled = CwDeepSpectrogram.resampleLinear(
samples, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
bandLimited, sampleRate, CwDeepSpectrogram.SAMPLE_RATE
)
val prepared = applyToneShift(resampled)
// Anything applyToneShift just retired from the window is older than what follows, so
// it is archived before the new audio is buffered - otherwise the record comes out
// with its text transposed. Archived whole rather than accumulated: it is already a
// full window's worth, and holding it back would only expose it to the next retirement.
val retired = drainRetiredAudio()
for (batch in retired) {
try {
archiveDecode(batch)
} catch (t: Throwable) {
if (t is CancellationException) throw t
Log.e(TAG, "retired audio decode failed", t)
}
}
val shouldRedecode = buffer.append(prepared)
// Archive audio that scrolled out of the live window. It is decoded once
@@ -385,12 +470,50 @@ class CwDeepDecoder(
* cannot be un-shifted, so they are dropped rather than decoded against the new
* shift. Text already committed to [historyText] stays: it was correct when decoded.
*/
/**
* Drop audio that was shifted by a setting no longer in force - but keep what can be kept.
*
* The live window has to go: it holds samples moved by two different amounts, and one
* spectrogram over both smears the tone. The pending archive batch does not. Those samples
* already left the window, they were shifted consistently, and they are complete, so
* discarding them threw away decodable audio for no reason. They are left in place here to
* be archived by the normal path, which keeps this function non-suspending: its two
* callers sit on the synchronous capture path, and making them suspend to run an inference
* here would put a decode inside the tone-detection scan.
*
* It mattered because this runs on every change of shift, which tracks the detected tone,
* which drifts across a pass. Modelled at 18 WPM, a drift every 20 s left the record
* permanently empty however long the operator listened: the window was wiped before any
* batch could complete, so nothing was ever committed. With the record still concatenating
* the live decode at the time, each wipe visibly cut the transcript short as well - text
* going backwards, then never accumulating at all.
*/
/** Take every retired window, oldest first, leaving the queue empty. */
private fun drainRetiredAudio(): List<FloatArray> = synchronized(retiredAudio) {
if (retiredAudio.isEmpty()) {
emptyList()
} else {
retiredAudio.toList().also { retiredAudio.clear() }
}
}
private fun dropBufferedAudio() {
// Retired, not discarded. The samples cannot stay in the window - mixing two shifts
// in one spectrogram smears the tone - but they are internally consistent and
// complete, so they decode fine on their own. Handed to the archive path rather than
// decoded here, because both callers sit on the synchronous capture path.
val retiring = buffer.snapshot()
if (retiring.isNotEmpty()) {
synchronized(retiredAudio) {
while (retiredAudio.size >= retiredAudioLimit) {
Log.w(TAG, "retired audio queue full, dropping the oldest window")
retiredAudio.removeFirst()
}
retiredAudio.addLast(retiring)
}
}
buffer.reset()
archiveSize = 0
// The provisional text describes audio being discarded, so it goes with it.
// Committed text stays: it was correct for audio that really was archived.
pendingText = ""
_historyText.value = committedText
}
@@ -475,40 +598,63 @@ class CwDeepDecoder(
updateSignalMetrics(spectrogram)
}
/**
* Archive whatever audio is still in the pipeline, so stopping does not discard it.
*
* Two places hold audio that would otherwise never be decoded into the record: the
* batch accumulating towards [ARCHIVE_THRESHOLD], and the live window itself, whose
* contents only ever reach the archive by being pushed out by newer audio. Together
* that is the last [CwDeepBuffer.DEFAULT_MAX_SECONDS] + [ARCHIVE_SECONDS] of a session
* - which includes the end of every transmission, the part with the call sign in it.
*
* Order matters: the pending batch left the window before anything still in it, so it
* has to be archived first or the record comes out with its text transposed.
*/
override suspend fun flush() = archiveLock.withLock {
// Oldest first, all the way down: retired window contents, then the batch accumulating
// towards the threshold, then what is still live.
for (batch in drainRetiredAudio()) {
runCatching { archiveDecodeLocked(batch) }
.onFailure { if (it is CancellationException) throw it }
}
if (archiveSize > 0) {
val pending = archiveBuffer.copyOf(archiveSize)
archiveSize = 0
runCatching { archiveDecodeLocked(pending) }
.onFailure { if (it is CancellationException) throw it }
}
// Draining the window empties it, so a second flush cannot double-archive the tail.
val window = buffer.snapshot()
if (window.isNotEmpty()) {
buffer.reset()
runCatching { archiveDecodeLocked(window) }
.onFailure { if (it is CancellationException) throw it }
}
// The live line described audio that is now in the record; leaving it would show the
// same characters twice, in two places, one of them stale.
_decodedText.value = ""
}
/**
* Decode a chunk of audio that has scrolled out of the live window and
* append it to [historyText]. Unlike the live window this never replaces —
* the archived audio is final, so its text is permanent.
*/
private suspend fun archiveDecode(audio: FloatArray) = withContext(Dispatchers.Default) {
private suspend fun archiveDecode(audio: FloatArray) = archiveLock.withLock {
archiveDecodeLocked(audio)
}
/** [archiveDecode] without the lock, for callers already holding [archiveLock]. */
private suspend fun archiveDecodeLocked(audio: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
val spectrogram = CwDeepSpectrogram.compute(audio)
val text = runInference(activeSession, activeEnvironment, spectrogram)
// This batch is final, so its provisional decode is superseded rather than kept.
committedText += text
pendingText = ""
_historyText.value = committedText
}
/**
* Decode the audio waiting to be archived, so it stays on screen until it is.
*
* Provisional: the batch is still growing, and the final decode sees all of it at once
* with more context. Runs on the redecode cycle rather than per capture chunk -
* decoding every 100 ms chunk separately measured 14x the inference load, over 250% of
* one core, and a chunk that short carries under two dot-lengths of context anyway.
*/
private suspend fun decodePending(audio: FloatArray) = withContext(Dispatchers.Default) {
val activeSession = session ?: return@withContext
val activeEnvironment = environment ?: return@withContext
if (audio.size < CwDeepSpectrogram.FFT_LENGTH) return@withContext
val spectrogram = CwDeepSpectrogram.compute(audio)
pendingText = runInference(activeSession, activeEnvironment, spectrogram)
_historyText.value = committedText + pendingText
}
/** Run the ONNX model over a pre-computed spectrogram and return the decoded text. */
private fun runInference(
activeSession: OrtSession,
@@ -583,12 +729,19 @@ class CwDeepDecoder(
_signalStrength.value = if (decodable) prominence else 0f
}
/**
* Clear everything. Not serialised against [archiveLock]: an archive decode already in
* flight can land its batch after this returns, leaving a few characters behind. The
* operator asked to clear and can ask again; making this suspend to close that window
* would push it onto every caller, including a synchronous button handler.
*/
override fun reset() {
antiAlias?.reset()
buffer.reset()
synchronized(retiredAudio) { retiredAudio.clear() }
_decodedText.value = ""
_historyText.value = ""
committedText = ""
pendingText = ""
archiveSize = 0
_estimatedPitch.value = null
_detectedToneHz.value = null
@@ -36,6 +36,7 @@ import com.rtbishop.look4sat.core.data.repository.SelectionRepo
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
import com.rtbishop.look4sat.core.data.source.LocalSource
import com.rtbishop.look4sat.core.data.source.OkHttpHttpClient
import com.rtbishop.look4sat.core.data.source.RemoteSource
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
@@ -73,11 +74,21 @@ import com.rtbishop.look4sat.core.data.qrz.QrzGridLookup
import com.rtbishop.look4sat.core.domain.qrz.IQrzGridLookup
import okhttp3.OkHttpClient
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
class MainContainer(private val context: Context) : IMainContainer {
private val localSource = provideLocalSource()
private val remoteSource by lazy { provideRemoteSource() }
/**
* WaveLogApi is a plain object in core:domain, and shared code has no socket API of its own on
* iOS, so the container hands it the platform client. Its requests used to be made by an
* HttpURLConnection built inside WaveLogApi, which only ever existed on the JVM.
*/
init {
WaveLogApi.installHttpClient(OkHttpHttpClient(OkHttpClient.Builder().build()))
}
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
override val settingsRepo = provideSettingsRepo()
@@ -29,7 +29,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.ByteArrayInputStream
import java.util.zip.ZipInputStream
class DatabaseRepo(
@@ -55,8 +55,8 @@ class DatabaseRepo(
override suspend fun updateTLEFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val entries = parseSatelliteStream(uri, unwrapIfZipped(uri, stream))
remoteSource.getFileBytes(uri)?.let { data ->
val entries = parseSatelliteData(uri, unwrapIfZipped(uri, data))
localSource.insertEntries(entries)
settingsRepo.setSatelliteTypeIds(customSourceType, entries.map { it.catnum })
importedCount = entries.size
@@ -67,8 +67,8 @@ class DatabaseRepo(
override suspend fun updateTransceiversFromFile(uri: String): Int = withContext(dispatcher) {
var importedCount = 0
remoteSource.getFileStream(uri)?.let { stream ->
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
remoteSource.getFileBytes(uri)?.let { data ->
val transceivers = dataParser.parseJSON(unwrapIfZipped(uri, data).decodeToString())
localSource.insertRadios(transceivers)
importedCount = transceivers.size
}
@@ -105,8 +105,8 @@ class DatabaseRepo(
Sources.transceiversDataUrls.filterValues { it.isNotBlank() }
}
// launch all network requests concurrently
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkBytes(url) } }
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkBytes(url) } }
val tleResults = tleJobs.awaitAll()
val radioResults = radioJobs.awaitAll()
// Orbital elements are counted on their own. A combined count let a successful transceivers
@@ -120,14 +120,14 @@ class DatabaseRepo(
throw java.io.IOException("No orbital data source could be downloaded")
}
// parse fetched data concurrently and associate with types
val importedEntries = tleResults.flatMap { (url, stream) ->
val importedEntries = tleResults.flatMap { (url, data) ->
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
data?.let { parseSatelliteData(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
}
}
val importedRadios = radioResults.flatMap { (url, stream) ->
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
val importedRadios = radioResults.flatMap { (url, data) ->
data?.let { dataParser.parseJSON(unwrapIfZipped(url, it).decodeToString()) }.orEmpty()
}
// insert parsed data into the database
localSource.insertEntries(importedEntries)
@@ -141,11 +141,11 @@ class DatabaseRepo(
setUpdateSuccessful(0L)
}
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
val bufferedStream = stream.buffered()
private suspend fun parseSatelliteData(url: String, data: ByteArray): List<OrbitalData> {
val text = data.decodeToString()
return when {
hasCsvHint(url) || looksLikeCsv(bufferedStream) -> dataParser.parseCSVStream(bufferedStream)
else -> dataParser.parseTLEStream(bufferedStream)
hasCsvHint(url) || looksLikeCsv(text) -> dataParser.parseCSV(text)
else -> dataParser.parseTLE(text)
}
}
@@ -155,14 +155,9 @@ class DatabaseRepo(
url.endsWith(".csv.zip", ignoreCase = true)
}
private fun looksLikeCsv(stream: InputStream): Boolean {
if (!stream.markSupported()) return false
stream.mark(4096)
val preview = ByteArray(4096)
val length = stream.read(preview)
stream.reset()
if (length <= 0) return false
val line = preview.decodeToString(0, length).lineSequence().firstOrNull()?.trim().orEmpty()
private fun looksLikeCsv(text: String): Boolean {
val line = text.lineSequence().firstOrNull()?.trim().orEmpty()
if (line.isEmpty()) return false
return line.contains("OBJECT_NAME", ignoreCase = true) ||
line.contains("NORAD_CAT_ID", ignoreCase = true) ||
line.count { it == ',' } >= 4
@@ -174,6 +169,10 @@ class DatabaseRepo(
)
}
private fun unwrapIfZipped(url: String, stream: InputStream): InputStream =
if (url.endsWith(".zip", ignoreCase = true)) ZipInputStream(stream).apply { nextEntry } else stream
private fun unwrapIfZipped(url: String, data: ByteArray): ByteArray =
if (url.endsWith(".zip", ignoreCase = true)) {
ZipInputStream(ByteArrayInputStream(data)).apply { nextEntry }.readBytes()
} else {
data
}
}
@@ -0,0 +1,89 @@
/*
* 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.source
import com.rtbishop.look4sat.core.domain.source.HttpResult
import com.rtbishop.look4sat.core.domain.source.IHttpClient
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.util.concurrent.TimeUnit
/**
* Android [IHttpClient] for the shared Wavelog/QRZ code, which cannot reach java.net on iOS.
*
* Connect and read timeouts are the 15 s the previous HttpURLConnection client used, applied to
* the passed client so the caller keeps one connection pool. The response body is returned for
* error codes as well, which is what reading errorStream did.
*/
class OkHttpHttpClient(
baseClient: OkHttpClient,
dispatcher: CoroutineDispatcher = Dispatchers.IO
) : IHttpClient {
private val dispatcher = dispatcher
private val client = baseClient.newBuilder()
.connectTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
override suspend fun post(url: String, headers: Map<String, String>, body: String): HttpResult =
execute {
Request.Builder().url(url).post(body.toRequestBody(JSON_MEDIA_TYPE)).withHeaders(headers).build()
}
override suspend fun get(url: String, headers: Map<String, String>): HttpResult =
execute { Request.Builder().url(url).withHeaders(headers).build() }
private suspend fun execute(buildRequest: () -> Request): HttpResult = withContext(dispatcher) {
try {
// Built in here, not by the caller: a URL OkHttp refuses to parse has to come back as
// the HTTP -1 the old client reported, not as an exception thrown at the caller.
val request = buildRequest()
client.newCall(request).execute().use { response ->
HttpResult(response.code, response.body.string())
}
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
HttpResult(NO_RESPONSE, "", exception.message ?: exception.javaClass.simpleName)
}
}
private fun Request.Builder.withHeaders(headers: Map<String, String>): Request.Builder {
headers.forEach { (name, value) -> header(name, value) }
return this
}
private companion object {
/** Matches HttpURLConnection's connect/read timeout in the original WaveLog client. */
const val TIMEOUT_MS = 15_000L
/** What HttpURLConnection's responseCode() reported when a request never got a response. */
const val NO_RESPONSE = -1
/** WaveLog's v2 and v1 endpoints take application/json in both directions. */
val JSON_MEDIA_TYPE = "application/json".toMediaType()
}
}
@@ -25,7 +25,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.InputStream
class RemoteSource(
private val dispatcher: CoroutineDispatcher,
@@ -33,10 +32,10 @@ class RemoteSource(
private val httpClient: OkHttpClient
) : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = withContext(dispatcher) {
override suspend fun getFileBytes(uri: String): ByteArray? = withContext(dispatcher) {
try {
val fileUri = uri.toUri()
contentResolver.openInputStream(fileUri)?.buffered()
contentResolver.openInputStream(fileUri)?.use { it.readBytes() }
} catch (exception: CancellationException) {
throw exception
} catch (exception: Exception) {
@@ -45,17 +44,14 @@ class RemoteSource(
}
}
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
override suspend fun getNetworkBytes(url: String): ByteArray? = withContext(dispatcher) {
try {
val networkRequest = Request.Builder().url(url).build()
val response = httpClient.newCall(networkRequest).execute()
if (!response.isSuccessful) {
response.close()
return@withContext null
// The whole body is read here, which also returns the connection to OkHttp's pool
httpClient.newCall(networkRequest).execute().use { response ->
if (!response.isSuccessful) return@use null
response.body.bytes()
}
// 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) {
@@ -0,0 +1,150 @@
/*
* 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.cw
import com.rtbishop.look4sat.core.domain.cw.CwDeepBuffer
import com.rtbishop.look4sat.core.domain.cw.CwDeepSpectrogram
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.floor
/**
* How long audio waits before its text can reach the record pane.
*
* The record binds to archived text only. The live decode is rewritten from scratch every
* cycle, so a pane that concatenated it lost characters the operator had already read - the
* decoder appeared to delete its own output while still running. Binding to archived text
* makes the pane monotonic, and the cost is latency, which is additive: audio must first be
* pushed out of the live window, then accumulate into a full archive batch.
*
* [CwDeepDecoder] needs a Context and a loaded ONNX model, so it cannot be constructed here.
* These tests read its real constants rather than copies, so retuning one without
* reconsidering the user-visible delay fails here.
*/
class CwArchiveTimingTest {
/** Sending speed for the character counts, typical for satellite CW. */
private val wpm = 18.0
/** PARIS standard: one word is five characters. */
private val charsPerSecond = wpm * 5 / 60.0
private val window = CwDeepBuffer.DEFAULT_MAX_SECONDS
private val batch = CwDeepDecoder.ARCHIVE_SECONDS
/** Seconds of audio that have reached the archive after listening for [elapsed]. */
private fun archivedSeconds(elapsed: Double): Double {
val evicted = elapsed - window
if (evicted <= 0.0) return 0.0
return floor(evicted / batch) * batch
}
@Test
fun thresholdMatchesTheDeclaredBatchLength() {
assertEquals(
"threshold must be the batch length in samples",
(CwDeepSpectrogram.SAMPLE_RATE * batch).toInt(),
CwDeepDecoder.ARCHIVE_THRESHOLD
)
}
@Test
fun archiveBatchIsShorterThanACallSign() {
// A seven-character call sign at 18 WPM takes about 4.7 s. A batch longer than that
// means the record can stall for longer than the single most important thing being
// sent, which is what made the stall read as deletion.
val callSignSeconds = 7 / charsPerSecond
assertTrue(
"batch $batch s must not exceed a call sign at $wpm WPM " +
"(${"%.1f".format(callSignSeconds)} s)",
batch <= callSignSeconds
)
}
@Test
fun firstTextReachesTheRecordWithinHalfAMinute() {
// At the previous 15 s batch this was 35 s, so a short exchange ended with the record
// still completely empty: every decoded character had only ever been in the live line,
// which shows 64 characters and overwrites them.
val firstArchive = window + batch
assertTrue("first archived text must appear within 30 s, got $firstArchive s", firstArchive <= 30.0)
}
@Test
fun aThirtySecondSessionStillProducesARecord() {
val archived = archivedSeconds(30.0)
assertTrue("30 s of listening must archive something, got $archived s", archived > 0.0)
}
@Test
fun theRecordNeverStallsForLongerThanOneBatch() {
var longestStall = 0.0
var lastGrowthAt = window
var previous = 0.0
var t = window
while (t <= 600.0) {
val archived = archivedSeconds(t)
if (archived > previous) {
longestStall = maxOf(longestStall, t - lastGrowthAt)
lastGrowthAt = t
previous = archived
}
t += 0.1
}
assertTrue(
"record stalled ${"%.1f".format(longestStall)} s, one batch is $batch s",
longestStall <= batch + 0.11
)
}
@Test
fun audioInFlightWhenCaptureStopsWouldLoseTheEndOfTheTransmission() {
// Why flush() exists. Neither holding place drains on its own: the live window only
// reaches the archive by being pushed out by newer audio, and the pending batch only
// by filling up. Both hold the end of the transmission, where the call sign is.
val worstCase = window + batch
val lostCharacters = worstCase * charsPerSecond
assertTrue(
"flush() must exist: ${"%.0f".format(lostCharacters)} characters would be lost",
lostCharacters > 20
)
}
@Test
fun archivingStaysRarerThanTheLiveDecode() {
// Each batch is one inference. Shortening the batch trades CPU for latency, so it must
// stay rarer than the live redecode or the archive path becomes the dominant cost.
val liveIntervalSeconds = CwDeepBuffer.DEFAULT_REDECODE_INTERVAL_MS / 1000.0
assertTrue(
"batch $batch s must stay longer than the live cycle $liveIntervalSeconds s",
batch > liveIntervalSeconds
)
}
@Test
fun aBatchIsLongEnoughToDecode() {
// compute() rejects audio shorter than one FFT frame, so a batch below that would be
// silently dropped by archiveDecode's size guard and its text lost outright.
assertTrue(
"batch of ${CwDeepDecoder.ARCHIVE_THRESHOLD} samples must exceed " +
"FFT_LENGTH ${CwDeepSpectrogram.FFT_LENGTH}",
CwDeepDecoder.ARCHIVE_THRESHOLD > CwDeepSpectrogram.FFT_LENGTH
)
}
}
@@ -5,7 +5,6 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.Locale
@@ -18,8 +17,8 @@ import java.util.TimeZone
class AmSatAuditTest {
private object UnusedSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = null
override suspend fun getNetworkStream(url: String): InputStream? = null
override suspend fun getFileBytes(uri: String): ByteArray? = null
override suspend fun getNetworkBytes(url: String): ByteArray? = null
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun getAmSatSummary(hours: Int): String? = null
@@ -4,7 +4,6 @@ import com.rtbishop.look4sat.core.domain.source.IRemoteSource
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
import java.util.Calendar
import java.util.TimeZone
@@ -24,8 +23,8 @@ import java.util.TimeZone
class AmSatSlotBuildTest {
private object UnusedSource : IRemoteSource {
override suspend fun getFileStream(uri: String): InputStream? = null
override suspend fun getNetworkStream(url: String): InputStream? = null
override suspend fun getFileBytes(uri: String): ByteArray? = null
override suspend fun getNetworkBytes(url: String): ByteArray? = null
override suspend fun getAmSatCatalog(): String? = null
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
override suspend fun getAmSatSummary(hours: Int): String? = null
@@ -41,7 +41,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.InputStream
@OptIn(ExperimentalCoroutinesApi::class)
class DatabaseRepoTest {
@@ -54,7 +53,7 @@ class DatabaseRepoTest {
val uri = "content://look4sat/import/satellites"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[uri] = { validCsvStream() }
fileData[uri] = { validCsvBytes() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
@@ -72,7 +71,7 @@ class DatabaseRepoTest {
val uri = "content://look4sat/import/legacy"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
fileStreams[uri] = { validTleStream() }
fileData[uri] = { validTleBytes() }
}
val settingsRepo = FakeSettingsRepo()
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
@@ -88,7 +87,7 @@ class DatabaseRepoTest {
val customCsvUrl = "https://example.com/custom-omm.csv"
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
networkStreams[customCsvUrl] = { validCsvStream() }
networkData[customCsvUrl] = { validCsvBytes() }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
@@ -134,7 +133,7 @@ class DatabaseRepoTest {
// unanswered on purpose: org.json is compileOnly in core:domain, so DataParser cannot
// parse a radio payload on the JVM anyway.
Sources.satelliteDataUrls.values.filter { it.isNotBlank() }
.forEach { networkStreams[it] = { validCsvStream() } }
.forEach { networkData[it] = { validCsvBytes() } }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
@@ -172,7 +171,7 @@ class DatabaseRepoTest {
val localSource = FakeLocalSource()
val remoteSource = FakeRemoteSource().apply {
Sources.transceiversDataUrls.values.filter { it.isNotBlank() }
.forEach { networkStreams[it] = { "[]".byteInputStream() } }
.forEach { networkData[it] = { "[]".encodeToByteArray() } }
}
val settingsRepo = FakeSettingsRepo(
dataSources = DataSourcesSettings(
@@ -195,30 +194,30 @@ class DatabaseRepoTest {
assertTrue("no entries may be inserted", localSource.insertedEntries.isEmpty())
}
private fun validCsvStream(): InputStream = """
private fun validCsvBytes(): ByteArray = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
""".trimIndent().encodeToByteArray()
private fun validTleStream(): InputStream = """
private fun validTleBytes(): ByteArray = """
ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
""".trimIndent().encodeToByteArray()
}
private class FakeRemoteSource : IRemoteSource {
val fileStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
val networkStreams: MutableMap<String, () -> InputStream> = mutableMapOf()
val fileData: MutableMap<String, () -> ByteArray> = mutableMapOf()
val networkData: MutableMap<String, () -> ByteArray> = mutableMapOf()
/** Every URL asked for, so a test can assert WHICH sources were fetched, not just the result. */
val requestedUrls = mutableListOf<String>()
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
override suspend fun getFileBytes(uri: String): ByteArray? = fileData[uri]?.invoke()
override suspend fun getNetworkStream(url: String): InputStream? {
override suspend fun getNetworkBytes(url: String): ByteArray? {
requestedUrls += url
return networkStreams[url]?.invoke()
return networkData[url]?.invoke()
}
override suspend fun getAmSatCatalog(): String? = null
-5
View File
@@ -1,8 +1,3 @@
plugins {
alias(libs.plugins.convention.coreDomainPlugin)
}
dependencies {
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
compileOnly("org.json:json:20240303")
}
@@ -91,7 +91,7 @@ object AprsBeacon {
)
val header = "$source>$DESTINATION,$PATH:="
val body = position.toUncompressedString()
val room = MAX_LINE_BYTES - CRLF_BYTES - header.toByteArray().size - body.toByteArray().size
val room = MAX_LINE_BYTES - CRLF_BYTES - header.encodeToByteArray().size - body.encodeToByteArray().size
return Result.Line(header + body + sanitiseComment(comment, room))
}
@@ -2,7 +2,7 @@ package com.rtbishop.look4sat.core.domain.aprs
import kotlin.math.abs
import kotlin.math.round
import java.util.Locale
import com.rtbishop.look4sat.core.domain.utility.formatString
/**
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
@@ -30,7 +30,7 @@ object AprsPacket {
/** Optional distance filter: filter r/lat/lon/dist */
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
return String.format(Locale.ROOT, "r/%.3f/%.3f/%d", latitude, longitude, distKm)
return formatString("r/%.3f/%.3f/%d", latitude, longitude, distKm)
}
/**
@@ -42,7 +42,7 @@ object AprsPacket {
fun formatAltitude(altitudeMeters: Double?): String {
if (altitudeMeters == null) return ""
val feet = (altitudeMeters * 3.2808399).toInt().coerceIn(0, 999999)
return String.format(Locale.ROOT, "/A=%06d", feet)
return formatString("/A=%06d", feet)
}
/**
@@ -54,7 +54,7 @@ object AprsPacket {
if (speedMps == null || bearing == null) return ""
val knots = (speedMps * 1.94384449).toInt().coerceIn(0, 999)
val course = ((bearing.toInt() % 360) + 360) % 360
return String.format(Locale.ROOT, "/%03d/%03d", course, knots)
return formatString("/%03d/%03d", course, knots)
}
}
@@ -106,17 +106,17 @@ class AprsPosition(
val hundredths = iRound % 100
val frac = when (positionAmbiguity) {
1 -> " . "
2 -> String.format(Locale.ROOT, "%d . ", minutes / 10)
3 -> String.format(Locale.ROOT, "%02d. ", minutes)
4 -> String.format(Locale.ROOT, "%02d.%d ", minutes, hundredths / 10)
else -> String.format(Locale.ROOT, "%02d.%02d", minutes, hundredths)
2 -> formatString("%d . ", minutes / 10)
3 -> formatString("%02d. ", minutes)
4 -> formatString("%02d.%d ", minutes, hundredths / 10)
else -> formatString("%02d.%02d", minutes, hundredths)
}
return if (isLat) {
val ns = if (value >= 0) 'N' else 'S'
String.format(Locale.ROOT, "%02d%s%c", degrees, frac, ns)
formatString("%02d%s%c", degrees, frac, ns)
} else {
val ew = if (value >= 0) 'E' else 'W'
String.format(Locale.ROOT, "%03d%s%c", degrees, frac, ew)
formatString("%03d%s%c", degrees, frac, ew)
}
}
}
@@ -0,0 +1,195 @@
/*
* 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.domain.cw
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.sin
/**
* Low-pass filter applied before decimating to the model's sample rate.
*
* [CwDeepSpectrogram.resampleLinear] drops from the capture rate to 3200 Hz by
* interpolating between samples, with nothing removing the content above the new
* Nyquist of 1600 Hz first. Everything higher folds back into the audible window,
* which is not a subtle degradation - measured on 44100 Hz input a 3000 Hz tone
* reappears at 200 Hz at 119 times the spectral mean, indistinguishable from a real
* signal, and 1800 Hz lands on 1400 Hz. Worse for copy, the whole 1600-22050 Hz band
* of hiss folds down on top of the signal and lifts the noise floor across the entire
* display.
*
* The resampler itself is deliberately left alone: its comment notes it matches the
* reference implementation the model was trained against, so changing its arithmetic
* would move the spectrogram away from what DeepCW expects. Filtering first fixes the
* aliasing without touching that contract.
*
* A windowed-sinc FIR rather than a biquad cascade: the transition band has to be
* steep to keep 1600 Hz while rejecting 1800 Hz, and a linear-phase FIR does not
* smear the keying envelope the way a high-order IIR would.
*/
object CwAntiAlias {
/**
* Cut-off as a fraction of the target Nyquist.
*
* Below 1.0 so the transition band lands inside the discarded region rather than
* straddling it. At 0.92 the response is flat to 1470 Hz, which still covers the
* model's 1200 Hz window and the shifter's detection range with room to spare.
*/
private const val CUTOFF_FRACTION = 0.92
/**
* Filter length. Odd so the group delay is a whole number of samples.
*
* 127 taps at 44100 Hz gives roughly a 700 Hz transition width - enough to put
* 1800 Hz down by more than 40 dB while passing 1470 Hz unattenuated. Longer would
* be sharper and slower; this runs on a phone during a pass.
*/
private const val TAPS = 127
/** Delay introduced by [TAPS], for callers that need to align another path. */
const val GROUP_DELAY_SAMPLES = TAPS / 2
/**
* Filter [audio] so that decimating to [targetRate] cannot alias.
*
* A no-op when [sourceRate] is at or below [targetRate], since there is nothing
* above the target Nyquist to remove. Returns a new array; [audio] is unchanged.
*/
fun prepareForDecimation(audio: FloatArray, sourceRate: Int, targetRate: Int): FloatArray {
if (audio.isEmpty() || sourceRate <= targetRate) return audio
val cutoffHz = targetRate / 2.0 * CUTOFF_FRACTION
return applyFir(audio, kernelFor(cutoffHz, sourceRate))
}
/**
* Windowed-sinc low-pass kernel, normalised to unity gain at DC.
*
* Blackman window: its sidelobes are around -58 dB against the Hamming window's
* -41 dB, and sidelobe level is exactly what decides how much of the folded band
* survives.
*/
private fun kernelFor(cutoffHz: Double, sampleRate: Int): FloatArray {
val normalised = cutoffHz / sampleRate
val half = TAPS / 2
val raw = DoubleArray(TAPS) { i ->
val n = i - half
val sinc = if (n == 0) {
2.0 * normalised
} else {
sin(2.0 * PI * normalised * n) / (PI * n)
}
val window = 0.42 -
0.5 * cos(2.0 * PI * i / (TAPS - 1)) +
0.08 * cos(4.0 * PI * i / (TAPS - 1))
sinc * window
}
val sum = raw.sum()
// Unity DC gain, so filtering does not change the level the model was trained on.
return FloatArray(TAPS) { i -> (raw[i] / sum).toFloat() }
}
/**
* Convolve, compensating for the filter's own delay so the output lines up with
* the input. Edge taps that fall outside the buffer see zeros, which costs the
* first and last [GROUP_DELAY_SAMPLES] samples of an isolated buffer.
*/
private fun applyFir(audio: FloatArray, kernel: FloatArray): FloatArray {
val out = FloatArray(audio.size)
for (i in audio.indices) {
var sum = 0f
for (k in kernel.indices) {
val j = i - k + GROUP_DELAY_SAMPLES
if (j >= 0 && j < audio.size) sum += kernel[k] * audio[j]
}
out[i] = sum
}
return out
}
/**
* Chunk-by-chunk filter that carries the state [prepareForDecimation] cannot.
*
* Two things are needed for concatenated chunks to match a whole-buffer filter.
* History is the obvious one: the FIR spans [TAPS] samples, so a chunk's first
* outputs need the tail of the one before it.
*
* The second is less obvious and was measured rather than reasoned about. A
* linear-phase FIR is centred, so output sample `i` needs input up to
* `i + GROUP_DELAY_SAMPLES` - samples that have not been captured yet when the
* chunk arrives. A first attempt let those taps fall off the end of the buffer and
* read as zeros; against a whole-buffer filter that diverged by 0.134 across the
* last 44 samples of every chunk, which is a click at each boundary rather than a
* rounding difference.
*
* So output is held back by [GROUP_DELAY_SAMPLES] samples: each call emits the
* samples whose lookahead has now arrived, and keeps the rest until the next chunk
* completes them. The cost is a fixed 63-sample delay, about 1.4 ms at 44100 Hz,
* against a 20 WPM dot of roughly 60 ms.
*
* Not thread-safe: driven from the single capture coroutine.
*/
class Streaming(sourceRate: Int, targetRate: Int) {
private val kernel: FloatArray? =
if (sourceRate <= targetRate) {
null
} else {
kernelFor(targetRate / 2.0 * CUTOFF_FRACTION, sourceRate)
}
/** Samples not yet emitted: filter history plus the lookahead still owed. */
private var pending = FloatArray(0)
/** Filter one chunk, continuing from the previous call. */
fun process(chunk: FloatArray): FloatArray {
val k = kernel ?: return chunk
if (chunk.isEmpty()) return chunk
val combined = FloatArray(pending.size + chunk.size)
pending.copyInto(combined)
chunk.copyInto(combined, pending.size)
// Only samples with a full window on both sides are ready. Everything from
// here on still needs input that has not arrived.
val ready = combined.size - TAPS + 1
if (ready <= 0) {
pending = combined
return FloatArray(0)
}
val out = FloatArray(ready)
for (i in 0 until ready) {
var sum = 0f
for (t in k.indices) {
sum += k[t] * combined[i + TAPS - 1 - t]
}
out[i] = sum
}
// Carry the tail that the next chunk will complete.
pending = combined.copyOfRange(ready, combined.size)
return out
}
/** Clear pending state, e.g. after a decoder reset. */
fun reset() {
pending = FloatArray(0)
}
}
}
@@ -37,9 +37,11 @@ interface ICwDecoder {
val decodedText: StateFlow<String>
/**
* Permanent transcript of everything that has scrolled out of the live
* window. Unlike [decodedText] this only ever grows (until [reset]); it is
* what the user reads back after a signal has passed.
* Transcript of audio that has been archived, and will not be revised.
*
* Only ever grows until [reset]. [decodedText] is rewritten from scratch on every
* redecode, so a pane that concatenates it loses text the operator has already read -
* which a paper log does not do. This is the flow such a pane must bind to.
*/
val historyText: StateFlow<String>
@@ -74,6 +76,17 @@ interface ICwDecoder {
/** Non-null when the decoder cannot run, for example the model failed to load. */
val errorMessage: StateFlow<String?>
/**
* Decode whatever audio is still held in the pipeline into [historyText].
*
* Nothing reaches [historyText] until audio has been pushed out of the live window and
* then accumulated into a full archive batch, so the last stretch of a session is always
* still in flight when capture stops - and neither holding place drains on its own. That
* stretch is the end of the transmission, the part with the call sign in it. Call on
* pause and before [close].
*/
suspend fun flush()
/** Feed captured mono PCM in -1..1. Safe to call from a capture thread. */
suspend fun processBuffer(samples: FloatArray, sampleRate: Int)
@@ -91,8 +91,7 @@ object QrzGridParser {
*
* Accepts a raw `k=v; k=v` header or the JSON array a cookie-export extension produces,
* since the operator pastes whatever their browser handed them. Parsed by regex rather
* than a JSON library because org.json is compileOnly here - it is supplied by Android at
* runtime and absent from unit tests, so a JSON path could not be tested.
* than a JSON library so it needs no extra dependency and stays testable as plain text.
*/
fun cookieHeader(raw: String): String {
val text = raw.trim()
@@ -19,10 +19,16 @@ package com.rtbishop.look4sat.core.domain.repository
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
@OptIn(ExperimentalTime::class)
interface ISensorsRepo {
val sensorData: StateFlow<Pair<Float, Float>>
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
// The default used to be System.currentTimeMillis(), which Kotlin/Native does not have;
// kotlin.time.Clock is the multiplatform equivalent.
fun getMagDeclination(geoPos: GeoPos, time: Long = Clock.System.now().toEpochMilliseconds()): Float
fun enableSensor()
fun disableSensor()
}
@@ -0,0 +1,28 @@
/*
* 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.domain.source
/** Minimal platform HTTP client used by Wavelog/QRZ features. Implemented per platform
* (OkHttp on Android, NSURLSession on iOS). */
interface IHttpClient {
suspend fun post(url: String, headers: Map<String, String>, body: String): HttpResult
suspend fun get(url: String, headers: Map<String, String>): HttpResult
}
/** [code] is the HTTP status code, or 0 when the request could not be sent at all. */
data class HttpResult(val code: Int, val body: String, val failure: String? = null)
@@ -17,11 +17,9 @@
*/
package com.rtbishop.look4sat.core.domain.source
import java.io.InputStream
interface IRemoteSource {
suspend fun getFileStream(uri: String): InputStream?
suspend fun getNetworkStream(url: String): InputStream?
suspend fun getFileBytes(uri: String): ByteArray?
suspend fun getNetworkBytes(url: String): ByteArray?
/** Fetch AMSAT API catalog (JSON string; null on failure) */
suspend fun getAmSatCatalog(): String?
@@ -0,0 +1,186 @@
/*
* 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.domain.utility
import kotlin.math.abs
/**
* Dependency-free replacement for jvm/Android `java.lang.String.format`, required because
* JVM formatting APIs do not exist on Kotlin/Native (iOS).
*
* Supported conversions: `%d` `%x` `%X` `%f` `%s` `%c` `%%`, plus the `0` flag, a width and
* `.precision` (for `%f`). Anything else throws, so an unsupported pattern never silently
* produces a wrong string.
*
* `%f` rounding matches java.lang.String.format (half-up on the exact double value) whenever
* the scaled value fits in a Long (< 2^53), which covers every frequency/coordinate string
* the app builds. Negative zero is preserved like the JVM does ("-0.000").
*/
fun formatString(pattern: String, vararg args: Any?): String {
val out = StringBuilder(pattern.length + 16)
var argIndex = 0
var i = 0
while (i < pattern.length) {
val ch = pattern[i]
if (ch != '%') {
out.append(ch); i++; continue
}
i++
if (i >= pattern.length) throw IllegalArgumentException("dangling '%' in pattern: $pattern")
if (pattern[i] == '%') {
out.append('%'); i++; continue
}
var zeroPadded = false
if (pattern[i] == '0') {
zeroPadded = true; i++
}
var width = 0
while (i < pattern.length && pattern[i].isDigit()) {
width = width * 10 + (pattern[i] - '0'); i++
}
// java.lang.String.format throws MissingFormatWidthException for this; an illegal
// pattern must not quietly format one way on Android and another way on iOS.
if (zeroPadded && width == 0) {
throw IllegalArgumentException("'0' flag without a width in pattern: $pattern")
}
var precision = -1 // java.lang.String.format defaults %f to 6 decimals
if (i < pattern.length && pattern[i] == '.') {
i++
precision = 0 // the digits accumulate from zero; -1 means "not specified"
while (i < pattern.length && pattern[i].isDigit()) {
precision = precision * 10 + (pattern[i] - '0'); i++
}
}
if (i >= pattern.length) throw IllegalArgumentException("truncated conversion in pattern: $pattern")
val conversion = pattern[i]
i++
val arg = if (argIndex < args.size) args[argIndex++] else null
val rendered = when (conversion) {
'd' -> longArg(arg, conversion, pattern).toString()
'x' -> longArg(arg, conversion, pattern).toString(16)
'X' -> longArg(arg, conversion, pattern).toString(16).uppercase()
'f' -> formatFixed(doubleArg(arg, pattern), if (precision < 0) 6 else precision, pattern)
's' -> arg?.toString() ?: "null"
'c' -> when (arg) {
is Char -> arg.toString()
is Int -> arg.toChar().toString()
else -> throw IllegalArgumentException("unsupported %c argument: $arg in pattern: $pattern")
}
else -> throw IllegalArgumentException("unsupported conversion %$conversion in pattern: $pattern")
}
if (width <= rendered.length) {
out.append(rendered)
} else if (zeroPadded && !rendered.startsWith("-") && !rendered.startsWith("+")) {
repeat(width - rendered.length) { out.append('0') }
out.append(rendered)
} else if (zeroPadded) {
out.append(rendered[0])
repeat(width - rendered.length) { out.append('0') }
out.append(rendered.substring(1))
} else {
repeat(width - rendered.length) { out.append(' ') }
out.append(rendered)
}
}
// Extra arguments are ignored, exactly like java.lang.String.format: call sites already
// pass what they pass and a port should not turn a latent extra argument into a crash.
return out.toString()
}
/** `"%.3f".format(1.2345)` -> `"1.235"` */
fun String.format(vararg args: Any?): String = formatString(this, *args)
private val POWERS_OF_TEN = longArrayOf(1, 10, 100, 1_000, 10_000, 100_000, 1_000_000, 10_000_000, 100_000_000)
private const val MAX_LONG_DIGITS = 18 // the most decimal digits that still fit in a Long
private fun longArg(arg: Any?, conversion: Char, pattern: String): Long = when (arg) {
is Int -> arg.toLong()
is Long -> arg
is Short -> arg.toLong()
is Byte -> arg.toLong()
else -> throw IllegalArgumentException("unsupported %$conversion argument: $arg in pattern: $pattern")
}
private fun doubleArg(arg: Any?, pattern: String): Double = when (arg) {
is Double -> arg
is Float -> arg.toDouble()
is Int -> arg.toDouble()
is Long -> arg.toDouble()
else -> throw IllegalArgumentException("unsupported %f argument: $arg in pattern: $pattern")
}
private fun formatFixed(value: Double, precision: Int, pattern: String): String {
if (precision !in 0..8) throw IllegalArgumentException("precision $precision too large in pattern: $pattern")
if (value.isNaN()) return "NaN"
if (value.isInfinite()) return if (value > 0.0) "Infinity" else "-Infinity"
val negative = value < 0.0 || (value == 0.0 && 1.0 / value < 0.0)
val rounded = roundHalfUp(abs(value), precision, value, pattern)
val power = POWERS_OF_TEN[precision]
val integerPart = rounded / power
val fractionPart = rounded % power
val result = StringBuilder()
if (negative) result.append('-')
result.append(integerPart)
if (precision > 0) {
result.append('.')
result.append(fractionPart.toString().padStart(precision, '0'))
}
return result.toString()
}
/**
* Rounds to [precision] decimals the way java.lang.String.format does: it rounds the shortest
* decimal representation of the double half-up, not its binary value. `"%.3f"` of 0.5005 is
* therefore `"0.501"`, even though the double holds 0.50049999999999994493.
*
* Scaling in binary first - floor(magnitude * 10^precision + 0.5) - loses exactly that and printed
* "0.500", so the digits come from the decimal representation and are rounded by integer
* arithmetic instead. Returns the value scaled by 10^precision.
*/
private fun roundHalfUp(magnitude: Double, precision: Int, value: Double, pattern: String): Long {
val text = magnitude.toString() // shortest representation that still round-trips
val exponentIndex = text.indexOfFirst { it == 'E' || it == 'e' }
val mantissa = if (exponentIndex < 0) text else text.substring(0, exponentIndex)
val exponent = if (exponentIndex < 0) 0 else text.substring(exponentIndex + 1).toInt()
val pointIndex = mantissa.indexOf('.')
val integerDigits = if (pointIndex < 0) mantissa else mantissa.substring(0, pointIndex)
val fractionDigits = if (pointIndex < 0) "" else mantissa.substring(pointIndex + 1)
val digits = integerDigits + fractionDigits
// magnitude == digits * 10^scale, so digits * 10^(scale + precision) is the scaled value.
val shift = exponent - fractionDigits.length + precision
val unscaled = digits.toLong()
if (shift >= 0) {
if (digits.length + shift > MAX_LONG_DIGITS) throw ValueTooLarge(value, precision, pattern)
var scaled = unscaled
repeat(shift) { scaled *= 10 }
return scaled
}
// Below half of the last printed digit everything rounds to zero, and 10^divisorDigits would
// no longer fit in a Long, so stop before building it.
val divisorDigits = -shift
if (divisorDigits > MAX_LONG_DIGITS) return 0L
var divisor = 1L
repeat(divisorDigits) { divisor *= 10 }
val quotient = unscaled / divisor
val remainder = unscaled % divisor
return if (2 * remainder >= divisor) quotient + 1 else quotient
}
// Values this large are never produced by the app; avoid silently wrong digits.
private class ValueTooLarge(value: Double, precision: Int, pattern: String) :
IllegalArgumentException("value $value too large for %.$precision" + "f in pattern: $pattern")
@@ -24,7 +24,6 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
import java.io.InputStream
import kotlin.math.pow
class DataParser(private val dispatcher: CoroutineDispatcher) {
@@ -34,22 +33,20 @@ class DataParser(private val dispatcher: CoroutineDispatcher) {
coerceInputValues = true
}
suspend fun parseCSVStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().useLines { lines ->
lines.drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
}
suspend fun parseCSV(data: String): List<OrbitalData> = withContext(dispatcher) {
data.lineSequence().drop(1).mapNotNull { parseCSV(it.split(",")) }.toList()
}
suspend fun parseTLEStream(stream: InputStream): List<OrbitalData> = withContext(dispatcher) {
stream.bufferedReader().readLines()
suspend fun parseTLE(data: String): List<OrbitalData> = withContext(dispatcher) {
data.lineSequence().toList()
.chunked(3)
.filter { it.size == 3 && it[1].startsWith("1") && it[2].startsWith("2") }
.mapNotNull { parseTLE(it) }
}
suspend fun parseJSONStream(stream: InputStream): List<SatRadio> = withContext(dispatcher) {
suspend fun parseJSON(data: String): List<SatRadio> = withContext(dispatcher) {
runCatching {
val root = json.parseToJsonElement(stream.bufferedReader().readText())
val root = json.parseToJsonElement(data)
(root as? JsonArray)?.mapNotNull { element ->
runCatching { json.decodeFromJsonElement<SatRadio>(element) }
.onFailure { println("JSON parsing exception: $it") }
@@ -11,7 +11,6 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import java.util.Locale
/**
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
@@ -110,10 +109,10 @@ object DopplerFrequencyCalculator {
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
if (!isLinearTransponder(transponder)) return false
val info = transponder.info.lowercase(Locale.ENGLISH)
val info = transponder.info.lowercase()
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
.joinToString(separator = " ")
.lowercase(Locale.ENGLISH)
.lowercase()
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
info.contains("xponder") || info.contains("xpdr")
@@ -17,16 +17,13 @@
*/
package com.rtbishop.look4sat.core.domain.utility
import java.util.Locale
import java.util.concurrent.TimeUnit
fun Long.toTimerString(): String {
val millis = coerceAtLeast(0L)
val format = "%02d:%02d:%02d"
val hours = TimeUnit.MILLISECONDS.toHours(millis)
val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) % 60
val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) % 60
return String.format(Locale.ENGLISH, format, hours, minutes, seconds)
val hours = millis / 3_600_000L
val minutes = millis / 60_000L % 60
val seconds = millis / 1_000L % 60
return formatString(format, hours, minutes, seconds)
}
fun Float.round(decimals: Int): Float {
@@ -0,0 +1,29 @@
/*
* 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.domain.utility
/**
* Runs [block] while holding [lock]'s monitor, the way kotlin.jvm.Synchronized used to hold it
* before core:domain became a multiplatform module.
*
* The annotation survives in common code as an optional expectation, but the stdlib deprecated it
* there in Kotlin 1.8 and made it an error in 2.1: "Synchronizing methods on a class instance is
* not supported on platforms other than JVM." The monitor therefore moves behind a platform
* actual, which keeps the JVM semantics exactly and lets the iOS side say what it does instead.
*/
internal expect fun <T> synchronizedOn(lock: Any, block: () -> T): T
@@ -5,6 +5,10 @@
*/
package com.rtbishop.look4sat.core.domain.wavelog
// kotlin.jvm.Volatile 是 common 里 2.1 起的编译错误;kotlin.concurrent.Volatile 才是多平台的那个,
// 且在 JVM 与 Kotlin/Native 上都生效。
import kotlin.concurrent.Volatile
object LotwSatellites {
private val staticNames: Set<String> = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9")
@@ -10,15 +10,17 @@
*/
package com.rtbishop.look4sat.core.domain.wavelog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
import com.rtbishop.look4sat.core.domain.source.HttpResult
import com.rtbishop.look4sat.core.domain.source.IHttpClient
import com.rtbishop.look4sat.core.domain.utility.formatString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
/** Station info (GET /api/v2/station/{id} result) */
data class WavelogStation(
@@ -35,7 +37,19 @@ sealed class WavelogResult {
object WaveLogApi {
private const val TIMEOUT_MS = 15000
/** Milliseconds in a day; date fields are derived from the QSO timestamp without Calendar. */
private const val MS_PER_DAY = 86_400_000L
/**
* Platform HTTP client for every request below. Handed over by the DI container because an
* object has no constructor for it, and a shared object has no platform socket API to use
* on its own.
*/
private var httpClient: IHttpClient? = null
fun installHttpClient(client: IHttpClient) {
httpClient = client
}
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
fun normalizeUrl(raw: String): String {
@@ -47,54 +61,54 @@ object WaveLogApi {
}
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
// v2: GET /index.php/api/v2/token
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
val v2 = httpRequest("$base/index.php/api/v2/token", apiKey, null)
if (v2.code in 200..299) return WavelogResult.Success("连接成功 (API v2)")
// v1: POST /index.php/api/get_contacts_adif (key in body)
if (stationId.isNotBlank()) {
val body = JSONObject().apply {
val body = buildJsonObject {
put("key", apiKey)
put("station_id", stationId)
put("fetchfromid", 0)
}.toString()
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", apiKey, body)
if (v1.code in 200..299) return WavelogResult.Success("连接成功 (API v1)")
if (v1.code == 401) return WavelogResult.Failure("API 密钥无效 (v1: 401)")
}
// v1 attempt without index.php
val body = JSONObject().apply {
val body = buildJsonObject {
put("key", apiKey)
put("station_id", stationId)
put("fetchfromid", 0)
}.toString()
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
val v1b = httpRequest("$base/api/get_contacts_adif", apiKey, body)
if (v1b.code in 200..299) return WavelogResult.Success("连接成功 (API v1)")
if (v1b.code == 401) return WavelogResult.Failure("API 密钥无效 (v1: 401)")
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
return WavelogResult.Failure("连接失败: v2 HTTP ${v2.code}, v1 HTTP ${v1b.code} — 请确认服务器地址/密钥正确")
}
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", apiKey, null)
if (code in 200..299) {
return@withContext try {
val obj = JSONObject(resp)
val data = obj.optJSONObject("data") ?: obj
return try {
val obj = Json.parseToJsonElement(resp).jsonObject
val data = obj["data"] as? JsonObject ?: obj
val station = WavelogStation(
id = data.optInt("id"),
name = data.optString("name"),
callsign = data.optString("callsign"),
gridsquare = data.optString("gridsquare")
id = data["id"]?.jsonPrimitive?.intOrNull ?: 0,
name = data["name"]?.jsonPrimitive?.contentOrNull.orEmpty(),
callsign = data["callsign"]?.jsonPrimitive?.contentOrNull.orEmpty(),
gridsquare = data["gridsquare"]?.jsonPrimitive?.contentOrNull.orEmpty()
)
WavelogResult.Success(JSONObject().apply {
WavelogResult.Success(buildJsonObject {
put("id", station.id); put("name", station.name)
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
}.toString())
@@ -103,7 +117,7 @@ object WaveLogApi {
}
}
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
WavelogResult.Success("")
return WavelogResult.Success("")
}
/**
@@ -182,7 +196,7 @@ object WaveLogApi {
LotwSatellites.names.firstOrNull { it.squashSeparators() == squashed }
?.let { return it }
}
return trimmed.uppercase(Locale.ENGLISH)
return trimmed.uppercase()
}
/** True when [normalizeSatName] produced a name LoTW will accept rather than a guess. */
@@ -206,7 +220,7 @@ object WaveLogApi {
return parts.filter { it.isNotEmpty() }.distinct()
}
private fun String.squashSeparators() = replace(Regex("[-\\s._/]"), "").uppercase(Locale.ENGLISH)
private fun String.squashSeparators() = replace(Regex("[-\\s._/]"), "").uppercase()
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
suspend fun postQso(
@@ -215,23 +229,23 @@ object WaveLogApi {
stationProfileId: String,
qso: WavelogQso,
gridsquare: String
): WavelogResult = withContext(Dispatchers.IO) {
): WavelogResult {
val base = normalizeUrl(url)
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
if (base.isBlank()) return WavelogResult.Failure("服务器地址为空")
val satName = normalizeSatName(qso.satName, qso.catnum.takeIf { it > 0 })
// v2: POST /index.php/api/v2/qso (JSON fields)
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
val v2Body = JSONObject().apply {
val v2Body = buildJsonObject {
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
put("call", qso.call)
put("band", bandFromHz(qso.freqTxHz))
put("mode", qso.mode)
put("qso_date", utcDate(qso.timeUtcMs))
put("time_on", utcTime(qso.timeUtcMs))
put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0))
put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0))
put("freq", formatString("%.6fM", qso.freqTxHz / 1_000_000.0))
put("freq_rx", formatString("%.6fM", qso.freqRxHz / 1_000_000.0))
put("gridsquare", gridsquare)
put("rst_sent", "59")
put("rst_rcvd", "59")
@@ -241,11 +255,11 @@ object WaveLogApi {
// The body decides, not the status code: Wavelog validates after responding, so a rejected
// QSO arrives as HTTP 200 with {"status":"failed"}. Trusting the code marked it uploaded
// and dropped it from the queue.
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", apiKey, v2Body.toString())
val v2Verdict = WavelogResponse.verdict(code, resp)
when (v2Verdict) {
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v2")
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v2")
WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
// Anything else falls through to v1. A rejection here is NOT final: v2 refuses a legacy
// v1 key with 401 invalid_token, and returning at that point stopped a v1-only operator
// from uploading at all. The v1 attempt below is the one that can speak for them.
@@ -253,29 +267,29 @@ object WaveLogApi {
}
// v1: POST /index.php/api/qso (key in body + ADIF)
val v1Body = JSONObject().apply {
val v1Body = buildJsonObject {
put("key", apiKey)
put("station_profile_id", stationProfileId)
put("type", "adif")
put("string", toAdif(qso, gridsquare, satName))
}
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
val (code1, resp1) = httpRequest("$base/index.php/api/qso", apiKey, v1Body.toString())
val v1Verdict = WavelogResponse.verdict(code1, resp1)
when (v1Verdict) {
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v1")
WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
// Also falls through: a server with different rewrite rules answers this path with a
// 404 page, which is a rejection but says nothing about whether the QSO can be stored.
else -> Unit
}
// v1 without index.php, for a server whose rewrite rules differ
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
val (code1b, resp1b) = httpRequest("$base/api/qso", apiKey, v1Body.toString())
when (val verdict = WavelogResponse.verdict(code1b, resp1b)) {
is WavelogResponse.Verdict.Accepted -> return@withContext WavelogResult.Success("v1")
WavelogResponse.Verdict.Duplicate -> return@withContext WavelogResult.Success("duplicate")
is WavelogResponse.Verdict.Accepted -> return WavelogResult.Success("v1")
WavelogResponse.Verdict.Duplicate -> return WavelogResult.Success("duplicate")
is WavelogResponse.Verdict.Rejected ->
return@withContext WavelogResult.Failure(verdict.reason)
return WavelogResult.Failure(verdict.reason)
is WavelogResponse.Verdict.Unreadable -> Unit
}
@@ -288,7 +302,7 @@ object WaveLogApi {
(v1Verdict as? WavelogResponse.Verdict.Rejected)?.reason,
(v2Verdict as? WavelogResponse.Verdict.Rejected)?.reason
).filter { it.isNotBlank() }
WavelogResult.Failure(
return WavelogResult.Failure(
reasons.firstOrNull()
?: ("no endpoint accepted it: v2 HTTP $code, v1 HTTP $code1, v1-alt HTTP $code1b" +
" - " + shortError(resp1.ifBlank { resp1b }))
@@ -298,7 +312,7 @@ object WaveLogApi {
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
internal fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
fun field(name: String, value: String): String {
val bytes = value.toByteArray(Charsets.UTF_8).size
val bytes = value.encodeToByteArray().size
return "<$name:$bytes>$value"
}
val satMode = satModeFrom(qso.freqTxHz, qso.freqRxHz)
@@ -306,9 +320,9 @@ object WaveLogApi {
append(field("call", qso.call))
append(field("band", bandFromHz(qso.freqTxHz)))
append(field("mode", qso.mode))
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
append(field("freq", formatString("%.6f", qso.freqTxHz / 1_000_000.0)))
if (qso.freqRxHz > 0) {
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
append(field("freq_rx", formatString("%.6f", qso.freqRxHz / 1_000_000.0)))
}
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
@@ -328,76 +342,78 @@ object WaveLogApi {
}
}
/** Generic HTTP request (returns code + body) */
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
return try {
val conn = URL(url).openConnection() as HttpURLConnection
conn.requestMethod = method
conn.connectTimeout = TIMEOUT_MS
conn.readTimeout = TIMEOUT_MS
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
/**
* POST [jsonBody] when one is given, GET otherwise. A request that could not be sent at all
* comes back as code 0, so callers only have to look at the code.
*/
private suspend fun httpRequest(url: String, apiKey: String, jsonBody: String?): HttpResult {
val client = httpClient ?: error("WaveLogApi has no HTTP client installed")
val headers = buildMap {
if (apiKey.isNotBlank()) put("Authorization", "Bearer $apiKey")
if (jsonBody != null) {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Accept", "application/json")
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
put("Content-Type", "application/json")
put("Accept", "application/json")
}
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val body = if (stream != null) {
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
} else ""
code to body
} catch (e: Exception) {
-1 to (e.message ?: e.javaClass.simpleName)
}
val result = if (jsonBody != null) client.post(url, headers, jsonBody) else client.get(url, headers)
// A request that never left the phone used to report the exception text where the body
// goes, which is what the failure messages below read; keep it there.
val failure = result.failure
return if (result.body.isEmpty() && failure != null) result.copy(body = failure) else result
}
private fun shortError(body: String): String {
if (body.startsWith("<")) return body.take(80) // HTML error page
return try {
val obj = JSONObject(body)
val err = obj.optJSONObject("error")
err?.optString("message")?.ifBlank { body.take(120) }
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
val obj = Json.parseToJsonElement(body).jsonObject
val err = obj["error"] as? JsonObject
if (err != null) {
err["message"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { body.take(120) }
} else {
obj["reason"]?.jsonPrimitive?.contentOrNull.orEmpty()
.ifBlank { obj["message"]?.jsonPrimitive?.contentOrNull.orEmpty().ifBlank { body.take(120) } }
}
} catch (_: Exception) {
body.take(120)
}
}
/** UTC civil time of a Unix millisecond stamp; the JVM Calendar is not multiplatform. */
private data class UtcFields(val year: Int, val month: Int, val day: Int, val hour: Int, val minute: Int, val second: Int)
private fun utcFieldsOf(ms: Long): UtcFields {
val days = ms.floorDiv(MS_PER_DAY)
val millisOfDay = ms.mod(MS_PER_DAY)
val shifted = days + 719_468
val era = shifted.floorDiv(146_097)
val dayOfEra = shifted - era * 146_097
val yearOfEra = (dayOfEra - dayOfEra / 1_460 + dayOfEra / 36_524 - dayOfEra / 146_096) / 365
val dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100)
val monthPart = (5 * dayOfYear + 2) / 153
val day = (dayOfYear - (153 * monthPart + 2) / 5 + 1).toInt()
val month = (if (monthPart < 10) monthPart + 3 else monthPart - 9).toInt()
val year = yearOfEra.toInt() + era.toInt() * 400 + (if (month <= 2) 1 else 0)
val secondOfDay = (millisOfDay / 1000).toInt()
return UtcFields(year, month, day, secondOfDay / 3_600, secondOfDay / 60 % 60, secondOfDay % 60)
}
private fun utcDate(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%04d-%02d-%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
val utc = utcFieldsOf(ms)
return formatString("%04d-%02d-%02d", utc.year, utc.month, utc.day)
}
private fun utcTime(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%02d:%02d:%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
val utc = utcFieldsOf(ms)
return formatString("%02d:%02d:%02d", utc.hour, utc.minute, utc.second)
}
private fun utcDateCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%04d%02d%02d".format(
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
cal.get(java.util.Calendar.DAY_OF_MONTH)
)
val utc = utcFieldsOf(ms)
return formatString("%04d%02d%02d", utc.year, utc.month, utc.day)
}
private fun utcTimeCompact(ms: Long): String {
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
cal.timeInMillis = ms
return "%02d%02d%02d".format(
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
cal.get(java.util.Calendar.SECOND)
)
val utc = utcFieldsOf(ms)
return formatString("%02d%02d%02d", utc.hour, utc.minute, utc.second)
}
}
@@ -0,0 +1,162 @@
/*
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
*
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
* implemented with SharedPreferences in core/data.
* Queue capped at 500 entries (oldest dropped beyond that).
*/
package com.rtbishop.look4sat.core.domain.wavelog
import com.rtbishop.look4sat.core.domain.utility.synchronizedOn
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
/** Storage abstraction (SharedPreferences impl lives in core/data) */
interface IWavelogQueueStore {
fun load(): String
fun save(json: String)
}
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
data class WavelogQso(
val id: String, // 本地唯一 id(UUID)
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
val call: String,
val mode: String,
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
val freqRxHz: Long, // 下行
val satName: String,
/**
* NORAD catalogue number of the satellite, or 0 when it was not recorded.
*
* Carried because the name alone cannot decide the LoTW spelling - sources disagree, and
* the same satellite named two ways would upload two ways. Zero means a QSO logged before
* this field existed; those fall back to resolving from the name.
*/
val catnum: Int = 0,
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
)
/**
* Every mutator is a read-modify-write over the single stored blob and serialises on a private
* monitor, so the Compose thread and the upload coroutine cannot drop each other's entries. The
* monitor is a platform actual (utility/SynchronizedOn.kt) because kotlin.jvm.Synchronized is an
* error in common code since Kotlin 2.1.
*/
class WavelogQueue(private val store: IWavelogQueueStore) {
private val key = "wavelog_queue"
private val lock = Any()
fun all(): List<WavelogQso> {
val raw = store.load()
return try {
Json.parseToJsonElement(raw).jsonArray.map { element ->
val o = element.jsonObject
WavelogQso(
id = o.getValue("id").jsonPrimitive.content,
timeUtcMs = o["timeUtcMs"].readLong(),
call = o["call"].readString(),
mode = o["mode"].readString(),
freqTxHz = o["freqTxHz"].readLong(),
freqRxHz = o["freqRxHz"].readLong(),
satName = o["satName"].readString(),
catnum = o["catnum"].readInt(),
sessionId = o["sessionId"].readString(),
gridsquare = o["gridsquare"].readString(),
uploaded = o["uploaded"].readBoolean()
)
}
} catch (_: Exception) {
emptyList()
}
}
fun add(qso: WavelogQso) {
synchronizedOn(lock) {
val list = all().toMutableList()
list.add(0, qso) // 最新在前
if (list.size > 500) list.removeAt(list.size - 1)
save(list)
}
}
fun remove(id: String) {
synchronizedOn(lock) { save(all().filter { it.id != id }) }
}
fun removeAll(ids: Set<String>) {
synchronizedOn(lock) { save(all().filter { it.id !in ids }) }
}
/** Mark as uploaded (kept in the queue; checkmark in the table) */
fun markUploaded(id: String) {
synchronizedOn(lock) { save(all().map { if (it.id == id) it.copy(uploaded = true) else it }) }
}
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
fun updateGridsquare(id: String, grid: String) {
synchronizedOn(lock) { save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it }) }
}
/** Remove all uploaded entries (optional; keeps the queue lean) */
fun removeUploaded() {
synchronizedOn(lock) { save(all().filter { !it.uploaded }) }
}
private fun save(list: List<WavelogQso>) {
val arr = buildJsonArray {
list.forEach { q ->
add(buildJsonObject {
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
put("catnum", q.catnum)
put("sessionId", q.sessionId)
put("gridsquare", q.gridsquare)
put("uploaded", q.uploaded)
})
}
}
store.save(arr.toString())
}
}
/*
* org.json's opt* readers never threw: a decimal ("1234.0", or the string "1234.0") was coerced to
* a whole number, a missing or mismatched field fell back to the default, and a field holding an
* object was stringified. kotlinx answers null for the first two - which turned a readable
* timestamp into 0L, i.e. a QSO uploaded as 1970 - and throws for the third, which took the whole
* list down with it. These keep the old behaviour, except that a JSON null becomes the empty
* string or 0 instead of the literal "null".
*/
private fun JsonElement?.readLong(default: Long = 0L): Long {
val primitive = this as? JsonPrimitive ?: return default
primitive.longOrNull?.let { return it }
return primitive.content.toDoubleOrNull()?.takeIf { it.isFinite() }?.toLong() ?: default
}
private fun JsonElement?.readInt(default: Int = 0): Int {
val primitive = this as? JsonPrimitive ?: return default
primitive.intOrNull?.let { return it }
return primitive.content.toDoubleOrNull()?.takeIf { it.isFinite() }?.toInt() ?: default
}
private fun JsonElement?.readString(default: String = ""): String =
(this as? JsonPrimitive)?.contentOrNull ?: default
private fun JsonElement?.readBoolean(default: Boolean = false): Boolean =
(this as? JsonPrimitive)?.booleanOrNull ?: default
@@ -25,8 +25,8 @@ package com.rtbishop.look4sat.core.domain.wavelog
* the code alone marked it uploaded and dropped it from the queue - the same class of defect as
* the APRS reporter claiming a send succeeded when nothing had left the phone.
*
* Parsed as text rather than with JSONObject on purpose: org.json is compileOnly in core:domain,
* so a JVM unit test gets the stub and every assertion against it would be vacuous.
* Parsed as text rather than through a JSON library on purpose: what Wavelog answers is sparse and
* freely worded, and matching it as text keeps the verdict testable without a parser.
*/
object WavelogResponse {
@@ -9,7 +9,10 @@
package com.rtbishop.look4sat.core.domain.wavelog
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
sealed class UploadOutcome {
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
@@ -99,7 +102,8 @@ class WavelogUploader(
val result = WaveLogApi.getStation(url, apiKey, stationId)
if (result is WavelogResult.Success) {
return try {
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
Json.parseToJsonElement(result.message).jsonObject["gridsquare"]
?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
?: cachedStationGrid
} catch (_: Exception) { cachedStationGrid }
}
@@ -19,104 +19,108 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.utility.DataParser
import com.rtbishop.look4sat.core.domain.utility.aprsPasscode
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
@OptIn(ExperimentalTime::class)
@ExperimentalCoroutinesApi
class DataParserTest {
private val testDispatcher = StandardTestDispatcher()
private val dataParser = DataParser(testDispatcher)
private val validCSVStream = """
private val validCSV = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
ISS (ZARYA),1998-067A,2024-03-09T05:45:04.737024,15.49756209,.0005741,51.6418,90.7424,343.9724,92.8274,0,U,25544,999,44305,.25016E-3,.1373E-3,0
""".trimIndent().byteInputStream()
private val invalidCSVStream = """
""".trimIndent()
private val invalidCSV = """
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
""".trimIndent().byteInputStream()
private val validTLEStream = """
""".trimIndent()
private val validTLE = """
ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
ISS (ZARYA)
1 25544U 98067A 24069.23963816 .00013730 00000+0 25016-3 0 9999
2 25544 51.6418 90.7424 0005741 343.9724 92.8274 15.49756209443058
""".trimIndent().byteInputStream()
private val invalidTLEStream = """
""".trimIndent()
private val invalidTLE = """
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
private val validJSONStream = """
""".trimIndent()
private val validJSON = """
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}]
""".trimIndent().byteInputStream()
private val invalidJSONStream = """
""".trimIndent()
private val invalidJSON = """
[{"description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":136658500,"downlink_high":null,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED - https://xkcd.com/285/","service":"Unknown","coordination":"","coordination_url":""}]
""".trimIndent().byteInputStream()
""".trimIndent()
@Test
fun `Given valid CSV stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseCSVStream(validCSVStream)
assert(parsedList.size == 2)
assert(parsedList[0].epoch == 21320.51955234)
assert(parsedList[1].epoch == 24069.23963816)
val parsedList = dataParser.parseCSV(validCSV)
assertTrue(parsedList.size == 2)
assertTrue(parsedList[0].epoch == 21320.51955234)
assertTrue(parsedList[1].epoch == 24069.23963816)
}
@Test
fun `Given valid CSV stream all orbital fields are parsed correctly`() = runTest(testDispatcher) {
val csvStream = """
val csv = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
val sat = dataParser.parseCSVStream(csvStream)[0]
assert(sat.name == "ISS (ZARYA)")
assert(sat.catnum == 25544)
assert(sat.meanmo == 15.48582035)
assert(sat.eccn == 0.0004694)
assert(sat.incl == 51.6447)
assert(sat.raan == 309.4881)
assert(sat.argper == 203.6966)
assert(sat.meanan == 299.8876)
assert(sat.bstar == 0.31985E-4)
assert(sat.ndot == 0.1288E-4)
""".trimIndent()
val sat = dataParser.parseCSV(csv)[0]
assertTrue(sat.name == "ISS (ZARYA)")
assertTrue(sat.catnum == 25544)
assertTrue(sat.meanmo == 15.48582035)
assertTrue(sat.eccn == 0.0004694)
assertTrue(sat.incl == 51.6447)
assertTrue(sat.raan == 309.4881)
assertTrue(sat.argper == 203.6966)
assertTrue(sat.meanan == 299.8876)
assertTrue(sat.bstar == 0.31985E-4)
assertTrue(sat.ndot == 0.1288E-4)
}
@Test
fun `Given valid CSV stream ndot is parsed for decay detection`() = runTest(testDispatcher) {
val csvStream = """
val csv = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,2021-11-16T12:28:09.322176,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
val sat = dataParser.parseCSVStream(csvStream)[0]
""".trimIndent()
val sat = dataParser.parseCSV(csv)[0]
// ISS is healthy, should not be decayed even years later
assert(!sat.hasDecayed(System.currentTimeMillis()))
assertTrue(!sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
}
@Test
fun `Given CSV with high drag satellite detects decay`() = runTest(testDispatcher) {
// Simulate a satellite with high drag and old epoch that should have decayed
val csvStream = """
val csv = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
DEBRIS,2020-001A,2020-01-15T00:00:00.000000,15.9,.001,51.0,100.0,200.0,300.0,0,U,99999,1,100,.5E-3,.05,0
""".trimIndent().byteInputStream()
val sat = dataParser.parseCSVStream(csvStream)[0]
""".trimIndent()
val sat = dataParser.parseCSV(csv)[0]
// High mean motion (15.9) + high drag (.05) + old epoch → should be decayed by now
assert(sat.hasDecayed(System.currentTimeMillis()))
assertTrue(sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
}
@Test
fun `Given invalid CSV stream returns empty list`() = runTest(testDispatcher) {
assert(dataParser.parseCSVStream(invalidCSVStream).isEmpty())
assertTrue(dataParser.parseCSV(invalidCSV).isEmpty())
}
private fun csvWithEpoch(epoch: String) = """
OBJECT_NAME,OBJECT_ID,EPOCH,MEAN_MOTION,ECCENTRICITY,INCLINATION,RA_OF_ASC_NODE,ARG_OF_PERICENTER,MEAN_ANOMALY,EPHEMERIS_TYPE,CLASSIFICATION_TYPE,NORAD_CAT_ID,ELEMENT_SET_NO,REV_AT_EPOCH,BSTAR,MEAN_MOTION_DOT,MEAN_MOTION_DDOT
ISS (ZARYA),1998-067A,$epoch,15.48582035,.0004694,51.6447,309.4881,203.6966,299.8876,0,U,25544,999,31220,.31985E-4,.1288E-4,0
""".trimIndent().byteInputStream()
""".trimIndent()
@Test
fun `Given CSV epoch one minute past midnight the day fraction is correct`() = runTest(testDispatcher) {
@@ -125,41 +129,41 @@ class DataParserTest {
// notation below 1e-3, so the leading significant digit was truncated.
// 00:01:00 produced "25001.944444444444445E-4" -> 2.50019..., an epoch
// roughly 26 years off, with no exception to reveal it.
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, 1e-9)
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:01:00.000000"))[0]
assertEquals(25001.0 + 60.0 / 86400.0, sat.epoch, absoluteTolerance = 1e-9)
}
@Test
fun `Given CSV epoch one second past midnight the day fraction is correct`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, 1e-9)
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:00:01.000000"))[0]
assertEquals(25001.0 + 1.0 / 86400.0, sat.epoch, absoluteTolerance = 1e-9)
}
@Test
fun `Given CSV epoch exactly at midnight the day fraction is zero`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
assertEquals(25001.0, sat.epoch, 1e-9)
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T00:00:00.000000"))[0]
assertEquals(25001.0, sat.epoch, absoluteTolerance = 1e-9)
}
@Test
fun `Given CSV epoch at midday the day fraction is one half`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
assertEquals(25001.5, sat.epoch, 1e-9)
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T12:00:00.000000"))[0]
assertEquals(25001.5, sat.epoch, absoluteTolerance = 1e-9)
}
@Test
fun `Given CSV epoch late in the day the day fraction stays below one`() = runTest(testDispatcher) {
val sat = dataParser.parseCSVStream(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
assert(sat.epoch > 25001.999) { "expected almost a full day, got ${sat.epoch}" }
assert(sat.epoch < 25002.0) { "day fraction must not roll into the next day, got ${sat.epoch}" }
val sat = dataParser.parseCSV(csvWithEpoch("2025-01-01T23:59:59.999000"))[0]
assertTrue(sat.epoch > 25001.999, "expected almost a full day, got ${sat.epoch}")
assertTrue(sat.epoch < 25002.0, "day fraction must not roll into the next day, got ${sat.epoch}")
}
@Test
fun `Given valid TLE stream returns valid data`() = runTest(testDispatcher) {
val parsedList = dataParser.parseTLEStream(validTLEStream)
assert(parsedList.size == 2)
assert(parsedList[0].epoch == 21320.51955234)
assert(parsedList[1].epoch == 24069.23963816)
val parsedList = dataParser.parseTLE(validTLE)
assertTrue(parsedList.size == 2)
assertTrue(parsedList[0].epoch == 21320.51955234)
assertTrue(parsedList[1].epoch == 24069.23963816)
}
@Test
@@ -168,17 +172,17 @@ class DataParserTest {
ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
val sat = dataParser.parseTLEStream(tleStream)[0]
assert(sat.name == "ISS (ZARYA)")
assert(sat.catnum == 25544)
assert(sat.meanmo == 15.48582035)
assert(sat.eccn == 0.0004694)
assert(sat.incl == 51.6447)
assert(sat.raan == 309.4881)
assert(sat.argper == 203.6966)
assert(sat.meanan == 299.8876)
assert(sat.ndot == 0.00001288)
""".trimIndent()
val sat = dataParser.parseTLE(tleStream)[0]
assertTrue(sat.name == "ISS (ZARYA)")
assertTrue(sat.catnum == 25544)
assertTrue(sat.meanmo == 15.48582035)
assertTrue(sat.eccn == 0.0004694)
assertTrue(sat.incl == 51.6447)
assertTrue(sat.raan == 309.4881)
assertTrue(sat.argper == 203.6966)
assertTrue(sat.meanan == 299.8876)
assertTrue(sat.ndot == 0.00001288)
}
@Test
@@ -187,66 +191,66 @@ class DataParserTest {
ISS (ZARYA)
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
""".trimIndent().byteInputStream()
val sat = dataParser.parseTLEStream(tleStream)[0]
assert(!sat.hasDecayed(System.currentTimeMillis()))
""".trimIndent()
val sat = dataParser.parseTLE(tleStream)[0]
assertTrue(!sat.hasDecayed(Clock.System.now().toEpochMilliseconds()))
}
@Test
fun `Given invalid TLE stream returns empty list`() = runTest(testDispatcher) {
assert(dataParser.parseTLEStream(invalidTLEStream).isEmpty())
assertTrue(dataParser.parseTLE(invalidTLE).isEmpty())
}
@Test
fun `Given valid JSON stream returns valid data`() = runTest(testDispatcher) {
assert(dataParser.parseJSONStream(validJSONStream)[0].downlinkLow == 136658500L)
assertTrue(dataParser.parseJSON(validJSON)[0].downlinkLow == 136658500L)
}
@Test
fun `Given valid JSON stream all radio fields are parsed correctly`() = runTest(testDispatcher) {
val jsonStream = """
[{"uuid":"UzPz4gcsNBPKPKAFPmer7g","description":"Upper side band (drifting)","alive":true,"type":"Transmitter","uplink_low":145900000,"uplink_high":146000000,"uplink_drift":null,"downlink_low":136658500,"downlink_high":136700000,"downlink_drift":null,"mode":"USB","mode_id":9,"uplink_mode":"FM","invert":true,"baud":null,"sat_id":"SCHX-0895-2361-9925-0309","norad_cat_id":965,"status":"active","updated":"2019-04-18T05:39:53.343316Z","citation":"CITATION NEEDED","service":"Unknown","coordination":"","coordination_url":""}]
""".trimIndent().byteInputStream()
val radio = dataParser.parseJSONStream(jsonStream)[0]
assert(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
assert(radio.info == "Upper side band (drifting)")
assert(radio.isAlive)
assert(radio.downlinkLow == 136658500L)
assert(radio.downlinkHigh == 136700000L)
assert(radio.downlinkMode == "USB")
assert(radio.uplinkLow == 145900000L)
assert(radio.uplinkHigh == 146000000L)
assert(radio.uplinkMode == "FM")
assert(radio.isInverted)
assert(radio.catnum == 965)
""".trimIndent()
val radio = dataParser.parseJSON(jsonStream)[0]
assertTrue(radio.uuid == "UzPz4gcsNBPKPKAFPmer7g")
assertTrue(radio.info == "Upper side band (drifting)")
assertTrue(radio.isAlive)
assertTrue(radio.downlinkLow == 136658500L)
assertTrue(radio.downlinkHigh == 136700000L)
assertTrue(radio.downlinkMode == "USB")
assertTrue(radio.uplinkLow == 145900000L)
assertTrue(radio.uplinkHigh == 146000000L)
assertTrue(radio.uplinkMode == "FM")
assertTrue(radio.isInverted)
assertTrue(radio.catnum == 965)
}
@Test
fun `Given JSON with null optional fields parses without error`() = runTest(testDispatcher) {
val jsonStream = """
[{"uuid":"abc123","description":"Beacon","alive":false,"type":"Transmitter","uplink_low":null,"uplink_high":null,"uplink_drift":null,"downlink_low":145800000,"downlink_high":null,"downlink_drift":null,"mode":null,"mode_id":null,"uplink_mode":null,"invert":false,"baud":null,"sat_id":"TEST","norad_cat_id":12345,"status":"active","updated":"2024-01-01T00:00:00Z","citation":"","service":"Unknown","coordination":"","coordination_url":""}]
""".trimIndent().byteInputStream()
val radio = dataParser.parseJSONStream(jsonStream)[0]
assert(radio.uuid == "abc123")
assert(!radio.isAlive)
assert(radio.downlinkLow == 145800000L)
assert(radio.downlinkHigh == null)
assert(radio.downlinkMode == null)
assert(radio.uplinkLow == null)
assert(radio.uplinkHigh == null)
assert(radio.uplinkMode == null)
assert(!radio.isInverted)
assert(radio.catnum == 12345)
""".trimIndent()
val radio = dataParser.parseJSON(jsonStream)[0]
assertTrue(radio.uuid == "abc123")
assertTrue(!radio.isAlive)
assertTrue(radio.downlinkLow == 145800000L)
assertTrue(radio.downlinkHigh == null)
assertTrue(radio.downlinkMode == null)
assertTrue(radio.uplinkLow == null)
assertTrue(radio.uplinkHigh == null)
assertTrue(radio.uplinkMode == null)
assertTrue(!radio.isInverted)
assertTrue(radio.catnum == 12345)
}
@Test
fun `Given invalid JSON stream returns empty list`() = runTest(testDispatcher) {
assert(dataParser.parseJSONStream(invalidJSONStream).isEmpty())
assertTrue(dataParser.parseJSON(invalidJSON).isEmpty())
}
@Test
fun `Given valid data streams parsed results match`() = runTest(testDispatcher) {
assert(dataParser.parseCSVStream(validCSVStream) == dataParser.parseTLEStream(validTLEStream))
assertTrue(dataParser.parseCSV(validCSV) == dataParser.parseTLE(validTLE))
}
@Test
@@ -254,38 +258,38 @@ class DataParserTest {
val years = listOf(1900, 1984, 1994, 2000, 2016, 2022, 2024, 2042, 2048, 2100)
val expected = listOf(false, true, false, true, true, false, true, false, true, false)
val results = years.map { dataParser.isLeapYear(it) }
assert(results == expected)
assertTrue(results == expected)
}
@Test
fun `getDayOfYear returns correct day for January 1st`() {
assert(dataParser.getDayOfYear(2024, 1, 1) == 1)
assert(dataParser.getDayOfYear(2023, 1, 1) == 1)
assertTrue(dataParser.getDayOfYear(2024, 1, 1) == 1)
assertTrue(dataParser.getDayOfYear(2023, 1, 1) == 1)
}
@Test
fun `getDayOfYear returns correct day for March 1st in leap and non-leap years`() {
// 2024 is leap: Jan(31) + Feb(29) + 1 = 61
assert(dataParser.getDayOfYear(2024, 3, 1) == 61)
assertTrue(dataParser.getDayOfYear(2024, 3, 1) == 61)
// 2023 is not leap: Jan(31) + Feb(28) + 1 = 60
assert(dataParser.getDayOfYear(2023, 3, 1) == 60)
assertTrue(dataParser.getDayOfYear(2023, 3, 1) == 60)
}
@Test
fun `getDayOfYear returns correct day for December 31st`() {
assert(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
assert(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
assertTrue(dataParser.getDayOfYear(2024, 12, 31) == 366) // leap year
assertTrue(dataParser.getDayOfYear(2023, 12, 31) == 365) // non-leap year
}
@Test
fun `getDayOfYear returns correct day for November 16th`() {
// Matches the CSV test data epoch: 2021-11-16 → day 320
assert(dataParser.getDayOfYear(2021, 11, 16) == 320)
assertTrue(dataParser.getDayOfYear(2021, 11, 16) == 320)
}
@Test
fun `check APRS passcode calculation`() {
assert("M7LNB".aprsPasscode() == 12443)
assert("N0CALL".aprsPasscode() == 13023)
assertTrue("M7LNB".aprsPasscode() == 12443)
assertTrue("N0CALL".aprsPasscode() == 13023)
}
}
@@ -3,12 +3,12 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DopplerFrequencyCalculatorTest {
@@ -268,6 +268,6 @@ class DopplerFrequencyCalculatorTest {
val roundTripDownlink = DopplerFrequencyCalculator.computeDownlinkFromUplink(uplink!!, xpdr, orbitalPos)
assertNotNull(roundTripDownlink)
val error = kotlin.math.abs(roundTripDownlink!! - originalDownlink)
assertTrue("Round-trip error too large: $error", error < 10000)
assertTrue(error < 10000, "Round-trip error too large: $error")
}
}
@@ -21,59 +21,60 @@ import com.rtbishop.look4sat.core.domain.utility.positionToQth
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertTrue
class QthConverterTest {
@Test
fun `Given valid QTH returns correct POS`() {
var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
assertTrue(result?.latitude == 51.499913 && result.longitude == -0.22309)
result = qthToPosition("gf15vc")
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
assertTrue(result?.latitude == -34.895833 && result.longitude == -56.208333)
// 8-char locators: finer 30" x 15" cell center
result = qthToPosition("io91vl47")
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
assertTrue(result?.latitude == 51.489583 && result.longitude == -0.2125)
result = qthToPosition("jn58td25")
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
assertTrue(result?.latitude == 48.147917 && result.longitude == 11.604167)
}
@Test
fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null)
assert(qthToPosition("io9") == null)
assert(qthToPosition("IO91VL7") == null)
assert(qthToPosition("IO91VL4X") == null)
assertTrue(qthToPosition("ZZ00zz") == null)
assertTrue(qthToPosition("JN58") == null)
assertTrue(qthToPosition("io9") == null)
assertTrue(qthToPosition("IO91VL7") == null)
assertTrue(qthToPosition("IO91VL4X") == null)
}
@Test
fun `Given valid POS returns correct QTH`() {
// default precision is 8 chars
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
assertTrue(positionToQth(51.4878, -0.2146) == "IO91vl47")
assertTrue(positionToQth(48.1466, 11.6083) == "JN58td25")
// 6-char precision still available for backwards compatibility
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
assertTrue(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
assertTrue(positionToQth(48.1466, 11.6083, 6) == "JN58td")
// 10-char precision
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
assertTrue(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
assertTrue(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
}
@Test
fun `Given invalid POS returns null`() {
assert(positionToQth(91.0542, -170.1142) == null)
assert(positionToQth(89.0542, -240.1142) == null)
assertTrue(positionToQth(91.0542, -170.1142) == null)
assertTrue(positionToQth(89.0542, -240.1142) == null)
}
@Test
fun `Given boundary POS stays in valid grid`() {
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
assertTrue(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
// Exact positive bounds belong to the final cell, not a modulo-wrapped
// R-field/0-square combination that decodes 10°/20° away.
assert(positionToQth(90.0, 180.0, 8) == "RR99xx99")
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
assertTrue(positionToQth(90.0, 180.0, 8) == "RR99xx99")
assertTrue(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
val positions = listOf(
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
@@ -83,7 +84,7 @@ class QthConverterTest {
val qth = positionToQth(lat, lon, 8)
val pos = qthToPosition(qth!!)
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
assertTrue(qth == qth2, "Roundtrip failed for ($lat, $lon): $qth -> $qth2")
}
}
@@ -113,64 +114,68 @@ class QthConverterTest {
}
lat += 0.5
}
assert(worstLat <= 0.01 && worstLon <= 0.01) {
assertTrue(
worstLat <= 0.01 && worstLon <= 0.01,
"roundtrip drifted by (${worstLat}, ${worstLon}) deg, worst: $worst"
}
)
}
@Test
fun `Given out of range longitude returns null`() {
// Maidenhead only covers -180..180; 181..360 used to be accepted and
// encoded into a plausible-looking locator 20-200 deg away.
assert(positionToQth(0.0, 181.0) == null)
assert(positionToQth(0.0, 270.0) == null)
assert(positionToQth(0.0, 360.0) == null)
assertTrue(positionToQth(0.0, 181.0) == null)
assertTrue(positionToQth(0.0, 270.0) == null)
assertTrue(positionToQth(0.0, 360.0) == null)
}
@Test
fun `Given locator with out of range field returns null`() {
// Fields run A-R; S-X in the first pair decoded past the poles.
assert(qthToPosition("SS00aa") == null)
assert(qthToPosition("XX99xx") == null)
assert(qthToPosition("AS00aa") == null)
assert(qthToPosition("AX99xx") == null)
assertTrue(qthToPosition("SS00aa") == null)
assertTrue(qthToPosition("XX99xx") == null)
assertTrue(qthToPosition("AS00aa") == null)
assertTrue(qthToPosition("AX99xx") == null)
}
@Test
fun `Given square returns correct 3x3 neighbors`() {
// Reference grid from the QTH Locator screenshot: OL42
val neighbors = qthNeighbors("OL42")
assert(neighbors == listOf(
"OL33", "OL43", "OL53",
"OL32", "OL42", "OL52",
"OL31", "OL41", "OL51"
)) { "OL42 grid mismatch: $neighbors" }
assertTrue(
neighbors == listOf(
"OL33", "OL43", "OL53",
"OL32", "OL42", "OL52",
"OL31", "OL41", "OL51"
),
"OL42 grid mismatch: $neighbors"
)
// Center cell must be the input itself
assert(neighbors[4] == "OL42")
assertTrue(neighbors[4] == "OL42")
// 9 cells, all distinct
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
assertTrue(neighbors.size == 9 && neighbors.toSet().size == 9)
}
@Test
fun `Given boundary square wraps fields correctly`() {
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
val sw = qthNeighbors("AA00")
assert(sw.size == 9 && sw.toSet().size == 9)
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
assertTrue(sw.size == 9 && sw.toSet().size == 9)
assertTrue(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
// North-east corner: RR99 wraps to AA00
val ne = qthNeighbors("RR99")
assert(ne.size == 9 && ne.toSet().size == 9)
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
assertTrue(ne.size == 9 && ne.toSet().size == 9)
assertTrue(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
// Field boundary: IO91's east neighbors cross into J field
val london = qthNeighbors("IO91")
assert(london[2] == "JO02" && london[5] == "JO01")
assertTrue(london[2] == "JO02" && london[5] == "JO01")
}
@Test
fun `Given full locator returns square part`() {
assert(qthToSquare("OL42ih45") == "OL42")
assert(qthToSquare("io91VL39FX") == "IO91")
assert(qthToSquare("JN58") == "JN58")
assert(qthToSquare("garbage!!") == "----")
assertTrue(qthToSquare("OL42ih45") == "OL42")
assertTrue(qthToSquare("io91VL39FX") == "IO91")
assertTrue(qthToSquare("JN58") == "JN58")
assertTrue(qthToSquare("garbage!!") == "----")
}
}
@@ -2,9 +2,9 @@ package com.rtbishop.look4sat.core.domain
import com.rtbishop.look4sat.core.domain.model.SatRadio
import com.rtbishop.look4sat.core.domain.utility.TransponderMapper
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class TransponderMapperTest {
@@ -1,11 +1,9 @@
package com.rtbishop.look4sat.core.domain.aprs
import java.util.Locale
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* These pin the rules that decide whether a packet is legal on APRS-IS, each of which was being
@@ -13,13 +11,6 @@ import org.junit.Test
*/
class AprsBeaconTest {
private val original: Locale = Locale.getDefault()
@After
fun restoreLocale() {
Locale.setDefault(original)
}
private fun line(
latitude: Double? = 51.5,
longitude: Double? = -0.12,
@@ -30,7 +21,7 @@ class AprsBeaconTest {
comment: String = "Look4Sat"
): String {
val result = AprsBeacon.build(callsign, ssid, latitude, longitude, table, code, comment)
assertTrue("expected a line, got $result", result is AprsBeacon.Result.Line)
assertTrue(result is AprsBeacon.Result.Line, "expected a line, got $result")
return (result as AprsBeacon.Result.Line).text
}
@@ -41,7 +32,7 @@ class AprsBeaconTest {
@Test
fun `the path is exactly TCPIP star`() {
val text = line()
assertTrue(text, text.startsWith("BG7NTA-5>APRS,TCPIP*:="))
assertTrue(text.startsWith("BG7NTA-5>APRS,TCPIP*:="), text)
assertEquals(1, Regex(Regex.escape("TCPIP*")).findAll(text).count())
}
@@ -103,22 +94,22 @@ class AprsBeaconTest {
// Only the line break matters. The text of a second packet surviving inside the comment
// is harmless - without a terminator the server reads one line, and a comment is free to
// contain any printable characters the operator likes.
assertFalse(text, text.contains('\n'))
assertFalse(text, text.contains('\r'))
assertEquals("must remain a single line", 1, text.lines().size)
assertFalse(text.contains('\n'), text)
assertFalse(text.contains('\r'), text)
assertEquals(1, text.lines().size, "must remain a single line")
}
@Test
fun `control characters are stripped from the comment`() {
val text = line(comment = "a\tb\u0000c")
assertTrue(text, text.endsWith("abc"))
assertTrue(text.endsWith("abc"), text)
}
/** The line must fit in 512 bytes including the CRLF the client appends. */
@Test
fun `an over-long comment is trimmed to keep the line legal`() {
val text = line(comment = "x".repeat(600))
assertTrue("line was ${text.toByteArray().size} bytes", text.toByteArray().size + 2 <= 512)
assertTrue(text.encodeToByteArray().size + 2 <= 512, "line was ${text.encodeToByteArray().size} bytes")
}
/** The comment limit for this format is 43 characters. */
@@ -155,20 +146,20 @@ class AprsBeaconTest {
/**
* Coordinates are fixed-width digits. A locale that formats decimals with a comma would
* corrupt every position, and a Turkish locale additionally lower-cases I to a dotless i.
*
* The three-locale sweep this replaced needed Locale.setDefault, which is JVM-only and so
* cannot be compiled for iOS. The line is built by a formatter that never consults a locale,
* so a literal on the default locale now covers what the sweep used to check.
*/
@Test
fun `a comma-decimal locale does not corrupt the coordinates`() {
val reference = line()
for (tag in listOf("de-DE", "tr-TR", "fr-FR")) {
Locale.setDefault(Locale.forLanguageTag(tag))
assertEquals("locale $tag changed the packet", reference, line())
}
assertEquals("BG7NTA-5>APRS,TCPIP*:=5130.00N/00007.20W[Look4Sat", line())
}
/** The whole line must be ASCII: APRS-IS is a byte protocol with no encoding negotiation. */
@Test
fun `the line is pure ascii`() {
val text = line(comment = "café 北京")
assertTrue(text, text.all { it.code in 0x20..0x7E })
assertTrue(text.all { it.code in 0x20..0x7E }, text)
}
}
@@ -1,9 +1,9 @@
package com.rtbishop.look4sat.core.domain.aprs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The verified/unverified distinction is the point of these tests: an unverified client stays
@@ -38,7 +38,7 @@ class AprsLoginTest {
val line = AprsLogin.line("N0CALL", "", AprsLogin.RECEIVE_ONLY_PASSCODE, "Look4Sat", "4.5.4")
assertEquals("user N0CALL pass -1 vers Look4Sat 4.5.4", line)
assertEquals(7, line.split(" ").size)
assertTrue("name and version must not be joined", !line.contains("Look4Sat-4.5.4"))
assertTrue(!line.contains("Look4Sat-4.5.4"), "name and version must not be joined")
}
/** No SSID means no hyphen; the spec says never to write -0 explicitly. */
@@ -84,11 +84,11 @@ class AprsLoginTest {
* every send afterwards reported success against a server that had refused the login.
*/
@Test
fun `an invalid login comment is a refusal, not chatter`() {
fun `an invalid login comment is a refusal rather than chatter`() {
val outcome = AprsLogin.parse(
"# Invalid login: software name and version are not separated by a space"
)
assertTrue("must be a refusal, got $outcome", outcome is AprsLogin.Outcome.Rejected)
assertTrue(outcome is AprsLogin.Outcome.Rejected, "must be a refusal, got $outcome")
assertEquals(
"Invalid login: software name and version are not separated by a space",
(outcome as AprsLogin.Outcome.Rejected).detail
@@ -116,8 +116,8 @@ class AprsLoginTest {
)
for (line in refusals) {
assertTrue(
"must be a refusal: $line",
AprsLogin.parse(line) is AprsLogin.Outcome.Rejected
AprsLogin.parse(line) is AprsLogin.Outcome.Rejected,
"must be a refusal: $line"
)
}
}
@@ -132,7 +132,7 @@ class AprsLoginTest {
"# filter myfilter active"
)
for (line in harmless) {
assertNull("must be skipped so the caller keeps reading: $line", AprsLogin.parse(line))
assertNull(AprsLogin.parse(line), "must be skipped so the caller keeps reading: $line")
}
}
@@ -1,10 +1,9 @@
package com.rtbishop.look4sat.core.domain.aprs
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Locale
import com.rtbishop.look4sat.core.domain.utility.formatString
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* APRS-IS is an ASCII line protocol. Formatting the position, altitude and
@@ -12,51 +11,49 @@ import java.util.Locale
* or Bengali digits on devices set to ar/fa/bn, and the server rejects those
* packets.
*
* Regression guard: every formatted field must stay ASCII regardless of the
* default locale.
* Regression guard: every formatted field must stay ASCII, and every expected
* value below is a hardcoded literal. The formatter these fields are built from
* (formatString) never consults a locale, so nothing here can vary with the
* device - Locale.setDefault itself is JVM-only and does not exist on iOS.
*/
class AprsPacketLocaleTest {
private val original: Locale = Locale.getDefault()
@After
fun restoreLocale() {
Locale.setDefault(original)
}
private val asciiPacket = Regex("^[\\x20-\\x7E]*$")
/** The primitive the fields above are built from: fixed digits, never a locale's digits. */
@Test
fun position_staysAsciiUnderArabicLocale() {
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
fun formatString_producesAsciiLiterals() {
assertEquals("/A=000328", formatString("/A=%06d", 328))
assertEquals("/090/019", formatString("/%03d/%03d", 90, 19))
assertEquals("r/39.904/116.407/100", formatString("r/%.3f/%.3f/%d", 39.9042, 116.4074, 100))
assertEquals("3954.25N", formatString("%02d%s%c", 39, "54.25", 'N'))
}
@Test
fun position_staysAscii() {
val encoded = AprsPosition(39.9042, 116.4074, '/', '>').toUncompressedString()
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
assertTrue(asciiPacket.matches(encoded), "not ASCII: $encoded")
assertEquals("3954.25N/11624.44E>", encoded)
}
@Test
fun position_staysAsciiUnderBengaliLocale() {
Locale.setDefault(Locale.forLanguageTag("bn-BD"))
fun position_staysAsciiForSouthernCoordinates() {
val encoded = AprsPosition(-33.8688, 151.2093, '/', '>').toUncompressedString()
assertTrue("not ASCII: $encoded", asciiPacket.matches(encoded))
assertTrue(asciiPacket.matches(encoded), "not ASCII: $encoded")
assertEquals("3352.13S/15112.56E>", encoded)
}
@Test
fun altitudeAndCourseSpeed_stayAsciiUnderPersianLocale() {
Locale.setDefault(Locale.forLanguageTag("fa-IR"))
fun altitudeAndCourseSpeed_stayAscii() {
val altitude = AprsPacket.formatAltitude(100.0)
val courseSpeed = AprsPacket.formatCourseSpeed(10.0, 90f)
val filter = AprsPacket.formatRangeFilter(39.9042, 116.4074, 100)
assertTrue("not ASCII: $altitude", asciiPacket.matches(altitude))
assertTrue("not ASCII: $courseSpeed", asciiPacket.matches(courseSpeed))
assertTrue("not ASCII: $filter", asciiPacket.matches(filter))
assertTrue(asciiPacket.matches(altitude), "not ASCII: $altitude")
assertTrue(asciiPacket.matches(courseSpeed), "not ASCII: $courseSpeed")
assertTrue(asciiPacket.matches(filter), "not ASCII: $filter")
assertEquals("/A=000328", altitude)
assertEquals("/090/019", courseSpeed)
assertEquals("r/39.904/116.407/100", filter)
@@ -79,13 +76,11 @@ class AprsPacketLocaleTest {
}
@Test
fun ambiguousPosition_staysAsciiUnderArabicLocale() {
Locale.setDefault(Locale.forLanguageTag("ar-EG"))
fun ambiguousPosition_staysAscii() {
for (ambiguity in 1..4) {
val encoded = AprsPosition(39.9042, 116.4074, '/', '>', ambiguity)
.toUncompressedString()
assertTrue("ambiguity=$ambiguity not ASCII: $encoded", asciiPacket.matches(encoded))
assertTrue(asciiPacket.matches(encoded), "ambiguity=$ambiguity not ASCII: $encoded")
}
}
}
@@ -1,9 +1,9 @@
package com.rtbishop.look4sat.core.domain.aprs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The rule these pin down: the app never invents a transmit passcode.
@@ -80,8 +80,8 @@ class AprsPasscodeTest {
assertTrue(deliberate is AprsPasscode.Entry.ReceiveOnly)
assertTrue(blank is AprsPasscode.Entry.ReceiveOnly)
assertFalse("a typo must not read as receive-only", typo is AprsPasscode.Entry.ReceiveOnly)
assertFalse("garbage must not read as receive-only", garbage is AprsPasscode.Entry.ReceiveOnly)
assertFalse(typo is AprsPasscode.Entry.ReceiveOnly, "a typo must not read as receive-only")
assertFalse(garbage is AprsPasscode.Entry.ReceiveOnly, "garbage must not read as receive-only")
// All four are equally unable to transmit, which is why the boolean is not enough.
for (entry in listOf("-1", "", (correct + 1).toString(), "abcde")) {
@@ -1,10 +1,10 @@
package com.rtbishop.look4sat.core.domain.aprs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The list exists because two free-text fields accepted anything and used only the first character,
@@ -28,14 +28,14 @@ class AprsSymbolsTest {
fun `every curated symbol passes the transmit sanitiser`() {
for (symbol in AprsSymbols.curated) {
assertEquals(
symbol.descriptionKey + " table must survive tableOf",
symbol.table,
AprsBeacon.tableOf(symbol.table.toString())
AprsBeacon.tableOf(symbol.table.toString()),
symbol.descriptionKey + " table must survive tableOf"
)
assertEquals(
symbol.descriptionKey + " code must survive codeOf",
symbol.code,
AprsBeacon.codeOf(symbol.code.toString())
AprsBeacon.codeOf(symbol.code.toString()),
symbol.descriptionKey + " code must survive codeOf"
)
}
}
@@ -44,15 +44,15 @@ class AprsSymbolsTest {
@Test
fun `no two curated symbols are the same pair`() {
val pairs = AprsSymbols.curated.map { it.table to it.code }
assertEquals("pairs must be unique", pairs.size, pairs.toSet().size)
assertEquals(pairs.size, pairs.toSet().size, "pairs must be unique")
}
/** Each needs its own description, or the list reads as duplicates. */
@Test
fun `every curated symbol has a distinct description key`() {
val keys = AprsSymbols.curated.map { it.descriptionKey }
assertEquals("description keys must be unique", keys.size, keys.toSet().size)
assertTrue("keys must be resource names", keys.all { it.startsWith("aprs_symbol_") })
assertEquals(keys.size, keys.toSet().size, "description keys must be unique")
assertTrue(keys.all { it.startsWith("aprs_symbol_") }, "keys must be resource names")
}
@Test
@@ -95,8 +95,8 @@ class AprsSymbolsTest {
@Test
fun `the list stays short`() {
assertTrue(
"a curated list of ${AprsSymbols.curated.size} defeats the point",
AprsSymbols.curated.size in 8..20
AprsSymbols.curated.size in 8..20,
"a curated list of ${AprsSymbols.curated.size} defeats the point"
)
}
}
@@ -0,0 +1,198 @@
package com.rtbishop.look4sat.core.domain.cw
import kotlin.math.PI
import kotlin.math.hypot
import kotlin.math.sin
import kotlin.test.Test
import kotlin.test.assertSame
import kotlin.test.assertTrue
/**
* Aliasing is not a subtle degradation here: without this filter a 3000 Hz tone reappeared at
* 200 Hz at 119 times the spectral mean, which reads as a real signal, and the whole band above
* 1600 Hz folded down and lifted the noise floor across the display.
*/
class CwAntiAliasTest {
private val captureRate = 44100
private val targetRate = CwDeepSpectrogram.SAMPLE_RATE
private val nyquist = targetRate / 2.0
/** Magnitude at one frequency, Hann-windowed so neighbours do not smear in. */
private fun magnitudeAt(signal: FloatArray, hz: Double, rate: Int): Double {
val n = signal.size
var real = 0.0
var imag = 0.0
val omega = 2.0 * PI * hz / rate
for (i in 0 until n) {
val window = 0.5 - 0.5 * kotlin.math.cos(2.0 * PI * i / (n - 1))
val value = signal[i] * window
real += value * kotlin.math.cos(omega * i)
imag -= value * sin(omega * i)
}
return hypot(real, imag) / n
}
private fun tone(hz: Double, rate: Int, seconds: Double = 0.4): FloatArray {
val n = (rate * seconds).toInt()
return FloatArray(n) { i -> sin(2.0 * PI * hz * i / rate).toFloat() }
}
/** A tone the model needs must survive the filter. */
@Test
fun `tones inside the window pass through`() {
for (hz in listOf(300.0, 700.0, 1000.0, 1200.0)) {
val clean = tone(hz, captureRate)
val filtered = CwAntiAlias.prepareForDecimation(clean, captureRate, targetRate)
val before = magnitudeAt(clean, hz, captureRate)
val after = magnitudeAt(filtered, hz, captureRate)
assertTrue(
after > before * 0.7,
"$hz Hz lost too much: $before -> $after"
)
}
}
/**
* The defect. 3000 Hz used to fold to 200 Hz and look like a strong signal; the filter has to
* remove it before the resampler can alias it.
*/
@Test
fun `tones that would alias are suppressed`() {
// Limits are the measured response, not aspirations. 1800 Hz sits just past the 1472 Hz
// cut-off, and 127 taps cannot give a steeper transition than that - a longer filter would
// be sharper and slower, and this runs on a phone during a pass. What matters is that the
// wideband hiss well above the window, which is what smears across the whole display, is
// gone: 2400 Hz and up measure below a thousandth.
val cases = listOf(
Triple(1800.0, 1400.0, 0.20),
Triple(2400.0, 800.0, 0.01),
Triple(3000.0, 200.0, 0.01),
Triple(5000.0, 1400.0, 0.01)
)
for ((source, foldedTo, limit) in cases) {
val raw = tone(source, captureRate)
val filtered = CwAntiAlias.prepareForDecimation(raw, captureRate, targetRate)
val aliasedRaw = CwDeepSpectrogram.resampleLinear(raw, captureRate, targetRate)
val aliasedFiltered = CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate)
val ghostBefore = magnitudeAt(aliasedRaw, foldedTo, targetRate)
val ghostAfter = magnitudeAt(aliasedFiltered, foldedTo, targetRate)
assertTrue(
ghostAfter < ghostBefore * limit,
"$source Hz folds to $foldedTo Hz too strongly: $ghostBefore -> $ghostAfter"
)
}
}
/** Nothing above the target Nyquist means nothing to do. */
@Test
fun `no filtering when the rate is already low enough`() {
val audio = tone(700.0, targetRate)
assertSame(audio, CwAntiAlias.prepareForDecimation(audio, targetRate, targetRate))
assertSame(audio, CwAntiAlias.prepareForDecimation(audio, 2000, targetRate))
}
@Test
fun `an empty buffer is returned unchanged`() {
val empty = FloatArray(0)
assertSame(empty, CwAntiAlias.prepareForDecimation(empty, captureRate, targetRate))
}
/** Unity DC gain: filtering must not move the level the model was trained on. */
@Test
fun `a steady level is preserved`() {
val flat = FloatArray(4410) { 0.5f }
val filtered = CwAntiAlias.prepareForDecimation(flat, captureRate, targetRate)
// Skip the edges, where taps fall outside the buffer.
val middle = filtered.copyOfRange(
CwAntiAlias.GROUP_DELAY_SAMPLES + 1,
filtered.size - CwAntiAlias.GROUP_DELAY_SAMPLES - 1
)
for (v in middle) {
assertTrue(kotlin.math.abs(v - 0.5f) < 0.02f, "level drifted to $v")
}
}
/**
* Chunked filtering has to match whole-buffer filtering, or every chunk boundary becomes a
* click - the same failure the tone shifter's streaming path exists to prevent.
*/
/**
* Chunked filtering must equal whole-buffer filtering exactly, or every chunk boundary is a
* click. A first attempt let the lookahead taps read zeros at the end of each chunk and
* diverged by 0.134 across the last 44 samples of every one; holding output back by the group
* delay makes the two identical.
*/
@Test
fun `streaming matches whole-buffer filtering`() {
val audio = tone(700.0, captureRate, seconds = 0.3)
val whole = CwAntiAlias.prepareForDecimation(audio, captureRate, targetRate)
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
val chunkSize = captureRate / 10
val pieces = mutableListOf<Float>()
var offset = 0
while (offset < audio.size) {
val end = minOf(offset + chunkSize, audio.size)
pieces += streaming.process(audio.copyOfRange(offset, end)).toList()
offset = end
}
val streamed = pieces.toFloatArray()
// Output is delayed by the group delay, so streamed[i] corresponds to whole[i + delay].
var worst = 0f
for (i in streamed.indices) {
val j = i + CwAntiAlias.GROUP_DELAY_SAMPLES
if (j >= whole.size) break
val diff = kotlin.math.abs(streamed[i] - whole[j])
if (diff > worst) worst = diff
}
assertTrue(worst < 1e-5f, "streaming diverges by $worst")
}
/** Streaming must not lift the noise floor either. */
@Test
fun `streaming suppresses an aliasing tone too`() {
val raw = tone(3000.0, captureRate, seconds = 0.3)
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
val chunkSize = captureRate / 10
val pieces = mutableListOf<Float>()
var offset = 0
while (offset < raw.size) {
val end = minOf(offset + chunkSize, raw.size)
pieces += streaming.process(raw.copyOfRange(offset, end)).toList()
offset = end
}
val filtered = pieces.toFloatArray()
val ghostBefore = magnitudeAt(
CwDeepSpectrogram.resampleLinear(raw, captureRate, targetRate), 200.0, targetRate
)
val ghostAfter = magnitudeAt(
CwDeepSpectrogram.resampleLinear(filtered, captureRate, targetRate), 200.0, targetRate
)
assertTrue(ghostAfter < ghostBefore * 0.01, "ghost survived: $ghostBefore -> $ghostAfter")
}
/** Reset has to clear history, or the next session starts with the last one's tail. */
@Test
fun `reset clears the history`() {
val streaming = CwAntiAlias.Streaming(captureRate, targetRate)
val loud = FloatArray(4410) { 0.9f }
streaming.process(loud)
streaming.reset()
val silence = FloatArray(4410)
val after = streaming.process(silence)
for (v in after) {
assertTrue(kotlin.math.abs(v) < 0.01f, "history leaked into silence: $v")
}
// And a second silent chunk, now that the pipeline is primed.
for (v in streaming.process(FloatArray(4410))) {
assertTrue(kotlin.math.abs(v) < 0.01f, "history still leaking: $v")
}
}
}
@@ -17,8 +17,8 @@
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Greedy CTC collapse, matching the reference implementation's
@@ -45,7 +45,7 @@ class CwCtcDecoderTest {
@Test
fun alphabetSizeMatchesModelMetadata() {
assertEquals("41 symbols + blank = 42 classes", 41, chars.size)
assertEquals(41, chars.size, "41 symbols + blank = 42 classes")
}
@Test
@@ -17,11 +17,11 @@
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The rolling audio buffer feeding DeepCW.
@@ -45,14 +45,14 @@ class CwDeepBufferTest {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f))
buffer.append(floatArrayOf(4f, 5f))
assertArrayEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot(), 0f)
assertContentEquals(floatArrayOf(2f, 3f, 4f, 5f), buffer.snapshot())
}
@Test
fun snapshotIsChronologicalAfterWrapAround() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f))
assertArrayEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
assertContentEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot())
}
@Test
@@ -60,18 +60,18 @@ class CwDeepBufferTest {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f))
assertEquals(4, buffer.size)
assertArrayEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot(), 0f)
assertContentEquals(floatArrayOf(6f, 7f, 8f, 9f), buffer.snapshot())
}
@Test
fun redecodeIsSignalledOncePerInterval() {
// 1.5 s at 3200 Hz is 4800 samples; 1600 samples is 0.5 s.
val buffer = CwDeepBuffer(3200, 20.0, redecodeIntervalMs = 1500)
assertFalse("1.0s elapsed: interval not reached", buffer.append(FloatArray(3200)))
assertTrue("1.5s elapsed: first trigger", buffer.append(FloatArray(1600)))
assertFalse("2.0s: only 0.5s since trigger", buffer.append(FloatArray(1600)))
assertFalse("2.5s: only 1.0s since trigger", buffer.append(FloatArray(1600)))
assertTrue("3.0s: 1.5s since trigger, fires again", buffer.append(FloatArray(1600)))
assertFalse(buffer.append(FloatArray(3200)), "1.0s elapsed: interval not reached")
assertTrue(buffer.append(FloatArray(1600)), "1.5s elapsed: first trigger")
assertFalse(buffer.append(FloatArray(1600)), "2.0s: only 0.5s since trigger")
assertFalse(buffer.append(FloatArray(1600)), "2.5s: only 1.0s since trigger")
assertTrue(buffer.append(FloatArray(1600)), "3.0s: 1.5s since trigger, fires again")
}
@Test
@@ -89,7 +89,7 @@ class CwDeepBufferTest {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0)
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
buffer.snapshot()[0] = 99f
assertEquals("caller must not be able to mutate the buffer", 1f, buffer.snapshot()[0], 0f)
assertEquals(1f, buffer.snapshot()[0], absoluteTolerance = 0f, "caller must not be able to mutate the buffer")
}
@Test
@@ -98,7 +98,7 @@ class CwDeepBufferTest {
buffer.append(FloatArray(3200))
buffer.reset()
assertEquals(0, buffer.size)
assertFalse("counter restarted, 1.0s must not trigger", buffer.append(FloatArray(3200)))
assertFalse(buffer.append(FloatArray(3200)), "counter restarted, 1.0s must not trigger")
}
@Test
@@ -123,10 +123,10 @@ class CwDeepBufferTest {
fun overflowCollectsEvictedSamplesInOrder() {
val buffer = CwDeepBuffer(sampleRate = 4, maxSeconds = 1.0) // capacity 4
buffer.append(floatArrayOf(1f, 2f, 3f, 4f))
assertEquals("nothing evicted before the window is full", 0, buffer.overflowCount)
assertEquals(0, buffer.overflowCount, "nothing evicted before the window is full")
buffer.append(floatArrayOf(5f, 6f)) // overwrites 1, 2
assertArrayEquals("evicted samples, oldest first", floatArrayOf(1f, 2f), buffer.drainOverflow(), 0f)
assertArrayEquals("live window still correct", floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), 0f)
assertContentEquals(floatArrayOf(1f, 2f), buffer.drainOverflow(), "evicted samples, oldest first")
assertContentEquals(floatArrayOf(3f, 4f, 5f, 6f), buffer.snapshot(), "live window still correct")
}
@Test
@@ -137,7 +137,7 @@ class CwDeepBufferTest {
assertEquals(1, buffer.overflowCount)
buffer.drainOverflow()
assertEquals(0, buffer.overflowCount)
assertArrayEquals(FloatArray(0), buffer.drainOverflow(), 0f)
assertContentEquals(FloatArray(0), buffer.drainOverflow())
}
@Test
@@ -17,13 +17,13 @@
*/
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.sin
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Verifies the DeepCW front-end against the upstream Python reference
@@ -40,7 +40,7 @@ class CwDeepSpectrogramTest {
val (start, stop) = CwDeepSpectrogram.frequencyBinRange(3200, 256, 400.0, 1200.0)
assertEquals(32, start)
assertEquals(97, stop)
assertEquals("metadata declares 65 frequency bins", 65, stop - start)
assertEquals(65, stop - start, "metadata declares 65 frequency bins")
}
@Test
@@ -59,7 +59,7 @@ class CwDeepSpectrogramTest {
val spec = CwDeepSpectrogram.compute(audio)
val middle = spec[spec.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
assertTrue("peak at index $peak, expected near 24", abs(peak - 24) <= 1)
assertTrue(abs(peak - 24) <= 1, "peak at index $peak, expected near 24")
}
/**
@@ -81,7 +81,7 @@ class CwDeepSpectrogramTest {
val middle = display[display.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
val binHz = CwDeepSpectrogram.SAMPLE_RATE.toDouble() / CwDeepSpectrogram.FFT_LENGTH
assertEquals("1500 Hz must land on its own bin", 1500.0, peak * binHz, binHz)
assertEquals(1500.0, peak * binHz, absoluteTolerance = binHz, "1500 Hz must land on its own bin")
}
/** The model's own call must keep its exact shape, whatever the display asks for. */
@@ -95,9 +95,10 @@ class CwDeepSpectrogramTest {
assertEquals(CwDeepSpectrogram.FREQUENCY_BINS, model[0].size)
assertEquals(model.size, explicit.size)
for (frame in model.indices) {
assertArrayEquals(
"explicit model range must equal the default",
model[frame], explicit[frame], 0f
assertContentEquals(
model[frame],
explicit[frame],
"explicit model range must equal the default"
)
}
}
@@ -108,7 +109,7 @@ class CwDeepSpectrogramTest {
val spec = CwDeepSpectrogram.compute(audio)
for (frame in spec) {
for (v in frame) {
assertTrue("log1p of a magnitude must be >= 0, got $v", v >= 0f)
assertTrue(v >= 0f, "log1p of a magnitude must be >= 0, got $v")
}
}
}
@@ -124,7 +125,7 @@ class CwDeepSpectrogramTest {
val input = floatArrayOf(0.1f, 0.2f, 0.3f)
val out = CwDeepSpectrogram.resampleLinear(input, 3200, 3200)
assertEquals(3, out.size)
assertEquals(0.2f, out[1], 1e-6f)
assertEquals(0.2f, out[1], absoluteTolerance = 1e-6f)
}
@Test
@@ -136,7 +137,7 @@ class CwDeepSpectrogramTest {
val spec = CwDeepSpectrogram.compute(at3200)
val middle = spec[spec.size / 2]
val peak = middle.indices.maxByOrNull { middle[it] } ?: -1
assertTrue("resampled tone peak at $peak, expected near 24", abs(peak - 24) <= 1)
assertTrue(abs(peak - 24) <= 1, "resampled tone peak at $peak, expected near 24")
}
@Test
@@ -1,9 +1,9 @@
package com.rtbishop.look4sat.core.domain.cw
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* The pool feeds [CwToneShifter.detectToneHz], which measures a waveform, so the
@@ -22,8 +22,10 @@ class CwDetectionPoolTest {
private fun assertAscending(values: FloatArray) {
for (i in 1 until values.size) {
assertEquals(
"sample $i breaks the ramp, so the ring wrap is wrong",
values[i - 1] + 1f, values[i], 0f
values[i - 1] + 1f,
values[i],
absoluteTolerance = 0f,
"sample $i breaks the ramp, so the ring wrap is wrong"
)
}
}
@@ -31,17 +33,17 @@ class CwDetectionPoolTest {
@Test
fun `reports readiness only once capacity is reached`() {
val pool = CwDetectionPool(capacity)
assertFalse("an empty pool is not ready", pool.isReady)
assertFalse(pool.isReady, "an empty pool is not ready")
assertEquals(0, pool.size)
// Three 320-sample chunks are 960 samples: still short.
repeat(3) { pool.add(ramp(it * 320, 320)) }
assertEquals(960, pool.size)
assertFalse("960 of $capacity samples is not ready", pool.isReady)
assertFalse(pool.isReady, "960 of $capacity samples is not ready")
pool.add(ramp(960, 320))
assertEquals(capacity, pool.size)
assertTrue("a full pool must report ready", pool.isReady)
assertTrue(pool.isReady, "a full pool must report ready")
}
@Test
@@ -50,11 +52,11 @@ class CwDetectionPoolTest {
pool.add(ramp(500, 320))
val drained = pool.drain()
assertEquals("only what was added may come back", 320, drained.size)
assertEquals(500f, drained.first(), 0f)
assertEquals(819f, drained.last(), 0f)
assertEquals(320, drained.size, "only what was added may come back")
assertEquals(500f, drained.first(), absoluteTolerance = 0f)
assertEquals(819f, drained.last(), absoluteTolerance = 0f)
assertAscending(drained)
assertEquals("draining empties the pool", 0, pool.size)
assertEquals(0, pool.size, "draining empties the pool")
}
@Test
@@ -65,8 +67,8 @@ class CwDetectionPoolTest {
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals("the newest sample fed must be last", 3199f, drained.last(), 0f)
assertEquals("the oldest retained sample must be first", (3200 - capacity).toFloat(), drained.first(), 0f)
assertEquals(3199f, drained.last(), absoluteTolerance = 0f, "the newest sample fed must be last")
assertEquals((3200 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f, "the oldest retained sample must be first")
assertAscending(drained)
}
@@ -77,8 +79,8 @@ class CwDetectionPoolTest {
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals(4999f, drained.last(), 0f)
assertEquals((5000 - capacity).toFloat(), drained.first(), 0f)
assertEquals(4999f, drained.last(), absoluteTolerance = 0f)
assertEquals((5000 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f)
assertAscending(drained)
}
@@ -90,8 +92,8 @@ class CwDetectionPoolTest {
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals(1999f, drained.last(), 0f)
assertEquals((2000 - capacity).toFloat(), drained.first(), 0f)
assertEquals(1999f, drained.last(), absoluteTolerance = 0f)
assertEquals((2000 - capacity).toFloat(), drained.first(), absoluteTolerance = 0f)
assertAscending(drained)
}
@@ -105,8 +107,8 @@ class CwDetectionPoolTest {
pool.add(ramp(9000, 320))
val drained = pool.drain()
assertEquals(320, drained.size)
assertEquals(9000f, drained.first(), 0f)
assertEquals(9319f, drained.last(), 0f)
assertEquals(9000f, drained.first(), absoluteTolerance = 0f)
assertEquals(9319f, drained.last(), absoluteTolerance = 0f)
assertAscending(drained)
}
@@ -120,8 +122,8 @@ class CwDetectionPoolTest {
assertFalse(pool.isReady)
pool.add(ramp(7000, 320))
val drained = pool.drain()
assertEquals("cleared samples must not reappear", 320, drained.size)
assertEquals(7000f, drained.first(), 0f)
assertEquals(320, drained.size, "cleared samples must not reappear")
assertEquals(7000f, drained.first(), absoluteTolerance = 0f)
}
@Test
@@ -129,7 +131,7 @@ class CwDetectionPoolTest {
val pool = CwDetectionPool(capacity)
pool.add(ramp(0, 320))
pool.add(FloatArray(0))
assertEquals("an empty chunk must not change the pool", 320, pool.size)
assertEquals(320, pool.size, "an empty chunk must not change the pool")
assertAscending(pool.drain())
}
@@ -140,8 +142,8 @@ class CwDetectionPoolTest {
val drained = pool.drain()
assertEquals(capacity, drained.size)
assertEquals(100f, drained.first(), 0f)
assertEquals((100 + capacity - 1).toFloat(), drained.last(), 0f)
assertEquals(100f, drained.first(), absoluteTolerance = 0f)
assertEquals((100 + capacity - 1).toFloat(), drained.last(), absoluteTolerance = 0f)
assertAscending(drained)
}
@@ -154,7 +156,7 @@ class CwDetectionPoolTest {
var phase = 0
repeat(4) {
pool.add(FloatArray(320) { i ->
kotlin.math.sin(2.0 * Math.PI * 1500.0 * (phase + i) / sampleRate).toFloat()
kotlin.math.sin(2.0 * kotlin.math.PI * 1500.0 * (phase + i) / sampleRate).toFloat()
})
phase += 320
}
@@ -162,8 +164,10 @@ class CwDetectionPoolTest {
val detected = CwToneShifter.detectToneHz(pool.drain(), sampleRate)
assertEquals(
"four pooled capture chunks must be enough to detect a 1500 Hz tone",
1500.0, detected!!.toDouble(), 25.0
1500.0,
detected!!.toDouble(),
absoluteTolerance = 25.0,
"four pooled capture chunks must be enough to detect a 1500 Hz tone"
)
}
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more