Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions ft8af/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -201,9 +201,12 @@ dependencies {
implementation 'androidx.compose.runtime:runtime-livedata'
debugImplementation 'androidx.compose.ui:ui-tooling'

// Android Auto (phone projection) templated status screen. 1.4.0 is the last
// stable line compatible with AGP 8.7.3; its minSdk (23) matches ours.
implementation 'androidx.car.app:app:1.4.0'
// Android Auto (phone projection): surface-based map + status screen.
implementation 'androidx.car.app:app:1.7.0'
// DEBUG-ONLY: lets the same CarAppService render on an Android Automotive OS
// emulator (CarAppActivity host) so the car map can be tested without a phone
// + DHU. Never ships in release. Paired with src/debug/AndroidManifest.xml.
debugImplementation 'androidx.car.app:app-automotive:1.7.0'

// Image loading for QRZ profile avatars
implementation 'io.coil-kt:coil-compose:2.6.0'
Expand Down
47 changes: 47 additions & 0 deletions ft8af/app/src/debug/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Debug-only overlay. Registers the Android Auto map test-injection receiver so
`adb shell am broadcast -a ft8af.DEBUG_INJECT ...` can forge QSO state on an
emulator that has no radio. Merged only into the debug variant.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<!--
app-automotive declares minSdk 29; this app is minSdk 23. This overlay is
debug-only and only ever runs on an API 35 AAOS emulator, so overriding the
library floor is safe here (it never merges into the release/phone build).
-->
<uses-sdk tools:overrideLibrary="androidx.car.app.automotive" />

<application>
<receiver
android:name="radio.ks3ckc.ft8af.car.DebugInjectReceiver"
android:exported="true">
<intent-filter>
<action android:name="ft8af.DEBUG_INJECT" />
</intent-filter>
</receiver>

<!--
DEBUG-ONLY: hosts the car-app-library CarAppService on an Android
Automotive OS emulator. This launcher entry + distractionOptimized
flag are what the AAOS template host binds to. Provided by the
debugImplementation app-automotive dependency; absent from release.
-->
<activity
android:name="androidx.car.app.activity.CarAppActivity"
android:exported="true"
android:label="FT8AF Car"
android:launchMode="singleTask"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="distractionOptimized"
android:value="true" />
</activity>
</application>
</manifest>
153 changes: 153 additions & 0 deletions ft8af/app/src/debug/kotlin/radio/ks3ckc/ft8af/car/DebugInject.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package radio.ks3ckc.ft8af.car

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.k1af.ft8af.Ft8Message
import com.k1af.ft8af.GeneralVariables
import com.k1af.ft8af.MainViewModel
import com.k1af.ft8af.ft8transmit.TransmitCallsign
import com.k1af.ft8af.maidenhead.MaidenheadGrid
import radio.ks3ckc.ft8af.pota.PotaSessionManager
import radio.ks3ckc.ft8af.pskreporter.PskReporterSpot
import radio.ks3ckc.ft8af.pskreporter.WhoHeardMeCache

/**
* DEBUG-ONLY test harness for the Android Auto QSO map. Lives in `src/debug`, so
* it is never compiled into a release build.
*
* The car map is a read-out of the live engine: it draws the operator dot from
* [GeneralVariables.getMyMaidenheadGrid], the partner dot + connecting line from
* the current QSO target, decode dots from [MainViewModel.mutableFt8MessageList],
* and "who heard me" rings from [WhoHeardMeCache]. On an emulator there is no
* radio, so this receiver forges that state.
*
* Usage (app must be open so the engine singleton exists):
* ```
* adb shell am broadcast -a ft8af.DEBUG_INJECT \
* --es call W1ABC --es grid FN42 --es opgrid EM29 --es park K-1234 \
* --ei snr -8 --ei decodes 8 --ei psk 6
* ```
* `decodes` adds N sample US decode dots; `psk` adds N sample "who heard me"
* rings. All extras are optional; [parseDebugInject] fills sensible defaults.
*/
class DebugInjectReceiver : BroadcastReceiver() {

override fun onReceive(context: Context, intent: Intent) {
// Coerce any extra to a String so both `--es key v` and `--ei key n` work.
val spec = parseDebugInject { key -> intent.extras?.get(key)?.toString() }
val vm = MainViewModel.peekInstance()
if (vm == null) {
Log.w(TAG, "ignored — engine singleton is null; open the app on the phone first")
return
}
// Room writes (POTA start) must not run on the main thread; postValue is
// thread-safe, so drive the whole injection off a worker thread.
Thread {
applyDebugInject(spec, vm)
Log.i(TAG, "injected $spec")
}.start()
}

private companion object {
const val TAG = "DebugInject"
}
}

/** Sample US stations plotted as decode dots (stations we heard). */
private val SAMPLE_DECODES = listOf(
"K7ABC" to "CN87", "W6DEF" to "DM04", "N5GHI" to "EM12", "W4JKL" to "EL96",
"K1MNO" to "FN42", "K0PQR" to "EN35", "W7STU" to "DM79", "N9VWX" to "EN52",
"K4YZA" to "EM73", "W5BCD" to "EM10",
)

/** Sample stations plotted as "who heard me" PSK rings (stations that spotted us). */
private val SAMPLE_PSK = listOf(
"VE3XYZ" to "FN03", "K6ABC" to "CM97", "W2DEF" to "FN20", "N7GHI" to "DM43",
"K8JKL" to "EN80", "W9MNO" to "EM69", "KH6PQR" to "BL11", "VE7STU" to "CN89",
)

/**
* Forges the engine state a [DebugInjectSpec] describes: operator grid, a decode
* for the QSO partner (+ optional sample decodes), an optional POTA activation,
* sample "who heard me" PSK spots, and finally the QSO target — set *last*
* because that is the LiveData the car screen observes to trigger a re-render.
*/
internal fun applyDebugInject(spec: DebugInjectSpec, vm: MainViewModel) {
GeneralVariables.setMyMaidenheadGrid(spec.opGrid)

val list = ArrayList(vm.mutableFt8MessageList.value ?: ArrayList())
fun putDecode(call: String, grid: String, snr: Int) {
list.removeAll { it.callsignFrom == call }
list.add(decodeMessage(call, grid, snr))
}
putDecode(spec.partnerCall, spec.partnerGrid, spec.snr)
SAMPLE_DECODES.take(spec.decodes).forEach { (call, grid) -> putDecode(call, grid, -15) }
vm.mutableFt8MessageList.postValue(list)

// "Who heard me" PSK spots — projected from grid to lat/lon like a real report.
if (spec.psk > 0) {
WhoHeardMeCache.spots = SAMPLE_PSK.take(spec.psk).mapNotNull { (call, grid) ->
val ll = try { MaidenheadGrid.gridToLatLng(grid) } catch (_: Exception) { null }
?: return@mapNotNull null
PskReporterSpot(call, grid, ll.latitude, ll.longitude, 14_074_000L, -10, "FT8", 0L)
}
}

spec.parkRef?.let { PotaSessionManager.start(listOf(it), "debug inject") }

// Setting the target triggers the car screen's observer → re-render.
val target = TransmitCallsign(0, 0, spec.partnerCall, 0f, 0, spec.snr)
vm.ft8TransmitSignal.mutableToCallsign.postValue(target)
}

/**
* A CQ decode. Built via the (i3,n3,callTo,callFrom,extra) constructor so
* callsignTo is non-null ("CQ") — the phone's Decode list renders this same
* object and calls checkIsCQ()/getMessageText(), both of which NPE on a bare
* Ft8Message. i3=0,n3=0 = free-text, the safest render path.
*/
private fun decodeMessage(call: String, grid: String, snr: Int): Ft8Message =
Ft8Message(0, 0, "CQ", call, grid).apply {
maidenGrid = grid
this.snr = snr
isValid = true
freq_hz = 1500f
}

/** What the [DebugInjectReceiver] extras resolve to; kept Android-free so it is unit-testable. */
internal data class DebugInjectSpec(
val opGrid: String,
val partnerCall: String,
val partnerGrid: String,
val snr: Int,
val parkRef: String?,
val decodes: Int,
val psk: Int,
)

/**
* Resolves the broadcast extras to a [DebugInjectSpec], applying defaults for any
* omitted key. Callsigns/grids are upper-cased so the map's grid lookup (an exact
* `callsignFrom` match) and Maidenhead parse both behave. [get] is the extras
* accessor; a blank value is treated as absent. `decodes`/`psk` counts are clamped
* to the available sample sizes by the caller via `take(n)`.
*/
internal fun parseDebugInject(get: (String) -> String?): DebugInjectSpec {
fun str(key: String) = get(key)?.trim()?.takeIf { it.isNotEmpty() }
return DebugInjectSpec(
opGrid = (str("opgrid") ?: DEFAULT_OP_GRID).uppercase(),
partnerCall = (str("call") ?: DEFAULT_CALL).uppercase(),
partnerGrid = (str("grid") ?: DEFAULT_GRID).uppercase(),
snr = str("snr")?.toIntOrNull() ?: DEFAULT_SNR,
parkRef = str("park")?.uppercase(),
decodes = str("decodes")?.toIntOrNull()?.coerceAtLeast(0) ?: 0,
psk = str("psk")?.toIntOrNull()?.coerceAtLeast(0) ?: 0,
)
}

private const val DEFAULT_OP_GRID = "EM29"
private const val DEFAULT_CALL = "W1XYZ"
private const val DEFAULT_GRID = "FN42"
private const val DEFAULT_SNR = -12
14 changes: 11 additions & 3 deletions ft8af/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />

<!-- Android Auto: NAVIGATION-category car app that draws its QSO map on the
car Surface (see QsoStatusScreen/CarMapSurfaceRenderer). -->
<uses-permission android:name="androidx.car.app.NAVIGATION_TEMPLATES" />
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE" />

<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
Expand Down Expand Up @@ -112,8 +117,8 @@
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- Level 1 = every templates-capable Android Auto host; we use no newer
template features. -->
<!-- Level 1 = backward-compat floor. The surface map (MapWithContentTemplate)
needs API level 5+; older hosts fall back to a text PaneTemplate. -->
<meta-data
android:name="androidx.car.app.minCarApiLevel"
android:value="1" />
Expand All @@ -123,7 +128,10 @@
android:exported="true">
<intent-filter>
<action android:name="androidx.car.app.CarAppService" />
<category android:name="androidx.car.app.category.IOT" />
<!-- NAVIGATION (not IOT/POI) so the full-bleed QSO map surface stays
visible while the vehicle is moving — the point of mobile FT8.
POI/IOT map surfaces are blanked or disallowed while driving. -->
<category android:name="androidx.car.app.category.NAVIGATION" />
</intent-filter>
</service>

Expand Down
106 changes: 106 additions & 0 deletions ft8af/app/src/main/kotlin/radio/ks3ckc/ft8af/car/CarMapProjection.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package radio.ks3ckc.ft8af.car

/**
* Maps geographic lon/lat to surface pixels for the car map, with an optional
* zoom that frames a set of points (the operator and their QSO partner) close up.
*
* The base projection is plain equirectangular over the whole globe — identical to
* the legacy fixed [world] view. A [fit] view additionally scales and re-centres
* around the midpoint of the supplied points so they sit inside a content box with
* padding, letting the driver actually see the operator→partner connection instead
* of two specks on a world map.
*
* Pure geometry with no Android dependencies so it can be unit-tested directly; the
* renderer owns all Canvas/Surface work.
*/
internal class CarMapProjection(
private val contentCenterX: Float,
private val contentCenterY: Float,
private val midBaseX: Float,
private val midBaseY: Float,
private val zoom: Float,
private val surfaceWidth: Int,
private val surfaceHeight: Int,
) {
/** Longitude (degrees) → surface x (pixels). */
fun x(lon: Float): Float = contentCenterX + (baseX(lon, surfaceWidth) - midBaseX) * zoom

/** Latitude (degrees) → surface y (pixels). */
fun y(lat: Float): Float = contentCenterY + (baseY(lat, surfaceHeight) - midBaseY) * zoom

/** Zoom multiplier over the world view (1 = whole globe). */
val zoomLevel: Float get() = zoom

/**
* Identity key for the projection. Land outline paths are expensive to build,
* so the renderer caches them and only rebuilds when this changes.
*/
val signature: String
get() = "$contentCenterX|$contentCenterY|$midBaseX|$midBaseY|$zoom|$surfaceWidth|$surfaceHeight"

companion object {
/** Never zoom in past this, so two stations in the same grid stay sane. */
const val MAX_ZOOM = 12f

/** Fraction of the content box kept clear around the fitted points. */
const val PADDING_FRACTION = 0.28f

/** Floor on the fitted span (px) so coincident points don't divide by zero. */
const val MIN_SPAN_PX = 1f

/** Base equirectangular projection (whole world fills the surface). */
fun baseX(lon: Float, width: Int): Float = (lon / 180f) * (width / 2f) + (width / 2f)
fun baseY(lat: Float, height: Int): Float = (-lat / 90f) * (height / 2f) + (height / 2f)

/** Full-world view, centred on the surface — matches the legacy fixed projection. */
fun world(width: Int, height: Int): CarMapProjection =
CarMapProjection(width / 2f, height / 2f, width / 2f, height / 2f, 1f, width, height)

/**
* A view zoomed + re-centred so every point in [points] (each `lat` to `lon`,
* degrees) fits inside the content box — centre ([cx], [cy]), size
* [contentW]×[contentH] px — with [PADDING_FRACTION] breathing room. The zoom
* is clamped to `[1, MAX_ZOOM]`: it never zooms *out* past the world view, and
* never in past [MAX_ZOOM]. Degenerate inputs fall back to [world].
*/
fun fit(
width: Int,
height: Int,
cx: Float,
cy: Float,
contentW: Float,
contentH: Float,
points: List<Pair<Double, Double>>,
): CarMapProjection {
if (width <= 0 || height <= 0 || contentW <= 0f || contentH <= 0f || points.isEmpty()) {
return world(width, height)
}
var minX = Float.MAX_VALUE
var maxX = -Float.MAX_VALUE
var minY = Float.MAX_VALUE
var maxY = -Float.MAX_VALUE
for ((lat, lon) in points) {
val bx = baseX(lon.toFloat(), width)
val by = baseY(lat.toFloat(), height)
minX = minOf(minX, bx)
maxX = maxOf(maxX, bx)
minY = minOf(minY, by)
maxY = maxOf(maxY, by)
}
val spanX = maxOf(maxX - minX, MIN_SPAN_PX)
val spanY = maxOf(maxY - minY, MIN_SPAN_PX)
val availW = contentW * (1f - PADDING_FRACTION)
val availH = contentH * (1f - PADDING_FRACTION)
val zoom = minOf(availW / spanX, availH / spanY).coerceIn(1f, MAX_ZOOM)
return CarMapProjection(
contentCenterX = cx,
contentCenterY = cy,
midBaseX = (minX + maxX) / 2f,
midBaseY = (minY + maxY) / 2f,
zoom = zoom,
surfaceWidth = width,
surfaceHeight = height,
)
}
}
}
Loading
Loading