diff --git a/CHANGELOG.md b/CHANGELOG.md index 37635c126..f1bc0435e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Pending +- fix: replace `commons-codec` Hex with pure-Java implementation in `Util` to fix `NoSuchMethodError` crash on Android API 24-27. - refactor!: remove `InvokeHostFunctionOperation.createStellarAssetContractOperationBuilder(Address, byte[])`, use `InvokeHostFunctionOperation.createContractOperationBuilder` instead. ## 2.2.2 diff --git a/src/main/java/org/stellar/sdk/Util.java b/src/main/java/org/stellar/sdk/Util.java index ab4f4c5bf..37cb4b046 100644 --- a/src/main/java/org/stellar/sdk/Util.java +++ b/src/main/java/org/stellar/sdk/Util.java @@ -6,8 +6,6 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Arrays; -import org.apache.commons.codec.DecoderException; -import org.apache.commons.codec.binary.Hex; import org.stellar.sdk.exception.UnexpectedException; /** @@ -23,7 +21,13 @@ public class Util { * @return hex representation of the byte array (uppercase) */ public static String bytesToHex(byte[] bytes) { - return Hex.encodeHexString(bytes, false); + char[] hexChars = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xFF; + hexChars[i * 2] = HEX_DIGITS[v >>> 4]; + hexChars[i * 2 + 1] = HEX_DIGITS[v & 0x0F]; + } + return new String(hexChars); } /** @@ -34,13 +38,24 @@ public static String bytesToHex(byte[] bytes) { * @throws IllegalArgumentException if the string contains non-hex characters or has odd length */ public static byte[] hexToBytes(String s) { - try { - return Hex.decodeHex(s); - } catch (DecoderException e) { - throw new IllegalArgumentException("Invalid hex string: " + s, e); + int len = s.length(); + if (len % 2 != 0) { + throw new IllegalArgumentException("Invalid hex string: " + s); + } + byte[] bytes = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + int high = Character.digit(s.charAt(i), 16); + int low = Character.digit(s.charAt(i + 1), 16); + if (high == -1 || low == -1) { + throw new IllegalArgumentException("Invalid hex string: " + s); + } + bytes[i / 2] = (byte) ((high << 4) | low); } + return bytes; } + private static final char[] HEX_DIGITS = "0123456789ABCDEF".toCharArray(); + /** * Returns SHA-256 hash of data. * diff --git a/src/test/kotlin/org/stellar/sdk/UtilTest.kt b/src/test/kotlin/org/stellar/sdk/UtilTest.kt new file mode 100644 index 000000000..c31e710ce --- /dev/null +++ b/src/test/kotlin/org/stellar/sdk/UtilTest.kt @@ -0,0 +1,99 @@ +package org.stellar.sdk + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class UtilTest : + FunSpec({ + context("bytesToHex") { + test("empty array") { Util.bytesToHex(byteArrayOf()) shouldBe "" } + + test("single byte") { + Util.bytesToHex(byteArrayOf(0x00)) shouldBe "00" + Util.bytesToHex(byteArrayOf(0x0A)) shouldBe "0A" + Util.bytesToHex(byteArrayOf(0x7F)) shouldBe "7F" + Util.bytesToHex(byteArrayOf(0x80.toByte())) shouldBe "80" + Util.bytesToHex(byteArrayOf(0xFF.toByte())) shouldBe "FF" + } + + test("multiple bytes") { + Util.bytesToHex(byteArrayOf(0x48, 0x65, 0x6C, 0x6C, 0x6F)) shouldBe "48656C6C6F" + Util.bytesToHex( + byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + ) shouldBe "DEADBEEF" + Util.bytesToHex(byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04)) shouldBe "0001020304" + } + + test("output is uppercase") { + Util.bytesToHex(byteArrayOf(0xAB.toByte(), 0xCD.toByte(), 0xEF.toByte())) shouldBe "ABCDEF" + } + + test("all 256 byte values") { + val allBytes = ByteArray(256) { it.toByte() } + val hex = Util.bytesToHex(allBytes) + hex.length shouldBe 512 + // spot check a few values + hex.substring(0, 2) shouldBe "00" + hex.substring(2, 4) shouldBe "01" + hex.substring(30, 32) shouldBe "0F" + hex.substring(32, 34) shouldBe "10" + hex.substring(510, 512) shouldBe "FF" + } + } + + context("hexToBytes") { + test("empty string") { Util.hexToBytes("") shouldBe byteArrayOf() } + + test("single byte") { + Util.hexToBytes("00") shouldBe byteArrayOf(0x00) + Util.hexToBytes("FF") shouldBe byteArrayOf(0xFF.toByte()) + Util.hexToBytes("ff") shouldBe byteArrayOf(0xFF.toByte()) + Util.hexToBytes("0a") shouldBe byteArrayOf(0x0A) + Util.hexToBytes("0A") shouldBe byteArrayOf(0x0A) + } + + test("multiple bytes") { + Util.hexToBytes("48656C6C6F") shouldBe byteArrayOf(0x48, 0x65, 0x6C, 0x6C, 0x6F) + Util.hexToBytes("DEADBEEF") shouldBe + byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + Util.hexToBytes("deadbeef") shouldBe + byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + } + + test("mixed case") { + Util.hexToBytes("AbCdEf") shouldBe byteArrayOf(0xAB.toByte(), 0xCD.toByte(), 0xEF.toByte()) + } + + test("odd length throws IllegalArgumentException") { + shouldThrow { Util.hexToBytes("ABC") } + shouldThrow { Util.hexToBytes("A") } + } + + test("invalid characters throw IllegalArgumentException") { + shouldThrow { Util.hexToBytes("ZZZZ") } + shouldThrow { Util.hexToBytes("GH") } + shouldThrow { Util.hexToBytes("0G") } + } + } + + context("round trip") { + test("bytesToHex then hexToBytes") { + val original = byteArrayOf(0x00, 0x11, 0x22, 0xAA.toByte(), 0xBB.toByte(), 0xFF.toByte()) + val hex = Util.bytesToHex(original) + Util.hexToBytes(hex) shouldBe original + } + + test("all 256 byte values round trip") { + val allBytes = ByteArray(256) { it.toByte() } + val hex = Util.bytesToHex(allBytes) + Util.hexToBytes(hex) shouldBe allBytes + } + + test("lowercase hex input round trips") { + val hex = "deadbeef0123456789abcdef" + val bytes = Util.hexToBytes(hex) + Util.bytesToHex(bytes).lowercase() shouldBe hex + } + } + })