diff --git a/app/build.gradle b/app/build.gradle index 9374147..c007cfe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,7 +12,7 @@ android { minSdkVersion 26 targetSdkVersion 29 versionCode 6 - versionName "0.4.0-SNAPSHOT" + versionName "0.4.0-alpha01" setProperty("archivesBaseName", "$applicationName-$versionName") testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -67,8 +67,8 @@ android { dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation 'com.jakewharton.timber:timber:4.7.1' - implementation 'androidx.appcompat:appcompat:1.3.0' - implementation 'com.google.android.material:material:1.3.0' + implementation 'androidx.appcompat:appcompat:1.3.1' + implementation 'com.google.android.material:material:1.4.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'com.google.guava:guava:29.0-android' implementation 'androidx.preference:preference:1.1.1' @@ -79,10 +79,10 @@ dependencies { implementation "com.craxiom:network-survey-messaging:${networkSurveyMessagingVersion}" implementation 'com.google.protobuf:protobuf-java-util:3.15.3' - implementation 'com.craxiom:mqtt-library:0.4.2' + implementation 'com.craxiom:mqtt-library:0.4.3' - testImplementation 'junit:junit:4.13.1' - androidTestImplementation 'androidx.test.ext:junit:1.1.2' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' } \ No newline at end of file diff --git a/app/src/main/java/com/craxiom/networksurveyplus/QcdmPcapWriter.java b/app/src/main/java/com/craxiom/networksurveyplus/QcdmPcapWriter.java index b695f6c..483044a 100644 --- a/app/src/main/java/com/craxiom/networksurveyplus/QcdmPcapWriter.java +++ b/app/src/main/java/com/craxiom/networksurveyplus/QcdmPcapWriter.java @@ -8,6 +8,7 @@ import com.craxiom.networksurveyplus.messages.DiagCommand; import com.craxiom.networksurveyplus.messages.QcdmConstants; +import com.craxiom.networksurveyplus.messages.QcdmGsmParser; import com.craxiom.networksurveyplus.messages.QcdmLteParser; import com.craxiom.networksurveyplus.messages.QcdmMessage; import com.craxiom.networksurveyplus.messages.QcdmUmtsParser; @@ -148,6 +149,15 @@ public void onQcdmMessage(QcdmMessage qcdmMessage) case QcdmConstants.UMTS_NAS_OTA_DSDS: pcapRecord = QcdmUmtsParser.convertUmtsNasOtaDsds(qcdmMessage, gpsListener.getLatestLocation()); break; + + case QcdmConstants.GSM_RR_SIGNALING_MESSAGES: + pcapRecord = QcdmGsmParser.convertGsmSignalingMessage(qcdmMessage, gpsListener.getLatestLocation()); + break; + + /*case QcdmConstants.GSM_RR_CELL_INFORMATION_C: + // TODO delete me once I find a record for this + Timber.i("GSM RR Cell Information: %s", qcdmMessage); + break;*/ } if (pcapRecord != null) diff --git a/app/src/main/java/com/craxiom/networksurveyplus/messages/GsmSubtypes.java b/app/src/main/java/com/craxiom/networksurveyplus/messages/GsmSubtypes.java new file mode 100644 index 0000000..3aef591 --- /dev/null +++ b/app/src/main/java/com/craxiom/networksurveyplus/messages/GsmSubtypes.java @@ -0,0 +1,32 @@ +package com.craxiom.networksurveyplus.messages; + +/** + * The GSMTAP mapping for the GSM subtypes. The ordinal value of these enums map to the value that is used in the + * GSMTAP header, so don't change the order of the enum values, and don't insert any new values in the middle. + *

+ * These values are pulled from Osmocom and scat. + * https://github.com/osmocom/libosmocore/blob/master/include/osmocom/core/gsmtap.h + * + * @since 0.4.0 + */ +public enum GsmSubtypes +{ + GSMTAP_CHANNEL_UNKNOWN, // 0x00 + GSMTAP_CHANNEL_BCCH, // 0x01 + GSMTAP_CHANNEL_CCCH, // 0x02 + GSMTAP_CHANNEL_RACH, // 0x03 + GSMTAP_CHANNEL_AGCH, // 0x04 + GSMTAP_CHANNEL_PCH, // 0x05 + GSMTAP_CHANNEL_SDCCH, // 0x06 + GSMTAP_CHANNEL_SDCCH4, // 0x07 + GSMTAP_CHANNEL_SDCCH8, // 0x08 + GSMTAP_CHANNEL_TCH_F, // 0x09 + GSMTAP_CHANNEL_TCH_H, // 0x0a = 10 + GSMTAP_CHANNEL_PACCH, // 0x0b = 11 + GSMTAP_CHANNEL_CBCH52, // 0x0c = 12 + GSMTAP_CHANNEL_PDCH, // 0x0d = 13 + GSMTAP_CHANNEL_PTCCH, // 0x0e = 14 + GSMTAP_CHANNEL_CBCH51, // 0x0f = 15 + GSMTAP_CHANNEL_VOICE_F, // 0x10 = 16 /* voice codec payload (FR/EFR/AMR) */ + GSMTAP_CHANNEL_VOICE_H, // 0x11 = 17 /* voice codec payload (HR/AMR) */ +} diff --git a/app/src/main/java/com/craxiom/networksurveyplus/messages/ParserUtils.java b/app/src/main/java/com/craxiom/networksurveyplus/messages/ParserUtils.java index 19c0096..b7b034f 100644 --- a/app/src/main/java/com/craxiom/networksurveyplus/messages/ParserUtils.java +++ b/app/src/main/java/com/craxiom/networksurveyplus/messages/ParserUtils.java @@ -359,4 +359,25 @@ public static String convertBytesToHexString(byte[] bytes, int offset, int lengt } return stringBuilder.toString(); } + + /** + * Converts a byte array to a hex string with the Java byte cast as a prefix and a comma at the end of each byte. + * This allows for the output to be placed in a byte array initializer for use in unit tests. + * + * @param bytes The byte array to convert. + * @param offset Offset within bytes to start processing. + * @param length Number of bytes to process. + * @return The byte array represented as a hex string. + * @since 0.4.0 + */ + public static String convertBytesToHexStringByteCast(byte[] bytes, int offset, int length) + { + final StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.setLength(0); + for (int i = offset, end = offset + length; i < end; i++) + { + stringBuilder.append(String.format("(byte) 0x%02x, ", bytes[i])); + } + return stringBuilder.toString(); + } } diff --git a/app/src/main/java/com/craxiom/networksurveyplus/messages/QcdmConstants.java b/app/src/main/java/com/craxiom/networksurveyplus/messages/QcdmConstants.java index 150c8e4..d3e10ee 100644 --- a/app/src/main/java/com/craxiom/networksurveyplus/messages/QcdmConstants.java +++ b/app/src/main/java/com/craxiom/networksurveyplus/messages/QcdmConstants.java @@ -11,6 +11,11 @@ private QcdmConstants() { } + // GSM Signaling + public static final int GSM_RR_SIGNALING_MESSAGES = 0x512F; + public static final int GSM_POWER_SCAN_C = 0x64; //Used to view the BA List power levels + public static final int GSM_RR_CELL_INFORMATION_C = 0x134; + // UMTS/WCDMA public static final int WCDMA_SEARCH_CELL_RESELECTION_RANK = 0x4005; public static final int WCDMA_RRC_STATES = 0x4125; diff --git a/app/src/main/java/com/craxiom/networksurveyplus/messages/QcdmGsmParser.java b/app/src/main/java/com/craxiom/networksurveyplus/messages/QcdmGsmParser.java new file mode 100644 index 0000000..caa41bf --- /dev/null +++ b/app/src/main/java/com/craxiom/networksurveyplus/messages/QcdmGsmParser.java @@ -0,0 +1,116 @@ +package com.craxiom.networksurveyplus.messages; + +import android.location.Location; + +import java.util.Arrays; + +import timber.log.Timber; + +/** + * Contains parser methods for converting the QCDM GSM messages to various formats, like pcap records or protobuf + * objects. + * + * @since 0.4.0 + */ +public class QcdmGsmParser +{ + private static final int GSM_SIGNAL_HEADER_LENGTH = 3; + + private QcdmGsmParser() + { + } + + /** + * Given a {@link QcdmMessage} that contains a WCDMA Signaling message {@link QcdmConstants#WCDMA_SIGNALING_MESSAGES}, + * convert it to a pcap record byte array that can be consumed by tools like Wireshark. + *

+ * The base header structure for the GSM RR Signaling Message: + * ************************************************************* + * | Channel Type | Message Type | Message Length | L3 Message | + * | 1 byte | 1 byte | 1 byte | n | + * ************************************************************* + *

+ * The code for this method was taken from SCAT: + * https://github.com/fgsect/scat/blob/0c1fe579376460ba5cd42d82a556fb88cf89da61/parsers/qualcomm/diaggsmlogparser.py#L191 + * + * @param qcdmMessage The QCDM message to convert into a pcap record. + * @param location The location to tie to the QCDM message when writing it to a pcap file. If null then no + * location will be added to the PPI header. + * @return The pcap record byte array to write to a pcap file, or null if the message could not be parsed. + */ + public static byte[] convertGsmSignalingMessage(QcdmMessage qcdmMessage, Location location) + { + Timber.v("Handling a GSM RR Signaling message"); + + final byte[] logPayload = qcdmMessage.getLogPayload(); + + final int channelTypeDir = logPayload[0] & 0xFF; + //final int messageType = logPayload[1] & 0xFF; + final int messageLength = logPayload[2] & 0xFF; + + if (logPayload.length < messageLength + GSM_SIGNAL_HEADER_LENGTH) + { + Timber.e("The qcdm log payload is shorter than the defined length for a GSM signal message"); + return null; + } + + byte[] l3Message = Arrays.copyOfRange(logPayload, GSM_SIGNAL_HEADER_LENGTH, messageLength + GSM_SIGNAL_HEADER_LENGTH); + + // Not sure why we take the channelTypeDir and do this to get the chan, but SCAT and all the other apps do it + int chan = channelTypeDir & 0x7F; + + final int subtype = getGsmtapGsmChannelType(chan); + + if (chan == 0 || chan == 4) + { + // SDCCH/8 expects LAPDm header + if (messageLength > 63) + { + Timber.w("The GSM signal message length is longer than 63 bytes, actual length=%d", messageLength); + return null; + } + + // SACCH/8 expects SACCH L1/LAPDm header + byte[] sacchL1 = chan == 4 ? new byte[]{0x00, 0x00} : new byte[]{}; + + l3Message = PcapUtils.concatenateByteArrays( + sacchL1, // SAACH header only if it is SAACH/8 (0x88?) + new byte[]{0x01}, // LAPDM Address Field + new byte[]{0x03}, // LAPDM Control Field + new byte[]{(byte) ((messageLength << 2) | 0x01)}, // LAPDM Length + l3Message); + } + + // Any channel type dir that has the 0x80 bit set is downlink, everything else is uplink + final boolean isUplink = (channelTypeDir & 0x80) == 0x00; + + return PcapUtils.getGsmtapPcapRecord(GsmtapConstants.GSMTAP_TYPE_UM, l3Message, subtype, 0, + isUplink, 0, 0, qcdmMessage.getSimId(), location); + } + + /** + * Converts the QCDM chan to the GSMTAP defined channel type that needs to be included in the GSMTAP pcap header. + * + * @param channelType The channel type found in the QCDM header. + * @return The GSMTAP Channel Type / Subtype that specifies what kind of message the payload of the GSMTAP frame + * contains, or 0 if a mapping was not found. + */ + public static int getGsmtapGsmChannelType(int channelType) + { + switch (channelType) + { + // Channel Type Map + case 0: + return GsmSubtypes.GSMTAP_CHANNEL_SDCCH8.ordinal(); + case 1: + return GsmSubtypes.GSMTAP_CHANNEL_BCCH.ordinal(); + case 3: // 0x03 + return GsmSubtypes.GSMTAP_CHANNEL_CCCH.ordinal(); + case 4: // 0x04 + return 0x88; // Not sure why 4 maps to 0x88, but that is what SCAT and others do + + default: + return 0; + } + } +} diff --git a/app/src/main/res/raw/ns_plus_diag.cfg b/app/src/main/res/raw/ns_plus_diag.cfg index b0b694a..77ee9cc 100644 Binary files a/app/src/main/res/raw/ns_plus_diag.cfg and b/app/src/main/res/raw/ns_plus_diag.cfg differ diff --git a/app/src/test/java/com/craxiom/networksurveyplus/GsmParserTest.java b/app/src/test/java/com/craxiom/networksurveyplus/GsmParserTest.java new file mode 100644 index 0000000..8e2ea87 --- /dev/null +++ b/app/src/test/java/com/craxiom/networksurveyplus/GsmParserTest.java @@ -0,0 +1,48 @@ +package com.craxiom.networksurveyplus; + +import android.location.Location; + +import com.craxiom.networksurveyplus.messages.QcdmConstants; +import com.craxiom.networksurveyplus.messages.QcdmGsmParser; +import com.craxiom.networksurveyplus.messages.QcdmMessage; + +import org.junit.Test; + +import java.util.Arrays; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Tests for the GSM QCDM Parser that takes QCDM input and converts it to a PCAP record. + * + * @since 0.4.0 + */ +public class GsmParserTest +{ + @Test + public void testGsmQcdmMessage_signalingMessage() + { + final byte[] qcdmMessageBytes = {(byte) 0x10, (byte) 0x00, (byte) 0x26, (byte) 0x00, (byte) 0x26, (byte) 0x00, (byte) 0x2f, (byte) 0x51, (byte) 0xfd, (byte) 0x19, (byte) 0x53, (byte) 0x7b, (byte) 0x87, (byte) 0x62, (byte) 0xf4, (byte) 0x00, (byte) 0x81, (byte) 0x1b, (byte) 0x17, (byte) 0x49, (byte) 0x06, (byte) 0x1b, (byte) 0x00, (byte) 0x3e, (byte) 0x62, (byte) 0xf2, (byte) 0x20, (byte) 0x1c, (byte) 0x4e, (byte) 0xd0, (byte) 0x01, (byte) 0x0a, (byte) 0x15, (byte) 0x65, (byte) 0x44, (byte) 0xb8, (byte) 0x00, (byte) 0x00, (byte) 0x80, (byte) 0x1f, (byte) 0x01, (byte) 0x1b}; + final byte[] expectedQcdmMessagePayloadBytes = {(byte) 0x81, (byte) 0x1b, (byte) 0x17, (byte) 0x49, (byte) 0x06, (byte) 0x1b, (byte) 0x00, (byte) 0x3e, (byte) 0x62, (byte) 0xf2, (byte) 0x20, (byte) 0x1c, (byte) 0x4e, (byte) 0xd0, (byte) 0x01, (byte) 0x0a, (byte) 0x15, (byte) 0x65, (byte) 0x44, (byte) 0xb8, (byte) 0x00, (byte) 0x00, (byte) 0x80, (byte) 0x1f, (byte) 0x01, (byte) 0x1b}; + final byte[] expectedPcapRecordBytes = {(byte) 0x63, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x63, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x20, (byte) 0x00, (byte) 0xe4, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x32, (byte) 0x75, (byte) 0x14, (byte) 0x00, (byte) 0x02, (byte) 0xcf, (byte) 0x14, (byte) 0x00, (byte) 0x0e, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x05, (byte) 0x21, (byte) 0x05, (byte) 0x84, (byte) 0x01, (byte) 0x8f, (byte) 0x90, (byte) 0x35, (byte) 0x3f, (byte) 0x1d, (byte) 0x61, (byte) 0x6b, (byte) 0x45, (byte) 0x00, (byte) 0x00, (byte) 0x43, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x40, (byte) 0x11, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x12, (byte) 0x79, (byte) 0x12, (byte) 0x79, (byte) 0x00, (byte) 0x2f, (byte) 0x00, (byte) 0x00, (byte) 0x02, (byte) 0x04, (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x49, (byte) 0x06, (byte) 0x1b, (byte) 0x00, (byte) 0x3e, (byte) 0x62, (byte) 0xf2, (byte) 0x20, (byte) 0x1c, (byte) 0x4e, (byte) 0xd0, (byte) 0x01, (byte) 0x0a, (byte) 0x15, (byte) 0x65, (byte) 0x44, (byte) 0xb8, (byte) 0x00, (byte) 0x00, (byte) 0x80, (byte) 0x1f, (byte) 0x01, (byte) 0x1b}; + + final QcdmMessage qcdmMessage = new QcdmMessage(qcdmMessageBytes, 0); + assertEquals(16, qcdmMessage.getOpCode()); + assertEquals(QcdmConstants.GSM_RR_SIGNALING_MESSAGES, qcdmMessage.getLogType()); + assertArrayEquals("The qcdm message bytes did not match what was expected", expectedQcdmMessagePayloadBytes, qcdmMessage.getLogPayload()); + + final Location location = new FakeLocation(); + location.setLatitude(41.4928645); + location.setLongitude(-90.1333759); + location.setAltitude(152.6591); + + final byte[] pcapRecordBytes = QcdmGsmParser.convertGsmSignalingMessage(qcdmMessage, location); + + assertNotNull(pcapRecordBytes); + + // Ignore the first 8 bytes since it contains the record timestamp. + assertArrayEquals(expectedPcapRecordBytes, Arrays.copyOfRange(pcapRecordBytes, 8, pcapRecordBytes.length)); + } +} diff --git a/app/src/test/java/com/craxiom/networksurveyplus/ParserUtilsTest.java b/app/src/test/java/com/craxiom/networksurveyplus/ParserUtilsTest.java index 00faa20..da79d00 100644 --- a/app/src/test/java/com/craxiom/networksurveyplus/ParserUtilsTest.java +++ b/app/src/test/java/com/craxiom/networksurveyplus/ParserUtilsTest.java @@ -90,6 +90,19 @@ public void testCrc16X25UmtsDiagCommandCalculation() assertEquals(Integer.toHexString(expectedCrc), Integer.toHexString(crc >= 0 ? crc : 0x10000 + crc)); } + /** + * Tests the modified Diag Command that is used to activate the GSM messages as part of the ns_plus_diag.cfg. + */ + @Test + public void testCrc16X25GsmDiagCommandCalculation() + { + final int expectedCrc = (short) 0x8bf8; + final byte[] diagCommandBytes = {(byte) 0x73, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x03, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x05, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x34, (byte) 0x01, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x00, (byte) 0x80, (byte) 0x10}; + + final int crc = ParserUtils.calculateCrc16X25(diagCommandBytes, diagCommandBytes.length); + assertEquals(Integer.toHexString(0x10000 + expectedCrc), Integer.toHexString(crc >= 0 ? crc : 0x10000 + crc)); + } + @Test public void testCrcQcdmMessage() {