diff --git a/README.md b/README.md index 2590d19..5c446fb 100644 --- a/README.md +++ b/README.md @@ -689,9 +689,11 @@ If not set, SDK will try to fetch data from SharedPreferences (key `IABTCF_gdprA - `gdprConsent` TCF EU v2 consent string. If not set, SDK will try to fetch data from SharedPreferences (key `IABTCF_TCString`), as stated in [standard](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details). - `gpp` GPP privacy string. -If not set, SDK will try to fetch data from SharedPreferences (key `IABGPP_2_TCString`), as stated in -[standard](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details) +If not set, SDK will try to fetch data from SharedPreferences (key `IABGPP_HDR_GppString`), as stated in +[standard](https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform/blob/main/Core/CMP%20API%20Specification.md) - `gppSid` A comma-separated list of up to two sections applicable in a given GPP privacy string. This value is required when gpp is present. +If not set and `gpp` is read from SharedPreferences key `IABGPP_HDR_GppString`, SDK will also read key `IABGPP_GppSID`, as stated in +[standard](https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform/blob/main/Core/CMP%20API%20Specification.md) - `req` Optable privacy regulation override, which can be one of: `gdpr`, `can`, `us`, or `null` and will override all other privacy regulations when present. **NOTE**: The custom values provided to the `OptableConfig` will override all other privacy regulations when present. diff --git a/android_sdk/src/main/java/co/optable/sdk/OptableConsents.kt b/android_sdk/src/main/java/co/optable/sdk/OptableConsents.kt index a4b2069..196db15 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableConsents.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableConsents.kt @@ -9,9 +9,11 @@ package co.optable.sdk * @param gdprConsent TCF EU v2 consent string. If not set, SDK will try to fetch data from SharedPreferences (key `IABTCF_TCString`), as stated in * [standard](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details). * @param gpp GPP privacy string. - * If not set, SDK will try to fetch data from SharedPreferences (key `IABGPP_2_TCString`), as stated in - * [standard](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details) + * If not set, SDK will try to fetch data from SharedPreferences (key `IABGPP_HDR_GppString`), as stated in + * [standard](https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform/blob/main/Core/CMP%20API%20Specification.md) * @param gppSid A comma-separated list of up to two sections applicable in a given GPP privacy string. This value is required when gpp is present. + * If not set and `gpp` is read from SharedPreferences key `IABGPP_HDR_GppString`, SDK will also read key `IABGPP_GppSID`, as stated in + * [standard](https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform/blob/main/Core/CMP%20API%20Specification.md) * @param reg Optable privacy regulation override, which can be one of: `gdpr`, `can`, `us`, or `null` and will override all other privacy regulations when present. */ data class OptableConsents( diff --git a/android_sdk/src/main/java/co/optable/sdk/core/ConsentsManager.kt b/android_sdk/src/main/java/co/optable/sdk/core/ConsentsManager.kt index 18b9f61..0737679 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/ConsentsManager.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/ConsentsManager.kt @@ -38,7 +38,14 @@ internal class ConsentsManager( } fun gppSid(): String? { - return customConsents.gppSid + if (customConsents.gppSid != null) { + return customConsents.gppSid + } + // The CMP's section IDs are only used with the CMP's GPP string, never with a custom one. + if (customConsents.gpp == null) { + return localStorage.getGppSid() + } + return null } fun customRegulation(): String? { diff --git a/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt b/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt index ecd70ca..dc21224 100644 --- a/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt +++ b/android_sdk/src/main/java/co/optable/sdk/core/LocalStorage.kt @@ -22,7 +22,10 @@ internal class LocalStorage( companion object { private const val KEY_SUBJECT_TO_GDPR = "IABTCF_gdprApplies" private const val KEY_GDPR_CONSENT = "IABTCF_TCString" - private const val KEY_GPP_CONSENT = "IABGPP_2_TCString" + private const val KEY_GPP_STRING = "IABGPP_HDR_GppString" + private const val KEY_GPP_SID = "IABGPP_GppSID" + // Not an IAB key, read before IABGPP_HDR_GppString was. Kept as a fallback for apps that write it. + private const val KEY_LEGACY_GPP_CONSENT = "IABGPP_2_TCString" } private val prefs = PreferenceManager.getDefaultSharedPreferences(config.context) @@ -121,13 +124,33 @@ internal class LocalStorage( fun getGppConsent(): String? { try { - return prefs.getString(KEY_GPP_CONSENT, null) + return getNonBlankString(KEY_GPP_STRING) ?: getNonBlankString(KEY_LEGACY_GPP_CONSENT) } catch (e: Exception) { Log.e("OptableSDK", "Can't get GPP consent: ${e.message}") } return null } + /** + * The CMP stores section IDs separated by underscores ("2_7"); the Edge API takes them comma-separated ("2,7"). + * Only returned alongside the CMP's own GPP string, never with the legacy key's. + */ + fun getGppSid(): String? { + try { + if (getNonBlankString(KEY_GPP_STRING) == null) { + return null + } + return getNonBlankString(KEY_GPP_SID)?.replace("_", ",") + } catch (e: Exception) { + Log.e("OptableSDK", "Can't get GPP SID: ${e.message}") + } + return null + } + + private fun getNonBlankString(key: String): String? { + return prefs.getString(key, null)?.takeIf { it.isNotBlank() } + } + private fun generateUniqueKey(kind: String, config: OptableConfig): String { val sfx = "${config.host}/${config.tenant}/${config.originSlug}" return "OPTABLE_" + kind + "_" + Base64.encodeToString(sfx.toByteArray(), 0) diff --git a/android_sdk/src/test/java/co/optable/sdk/core/ConsentsManagerTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/ConsentsManagerTest.kt index 2854d29..b9c76ed 100644 --- a/android_sdk/src/test/java/co/optable/sdk/core/ConsentsManagerTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/core/ConsentsManagerTest.kt @@ -104,6 +104,27 @@ class ConsentsManagerTest { assertNull(consentsManager.gppSid()) } + @Test + fun `gppSid should return value from localStorage when gpp is not in customConsents`() { + `when`(localStorage.getGppSid()).thenReturn("2,7") + consentsManager = ConsentsManager(localStorage) + assertEquals("2,7", consentsManager.gppSid()) + } + + @Test + fun `gppSid should not use localStorage value when gpp comes from customConsents`() { + `when`(localStorage.getGppSid()).thenReturn("2,7") + consentsManager = ConsentsManager(localStorage, OptableConsents(gpp = "test_gpp")) + assertNull(consentsManager.gppSid()) + } + + @Test + fun `gppSid from customConsents should win over localStorage value`() { + `when`(localStorage.getGppSid()).thenReturn("2,7") + consentsManager = ConsentsManager(localStorage, OptableConsents(gppSid = "8")) + assertEquals("8", consentsManager.gppSid()) + } + @Test fun `customRegulation should return value from customConsents`() { val regString = "test_reg" diff --git a/android_sdk/src/test/java/co/optable/sdk/core/LocalStorageTest.kt b/android_sdk/src/test/java/co/optable/sdk/core/LocalStorageTest.kt index 8f5598f..0a27344 100644 --- a/android_sdk/src/test/java/co/optable/sdk/core/LocalStorageTest.kt +++ b/android_sdk/src/test/java/co/optable/sdk/core/LocalStorageTest.kt @@ -23,7 +23,9 @@ class LocalStorageTest { private companion object { private const val KEY_SUBJECT_TO_GDPR = "IABTCF_gdprApplies" private const val KEY_GDPR_CONSENT = "IABTCF_TCString" - private const val KEY_GPP_CONSENT = "IABGPP_2_TCString" + private const val KEY_GPP_STRING = "IABGPP_HDR_GppString" + private const val KEY_GPP_SID = "IABGPP_GppSID" + private const val KEY_LEGACY_GPP_CONSENT = "IABGPP_2_TCString" private const val HOUR_MS = 60 * 60 * 1000L } @@ -281,16 +283,70 @@ class LocalStorageTest { @Test fun `getGppConsent should return value from SharedPreferences`() { val consent = "test-gpp-consent" - every { mockSharedPreferences.getString(KEY_GPP_CONSENT, null) } returns consent + every { mockSharedPreferences.getString(KEY_GPP_STRING, null) } returns consent + every { mockSharedPreferences.getString(KEY_LEGACY_GPP_CONSENT, null) } returns "legacy-gpp-consent" + assertEquals(consent, localStorage.getGppConsent()) + } + + @Test + fun `getGppConsent should fall back to the legacy key`() { + val consent = "legacy-gpp-consent" + every { mockSharedPreferences.getString(KEY_GPP_STRING, null) } returns null + every { mockSharedPreferences.getString(KEY_LEGACY_GPP_CONSENT, null) } returns consent assertEquals(consent, localStorage.getGppConsent()) } @Test fun `getGppConsent should return null when not set`() { - every { mockSharedPreferences.getString(KEY_GPP_CONSENT, null) } returns null + every { mockSharedPreferences.getString(KEY_GPP_STRING, null) } returns null + every { mockSharedPreferences.getString(KEY_LEGACY_GPP_CONSENT, null) } returns null + assertNull(localStorage.getGppConsent()) + } + + @Test + fun `getGppConsent should fall back to the legacy key when the IAB key is blank`() { + val consent = "legacy-gpp-consent" + every { mockSharedPreferences.getString(KEY_GPP_STRING, null) } returns "" + every { mockSharedPreferences.getString(KEY_LEGACY_GPP_CONSENT, null) } returns consent + assertEquals(consent, localStorage.getGppConsent()) + } + + @Test + fun `getGppConsent should return null when both keys are blank`() { + every { mockSharedPreferences.getString(KEY_GPP_STRING, null) } returns "" + every { mockSharedPreferences.getString(KEY_LEGACY_GPP_CONSENT, null) } returns " " assertNull(localStorage.getGppConsent()) } + @Test + fun `getGppSid should convert underscores to commas`() { + every { mockSharedPreferences.getString(KEY_GPP_STRING, null) } returns "test-gpp-consent" + every { mockSharedPreferences.getString(KEY_GPP_SID, null) } returns "2_7" + assertEquals("2,7", localStorage.getGppSid()) + } + + @Test + fun `getGppSid should return null when not set`() { + every { mockSharedPreferences.getString(KEY_GPP_STRING, null) } returns "test-gpp-consent" + every { mockSharedPreferences.getString(KEY_GPP_SID, null) } returns null + assertNull(localStorage.getGppSid()) + } + + @Test + fun `getGppSid should return null when blank`() { + every { mockSharedPreferences.getString(KEY_GPP_STRING, null) } returns "test-gpp-consent" + every { mockSharedPreferences.getString(KEY_GPP_SID, null) } returns "" + assertNull(localStorage.getGppSid()) + } + + @Test + fun `getGppSid should return null when the GPP string only comes from the legacy key`() { + every { mockSharedPreferences.getString(KEY_GPP_STRING, null) } returns null + every { mockSharedPreferences.getString(KEY_LEGACY_GPP_CONSENT, null) } returns "legacy-gpp-consent" + every { mockSharedPreferences.getString(KEY_GPP_SID, null) } returns "2_7" + assertNull(localStorage.getGppSid()) + } + private fun getPrivateKey(instance: Any, fieldName: String): String { val field: Field = instance.javaClass.getDeclaredField(fieldName) field.isAccessible = true