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
+ }
+ }
+ })