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..5e9f7f6 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,11 @@ 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 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 1564edb..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 @@ -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,107 @@ 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 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(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 96cda77..8b64470 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,84 @@ 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(" :abc"), + OptableIdentifier.Raw("xyz:"), + 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