Skip to content
Open
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
55 changes: 32 additions & 23 deletions adapters/android/jni/js8_engine_jni.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,9 @@ static bool is_callsign_like(std::string const& token) {
return has_digit;
}

static std::string maybe_insert_callsign_prefix(std::string const& text) {
// First frame of a line only; a continuation's payload is plain text.
static std::string maybe_insert_callsign_prefix(std::string const& text, bool first_frame) {
if (!first_frame) return text;
std::size_t first_sep = std::string::npos;
for (std::size_t i = 0; i < text.size(); ++i) {
if (std::isspace(static_cast<unsigned char>(text[i]))) {
Expand Down Expand Up @@ -267,15 +269,21 @@ static std::string maybe_insert_callsign_prefix(std::string const& text) {
return rebuilt;
}

static std::string render_decoded_text(JS8Engine_Native* native, js8core::events::Decoded const& decoded) {
// Renders the display text. *type gets the frame type, with the data flag
// added when only the payload carried it.
static std::string render_decoded_text(JS8Engine_Native* native,
js8core::events::Decoded const& decoded,
int* type) {
using namespace js8core::protocol::varicode;

*type = decoded.type;
auto const& frame = decoded.data;
if (frame.size() < 12 || frame.find(' ') != std::string::npos) {
return frame;
}

bool is_data_flag = (decoded.type & 0b100) == 0b100;
bool is_first_frame = (decoded.type & 0b1) == 0b1;
// Try data payloads first (mirrors desktop unpack order).
__android_log_print(ANDROID_LOG_DEBUG, "JS8FrameDebug",
"Trying data unpacker: frame='%s', decoded.type=0x%02x",
Expand All @@ -284,7 +292,7 @@ static std::string render_decoded_text(JS8Engine_Native* native, js8core::events
auto data = unpack_fast_data_message(frame);
__android_log_print(ANDROID_LOG_DEBUG, "JS8FrameDebug",
"unpack_fast_data returned: '%s'", data.c_str());
if (!data.empty()) return maybe_insert_callsign_prefix(data);
if (!data.empty()) return maybe_insert_callsign_prefix(data, is_first_frame);
// Fast-data frames should not be treated as heartbeat/compound/directed.
__android_log_print(ANDROID_LOG_WARN, "JS8FrameDebug",
"Fast data unpack failed, returning raw frame: '%s'", frame.c_str());
Expand All @@ -293,7 +301,12 @@ static std::string render_decoded_text(JS8Engine_Native* native, js8core::events
auto data = unpack_data_message(frame);
__android_log_print(ANDROID_LOG_DEBUG, "JS8FrameDebug",
"unpack_data returned: '%s'", data.c_str());
if (!data.empty()) return maybe_insert_callsign_prefix(data);
if (!data.empty()) {
// Normal and Slow carry the data flag in the payload, not in the
// frame type; set it so Kotlin sees one convention for all modes.
*type |= 0b100;
return maybe_insert_callsign_prefix(data, is_first_frame);
}
}

// Heartbeat (most common for status beacons)
Expand Down Expand Up @@ -429,19 +442,21 @@ static void event_callback(JS8Engine_Native* native, js8core::events::Variant co

// Handle different event types
if (auto* decoded = std::get_if<js8core::events::Decoded>(&event)) {
auto rendered = render_decoded_text(native, *decoded);

auto emit_decoded = [&](js8core::events::Decoded const& d, std::string const& text_str) {
jmethodID method = env->GetMethodID(handler_class, "onDecoded", "(IIFFLjava/lang/String;IFII)V");
if (method) {
jstring text = env->NewStringUTF(text_str.c_str());
env->CallVoidMethod(native->callback_handler, method,
d.utc, d.snr, d.xdt, d.frequency,
text, d.type, d.quality, d.mode, d.drift_ms);
env->DeleteLocalRef(text);
}
};
emit_decoded(*decoded, rendered);
int type = 0;
auto rendered = render_decoded_text(native, *decoded, &type);
__android_log_print(ANDROID_LOG_INFO, "JS8Engine_Native",
"DECODED: SNR=%d dB, freq=%.1f Hz, text='%s', raw='%s', type=%d, mode=%d",
decoded->snr, decoded->frequency, rendered.c_str(),
decoded->data.c_str(), type, decoded->mode);

jmethodID method = env->GetMethodID(handler_class, "onDecoded", "(IIFFLjava/lang/String;IFII)V");
if (method) {
jstring text = env->NewStringUTF(rendered.c_str());
env->CallVoidMethod(native->callback_handler, method,
decoded->utc, decoded->snr, decoded->xdt, decoded->frequency,
text, type, decoded->quality, decoded->mode, decoded->drift_ms);
env->DeleteLocalRef(text);
}
} else if (auto* spectrum = std::get_if<js8core::events::Spectrum>(&event)) {
// Call onSpectrum(float[] bins, float binHz, float powerDb, float peakDb)
jmethodID method = env->GetMethodID(handler_class, "onSpectrum", "([FFFF)V");
Expand Down Expand Up @@ -591,12 +606,6 @@ JS8Engine_Native* js8_engine_create(JNIEnv* env, jobject callback_handler,
auto const& e = std::get<js8core::events::DecodeFinished>(event);
__android_log_print(ANDROID_LOG_DEBUG, "JS8Engine_Native",
"DecodeFinished: count=%zu", e.decoded);
} else if (std::holds_alternative<js8core::events::Decoded>(event)) {
auto const& e = std::get<js8core::events::Decoded>(event);
auto rendered = render_decoded_text(native, e);
__android_log_print(ANDROID_LOG_INFO, "JS8Engine_Native",
"DECODED: SNR=%d dB, freq=%.1f Hz, text='%s', raw='%s', type=%d, mode=%d",
e.snr, e.frequency, rendered.c_str(), e.data.c_str(), e.type, e.mode);
}
event_callback(native, event);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ data class DecodedMessage(
*/
fun isSingleFrame(): Boolean = isFirstFrame() && isLastFrame()

/**
* Check if this frame carries a data payload.
* JS8CallData flag is bit 2 (type & 4).
*/
fun isDataFrame(): Boolean = isDataFrame(type)

/**
* Get color resource ID based on SNR level.
*/
Expand Down Expand Up @@ -82,6 +88,11 @@ data class DecodedMessage(
localCal.get(java.util.Calendar.SECOND)
)
}

companion object {
/** The same data-flag test for callers holding only the type bits. */
fun isDataFrame(type: Int): Boolean = (type and 4) != 0
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import com.js8call.core.TruSdxDirectSerial
import com.js8call.core.UsbSerialBridge
import com.js8call.core.UsbSerialPortCatalog
import com.js8call.example.MainActivity
import com.js8call.example.model.DecodedMessage
import com.js8call.example.ui.AudioDevices
import com.js8call.example.MessageLogWriter
import com.js8call.example.R
Expand Down Expand Up @@ -536,7 +537,7 @@ class JS8EngineService : Service() {
updateHeardCallsign(text)
broadcastDecode(utc, snr, dt, freq, text, type, quality, mode, driftMs)
handleRelayFrame(text, snr, mode, freq, type)
maybeHandleIncomingMessage(text, snr, freq, type)
maybeHandleIncomingMessage(text, snr, freq, type, mode)
maybeHandleAutoReply(text, snr, mode)
maybeReportToPskReporter(utc, snr, freq, text)
}
Expand Down Expand Up @@ -2369,7 +2370,7 @@ class JS8EngineService : Service() {

// Drifted timeline, so slots match the engine's cycle boundaries.
val now = System.currentTimeMillis() + (engine?.timeDriftMs() ?: 0L)
val frameDuration = getFrameDurationMs()
val frameDuration = framePeriodMs(getPreferredTxSubmode())

// Base delay
var delay = if (first) {
Expand Down Expand Up @@ -2414,8 +2415,8 @@ class JS8EngineService : Service() {
heartbeatHandler.postDelayed(heartbeatRunnable, waitMs)
}

private fun getFrameDurationMs(): Long {
return when (getPreferredTxSubmode()) {
private fun framePeriodMs(submode: Int): Long {
return when (submode) {
SUBMODE_SLOW -> 30000L
SUBMODE_NORMAL -> 15000L
SUBMODE_FAST -> 10000L
Expand Down Expand Up @@ -2918,7 +2919,7 @@ class JS8EngineService : Service() {
* FROM: TO MSG (multi-frame: command frame)
* payload... (multi-frame: data frames follow)
*/
private fun maybeHandleIncomingMessage(text: String, snr: Int, freq: Float, type: Int) {
private fun maybeHandleIncomingMessage(text: String, snr: Int, freq: Float, type: Int, submode: Int) {
val callsign = getConfiguredCallsign()
Log.d(TAG, "maybeHandleIncomingMessage: text='$text' type=$type callsign=$callsign")
if (callsign == null) {
Expand Down Expand Up @@ -2973,6 +2974,8 @@ class JS8EngineService : Service() {
snr = snr,
frequency = freq,
lastUpdated = now,
// Rides out three lost frames in a row before giving up.
timeoutMs = 4 * framePeriodMs(submode),
parts = if (initialPayload.isNotBlank()) mutableListOf(initialPayload) else mutableListOf()
)
synchronized(msgLock) {
Expand All @@ -2991,7 +2994,7 @@ class JS8EngineService : Service() {
}

// Not a directed command - check if it's a data frame for a buffered MSG
if (!isDataFrame(type)) {
if (!DecodedMessage.isDataFrame(type)) {
return
}

Expand Down Expand Up @@ -3020,17 +3023,17 @@ class JS8EngineService : Service() {
val snr: Int,
val frequency: Float,
var lastUpdated: Long,
val timeoutMs: Long,
val parts: MutableList<String> = mutableListOf()
)

private val msgBuffers = mutableMapOf<Int, MsgBuffer>()
private val msgLock = Any()
private val MSG_BUFFER_TIMEOUT_MS = 60_000L

private fun cleanupMsgBuffers(now: Long) {
synchronized(msgLock) {
msgBuffers.entries.removeIf { (_, buffer) ->
now - buffer.lastUpdated > MSG_BUFFER_TIMEOUT_MS
now - buffer.lastUpdated > buffer.timeoutMs
}
}
}
Expand Down Expand Up @@ -3069,7 +3072,6 @@ class JS8EngineService : Service() {
}
}

private fun isDataFrame(type: Int): Boolean = (type and 0x4) != 0

private fun isSubscribedGroup(target: String): Boolean {
if (!target.startsWith("@")) return false
Expand Down Expand Up @@ -3194,7 +3196,7 @@ class JS8EngineService : Service() {
return
}

if (!isRelayDataFrame(type)) return
if (!DecodedMessage.isDataFrame(type)) return

val result = synchronized(relayLock) {
val key = findMatchingRelayBufferKey(freq) ?: return@synchronized null
Expand Down Expand Up @@ -3441,7 +3443,6 @@ class JS8EngineService : Service() {
return target.contains("@")
}

private fun isRelayDataFrame(type: Int): Boolean = (type and 0x4) != 0

private fun isLastFrame(type: Int): Boolean = (type and 0x2) != 0

Expand Down
80 changes: 22 additions & 58 deletions android/app/src/main/java/com/js8call/example/ui/DecodeViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,51 +14,21 @@ import kotlin.math.roundToInt
import org.json.JSONArray
import org.json.JSONObject

internal fun assembleMultipartDecodeText(frameTexts: List<String>): String {
internal fun assembleMultipartDecodeText(frames: List<DecodedMessage>): String {
val parts = frames.filter { it.text.isNotEmpty() }
return buildString {
frameTexts.forEachIndexed { index, frameText ->
if (index > 0 && shouldInsertMultipartSpace(this, frameText)) {
append(' ')
}
append(frameText)
parts.forEachIndexed { index, frame ->
if (index > 0 && needsSpaceBefore(parts[index - 1], frame)) append(' ')
append(frame.text)
}
}
}

internal fun shouldInsertMultipartSpace(builder: StringBuilder, nextText: String): Boolean {
if (builder.isEmpty()) return false
if (nextText.isEmpty()) return false
var prevIndex = builder.length - 1
while (prevIndex >= 0 && builder[prevIndex].isWhitespace()) {
prevIndex--
}
if (prevIndex < 0) return false

var nextIndex = 0
while (nextIndex < nextText.length && nextText[nextIndex].isWhitespace()) {
nextIndex++
}
if (nextIndex >= nextText.length) return false

val prevChar = builder[prevIndex]
val nextChar = nextText[nextIndex]
if ((!prevChar.isLetterOrDigit() && prevChar != ':') || !nextChar.isLetterOrDigit()) {
return false
}

var tokenStart = prevIndex
while (tokenStart >= 0 && !builder[tokenStart].isWhitespace()) {
tokenStart--
}
val prevToken = builder.substring(tokenStart + 1, prevIndex + 1)
val hasDigit = prevToken.any { it.isDigit() }
return hasDigit || prevChar == ':' || isGroupToken(prevToken)
}

private fun isGroupToken(token: String): Boolean {
if (!token.startsWith("@") || token.length < 2) return false
return token.drop(1).all { it.isLetterOrDigit() || it == '/' }
}
// Data frames split mid-word and carry their own spaces. A directed header
// can meet its payload flush: a buffered command strips the separator
// before packing, so the boundary needs the space put back.
private fun needsSpaceBefore(prev: DecodedMessage, next: DecodedMessage): Boolean =
!prev.isDataFrame() && !prev.text.last().isWhitespace() && !next.text.first().isWhitespace()

/**
* ViewModel for the Decodes screen.
Expand Down Expand Up @@ -248,10 +218,9 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application)
* Assemble a complete message from buffered frames.
*/
private fun assembleMessage(buffer: MessageBuffer): DecodedMessage {
val frameTexts = buffer.frames.map { it.text }.toMutableList()
normalizeCompoundDirectedHelpers(buffer.frames, frameTexts)

val assembledText = assembleMultipartDecodeText(frameTexts)
val frames = buffer.frames.toMutableList()
normalizeCompoundDirectedHelpers(frames)
val assembledText = assembleMultipartDecodeText(frames)

// Use the last frame's metadata (most recent)
val lastFrame = buffer.frames.last()
Expand All @@ -270,35 +239,32 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application)
)
}

private fun normalizeCompoundDirectedHelpers(
frames: List<DecodedMessage>,
frameTexts: MutableList<String>
) {
if (frameTexts.size < 2 || frames.size < 2) return
private fun normalizeCompoundDirectedHelpers(frames: MutableList<DecodedMessage>) {
if (frames.size < 2) return

val firstFrame = frames[0]
val secondFrame = frames[1]
if (isDataFrame(firstFrame.type) || isDataFrame(secondFrame.type)) return
if (firstFrame.isDataFrame() || secondFrame.isDataFrame()) return
if (!firstFrame.isFirstFrame() || firstFrame.isLastFrame()) return
if (secondFrame.isFirstFrame()) return

val first = frameTexts[0].trim()
val second = frameTexts[1].trim()
val first = firstFrame.text.trim()
val second = secondFrame.text.trim()
if (!isCompoundDeHelperFrame(first)) return

val fromCall = first.substringBefore(' ').trim()
if (fromCall.isEmpty()) return

val rewrittenDirected = rewriteDirectedPlaceholder(second, fromCall)
if (rewrittenDirected != null) {
frameTexts[0] = ""
frameTexts[1] = rewrittenDirected
frames[0] = firstFrame.copy(text = "")
frames[1] = secondFrame.copy(text = rewrittenDirected)
return
}

if (isDirectedCompoundHeader(second)) {
frameTexts[0] = ""
frameTexts[1] = "$fromCall: $second"
frames[0] = firstFrame.copy(text = "")
frames[1] = secondFrame.copy(text = "$fromCall: $second")
}
}

Expand Down Expand Up @@ -335,8 +301,6 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application)
return directedCommandTailRegex.matches(tail)
}

private fun isDataFrame(type: Int): Boolean = (type and 0x4) != 0

/**
* Find a buffer key that matches the given frequency within tolerance.
* Returns null if no matching buffer is found.
Expand Down
Loading
Loading