From 15b9eb6e8dab81f6a8d25380099a571fed4808aa Mon Sep 17 00:00:00 2001 From: tekstrand Date: Fri, 25 Sep 2026 07:20:10 -0500 Subject: [PATCH] Added a debug-only way to inject synthetic decodes over adb. Testing anything downstream of the decoder needed a radio, a second station and a band that cooperates. Multi-frame messages, directed commands, auto-reply and the Messages inbox could only be exercised with real traffic, and malformed frames or a message addressed a particular way could not be produced on demand at all. Debug builds now have a broadcast receiver in the debug source set. An am broadcast to it forwards the extras to the engine service, which is not exported, and the service runs the text through the same handlers a real decode takes: the heard-station list, the decode broadcast, relay, incoming messages and auto-reply. It skips time sync, PSK Reporter and the RX log, so a fake decode cannot move the clock or report a spot. The extras are the decode broadcast's own, text, snr, freq, type and mode, and type defaults to a single complete frame. To keep the two paths from drifting, the handler calls in the real decode callback moved into one function that both the callback and the injection call, with time sync before it and the PSK report after it, as before. A handler added there covers injected decodes without anyone remembering to. The receiver requires android.permission.DUMP, which the adb shell holds and an ordinary app cannot, so another app on a debug build cannot inject traffic. The debug manifest never merges into release, and the service also ignores the action unless BuildConfig.DEBUG. A start refused because the app is in the background is caught and logged rather than crashing the process. android/README.md gains an Inject decodes section with the commands, the extras and the frame flags. Two things came out of writing it. MSG continuation frames only count with the data flag set, so the last frame of a message is type 6, not 2. And auto-reply and relay act on injected decodes exactly as on real ones, including a reply queued with monitoring off that goes out at the next start, so the section says to turn both off on a station with a rig. Verified on the phone emulator by running every README command as written: a directed SNR? reached the decode list and queued the auto-reply, the two-frame MSG assembled into HELLO WORLD in Messages with a notification and an auto-ACK, and the optional extras came through. A broadcast from another app's uid was skipped at enqueue for lacking DUMP, an injection with the app in the background and monitoring off was dropped with a warning and the process kept running, and with monitoring on it reached the service from the background. The release merged manifest has no receiver. --- android/README.md | 26 +++++++++++++ android/app/src/debug/AndroidManifest.xml | 12 ++++++ .../example/debug/DebugDecodeReceiver.kt | 27 +++++++++++++ .../example/service/JS8EngineService.kt | 39 ++++++++++++++++--- 4 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 android/app/src/debug/AndroidManifest.xml create mode 100644 android/app/src/debug/java/com/js8call/example/debug/DebugDecodeReceiver.kt diff --git a/android/README.md b/android/README.md index 8e7a8935..507862e5 100644 --- a/android/README.md +++ b/android/README.md @@ -64,6 +64,32 @@ To run one class: JAVA_HOME=/opt/homebrew/opt/openjdk ANDROID_HOME=~/Library/Android/sdk ./gradlew :js8core-lib:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.js8call.core.JS8EngineLoopbackTest ``` +## Inject decodes + +On a station with a rig, turn off Enable Autoreply and Enable Relay in Settings first. Both act on injected decodes like real ones, and a reply queued with monitoring off goes out at the next start. + +Debug builds take synthetic decodes over adb, no audio needed. With the app in front, and your callsign from Settings in place of `N0CALL`: + +```bash +adb shell am broadcast -n com.js8call.example/.debug.DebugDecodeReceiver --es text "'K1ABC: N0CALL SNR?'" +``` + +Optional extras: + +- `--ei snr -12` (default -10) +- `--ef freq 1450` audio offset in Hz (default 1500) +- `--ei mode 2` submode: 0 Normal, 1 Fast, 2 Turbo, 4 Slow (default 0) +- `--ei type 1` frame flags, added together: 1 first, 2 last, 4 data (default 3, a single frame) + +A multi-frame message is one broadcast per frame, back to back, at the same `freq`. The frames after a `MSG` header are data frames: 4 in the middle, 6 last. + +```bash +adb shell am broadcast -n com.js8call.example/.debug.DebugDecodeReceiver --es text "'K1ABC: N0CALL MSG HELLO'" --ei type 1 +adb shell am broadcast -n com.js8call.example/.debug.DebugDecodeReceiver --es text "' WORLD'" --ei type 6 +``` + +Output: the Decodes tab, and Messages for a complete `MSG` to your callsign. Log: `adb logcat -s JS8EngineService DebugDecodeReceiver` + ## Build Release APK (signed) Create a keystore once: diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..3aa509ab --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/android/app/src/debug/java/com/js8call/example/debug/DebugDecodeReceiver.kt b/android/app/src/debug/java/com/js8call/example/debug/DebugDecodeReceiver.kt new file mode 100644 index 00000000..a510edf2 --- /dev/null +++ b/android/app/src/debug/java/com/js8call/example/debug/DebugDecodeReceiver.kt @@ -0,0 +1,27 @@ +package com.js8call.example.debug + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.js8call.example.service.JS8EngineService + +/** Forwards adb decode injections to [JS8EngineService]. */ +class DebugDecodeReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val forward = Intent(context, JS8EngineService::class.java).apply { + action = JS8EngineService.ACTION_DEBUG_INJECT_DECODE + intent.extras?.let { putExtras(it) } + } + try { + context.startService(forward) + } catch (e: IllegalStateException) { + // Refused from the background unless the engine is running + Log.w(TAG, "Injected decode dropped: bring the app to the front") + } + } + + companion object { + private const val TAG = "DebugDecodeReceiver" + } +} diff --git a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt index 836cfc3c..bfa48bd6 100644 --- a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt +++ b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt @@ -355,6 +355,9 @@ class JS8EngineService : Service() { driftMmaN = if (driftMs == 0L) 0 else 1 applyTimeDrift(driftMs) } + ACTION_DEBUG_INJECT_DECODE -> { + if (BuildConfig.DEBUG) injectDecode(intent) + } } return START_STICKY } @@ -552,11 +555,7 @@ class JS8EngineService : Service() { // Broadcast on main thread mainHandler.post { maybeApplyTimeSync(driftMs) - updateHeardCallsign(text) - broadcastDecode(utc, snr, dt, freq, text, type, quality, mode, driftMs) - handleRelayFrame(text, snr, mode, freq, type) - maybeHandleIncomingMessage(text, snr, freq, type, mode) - maybeHandleAutoReply(text, snr, mode) + handleDecode(utc, snr, dt, freq, text, type, quality, mode, driftMs) maybeReportToPskReporter(utc, snr, freq, text) } } @@ -1686,6 +1685,35 @@ class JS8EngineService : Service() { LocalBroadcastManager.getInstance(this).sendBroadcast(intent) } + // Shared with injected decodes + private fun handleDecode( + utc: Int, snr: Int, dt: Float, freq: Float, + text: String, type: Int, quality: Float, mode: Int, driftMs: Int + ) { + updateHeardCallsign(text) + broadcastDecode(utc, snr, dt, freq, text, type, quality, mode, driftMs) + handleRelayFrame(text, snr, mode, freq, type) + maybeHandleIncomingMessage(text, snr, freq, type, mode) + maybeHandleAutoReply(text, snr, mode) + } + + private fun injectDecode(intent: Intent) { + val text = intent.getStringExtra(EXTRA_TEXT) + if (text.isNullOrBlank()) { + Log.w(TAG, "Injected decode has no text") + return + } + val snr = intent.getIntExtra(EXTRA_SNR, -10) + val freq = intent.getFloatExtra(EXTRA_FREQ, 1500f) + val type = intent.getIntExtra(EXTRA_TYPE, 3) + val mode = intent.getIntExtra(EXTRA_MODE, 0) + val now = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + val utc = now.get(Calendar.HOUR_OF_DAY) * 10000 + + now.get(Calendar.MINUTE) * 100 + now.get(Calendar.SECOND) + Log.i(TAG, "Injected decode: '$text' snr=$snr freq=$freq type=$type mode=$mode") + handleDecode(utc, snr, 0f, freq, text, type, 1f, mode, 0) + } + private fun broadcastDecode( utc: Int, snr: Int, dt: Float, freq: Float, text: String, type: Int, quality: Float, mode: Int, driftMs: Int @@ -4067,6 +4095,7 @@ class JS8EngineService : Service() { const val ACTION_SET_TIME_DRIFT = "com.js8call.example.ACTION_SET_TIME_DRIFT" const val ACTION_TIME_DRIFT = "com.js8call.example.ACTION_TIME_DRIFT" const val ACTION_RIG_STATUS = "com.js8call.example.ACTION_RIG_STATUS" + const val ACTION_DEBUG_INJECT_DECODE = "com.js8call.example.ACTION_DEBUG_INJECT_DECODE" // Engine states const val STATE_STOPPED = "stopped"