Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>". 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()

Expand Down
124 changes: 95 additions & 29 deletions android_sdk/src/main/java/co/optable/sdk/core/IdentifiersEncoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
}


Expand All @@ -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<String>.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<number>". 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<number>" ("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<String>.addIfNotNull(key: String, value: String?, encoder: (String) -> String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>(), 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
Expand Down
Loading