diff --git a/app/src/main/java/com/bitchat/android/util/BinaryEncodingUtils.kt b/app/src/main/java/com/bitchat/android/util/BinaryEncodingUtils.kt index 549f5971..06615332 100644 --- a/app/src/main/java/com/bitchat/android/util/BinaryEncodingUtils.kt +++ b/app/src/main/java/com/bitchat/android/util/BinaryEncodingUtils.kt @@ -19,16 +19,28 @@ fun ByteArray.hexEncodedString(): String { } fun String.dataFromHexString(): ByteArray? { - val len = this.length / 2 - val data = ByteArray(len) - var index = 0 - - for (i in 0 until len) { - val hexByte = this.substring(i * 2, i * 2 + 2) - val byte = hexByte.toIntOrNull(16)?.toByte() ?: return null - data[index++] = byte + // Match iOS Data(hexString:): trim, optional 0x/0X prefix, reject odd length. + var hex = this.trim() + if (hex.startsWith("0x", ignoreCase = true)) { + hex = hex.substring(2) } - + if (hex.isEmpty()) { + return ByteArray(0) + } + // Radix parsing accepts signs and Unicode digits; encoded bytes require ASCII hex. + if (hex.length % 2 != 0 || hex.any { it !in '0'..'9' && it !in 'a'..'f' && it !in 'A'..'F' }) { + return null + } + + val len = hex.length / 2 + val data = ByteArray(len) + + for (i in 0 until len) { + val hexByte = hex.substring(i * 2, i * 2 + 2) + val byte = hexByte.toIntOrNull(16)?.toByte() ?: return null + data[i] = byte + } + return data } diff --git a/app/src/test/java/com/bitchat/android/util/HexStringTest.kt b/app/src/test/java/com/bitchat/android/util/HexStringTest.kt new file mode 100644 index 00000000..6472ede8 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/util/HexStringTest.kt @@ -0,0 +1,65 @@ +package com.bitchat.android.util + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class HexStringTest { + + @Test + fun `decodes even-length lowercase hex`() { + assertArrayEquals( + byteArrayOf(0x0a, 0xbc.toByte()), + "0abc".dataFromHexString() + ) + } + + @Test + fun `rejects odd-length hex instead of truncating`() { + // Previously length/2 silently dropped the trailing nibble. + assertNull("abc".dataFromHexString()) + assertNull("0ab".dataFromHexString()) + } + + @Test + fun `strips optional 0x prefix like iOS`() { + assertArrayEquals( + byteArrayOf(0xde.toByte(), 0xad.toByte()), + "0xdead".dataFromHexString() + ) + assertArrayEquals( + byteArrayOf(0xbe.toByte(), 0xef.toByte()), + "0Xbeef".dataFromHexString() + ) + } + + @Test + fun `empty string and empty after prefix yield empty array`() { + assertArrayEquals(ByteArray(0), "".dataFromHexString()) + assertArrayEquals(ByteArray(0), "0x".dataFromHexString()) + assertTrue(" ".dataFromHexString()!!.isEmpty()) + } + + @Test + fun `rejects non-hex characters`() { + assertNull("zz".dataFromHexString()) + assertNull("0xgg".dataFromHexString()) + } + + @Test + fun `rejects signed byte chunks and non-ascii digits`() { + for (invalid in listOf("0x-1", "0x+1", "-1", "+1", "00-1", "0X+100", "01")) { + assertNull(invalid.dataFromHexString()) + } + } + + @Test + fun `round-trips with hexEncodedString`() { + val original = byteArrayOf(0x00, 0x7f, 0xff.toByte()) + val encoded = original.hexEncodedString() + assertEquals("007fff", encoded) + assertArrayEquals(original, encoded.dataFromHexString()) + } +}