import Foundation // MARK: - TCF String Encoder /// Encodes a TC string (Transparency & Consent Framework v2.2) from consent state. /// /// The TC string is a Base64url-encoded bit field described in the IAB TCF v2.2 specification. /// This implementation encodes the core consent section (segment type 0) sufficient for /// signalling purpose consent to downstream vendors. /// /// Reference: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework public final class TCFStringEncoder: Sendable { // MARK: - Constants /// TCF specification version. private static let specVersion: Int = 2 /// A fixed CMP ID — replace with your registered IAB CMP ID in production. private static let cmpId: Int = 0 /// CMP SDK version number. private static let cmpVersion: Int = 1 /// IAB consent language (en). private static let consentLanguage: String = "EN" /// Vendor list version. In production, this should be fetched from the GVL. private static let vendorListVersion: Int = 1 /// Number of TCF purposes defined in the specification. private static let tcfPurposeCount: Int = 24 // MARK: - Public API /// Encodes a TC string for the given consent state and site configuration. /// /// - Parameters: /// - state: The resolved consent state containing accepted/rejected categories. /// - config: The site configuration (used for CMP metadata). /// - Returns: A Base64url-encoded TC string, or `nil` if encoding fails. public static func encode(state: ConsentState, config: ConsentConfig) -> String? { guard state.hasInteracted, let consentedAt = state.consentedAt else { return nil } // Derive the set of consented TCF purpose IDs from accepted categories. let consentedPurposeIds: Set = state.accepted.reduce(into: []) { result, category in category.tcfPurposeIds.forEach { result.insert($0) } } return buildCoreString( consentedAt: consentedAt, consentedPurposeIds: consentedPurposeIds ) } // MARK: - Core String Construction private static func buildCoreString( consentedAt: Date, consentedPurposeIds: Set ) -> String? { var bits = BitWriter() // --- Core segment fields (IAB TCF v2.2 spec, Table 1) --- // Version (6 bits) bits.write(specVersion, bitCount: 6) // Created — deciseconds since epoch (36 bits) let deciseconds = Int(consentedAt.timeIntervalSince1970 * 10) bits.write(deciseconds, bitCount: 36) // LastUpdated — deciseconds since epoch (36 bits) bits.write(deciseconds, bitCount: 36) // CmpId (12 bits) bits.write(cmpId, bitCount: 12) // CmpVersion (12 bits) bits.write(cmpVersion, bitCount: 12) // ConsentScreen (6 bits) — screen number within the CMP UI bits.write(1, bitCount: 6) // ConsentLanguage (12 bits) — two 6-bit characters, A=0 … Z=25 let langBits = encodeTwoLetterLanguage(consentLanguage) bits.write(langBits.0, bitCount: 6) bits.write(langBits.1, bitCount: 6) // VendorListVersion (12 bits) bits.write(vendorListVersion, bitCount: 12) // TcfPolicyVersion (6 bits) — must be 4 for TCF v2.2 bits.write(4, bitCount: 6) // IsServiceSpecific (1 bit) bits.write(0, bitCount: 1) // UseNonStandardTexts (1 bit) bits.write(0, bitCount: 1) // SpecialFeatureOptIns (12 bits) — none opted in bits.write(0, bitCount: 12) // PurposesConsent (24 bits) — one bit per purpose, LSB = purpose 1 for purposeId in 1 ... tcfPurposeCount { bits.write(consentedPurposeIds.contains(purposeId) ? 1 : 0, bitCount: 1) } // PurposesLITransparency (24 bits) — legitimate interest; none asserted bits.write(0, bitCount: 24) // PurposeOneTreatment (1 bit) bits.write(0, bitCount: 1) // PublisherCC (12 bits) — "GB" let ccBits = encodeTwoLetterLanguage("GB") bits.write(ccBits.0, bitCount: 6) bits.write(ccBits.1, bitCount: 6) // Vendor Consents — using BitRange encoding with MaxVendorId = 0 (no vendors) bits.write(0, bitCount: 16) // MaxVendorId bits.write(0, bitCount: 1) // IsRangeEncoding = false // (no bits to write for an empty vendor list) // Vendor Legitimate Interests — MaxVendorId = 0 bits.write(0, bitCount: 16) bits.write(0, bitCount: 1) // Publisher Restrictions count = 0 bits.write(0, bitCount: 12) // Serialise and Base64url-encode let data = bits.toData() return base64UrlEncode(data) } // MARK: - Helpers /// Encodes a two-letter language/country code into two 6-bit integers (A=0, Z=25). private static func encodeTwoLetterLanguage(_ code: String) -> (Int, Int) { // ASCII value of 'A' is 65. Subtracting this gives 0-based index (A=0 … Z=25). let asciiA: Int = 65 let upper = code.uppercased() let chars = Array(upper) guard chars.count == 2, let first = chars[0].asciiValue, let second = chars[1].asciiValue else { return (4, 13) // "EN" fallback (E=4, N=13) } return (Int(first) - asciiA, Int(second) - asciiA) } /// Converts a `Data` value to a Base64url string (RFC 4648, no padding). private static func base64UrlEncode(_ data: Data) -> String { data.base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .trimmingCharacters(in: CharacterSet(charactersIn: "=")) } } // MARK: - Bit Writer /// A utility type for packing integers into a bit-level byte buffer. private struct BitWriter { private var bytes: [UInt8] = [] private var currentByte: UInt8 = 0 private var bitPosition: Int = 0 // 0 = MSB of current byte /// Writes `bitCount` bits from the MSB of `value`. mutating func write(_ value: Int, bitCount: Int) { for i in stride(from: bitCount - 1, through: 0, by: -1) { let bit: UInt8 = (value >> i) & 1 == 1 ? 1 : 0 currentByte |= bit << (7 - bitPosition) bitPosition += 1 if bitPosition == 8 { bytes.append(currentByte) currentByte = 0 bitPosition = 0 } } } /// Flushes any remaining partial byte and returns the accumulated data. func toData() -> Data { var result = bytes if bitPosition > 0 { result.append(currentByte) } return Data(result) } }