From a14bf029fec35ecb05d2d125907986fc1d6dbf5a Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 9 Nov 2026 19:11:02 -0700 Subject: [PATCH 1/3] BREAKING: Check Raw identifiers' types and drop malformed values OptableIdentifier.Raw sent any string unchanged. Match the iOS SDK (optable-ios-sdk#98): - A known type is encoded exactly like its typed identifier. "e" and "p" must already be SHA-256 hashes, like Hem and HashedPhoneNumber, or the ID is dropped, so Raw("e:jane@example.com") never sends a plaintext Email. Custom types are "c" and "c". - An unknown type is sent as given, split on the first ":". - A value with no ":", an empty type or an empty value is dropped. Apps passing plaintext or malformed values through Raw stop sending them, so this is for 2.0.0. Co-Authored-By: Claude Opus 5.5 --- .../java/co/optable/sdk/OptableIdentifier.kt | 6 +- .../co/optable/sdk/core/IdentifiersEncoder.kt | 123 +++++++++++++----- .../co/optable/sdk/OptableIdentifiersTest.kt | 76 +++++++++++ 3 files changed, 174 insertions(+), 31 deletions(-) diff --git a/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt b/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt index be5a496..e70233f 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt @@ -106,8 +106,10 @@ sealed class OptableIdentifier { data class Custom(val key: String, val value: String) : OptableIdentifier() /** - * Pre-encoded EID string to include as-is. - * No encoding applied. Required format: "type:value" (e.g. "c1:value"). + * Pre-encoded EID string. Required format: "type:value" (e.g. "c1:value"), split on the first ":". + * A known type is encoded exactly like its typed identifier: "e" and "p" must already be SHA-256 hashes, like + * Hem and HashedPhoneNumber, or the ID is dropped; other known types get the same normalization. Custom types + * are "c" and "c". An unknown type is sent as given. Dropped when the type or value is empty. */ data class Raw(val value: String) : OptableIdentifier() 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..d856063 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 @@ -5,6 +5,7 @@ import androidx.core.net.toUri import co.optable.sdk.OptableIdentifier import java.security.MessageDigest import java.util.* +import kotlin.math.abs /** * Utility object responsible for encoding raw identifiers into Optable EIDs. @@ -28,6 +29,7 @@ internal class IdentifiersEncoder( private const val ID5 = "id5" private const val UTIQ = "utiq" private const val VID = "v" + private const val CUSTOM = "c" } @@ -40,43 +42,106 @@ 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.Hem -> result.addIfValidHash(EMAIL, identifier.value) - is OptableIdentifier.HashedPhoneNumber -> result.addIfValidHash(PHONE, identifier.value) - 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.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) - - is OptableIdentifier.GoogleGaid -> { - if (normalize(googleAdId ?: "") == normalize(identifier.value)) { - continue - } - result.addIfNotNull(GAID, identifier.value, ::normalize) + result.addEncoded(identifier, googleAdId) + } + + + return result + } + + private fun MutableList.addEncoded(identifier: OptableIdentifier, googleAdId: String?) { + when (identifier) { + is OptableIdentifier.Email -> addIfNotNull(EMAIL, identifier.value, ::encrypt) + is OptableIdentifier.PhoneNumber -> addIfNotNull(PHONE, identifier.value, ::encrypt) + is OptableIdentifier.Hem -> addIfValidHash(EMAIL, identifier.value) + is OptableIdentifier.HashedPhoneNumber -> addIfValidHash(PHONE, identifier.value) + is OptableIdentifier.PostalCode -> addIfNotNull(POSTAL, identifier.value, ::normalize) + is OptableIdentifier.IPv4 -> addIfNotNull(IPV4, identifier.value, ::removeWhitespaces) + is OptableIdentifier.IPv6 -> addIfNotNull(IPV6, identifier.value, ::normalize) + is OptableIdentifier.AppleIdfa -> addIfNotNull(IDFA, identifier.value, ::normalize) + is OptableIdentifier.RokuRida -> addIfNotNull(RIDA, identifier.value, ::normalize) + is OptableIdentifier.SamsungTifa -> addIfNotNull(TIFA, identifier.value, ::normalize) + is OptableIdentifier.AmazonFireAfai -> addIfNotNull(AFAI, identifier.value, ::normalize) + is OptableIdentifier.NetId -> addIfNotNull(NETID, identifier.value, ::removeWhitespaces) + is OptableIdentifier.ID5 -> addIfNotNull(ID5, identifier.value, ::removeWhitespaces) + is OptableIdentifier.Utiq -> addIfNotNull(UTIQ, identifier.value, ::normalize) + + is OptableIdentifier.GoogleGaid -> { + if (normalize(googleAdId ?: "") == normalize(identifier.value)) { + return } + addIfNotNull(GAID, identifier.value, ::normalize) + } - is OptableIdentifier.Custom -> { - if (identifier.key == VID) { - result.addIfNotNull(identifier.key, identifier.value, ::removeWhitespaces) - } else { - result.addIfNotNull(identifier.key, identifier.value, ::trim) - } + is OptableIdentifier.Custom -> { + if (identifier.key == VID) { + addIfNotNull(identifier.key, identifier.value, ::removeWhitespaces) + } else { + addIfNotNull(identifier.key, identifier.value, ::trim) } + } - is OptableIdentifier.Raw -> result.add(identifier.value) + is OptableIdentifier.Raw -> { + val known = knownIdentifierFromRaw(identifier.value) + if (known != null) { + addEncoded(known, googleAdId) + } else if (isWellFormedRaw(identifier.value)) { + add(identifier.value) + } } } + } + /** + * Returns the typed identifier for a "type:value" string with a known type, so that it is encoded exactly like + * that type. "e" and "p" map to Hem and HashedPhoneNumber, so a plaintext Email or Phone number is dropped rather + * than sent. Custom types are "c" and "c". Returns null for an unknown type, or when the type or value is + * empty. Matches the iOS SDK's OptableIdentifier(extendedIdentifier:). + */ + private fun knownIdentifierFromRaw(raw: String): OptableIdentifier? { + if (!isWellFormedRaw(raw)) return null + + val type = raw.substringBefore(':') + val value = raw.substringAfter(':') + return when (type) { + EMAIL -> OptableIdentifier.Hem(value) + PHONE -> OptableIdentifier.HashedPhoneNumber(value) + POSTAL -> OptableIdentifier.PostalCode(value) + IPV4 -> OptableIdentifier.IPv4(value) + IPV6 -> OptableIdentifier.IPv6(value) + IDFA -> OptableIdentifier.AppleIdfa(value) + GAID -> OptableIdentifier.GoogleGaid(value) + RIDA -> OptableIdentifier.RokuRida(value) + TIFA -> OptableIdentifier.SamsungTifa(value) + AFAI -> OptableIdentifier.AmazonFireAfai(value) + NETID -> OptableIdentifier.NetId(value) + ID5 -> OptableIdentifier.ID5(value) + UTIQ -> OptableIdentifier.Utiq(value) + VID -> OptableIdentifier.Custom(VID, value) + else -> customKey(type)?.let { OptableIdentifier.Custom(it, value) } + } + } - return result + /** + * Returns the custom type for "c" or "c" ("c0" is "c", a negative number uses its absolute value), + * or null for any other type. + */ + private fun customKey(type: String): String? { + if (!type.startsWith(CUSTOM)) return null + + val suffix = type.removePrefix(CUSTOM) + if (suffix.isEmpty()) return CUSTOM + + val index = suffix.toIntOrNull() ?: return null + return if (index == 0) CUSTOM else "$CUSTOM${abs(index)}" + } + + /** + * A raw identifier needs a non-empty type and a non-empty value separated by the first ":". + */ + private fun isWellFormedRaw(raw: String): Boolean { + val separator = raw.indexOf(':') + return separator > 0 && separator < raw.length - 1 } private fun MutableList.addIfNotNull(key: String, value: String?, encoder: (String) -> String) { 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..3f1804a 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt @@ -222,6 +222,82 @@ class OptableIdentifiersTest { assertEquals(expected, actual) } + @Test + fun `encode raw with a known type like its typed identifier`() { + val ids = listOf( + OptableIdentifier.Raw("i4: 8.8.8.8 "), + OptableIdentifier.Raw("i6:2001:DB8::1"), + OptableIdentifier.Raw("z: AB 12"), + OptableIdentifier.Raw("v: vid value "), + OptableIdentifier.Raw("id5: ID5 Value "), + ) + val actual = identifiersEncoder.encode(ids) + val expected = listOf("i4:8.8.8.8", "i6:2001:db8::1", "z:ab12", "v:vidvalue", "id5:ID5Value") + assertEquals(expected, actual) + } + + @Test + fun `encode raw custom types`() { + val ids = listOf( + OptableIdentifier.Raw("c: abc "), + OptableIdentifier.Raw("c3: abc "), + OptableIdentifier.Raw("c0:zero"), + OptableIdentifier.Raw("c-2:negative"), + ) + val actual = identifiersEncoder.encode(ids) + val expected = listOf("c:abc", "c3:abc", "c:zero", "c2:negative") + assertEquals(expected, actual) + } + + @Test + fun `encode raw email and phone only when they are already hashed`() { + val hash = "da146c4d301ce4a9bf01c8384a5b2f8966b3ed853adf1ee08746be5703ebeebf" + val ids = listOf( + OptableIdentifier.Raw("e:jane@example.com"), + OptableIdentifier.Raw("p:+15551234567"), + OptableIdentifier.Raw("e: ${hash.uppercase()} "), + OptableIdentifier.Raw("p:$hash"), + ) + val actual = identifiersEncoder.encode(ids) + val expected = listOf("e:$hash", "p:$hash") + assertEquals(expected, actual) + } + + @Test + fun `encode raw with an unknown type as given`() { + val ids = listOf( + OptableIdentifier.Raw("xyz:Some Value"), + OptableIdentifier.Raw("xyz:a:b"), + OptableIdentifier.Raw("cookie:abc"), + OptableIdentifier.Raw("E:Upper Case Type"), + ) + val actual = identifiersEncoder.encode(ids) + val expected = listOf("xyz:Some Value", "xyz:a:b", "cookie:abc", "E:Upper Case Type") + assertEquals(expected, actual) + } + + @Test + fun `drop malformed raw identifiers`() { + val ids = listOf( + OptableIdentifier.Raw("xyz"), + OptableIdentifier.Raw(":abc"), + OptableIdentifier.Raw("xyz:"), + OptableIdentifier.Raw("e:"), + OptableIdentifier.Raw(":"), + OptableIdentifier.Raw(""), + ) + val actual = identifiersEncoder.encode(ids) + assertEquals(emptyList(), actual) + } + + @Test + fun `raw gaid matching the device gaid is deduped`() { + every { mockGoogleAdIdManager.getId() } returns "sameId" + + val actual = identifiersEncoder.encode(listOf(OptableIdentifier.Raw("g:SAMEID"))) + assertEquals(listOf("g:sameid"), actual) + } + @Test fun `custom gaid`() { every { mockGoogleAdIdManager.getId() } returns null From a8b530e8c10f112351b8db3f308331dfe2f236ae Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 9 Nov 2026 19:24:20 -0700 Subject: [PATCH 2/3] Drop Raw identifiers whose value is whitespace-only Treat a whitespace-only value like an empty one, so "xyz: " is dropped instead of being sent, matching the iOS follow-up to optable-ios-sdk#98. Co-Authored-By: Claude Opus 5.5 --- .../src/main/java/co/optable/sdk/OptableIdentifier.kt | 3 ++- .../src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt | 5 +++-- .../src/test/java/co/optable/sdk/OptableIdentifiersTest.kt | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt b/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt index e70233f..1228dd7 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt @@ -109,7 +109,8 @@ sealed class OptableIdentifier { * Pre-encoded EID string. Required format: "type:value" (e.g. "c1:value"), split on the first ":". * A known type is encoded exactly like its typed identifier: "e" and "p" must already be SHA-256 hashes, like * Hem and HashedPhoneNumber, or the ID is dropped; other known types get the same normalization. Custom types - * are "c" and "c". An unknown type is sent as given. Dropped when the type or value is empty. + * are "c" and "c". An unknown type is sent as given. Dropped when the type is empty or the value is empty + * or whitespace-only. */ data class Raw(val value: String) : OptableIdentifier() 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 d856063..7017a5f 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 @@ -137,11 +137,12 @@ internal class IdentifiersEncoder( } /** - * A raw identifier needs a non-empty type and a non-empty value separated by the first ":". + * A raw identifier needs a non-empty type and a value that isn't empty or whitespace-only, separated by the + * first ":". */ private fun isWellFormedRaw(raw: String): Boolean { val separator = raw.indexOf(':') - return separator > 0 && separator < raw.length - 1 + return separator > 0 && raw.substring(separator + 1).isNotBlank() } private fun MutableList.addIfNotNull(key: String, value: String?, encoder: (String) -> String) { 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 3f1804a..f8101ea 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt @@ -282,6 +282,7 @@ class OptableIdentifiersTest { OptableIdentifier.Raw("xyz"), OptableIdentifier.Raw(":abc"), OptableIdentifier.Raw("xyz:"), + OptableIdentifier.Raw("xyz: "), OptableIdentifier.Raw("e:"), OptableIdentifier.Raw(":"), OptableIdentifier.Raw(""), From b8625249a2e0e45ef805756b21c898785c657ce5 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 9 Nov 2026 19:26:17 -0700 Subject: [PATCH 3/3] Drop Raw identifiers whose type is whitespace-only Treat a whitespace-only type like an empty one, so " :abc" is dropped, matching optable-ios-sdk#98. Co-Authored-By: Claude Opus 5.5 --- .../src/main/java/co/optable/sdk/OptableIdentifier.kt | 4 ++-- .../src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt | 6 +++--- .../src/test/java/co/optable/sdk/OptableIdentifiersTest.kt | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt b/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt index 1228dd7..5e9f7f6 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableIdentifier.kt @@ -109,8 +109,8 @@ sealed class OptableIdentifier { * Pre-encoded EID string. Required format: "type:value" (e.g. "c1:value"), split on the first ":". * A known type is encoded exactly like its typed identifier: "e" and "p" must already be SHA-256 hashes, like * Hem and HashedPhoneNumber, or the ID is dropped; other known types get the same normalization. Custom types - * are "c" and "c". An unknown type is sent as given. Dropped when the type is empty or the value is empty - * or whitespace-only. + * are "c" and "c". An unknown type is sent as given. Dropped when the type or the value is empty or + * whitespace-only. */ data class Raw(val value: String) : OptableIdentifier() 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 7017a5f..b0a6e03 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 @@ -137,12 +137,12 @@ internal class IdentifiersEncoder( } /** - * A raw identifier needs a non-empty type and a value that isn't empty or whitespace-only, separated by the - * first ":". + * A raw identifier needs a type and a value, separated by the first ":", that are neither empty nor + * whitespace-only. */ private fun isWellFormedRaw(raw: String): Boolean { val separator = raw.indexOf(':') - return separator > 0 && raw.substring(separator + 1).isNotBlank() + return separator > 0 && raw.substring(0, separator).isNotBlank() && raw.substring(separator + 1).isNotBlank() } private fun MutableList.addIfNotNull(key: String, value: String?, encoder: (String) -> String) { 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 f8101ea..8b64470 100644 --- a/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/OptableIdentifiersTest.kt @@ -281,6 +281,7 @@ class OptableIdentifiersTest { val ids = listOf( OptableIdentifier.Raw("xyz"), OptableIdentifier.Raw(":abc"), + OptableIdentifier.Raw(" :abc"), OptableIdentifier.Raw("xyz:"), OptableIdentifier.Raw("xyz: "), OptableIdentifier.Raw("e:"),