From 580e7fe693cba5dcb27c1286ae3ca72d5795863e Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Thu, 29 Oct 2026 16:12:37 -0600 Subject: [PATCH 1/2] Read GPP consent from the IAB's SharedPreferences keys The IAB GPP CMP API spec has in-app CMPs store the GPP string under IABGPP_HDR_GppString and the section IDs under IABGPP_GppSID (underscore-separated). The SDK read IABGPP_2_TCString, which isn't an IAB key, and never read the section IDs. - Read IABGPP_HDR_GppString first, keep IABGPP_2_TCString as a fallback. - Read IABGPP_GppSID and convert "2_7" to "2,7" for the Edge API. - Use the CMP's section IDs only when the GPP string also came from the CMP. - An explicit gppSid always wins. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++-- .../java/co/optable/sdk/OptableConsents.kt | 6 ++-- .../co/optable/sdk/core/ConsentsManager.kt | 9 +++++- .../java/co/optable/sdk/core/LocalStorage.kt | 19 ++++++++++-- .../optable/sdk/core/ConsentsManagerTest.kt | 24 +++++++++++++++ .../co/optable/sdk/core/LocalStorageTest.kt | 30 +++++++++++++++++-- 6 files changed, 84 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2590d19..21908c0 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, 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..fb65c14 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, 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..d9c1560 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 && localStorage.getGppConsent() != 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..ee24f07 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,25 @@ internal class LocalStorage( fun getGppConsent(): String? { try { - return prefs.getString(KEY_GPP_CONSENT, null) + return prefs.getString(KEY_GPP_STRING, null) ?: prefs.getString(KEY_LEGACY_GPP_CONSENT, null) } 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"). + */ + fun getGppSid(): String? { + try { + return prefs.getString(KEY_GPP_SID, null)?.replace("_", ",") + } catch (e: Exception) { + Log.e("OptableSDK", "Can't get GPP SID: ${e.message}") + } + return null + } + 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..0a9ebc0 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,30 @@ class ConsentsManagerTest { assertNull(consentsManager.gppSid()) } + @Test + fun `gppSid should return value from localStorage when gpp also comes from localStorage`() { + `when`(localStorage.getGppConsent()).thenReturn("local_storage_gpp") + `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.getGppConsent()).thenReturn("local_storage_gpp") + `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.getGppConsent()).thenReturn("local_storage_gpp") + `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..9881d91 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,38 @@ 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 `getGppSid should convert underscores to commas`() { + 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_SID, null) } returns null + assertNull(localStorage.getGppSid()) + } + private fun getPrivateKey(instance: Any, fieldName: String): String { val field: Field = instance.javaClass.getDeclaredField(fieldName) field.isAccessible = true From ae2b7e66cd4565ebafb6e1cd35d975e61e6ff2c3 Mon Sep 17 00:00:00 2001 From: John Rosendahl Date: Mon, 9 Nov 2026 15:26:35 -0700 Subject: [PATCH 2/2] Ignore blank GPP values; only pair IABGPP_GppSID with IABGPP_HDR_GppString Blank SharedPreferences values are treated as unset, so the SDK no longer sends `gpp=` or `gpp_sid=`, and a blank IABGPP_HDR_GppString falls back to the legacy key. The CMP's section IDs are only sent with the CMP's own GPP string, never with the legacy IABGPP_2_TCString value. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../java/co/optable/sdk/OptableConsents.kt | 2 +- .../co/optable/sdk/core/ConsentsManager.kt | 2 +- .../java/co/optable/sdk/core/LocalStorage.kt | 12 +++++-- .../optable/sdk/core/ConsentsManagerTest.kt | 5 +-- .../co/optable/sdk/core/LocalStorageTest.kt | 32 +++++++++++++++++++ 6 files changed, 46 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 21908c0..5c446fb 100644 --- a/README.md +++ b/README.md @@ -692,7 +692,7 @@ If not set, SDK will try to fetch data from SharedPreferences (key `IABTCF_gdprA 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, SDK will also read key `IABGPP_GppSID`, as stated in +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. 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 fb65c14..196db15 100644 --- a/android_sdk/src/main/java/co/optable/sdk/OptableConsents.kt +++ b/android_sdk/src/main/java/co/optable/sdk/OptableConsents.kt @@ -12,7 +12,7 @@ package co.optable.sdk * 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, SDK will also read key `IABGPP_GppSID`, as stated in + * 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. */ 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 d9c1560..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 @@ -42,7 +42,7 @@ internal class ConsentsManager( 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 && localStorage.getGppConsent() != null) { + if (customConsents.gpp == null) { return localStorage.getGppSid() } return null 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 ee24f07..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 @@ -124,7 +124,7 @@ internal class LocalStorage( fun getGppConsent(): String? { try { - return prefs.getString(KEY_GPP_STRING, null) ?: prefs.getString(KEY_LEGACY_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}") } @@ -133,16 +133,24 @@ internal class LocalStorage( /** * 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 { - return prefs.getString(KEY_GPP_SID, null)?.replace("_", ",") + 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 0a9ebc0..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 @@ -105,8 +105,7 @@ class ConsentsManagerTest { } @Test - fun `gppSid should return value from localStorage when gpp also comes from localStorage`() { - `when`(localStorage.getGppConsent()).thenReturn("local_storage_gpp") + 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()) @@ -114,7 +113,6 @@ class ConsentsManagerTest { @Test fun `gppSid should not use localStorage value when gpp comes from customConsents`() { - `when`(localStorage.getGppConsent()).thenReturn("local_storage_gpp") `when`(localStorage.getGppSid()).thenReturn("2,7") consentsManager = ConsentsManager(localStorage, OptableConsents(gpp = "test_gpp")) assertNull(consentsManager.gppSid()) @@ -122,7 +120,6 @@ class ConsentsManagerTest { @Test fun `gppSid from customConsents should win over localStorage value`() { - `when`(localStorage.getGppConsent()).thenReturn("local_storage_gpp") `when`(localStorage.getGppSid()).thenReturn("2,7") consentsManager = ConsentsManager(localStorage, OptableConsents(gppSid = "8")) assertEquals("8", consentsManager.gppSid()) 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 9881d91..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 @@ -303,18 +303,50 @@ class LocalStorageTest { 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