From 11a19da0b8f76c6acd4e15130fc0dd7cf3540dda Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 4 Jun 2025 16:53:31 +0200 Subject: [PATCH] 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. --- .../capod/common/ByteArrayExtensions.kt | 11 +++++++- .../capod/common/bluetooth/FakeBleData.kt | 26 ++++++------------ .../capod/common/ByteArrayExtensionsTest.kt | 16 +++++++++++ .../capod/monitor/core/RPACheckerTest.kt | 27 ++++++++++++++++--- .../capod/pods/core/apple/BaseAirPodsTest.kt | 6 ++--- .../general/GeneralSettingsFragment.kt | 14 +++++----- 6 files changed, 65 insertions(+), 35 deletions(-) create mode 100644 app-common/src/test/java/eu/darken/capod/common/ByteArrayExtensionsTest.kt diff --git a/app-common/src/main/java/eu/darken/capod/common/ByteArrayExtensions.kt b/app-common/src/main/java/eu/darken/capod/common/ByteArrayExtensions.kt index 98e65e7a..12a7e125 100644 --- a/app-common/src/main/java/eu/darken/capod/common/ByteArrayExtensions.kt +++ b/app-common/src/main/java/eu/darken/capod/common/ByteArrayExtensions.kt @@ -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() diff --git a/app-common/src/main/java/eu/darken/capod/common/bluetooth/FakeBleData.kt b/app-common/src/main/java/eu/darken/capod/common/bluetooth/FakeBleData.kt index 96e9b712..e8899463 100644 --- a/app-common/src/main/java/eu/darken/capod/common/bluetooth/FakeBleData.kt +++ b/app-common/src/main/java/eu/darken/capod/common/bluetooth/FakeBleData.kt @@ -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() - } - } \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/common/ByteArrayExtensionsTest.kt b/app-common/src/test/java/eu/darken/capod/common/ByteArrayExtensionsTest.kt new file mode 100644 index 00000000..871a33e4 --- /dev/null +++ b/app-common/src/test/java/eu/darken/capod/common/ByteArrayExtensionsTest.kt @@ -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 + } +} \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt b/app-common/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt index a089fc5a..9f42f58d 100644 --- a/app-common/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt +++ b/app-common/src/test/java/eu/darken/capod/monitor/core/RPACheckerTest.kt @@ -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 } } \ No newline at end of file diff --git a/app-common/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt b/app-common/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt index dfeb0444..52a87b05 100644 --- a/app-common/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt +++ b/app-common/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt @@ -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" } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt index c1340c80..4b628ca4 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/settings/general/GeneralSettingsFragment.kt @@ -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 {