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
72 changes: 71 additions & 1 deletion ft8af/app/src/main/java/com/k1af/ft8af/x6100/X6100Radio.java
Original file line number Diff line number Diff line change
Expand Up @@ -630,7 +630,7 @@ private void doReceiveLineEvent(String line) {
this.version = response.head.substring(1);
break;
case HANDLE:
this.handle = Integer.parseInt(response.head.substring(1), 16);
this.handle = parseXieguHandle(response.head, this.handle);
break;
case RESPONSE:
if (XieguCommand.AUDIO == response.xieguCommand) {//response information for audio command
Expand Down Expand Up @@ -675,6 +675,76 @@ private void doReceiveLineEvent(String line) {

}

/**
* Parse a Xiegu client-handle response ("H" + hex handle) into its numeric
* value, returning {@code currentHandle} unchanged when the frame is empty,
* truncated, or not valid hex.
*
* <p>This is the crash fix for the HANDLE branch of {@link #doReceiveLineEvent}.
* The previous {@code Integer.parseInt(head.substring(1), 16)} was unguarded,
* unlike every sibling parse in this class (seq_number, resultCode,
* play_volume) and unlike the identical handle parse in
* {@link com.k1af.ft8af.flex.FlexRadio}, which wraps it in a try/catch. Two
* inputs made it throw {@link NumberFormatException}: a garbled/truncated
* frame whose tail is not hex (even a lone {@code "H"}, whose tail is empty),
* and a legitimate high-bit 32-bit handle such as {@code "HFFFFFFFF"} — the
* Xiegu protocol handle is a full 32-bit value (see the {@code HANDLE} enum
* doc) and {@code 0xFFFFFFFF} overflows a signed {@code int}, so
* {@code Integer.parseInt} rejected it. This parse runs on the TCP CAT read
* thread ({@link com.k1af.ft8af.flex.RadioTcpClient}), whose read loop catches
* only {@code SocketException}/{@code IOException}; a {@code NumberFormatException}
* escaping here therefore killed that thread and crashed the whole app.
*
* <p>A valid handle is 1–8 <em>unsigned</em> hex digits. We validate that
* shape explicitly and only then parse via {@code Long.parseLong} (so the
* full {@code 0x00000000}–{@code 0xFFFFFFFF} range round-trips into the
* {@code int} handle field with the same bit pattern). Rejecting anything
* else keeps the last good handle rather than misinterpreting the frame:
* {@code Long.parseLong} would otherwise accept a leading sign
* (e.g. {@code "H-1"} → {@code -1}) and would accept more than 8 digits and
* then silently truncate them on the narrowing cast (e.g. {@code "H100000000"}
* → {@code 0}).
*
* <p>Package-private and static so it can be unit-tested without a live
* socket or {@code Context}.
*
* @param head the response head ({@code "H"} + hex handle); may be
* {@code null} or too short to carry a handle
* @param currentHandle the handle value to keep when {@code head} can't be parsed
* @return the parsed handle, or {@code currentHandle} on any parse failure
*/
static int parseXieguHandle(String head, int currentHandle) {
if (head == null || head.length() < 2) {
return currentHandle;
}
String hex = head.substring(1);
if (hex.length() > 8 || !isUnsignedHex(hex)) {
Log.e(TAG, "Xiegu handle parse failed (expected 1-8 hex digits): " + hex);
return currentHandle;
}
// Validated to 1-8 unsigned hex digits, so this never throws; parse as
// long so 0x80000000..0xFFFFFFFF narrow into the int field with the same
// 32-bit pattern (a plain signed-int parse would reject those).
return (int) Long.parseLong(hex, 16);
}

/** @return true iff {@code s} is non-empty and every char is {@code [0-9a-fA-F]}. */
private static boolean isUnsignedHex(String s) {
if (s.isEmpty()) {
return false;
}
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
boolean hex = (c >= '0' && c <= '9')
|| (c >= 'a' && c <= 'f')
|| (c >= 'A' && c <= 'F');
if (!hex) {
return false;
}
}
return true;
}

/**
* Get the radio's audio information
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.k1af.ft8af.x6100;

import static com.google.common.truth.Truth.assertThat;

import org.junit.Test;

/**
* Pure-JVM coverage for {@link X6100Radio#parseXieguHandle(String, int)} — the
* crash fix for the Xiegu {@code HANDLE} ("H" + 32-bit hex) response.
*
* <p>The parse runs on the TCP CAT read thread
* ({@link com.k1af.ft8af.flex.RadioTcpClient}), whose read loop catches only
* {@code SocketException}/{@code IOException}. The old unguarded
* {@code Integer.parseInt(head.substring(1), 16)} therefore let a
* {@link NumberFormatException} from a garbled/truncated frame — or from a
* legitimate high-bit 32-bit handle that overflows a signed {@code int} —
* escape and crash the whole app. These tests pin the guarded behaviour.
*
* <p>{@code android.util.Log} returns defaults under plain JUnit
* (unitTests.returnDefaultValues), so no Robolectric runner is needed.
*/
public class X6100RadioHandleParseTest {

private static final int KEEP = 0x1234; // sentinel "current handle" to preserve

@Test
public void parsesLowHandle() {
assertThat(X6100Radio.parseXieguHandle("H1A2B", KEEP)).isEqualTo(0x1A2B);
}

@Test
public void parsesFullEightDigitHandle() {
assertThat(X6100Radio.parseXieguHandle("H0009ABCD", KEEP)).isEqualTo(0x0009ABCD);
}

@Test
public void parsesHighBitHandle_wouldOverflowSignedIntParse() {
// 0xFFFFFFFF is a valid 32-bit Xiegu handle but overflows Integer.parseInt,
// which threw NumberFormatException and crashed the CAT read thread.
assertThat(X6100Radio.parseXieguHandle("HFFFFFFFF", KEEP)).isEqualTo(-1);
}

@Test
public void parsesHighBitHandle_minValue() {
assertThat(X6100Radio.parseXieguHandle("H80000000", KEEP)).isEqualTo(Integer.MIN_VALUE);
}

@Test
public void loneHandleLetter_keepsCurrent_doesNotCrash() {
// Regression: "H" -> substring(1) == "" -> parseInt("", 16) threw.
assertThat(X6100Radio.parseXieguHandle("H", KEEP)).isEqualTo(KEEP);
}

@Test
public void nonHexTail_keepsCurrent_doesNotCrash() {
// Regression: a truncated/garbled frame like "HELLO" -> parseInt("ELLO", 16) threw.
assertThat(X6100Radio.parseXieguHandle("HELLO", KEEP)).isEqualTo(KEEP);
}

@Test
public void lowercaseHex_isParsed() {
// The handle tail keeps the frame's original case (only the header char is
// upper-cased for dispatch), so lowercase hex is legitimate.
assertThat(X6100Radio.parseXieguHandle("Hbeef", KEEP)).isEqualTo(0xBEEF);
}

@Test
public void signedTail_keepsCurrent() {
// Long.parseLong would accept the sign ("-1" -> -1); a handle is unsigned,
// so a signed tail is malformed and must preserve the current handle.
assertThat(X6100Radio.parseXieguHandle("H-1", KEEP)).isEqualTo(KEEP);
}

@Test
public void overlongTail_keepsCurrent_noSilentTruncation() {
// 9 hex digits exceed a 32-bit handle; parsing+narrowing would silently
// truncate ("H100000000" -> 0), so reject and keep the current handle.
assertThat(X6100Radio.parseXieguHandle("H100000000", KEEP)).isEqualTo(KEEP);
}

@Test
public void whitespaceTail_keepsCurrent() {
assertThat(X6100Radio.parseXieguHandle("H 1A", KEEP)).isEqualTo(KEEP);
}

@Test
public void emptyString_keepsCurrent() {
assertThat(X6100Radio.parseXieguHandle("", KEEP)).isEqualTo(KEEP);
}

@Test
public void nullHead_keepsCurrent() {
assertThat(X6100Radio.parseXieguHandle(null, KEEP)).isEqualTo(KEEP);
}
}
Loading