From 12c26db0c60c2f58007a4aa047f036d936f10924 Mon Sep 17 00:00:00 2001 From: Chad Huff Date: Sun, 14 Jun 2026 15:22:42 -0600 Subject: [PATCH] Fix TcfCaV1 (Canada) OptimizedRange encoding to use Fibonacci Per the GPP Consent String Specification, the Canada section encodes several fields as OptimizedRange / N-ArrayOfRanges, both of which use Fibonacci coding. They were using fixed-integer encoders. - VendorExpressConsent, VendorImpliedConsent, DisclosedVendors (OptimizedRange): EncodableOptimizedFixedRange -> EncodableOptimizedFibonacciRange (new), backed by a new OptimizedFibonacciRangeEncoder. - PubRestrictions (N-ArrayOfRanges(6,2), whose ids are an OptimizedRange): EncodableArrayOfFixedIntegerRanges -> EncodableArrayOfOptimizedFibonacciRanges (new). The fixed variant is left untouched for TCF EU. Output is byte-identical to the master-based fix (PR #105): updated test vectors match (e.g. vendor section ...BhADVqxGAD0AILVgAA, PubRestrictions ...CCgAS7o). This PR is the encoding fix only; backwards-compatible decoding of pre-fix (fixed-range) strings follows in a separate PR stacked on this one. mvn test: 344 tests, 0 failures; spotless:check clean. Co-Authored-By: Claude Opus 4.8 --- ...odableArrayOfOptimizedFibonacciRanges.java | 76 +++++++++++++++++++ .../EncodableOptimizedFibonacciRange.java | 43 +++++++++++ .../OptimizedFibonacciRangeEncoder.java | 44 +++++++++++ .../iab/gpp/encoder/field/TcfCaV1Field.java | 12 +-- .../com/iab/gpp/encoder/GppModelTest.java | 4 +- .../iab/gpp/encoder/section/TcfCaV1Test.java | 10 +-- 6 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableArrayOfOptimizedFibonacciRanges.java create mode 100644 iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableOptimizedFibonacciRange.java create mode 100644 iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/encoder/OptimizedFibonacciRangeEncoder.java diff --git a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableArrayOfOptimizedFibonacciRanges.java b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableArrayOfOptimizedFibonacciRanges.java new file mode 100644 index 0000000..385a01f --- /dev/null +++ b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableArrayOfOptimizedFibonacciRanges.java @@ -0,0 +1,76 @@ +package com.iab.gpp.encoder.datatype; + +import com.iab.gpp.encoder.bitstring.BitString; +import com.iab.gpp.encoder.datatype.encoder.OptimizedFibonacciRangeEncoder; +import com.iab.gpp.encoder.field.FieldKey; +import com.iab.gpp.encoder.segment.EncodableSegment; +import java.util.List; + +/** + * Encodes an {@code N-ArrayOfRanges(X,Y)} field where each record's ids are encoded as an {@code + * OptimizedRange} (Fibonacci coded), per the GPP Consent String Specification. Counterpart to + * {@link EncodableArrayOfFixedIntegerRanges}, which encodes ids using fixed-integer ranges for + * downward compatibility (e.g. TCF EU). + */ +public final class EncodableArrayOfOptimizedFibonacciRanges & FieldKey> + extends AbstractDirtyableBitStringDataType> { + + private final int keyBitStringLength; + private final int typeBitStringLength; + + public EncodableArrayOfOptimizedFibonacciRanges( + String name, int keyBitStringLength, int typeBitStringLength) { + super(name, null); + this.keyBitStringLength = keyBitStringLength; + this.typeBitStringLength = typeBitStringLength; + } + + @Override + public String toString() { + return name + "=N-ArrayOfRanges(" + keyBitStringLength + "," + typeBitStringLength + ")"; + } + + @Override + protected DirtyableList initialize() { + return new DirtyableList<>(); + } + + @Override + protected boolean isPresent(DirtyableList value) { + return !value.isEmpty(); + } + + @Override + protected void encode( + BitString sb, DirtyableList entries, EncodableSegment segment) { + sb.writeInt(entries.size(), 12); + for (RangeEntry entry : entries) { + sb.writeInt(entry.getKey(), keyBitStringLength); + sb.writeInt(entry.getType(), typeBitStringLength); + OptimizedFibonacciRangeEncoder.encode(sb, entry.getIds()); + } + } + + @Override + protected DirtyableList decode(BitString reader, EncodableSegment segment) { + int size = reader.readInt(12); + DirtyableList value = initialize(); + for (int i = 0; i < size; i++) { + int key = reader.readInt(keyBitStringLength); + int type = reader.readInt(typeBitStringLength); + IntegerSet ids = OptimizedFibonacciRangeEncoder.decode(reader); + RangeEntry entry = new RangeEntry(key, type, ids); + value.add(entry); + } + return value; + } + + @SuppressWarnings("unchecked") + @Override + protected DirtyableList processValue( + DirtyableList oldValue, Object newValue) { + oldValue.clear(); + oldValue.addAll((List) newValue); + return oldValue; + } +} diff --git a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableOptimizedFibonacciRange.java b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableOptimizedFibonacciRange.java new file mode 100644 index 0000000..b99c6ad --- /dev/null +++ b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/EncodableOptimizedFibonacciRange.java @@ -0,0 +1,43 @@ +package com.iab.gpp.encoder.datatype; + +import com.iab.gpp.encoder.bitstring.BitString; +import com.iab.gpp.encoder.datatype.encoder.OptimizedFibonacciRangeEncoder; +import com.iab.gpp.encoder.field.FieldKey; +import com.iab.gpp.encoder.segment.EncodableSegment; +import java.util.Collection; + +public final class EncodableOptimizedFibonacciRange & FieldKey> + extends AbstractDirtyableBitStringDataType { + + public EncodableOptimizedFibonacciRange(String name) { + super(name, null); + } + + @Override + protected IntegerSet initialize() { + return new IntegerSet(); + } + + @Override + protected boolean isPresent(IntegerSet value) { + return !value.isEmpty(); + } + + @Override + protected void encode(BitString builder, IntegerSet value, EncodableSegment segment) { + OptimizedFibonacciRangeEncoder.encode(builder, value); + } + + @Override + protected IntegerSet decode(BitString reader, EncodableSegment segment) { + return OptimizedFibonacciRangeEncoder.decode(reader); + } + + @SuppressWarnings("unchecked") + @Override + protected IntegerSet processValue(IntegerSet oldValue, Object newValue) { + oldValue.clear(); + oldValue.addAll((Collection) newValue); + return oldValue; + } +} diff --git a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/encoder/OptimizedFibonacciRangeEncoder.java b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/encoder/OptimizedFibonacciRangeEncoder.java new file mode 100644 index 0000000..9c61123 --- /dev/null +++ b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/datatype/encoder/OptimizedFibonacciRangeEncoder.java @@ -0,0 +1,44 @@ +package com.iab.gpp.encoder.datatype.encoder; + +import com.iab.gpp.encoder.bitstring.BitString; +import com.iab.gpp.encoder.datatype.IntegerSet; +import com.iab.gpp.encoder.error.DecodingException; +import com.iab.gpp.encoder.error.EncodingException; + +/** + * Encodes an {@code OptimizedRange} that uses Fibonacci coding for the range representation, per + * the GPP Consent String Specification. Counterpart to {@link OptimizedFixedRangeEncoder}, which + * uses fixed-integer ranges for downward compatibility. + */ +public class OptimizedFibonacciRangeEncoder { + + public static void encode(BitString builder, IntegerSet value) throws EncodingException { + // TODO: encoding the range before choosing the shortest is inefficient. There is probably a way + // to identify in advance which will be shorter based on the array length and values + BitString rangeBitString = new BitString(); + int max = FibonacciIntegerRangeEncoder.encode(rangeBitString, value); + int rangeLength = rangeBitString.length(); + int bitFieldLength = max; + + if (rangeLength <= bitFieldLength) { + builder.writeInt(max, 16); + builder.writeBoolean(true); + builder.write(rangeBitString); + } else { + builder.writeInt(max, 16); + builder.writeBoolean(false); + for (int i = 0; i < max; i++) { + builder.writeBoolean(value.containsInt(i + 1)); + } + } + } + + public static IntegerSet decode(BitString reader) throws DecodingException { + int size = reader.readInt(16); + if (reader.readBoolean()) { + return FibonacciIntegerRangeEncoder.decode(reader); + } else { + return reader.readIntegerSet(size); + } + } +} diff --git a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/field/TcfCaV1Field.java b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/field/TcfCaV1Field.java index 6f264b7..d663a4f 100644 --- a/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/field/TcfCaV1Field.java +++ b/iabgpp-encoder/src/main/java/com/iab/gpp/encoder/field/TcfCaV1Field.java @@ -1,14 +1,14 @@ package com.iab.gpp.encoder.field; import com.iab.gpp.encoder.datatype.DataType; -import com.iab.gpp.encoder.datatype.EncodableArrayOfFixedIntegerRanges; +import com.iab.gpp.encoder.datatype.EncodableArrayOfOptimizedFibonacciRanges; import com.iab.gpp.encoder.datatype.EncodableBoolean; import com.iab.gpp.encoder.datatype.EncodableDatetime; import com.iab.gpp.encoder.datatype.EncodableFixedBitfield; import com.iab.gpp.encoder.datatype.EncodableFixedInteger; import com.iab.gpp.encoder.datatype.EncodableFixedString; import com.iab.gpp.encoder.datatype.EncodableFlexibleBitfield; -import com.iab.gpp.encoder.datatype.EncodableOptimizedFixedRange; +import com.iab.gpp.encoder.datatype.EncodableOptimizedFibonacciRange; import com.iab.gpp.encoder.section.TcfCaV1; public enum TcfCaV1Field implements FieldKey { @@ -25,9 +25,9 @@ public enum TcfCaV1Field implements FieldKey { SPECIAL_FEATURE_EXPRESS_CONSENT(new EncodableFixedBitfield<>("SpecialFeatureExpressConsent", 12)), PURPOSES_EXPRESS_CONSENT(new EncodableFixedBitfield<>("PurposesExpressConsent", 24)), PURPOSES_IMPLIED_CONSENT(new EncodableFixedBitfield<>("PurposesImpliedConsent", 24)), - VENDOR_EXPRESS_CONSENT(new EncodableOptimizedFixedRange<>("VendorExpressConsent")), - VENDOR_IMPLIED_CONSENT(new EncodableOptimizedFixedRange<>("VendorImpliedConsent")), - PUB_RESTRICTIONS(new EncodableArrayOfFixedIntegerRanges<>("PubRestrictions", 6, 2)), + VENDOR_EXPRESS_CONSENT(new EncodableOptimizedFibonacciRange<>("VendorExpressConsent")), + VENDOR_IMPLIED_CONSENT(new EncodableOptimizedFibonacciRange<>("VendorImpliedConsent")), + PUB_RESTRICTIONS(new EncodableArrayOfOptimizedFibonacciRanges<>("PubRestrictions", 6, 2)), PUB_PURPOSES_SEGMENT_TYPE(new EncodableFixedInteger<>("PubPurposesSegmentType", 3, 3)), PUB_PURPOSES_EXPRESS_CONSENT(new EncodableFixedBitfield<>("PubPurposesExpressConsent", 24)), @@ -41,7 +41,7 @@ public enum TcfCaV1Field implements FieldKey { "CustomPurposesImpliedConsent", TcfCaV1Field.NUM_CUSTOM_PURPOSES)), DISCLOSED_VENDORS_SEGMENT_TYPE(new EncodableFixedInteger<>("DisclosedVendorsSegmentType", 3, 1)), - DISCLOSED_VENDORS(new EncodableOptimizedFixedRange<>("DisclosedVendors")); + DISCLOSED_VENDORS(new EncodableOptimizedFibonacciRange<>("DisclosedVendors")); private final DataType type; diff --git a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/GppModelTest.java b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/GppModelTest.java index 147c340..9c82670 100644 --- a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/GppModelTest.java +++ b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/GppModelTest.java @@ -397,7 +397,7 @@ public void testEncodeUspV1AndTcfEuV2AndTcfCaV1() { String gppString = gppModel.encode(); Assertions.assertEquals( - "DBACOeA~CPSG_8APSG_8ANwAAAENAwCAAAAAAAAAAAAAAAAAAAAA.IAAA~BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBgABABAAABAB4AACACAAA.fHHHA4444ao~1YNN", + "DBACOeA~CPSG_8APSG_8ANwAAAENAwCAAAAAAAAAAAAAAAAAAAAA.IAAA~BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBhADVqxGAD0AILVgAA.fHHHA4444ao~1YNN", gppString); Assertions.assertEquals(4, gppString.split("~").length); @@ -616,7 +616,7 @@ public void testDecodeUspv1AndTcfEuV2() { @Test public void testDecodeUspv1AndTcfEuV2AndTcfCaV1() { String gppString = - "DBACOeA~CPSG_8APSG_8ANwAAAENAwCAAAAAAAAAAAAAAAAAAAAA.QAAA.IAAA~BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBgABABAAABAB4AACACAAA.fHHHA4444ao~1YNN"; + "DBACOeA~CPSG_8APSG_8ANwAAAENAwCAAAAAAAAAAAAAAAAAAAAA.QAAA.IAAA~BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBhADVqxGAD0AILVgAA.fHHHA4444ao~1YNN"; GppModel gppModel = new GppModel(gppString); Assertions.assertEquals(Set.of(2, 5, 6), gppModel.getSectionIds()); diff --git a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java index 9c184d5..45fc9b5 100644 --- a/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java +++ b/iabgpp-encoder/src/test/java/com/iab/gpp/encoder/section/TcfCaV1Test.java @@ -66,7 +66,7 @@ public void testEncode2() { ZonedDateTime.of(2022, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")).toInstant()); Assertions.assertEquals( - "BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBgABABAAABAB4AACACAAA.fHHHA4444ao", tcfCaV1.encode()); + "BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBhADVqxGAD0AILVgAA.fHHHA4444ao", tcfCaV1.encode()); } @Test @@ -103,8 +103,7 @@ public void testEncode4() throws EncodingException, InvalidFieldException { TcfCaV1Field.LAST_UPDATED, ZonedDateTime.of(2022, 1, 1, 0, 0, 0, 0, ZoneId.of("UTC")).toInstant()); Assertions.assertEquals( - "BPSG_8APSG_8AAAAAAENAACAAAAAAAAAAAAAAAAACCgBwABAAOAAoADgAJA.YAAAAAAAAAA", - tcfCaV1.encode()); + "BPSG_8APSG_8AAAAAAENAACAAAAAAAAAAAAAAAAACCgAS7o.YAAAAAAAAAA", tcfCaV1.encode()); } @Test @@ -143,7 +142,7 @@ public void testDecode1() { @Test public void testDecode2() { TcfCaV1 tcfCaV1 = - new TcfCaV1("BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBgABABAAABAB4AACACAAA.fHHHA4444ao"); + new TcfCaV1("BPSG_8APSG_8AAyACAENGdCgf_gfgAfgfgBhADVqxGAD0AILVgAA.fHHHA4444ao"); Assertions.assertEquals(50, tcfCaV1.getCmpId()); Assertions.assertEquals(2, tcfCaV1.getCmpVersion()); @@ -186,8 +185,7 @@ public void testDecode3() throws DecodingException { @Test public void testDecode4() throws DecodingException { - TcfCaV1 tcfCaV1 = - new TcfCaV1("BPSG_8APSG_8AAAAAAENAACAAAAAAAAAAAAAAAAACCgBwABAAOAAoADgAJA.YAAAAAAAAAA"); + TcfCaV1 tcfCaV1 = new TcfCaV1("BPSG_8APSG_8AAAAAAENAACAAAAAAAAAAAAAAAAACCgAS7o.YAAAAAAAAAA"); List pubRestictions = tcfCaV1.getPubRestrictions(); Assertions.assertEquals(1, pubRestictions.size());