From 51142b0174389819a6a5db42298dfbc434c38ddc Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 9 Nov 2026 19:22:35 -0700 Subject: [PATCH 1/2] Drop identifiers that are empty after normalization A typed identifier with a blank value was sent as "prefix:", and a blank Email or PhoneNumber was hashed, sending the SHA-256 of the empty string as if it were an ID. Drop both, as the iOS SDK now does. Co-Authored-By: Claude Opus 5.5 --- .../co/optable/sdk/core/IdentifiersEncoder.kt | 19 ++++++++++++-- .../co/optable/sdk/OptableIdentifiersTest.kt | 26 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt b/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt index 1564edb..d7329c5 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt @@ -41,8 +41,8 @@ internal class IdentifiersEncoder( for (identifier in identifiers) { when (identifier) { - is OptableIdentifier.Email -> result.addIfNotNull(EMAIL, identifier.value, ::encrypt) - is OptableIdentifier.PhoneNumber -> result.addIfNotNull(PHONE, identifier.value, ::encrypt) + is OptableIdentifier.Email -> result.addHashed(EMAIL, identifier.value) + is OptableIdentifier.PhoneNumber -> result.addHashed(PHONE, identifier.value) is OptableIdentifier.Hem -> result.addIfValidHash(EMAIL, identifier.value) is OptableIdentifier.HashedPhoneNumber -> result.addIfValidHash(PHONE, identifier.value) is OptableIdentifier.PostalCode -> result.addIfNotNull(POSTAL, identifier.value, ::normalize) @@ -79,14 +79,29 @@ internal class IdentifiersEncoder( return result } + /** + * Adds the encoded value, skipping it when it is empty after encoding, so "key:" is never sent. + */ private fun MutableList.addIfNotNull(key: String, value: String?, encoder: (String) -> String) { if (value == null) return val encodedValue = encoder(value) + if (encodedValue.isEmpty()) return + val result = "$key:$encodedValue" this.add(result) } + /** + * Adds the SHA-256 hash of the normalized value, skipping it when the normalized value is empty, + * so the hash of an empty string is never sent as if it were an ID. + */ + private fun MutableList.addHashed(key: String, value: String?) { + if (value == null || normalize(value).isEmpty()) return + + this.add("$key:${encrypt(value)}") + } + /** * Adds an already-hashed value, skipping it entirely unless it is a SHA-256 * digest. This keeps a plaintext Email or Phone number off the wire. diff --git a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt index 96cda77..f04172d 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt @@ -211,6 +211,32 @@ class OptableIdentifiersTest { assertEquals(expected, actual) } + @Test + fun `drop identifiers that are empty after normalization`() { + val ids = listOf( + OptableIdentifier.Email(""), + OptableIdentifier.Email(" "), + OptableIdentifier.PhoneNumber(" "), + OptableIdentifier.PostalCode(" "), + OptableIdentifier.IPv4(" "), + OptableIdentifier.IPv6(" "), + OptableIdentifier.AppleIdfa(" "), + OptableIdentifier.GoogleGaid(" "), + OptableIdentifier.RokuRida(" "), + OptableIdentifier.SamsungTifa(" "), + OptableIdentifier.AmazonFireAfai(" "), + OptableIdentifier.NetId(" "), + OptableIdentifier.ID5(" "), + OptableIdentifier.Utiq(" "), + OptableIdentifier.Custom("v", " "), + OptableIdentifier.Custom("c2", ""), + OptableIdentifier.Hem(" "), + OptableIdentifier.HashedPhoneNumber(""), + ) + val actual = identifiersEncoder.encode(ids) + assertEquals(emptyList(), actual) + } + @Test fun `encode raw`() { val ids = listOf( From 1cbce8aaa5bf086c17beff1cb09658d7e4cf49ed Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Wed, 11 Nov 2026 10:34:44 -0700 Subject: [PATCH 2/2] Drop all-zero device advertising IDs "00000000-0000-0000-0000-000000000000" is the placeholder Android and iOS return when the user has opted out or tracking isn't authorized, shared by every such device. Drop it, like an empty value, for the device GAID and for AppleIdfa, GoogleGaid, RokuRida, SamsungTifa and AmazonFireAfai. Co-Authored-By: Claude Opus 5.5 --- .../co/optable/sdk/core/IdentifiersEncoder.kt | 22 ++++++++++---- .../co/optable/sdk/OptableIdentifiersTest.kt | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt b/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt index d7329c5..c7dfdc7 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt @@ -36,7 +36,7 @@ internal class IdentifiersEncoder( val googleAdId = googleAdIdManager.getId() if (googleAdId != null) { - result.addIfNotNull(GAID, googleAdId, ::normalize) + result.addIfNotNull(GAID, googleAdId, ::normalizeDeviceId) } for (identifier in identifiers) { @@ -48,10 +48,10 @@ internal class IdentifiersEncoder( is OptableIdentifier.PostalCode -> result.addIfNotNull(POSTAL, identifier.value, ::normalize) is OptableIdentifier.IPv4 -> result.addIfNotNull(IPV4, identifier.value, ::removeWhitespaces) is OptableIdentifier.IPv6 -> result.addIfNotNull(IPV6, identifier.value, ::normalize) - is OptableIdentifier.AppleIdfa -> result.addIfNotNull(IDFA, identifier.value, ::normalize) - is OptableIdentifier.RokuRida -> result.addIfNotNull(RIDA, identifier.value, ::normalize) - is OptableIdentifier.SamsungTifa -> result.addIfNotNull(TIFA, identifier.value, ::normalize) - is OptableIdentifier.AmazonFireAfai -> result.addIfNotNull(AFAI, identifier.value, ::normalize) + is OptableIdentifier.AppleIdfa -> result.addIfNotNull(IDFA, identifier.value, ::normalizeDeviceId) + is OptableIdentifier.RokuRida -> result.addIfNotNull(RIDA, identifier.value, ::normalizeDeviceId) + is OptableIdentifier.SamsungTifa -> result.addIfNotNull(TIFA, identifier.value, ::normalizeDeviceId) + is OptableIdentifier.AmazonFireAfai -> result.addIfNotNull(AFAI, identifier.value, ::normalizeDeviceId) is OptableIdentifier.NetId -> result.addIfNotNull(NETID, identifier.value, ::removeWhitespaces) is OptableIdentifier.ID5 -> result.addIfNotNull(ID5, identifier.value, ::removeWhitespaces) is OptableIdentifier.Utiq -> result.addIfNotNull(UTIQ, identifier.value, ::normalize) @@ -60,7 +60,7 @@ internal class IdentifiersEncoder( if (normalize(googleAdId ?: "") == normalize(identifier.value)) { continue } - result.addIfNotNull(GAID, identifier.value, ::normalize) + result.addIfNotNull(GAID, identifier.value, ::normalizeDeviceId) } is OptableIdentifier.Custom -> { @@ -151,6 +151,16 @@ internal class IdentifiersEncoder( return encryptedValue } + /** + * Normalizes a device advertising ID like [normalize], returning an empty string (so it is dropped) for an + * all-zero ID such as "00000000-0000-0000-0000-000000000000": the placeholder Android and iOS return when the + * user has opted out or tracking isn't authorized, shared by every such device. + */ + private fun normalizeDeviceId(value: String): String { + val normalized = normalize(value) + return if (normalized.all { it == '0' || it == '-' }) "" else normalized + } + /** * Normalizes an identifier string by removing all whitespace and lowercasing the result. * This is used for hash-based EIDs (email, phone). diff --git a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt index f04172d..4e245f5 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt @@ -237,6 +237,35 @@ class OptableIdentifiersTest { assertEquals(emptyList(), actual) } + @Test + fun `drop all-zero device advertising IDs`() { + val zeros = "00000000-0000-0000-0000-000000000000" + val ids = listOf( + OptableIdentifier.AppleIdfa(zeros), + OptableIdentifier.GoogleGaid(zeros), + OptableIdentifier.RokuRida(zeros), + OptableIdentifier.SamsungTifa(" $zeros "), + OptableIdentifier.AmazonFireAfai(zeros.replace("-", "")), + ) + val actual = identifiersEncoder.encode(ids) + assertEquals(emptyList(), actual) + } + + @Test + fun `drop an all-zero device GAID`() { + every { mockGoogleAdIdManager.getId() } returns "00000000-0000-0000-0000-000000000000" + + val actual = identifiersEncoder.encode(emptyList()) + assertEquals(emptyList(), actual) + } + + @Test + fun `keep device advertising IDs that only contain some zeros`() { + val id = "00000000-0000-0000-0000-000000000001" + val actual = identifiersEncoder.encode(listOf(OptableIdentifier.AppleIdfa(id))) + assertEquals(listOf("a:$id"), actual) + } + @Test fun `encode raw`() { val ids = listOf(