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..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,22 +36,22 @@ internal class IdentifiersEncoder( val googleAdId = googleAdIdManager.getId() if (googleAdId != null) { - result.addIfNotNull(GAID, googleAdId, ::normalize) + result.addIfNotNull(GAID, googleAdId, ::normalizeDeviceId) } 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) 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 -> { @@ -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. @@ -136,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 96cda77..4e245f5 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,61 @@ 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 `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(