Add helper for HEX string to byte array conversion

This commit introduces a new helper function `fromHex()` that converts a HEX string (with optional separators like spaces or hyphens) into a byte array. It also adds a corresponding `toHex()` function for converting a byte array to a HEX string.

These helpers are now used in various parts of the codebase, including:
- Fake BLE data generation
- AirPod key input dialog
- RPA checker tests
- BaseAirPodsTest

Additionally, a unit test for these conversion functions has been added.
This commit is contained in:
darken
2025-06-06 06:03:41 +02:00
committed by Matthias Urhahn
parent eda81822aa
commit 11a19da0b8
6 changed files with 65 additions and 35 deletions
@@ -1,6 +1,6 @@
package eu.darken.capod.common
import java.util.*
import java.util.BitSet
fun Byte.toHex(): String = String.format("%02X", this)
fun UByte.toHex(): String = this.toByte().toHex()
@@ -18,3 +18,12 @@ fun UShort.isBitSet(pos: Int): Boolean = this.toShort().isBitSet(pos)
fun UShort.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(4, '0')
fun UByte.toBinaryString(): String = Integer.toBinaryString(this.toInt()).padStart(8, '0')
fun ByteArray.toHex(separator: String = "-"): String = joinToString(separator) { "%02X".format(it) }
fun String.fromHex(): ByteArray = this
.replace(" ", "")
.replace("-", "")
.also { require(it.length % 2 == 0) { "Not a HEX string, length: ${it.length}" } }
.chunked(2).map { it.toInt(16).toByte() }
.toByteArray()
@@ -3,6 +3,7 @@ package eu.darken.capod.common.bluetooth
import dagger.Reusable
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.debug.DebugSettings
import eu.darken.capod.common.fromHex
import java.time.Instant
import javax.inject.Inject
import kotlin.random.Random
@@ -25,7 +26,7 @@ class FakeBleData @Inject constructor(
address = "78:73:AF:B4:85:22",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 02 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
@@ -36,7 +37,7 @@ class FakeBleData @Inject constructor(
address = "78:73:FF:B4:85:5E",
rssi = Random.nextInt(100) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 100,
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 0F 20 75 AA B6 31 00 05 9C 5A A4 5D C0 2C A0 B4 6F B9 ED 8E CE 03 97 CA".fromHex())
).run {
fakeDevices.add(this)
}
@@ -47,7 +48,7 @@ class FakeBleData @Inject constructor(
address = "4E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 200,
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 13 20 55 AF 56 31 00 06 6F E4 DF 10 AF 10 60 81 03 3B 76 D9 C7 11 22 88".fromHex())
).run {
fakeDevices.add(this)
}
@@ -57,7 +58,7 @@ class FakeBleData @Inject constructor(
address = "7E:E5:C7:65:D2:B5",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 300,
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 0A 20 02 05 80 04 0F 44 A7 60 9B F8 3C FD B1 D8 1C 61 EA 82 60 A3 2C 4E".fromHex())
).run {
fakeDevices.add(this)
}
@@ -67,7 +68,7 @@ class FakeBleData @Inject constructor(
address = "5E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 10 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
@@ -78,7 +79,7 @@ class FakeBleData @Inject constructor(
address = "5E:9E:D1:29:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 400,
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 13 01 02 20 71 AA 37 32 00 10 00 64 64 FF 00 00 00 00 00 00".fromHex())
).run {
fakeDevices.add(this)
}
@@ -89,22 +90,11 @@ class FakeBleData @Inject constructor(
address = "6E:9E:D1:49:D2:6D",
rssi = Random.nextInt(15, 75) * -1,
generatedAtNanos = SystemClockWrap.elapsedRealtimeNanos + 500,
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".hexToByteArray())
manufacturerSpecificData = mapOf(76 to "07 19 01 FF 20 0A F4 8F 00 01 00 C4 71 9F 9C EF A2 E3 BA 66 FE 1D 45 9F C9 2F A0".fromHex())
).run {
fakeDevices.add(this)
}
return fakeDevices
}
private fun String.hexToByteArray(): ByteArray {
val trimmed = this
.replace(" ", "")
.replace(">", "")
.replace("<", "")
require(trimmed.length % 2 == 0) { "Not a HEX string" }
return trimmed.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
}
@@ -0,0 +1,16 @@
package eu.darken.capod.common
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class ByteArrayExtensionsTest : BaseTest() {
@Test
fun `hex - ByteArray conversion`() = runTest {
val addr = "78-73-AF-B4-85-22"
val raw = addr.fromHex()
raw.toHex() shouldBe addr
}
}
@@ -1,5 +1,6 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.fromHex
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
@@ -11,10 +12,28 @@ class RPACheckerTest : BaseTest() {
val checker = RPAChecker()
checker.verify(
address = "5A:16:2B:91:D1:CD",
irk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
.replace("-", "")
.also { require(it.length % 2 == 0) { "Not a HEX string" } }
.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
irk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79".fromHex(),
) shouldBe true
checker.verify(
address = "5A:16:2B:91:D1:CD",
irk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-AA".fromHex(),
) shouldBe false
}
@Test
fun `bad input check`() {
val checker = RPAChecker()
checker.verify(
address = "5A:16:2B:91:D1:CD",
irk = "".fromHex(),
) shouldBe false
checker.verify(
address = "",
irk = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-AA".fromHex(),
) shouldBe false
checker.verify(
address = "",
irk = "".fromHex(),
) shouldBe false
}
}
@@ -4,6 +4,7 @@ import dagger.BindsInstance
import dagger.Component
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.fromHex
import eu.darken.capod.common.serialization.SerializationModule
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.pods.core.PodDevice
@@ -39,12 +40,9 @@ abstract class BaseAirPodsTest : BaseTest() {
}
private fun hexToByteArray(hex: String): ByteArray = hex
.replace(" ", "")
.replace(">", "")
.replace("<", "")
.replace("-", "")
.also { require(it.length % 2 == 0) { "Not a HEX string" } }
.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
.fromHex()
private fun cleanKey(key: String): ByteArray = hexToByteArray(key)
.also { require(it.size == 16) { "Not a valid key: ${it.size} byte" } }
@@ -10,7 +10,9 @@ import dagger.hilt.android.AndroidEntryPoint
import eu.darken.capod.R
import eu.darken.capod.common.WebpageTool
import eu.darken.capod.common.bluetooth.ScannerMode
import eu.darken.capod.common.fromHex
import eu.darken.capod.common.preferences.PercentSliderPreference
import eu.darken.capod.common.toHex
import eu.darken.capod.common.uix.PreferenceFragment3
import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.main.core.GeneralSettings
@@ -52,8 +54,8 @@ class GeneralSettingsFragment : PreferenceFragment3() {
mainDeviceIdentityKeyPref.setOnPreferenceClickListener {
AirPodKeyInputDialog(requireContext()).create(
mode = AirPodKeyInputDialog.Mode.IRK,
current = generalSettings.mainDeviceIdentityKey.value?.toHumanReadable() ?: "",
onKey = { generalSettings.mainDeviceIdentityKey.value = it.fromHumanReadable() },
current = generalSettings.mainDeviceIdentityKey.value?.toHex() ?: "",
onKey = { generalSettings.mainDeviceIdentityKey.value = it.fromHex() },
onGuide = { webpageTool.open("https://github.com/d4rken-org/capod/wiki/airpod-Keys") }
).show()
true
@@ -61,8 +63,8 @@ class GeneralSettingsFragment : PreferenceFragment3() {
mainDeviceEncryptionKeyPref.setOnPreferenceClickListener {
AirPodKeyInputDialog(requireContext()).create(
mode = AirPodKeyInputDialog.Mode.ENC,
current = generalSettings.mainDeviceEncryptionKey.value?.toHumanReadable() ?: "",
onKey = { generalSettings.mainDeviceEncryptionKey.value = it.fromHumanReadable() },
current = generalSettings.mainDeviceEncryptionKey.value?.toHex() ?: "",
onKey = { generalSettings.mainDeviceEncryptionKey.value = it.fromHex() },
onGuide = { webpageTool.open("https://github.com/d4rken-org/capod/wiki/airpod-Keys") }
).show()
true
@@ -71,10 +73,6 @@ class GeneralSettingsFragment : PreferenceFragment3() {
super.onPreferencesCreated()
}
private fun ByteArray.toHumanReadable(): String = joinToString("-") { "%02X".format(it) }
private fun String.fromHumanReadable(): ByteArray = split("-").map { it.toInt(16).toByte() }.toByteArray()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
vm.state.observe2 { state ->
mainDeviceAddressPref.setOnPreferenceClickListener {