diff --git a/Consent String SDK Swift.xcodeproj/project.pbxproj b/Consent String SDK Swift.xcodeproj/project.pbxproj index c2170df..7a8ff2d 100644 --- a/Consent String SDK Swift.xcodeproj/project.pbxproj +++ b/Consent String SDK Swift.xcodeproj/project.pbxproj @@ -7,19 +7,22 @@ objects = { /* Begin PBXBuildFile section */ + 0B3E03CE22A0206C0078D316 /* ConsentStringBuilding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3E03CD22A0206C0078D316 /* ConsentStringBuilding.swift */; }; + 0B8FBC7222954495002F733D /* NSRange+ConsentString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B8FBC7122954495002F733D /* NSRange+ConsentString.swift */; }; + 0B8FBC76229558F3002F733D /* VendorIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B8FBC75229558F3002F733D /* VendorIdentifier.swift */; }; + 0BA327D3228EA54A00606697 /* ConsentStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA327D2228EA54A00606697 /* ConsentStringBuilder.swift */; }; + 0BA327D5228EC1A300606697 /* Purpose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA327D4228EC1A300606697 /* Purpose.swift */; }; + 0BA327D9228EC84900606697 /* ConsentStringBuilderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA327D8228EC84900606697 /* ConsentStringBuilderTests.swift */; }; + 0BA327DB228ED14600606697 /* VendorEncodingType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA327DA228ED14600606697 /* VendorEncodingType.swift */; }; + 0BAECEE12292B9A000CC4DDB /* BinaryStringTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BAECEE02292B9A000CC4DDB /* BinaryStringTestSupport.swift */; }; F142B52A2093A1C5001ABE60 /* Consent_String_SDK_Swift.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F142B5202093A1C5001ABE60 /* Consent_String_SDK_Swift.framework */; }; F142B52F2093A1C5001ABE60 /* Consent_String_SDK_SwiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B52E2093A1C5001ABE60 /* Consent_String_SDK_SwiftTests.swift */; }; F142B5312093A1C5001ABE60 /* Consent_String_SDK_Swift.h in Headers */ = {isa = PBXBuildFile; fileRef = F142B5232093A1C5001ABE60 /* Consent_String_SDK_Swift.h */; settings = {ATTRIBUTES = (Public, ); }; }; F142B53F2093A207001ABE60 /* ConsentString.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B53A2093A206001ABE60 /* ConsentString.swift */; }; - F142B5402093A207001ABE60 /* ConsentString.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B53A2093A206001ABE60 /* ConsentString.swift */; }; F142B5412093A207001ABE60 /* ConsentStringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B53B2093A206001ABE60 /* ConsentStringError.swift */; }; - F142B5422093A207001ABE60 /* ConsentStringError.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B53B2093A206001ABE60 /* ConsentStringError.swift */; }; F142B5432093A207001ABE60 /* ConsentStringProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B53C2093A206001ABE60 /* ConsentStringProtocol.swift */; }; - F142B5442093A207001ABE60 /* ConsentStringProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B53C2093A206001ABE60 /* ConsentStringProtocol.swift */; }; F142B5452093A207001ABE60 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B53D2093A207001ABE60 /* String.swift */; }; - F142B5462093A207001ABE60 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B53D2093A207001ABE60 /* String.swift */; }; F142B5472093A207001ABE60 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B53E2093A207001ABE60 /* Data.swift */; }; - F142B5482093A207001ABE60 /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = F142B53E2093A207001ABE60 /* Data.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -33,6 +36,14 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0B3E03CD22A0206C0078D316 /* ConsentStringBuilding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentStringBuilding.swift; sourceTree = ""; }; + 0B8FBC7122954495002F733D /* NSRange+ConsentString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSRange+ConsentString.swift"; sourceTree = ""; }; + 0B8FBC75229558F3002F733D /* VendorIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VendorIdentifier.swift; sourceTree = ""; }; + 0BA327D2228EA54A00606697 /* ConsentStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentStringBuilder.swift; sourceTree = ""; }; + 0BA327D4228EC1A300606697 /* Purpose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Purpose.swift; sourceTree = ""; }; + 0BA327D8228EC84900606697 /* ConsentStringBuilderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsentStringBuilderTests.swift; sourceTree = ""; }; + 0BA327DA228ED14600606697 /* VendorEncodingType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VendorEncodingType.swift; sourceTree = ""; }; + 0BAECEE02292B9A000CC4DDB /* BinaryStringTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryStringTestSupport.swift; sourceTree = ""; }; F142B5202093A1C5001ABE60 /* Consent_String_SDK_Swift.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Consent_String_SDK_Swift.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F142B5232093A1C5001ABE60 /* Consent_String_SDK_Swift.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Consent_String_SDK_Swift.h; sourceTree = ""; }; F142B5242093A1C5001ABE60 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -93,6 +104,12 @@ F142B53D2093A207001ABE60 /* String.swift */, F142B5232093A1C5001ABE60 /* Consent_String_SDK_Swift.h */, F142B5242093A1C5001ABE60 /* Info.plist */, + 0BA327D2228EA54A00606697 /* ConsentStringBuilder.swift */, + 0B3E03CD22A0206C0078D316 /* ConsentStringBuilding.swift */, + 0BA327D4228EC1A300606697 /* Purpose.swift */, + 0B8FBC75229558F3002F733D /* VendorIdentifier.swift */, + 0B8FBC7122954495002F733D /* NSRange+ConsentString.swift */, + 0BA327DA228ED14600606697 /* VendorEncodingType.swift */, ); path = "Consent String SDK Swift"; sourceTree = ""; @@ -101,6 +118,8 @@ isa = PBXGroup; children = ( F142B52E2093A1C5001ABE60 /* Consent_String_SDK_SwiftTests.swift */, + 0BA327D8228EC84900606697 /* ConsentStringBuilderTests.swift */, + 0BAECEE02292B9A000CC4DDB /* BinaryStringTestSupport.swift */, F142B5302093A1C5001ABE60 /* Info.plist */, ); path = "Consent String SDK SwiftTests"; @@ -216,10 +235,16 @@ buildActionMask = 2147483647; files = ( F142B5432093A207001ABE60 /* ConsentStringProtocol.swift in Sources */, + 0BA327D5228EC1A300606697 /* Purpose.swift in Sources */, + 0B8FBC76229558F3002F733D /* VendorIdentifier.swift in Sources */, + 0BA327D3228EA54A00606697 /* ConsentStringBuilder.swift in Sources */, + 0BA327DB228ED14600606697 /* VendorEncodingType.swift in Sources */, + 0B8FBC7222954495002F733D /* NSRange+ConsentString.swift in Sources */, F142B5472093A207001ABE60 /* Data.swift in Sources */, F142B5412093A207001ABE60 /* ConsentStringError.swift in Sources */, F142B5452093A207001ABE60 /* String.swift in Sources */, F142B53F2093A207001ABE60 /* ConsentString.swift in Sources */, + 0B3E03CE22A0206C0078D316 /* ConsentStringBuilding.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -228,11 +253,8 @@ buildActionMask = 2147483647; files = ( F142B52F2093A1C5001ABE60 /* Consent_String_SDK_SwiftTests.swift in Sources */, - F142B5482093A207001ABE60 /* Data.swift in Sources */, - F142B5442093A207001ABE60 /* ConsentStringProtocol.swift in Sources */, - F142B5462093A207001ABE60 /* String.swift in Sources */, - F142B5422093A207001ABE60 /* ConsentStringError.swift in Sources */, - F142B5402093A207001ABE60 /* ConsentString.swift in Sources */, + 0BAECEE12292B9A000CC4DDB /* BinaryStringTestSupport.swift in Sources */, + 0BA327D9228EC84900606697 /* ConsentStringBuilderTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -299,7 +321,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.3; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -356,7 +378,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.3; + IPHONEOS_DEPLOYMENT_TARGET = 10.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/Consent String SDK Swift/ConsentString.swift b/Consent String SDK Swift/ConsentString.swift index c8825a9..952cd66 100644 --- a/Consent String SDK Swift/ConsentString.swift +++ b/Consent String SDK Swift/ConsentString.swift @@ -8,8 +8,12 @@ import Foundation -class ConsentString:ConsentStringProtocol { - +public class ConsentString: ConsentStringProtocol { + + static let bitFieldVendorStart: Int = 173 + static let rangeEntryOffset: Int = 186 + static let vendorIdentifierSize: Int = 16 + /** The current Consent String. Setting will allow replacement of the curr */ @@ -41,30 +45,26 @@ class ConsentString:ConsentStringProtocol { } consentData = dataValue } - - + public var cmpId: Int { - return Int(consentData.intValue(fromBit: 78, toBit: 89)) + return Int(consentData.intValue(for: .cmpIdentifier)) } - + public var consentScreen: Int { - return Int(consentData.intValue(fromBit: 102, toBit: 107)) + return Int(consentData.intValue(for: .consentScreen)) } public var consentLanguage: String { - var data = consentData.data(fromBit: 108, toBit: 119) + var data = consentData.data(for: .consentLanguage) data.insert(0, at: 0) let string = data.base64EncodedString() return String(string[string.index(string.startIndex, offsetBy: 2)...]) } - let purposesStart:Int64 = 132 - let maxPurposes:Int64 = 24 - public var purposesAllowed: [Int8] { var resultsArray = [Int8]() - for purposeId in 1...maxPurposes { - let purposeBit = purposesStart - 1 + Int64(purposeId) + for purposeId in 1...NSRange.purposes.length { + let purposeBit = Int64(NSRange.purposes.lowerBound - 1 + purposeId) let value = Int(consentData.intValue(fromBit: purposeBit, toBit: purposeBit)) if value > 0 { resultsArray.append(Int8(purposeId)) @@ -73,29 +73,32 @@ class ConsentString:ConsentStringProtocol { return resultsArray } - public func purposeAllowed(forPurposeId purposeId: Int8) -> Bool { - if purposeId > 24 || purposeId < 1 { + if purposeId > NSRange.purposes.length || purposeId < 1 { return false } - let purposeBit = purposesStart - 1 + Int64(purposeId) + let purposeBit = Int64(NSRange.purposes.lowerBound) - 1 + Int64(purposeId) let value = Int(consentData.intValue(fromBit: purposeBit, toBit: purposeBit)) if value > 0 { return true } return false } + + public var vendorListVersion: Int { + return Int(consentData.intValue(for: .vendorListVersion)) + } //Used to determine whether we need to check for a vendor ID at all if it's greater than this value - private var maxVendorId : Int { + public var maxVendorId : Int { get { - return Int(consentData.intValue(fromBit: 156, toBit: 171)) + return Int(consentData.intValue(for: .maxVendorIdentifier)) } } private var isBitField:Bool { get { - let value = consentData.intValue(fromBit: 172, toBit: 172) + let value = consentData.intValue(for: .encodingType) return value == 0 } } @@ -105,16 +108,14 @@ class ConsentString:ConsentStringProtocol { return !isBitField } } - - private let bitFieldVendorStart:Int64 = 173 - private let rangeDefaultConsent:Int64 = 173 - + + public func isVendorAllowed(vendorId: Int) -> Bool { if vendorId > maxVendorId { return false } if isBitField { - let vendorBitField = bitFieldVendorStart + Int64(vendorId) - 1 + let vendorBitField = Int64(ConsentString.bitFieldVendorStart + vendorId - 1) //not enough bits guard vendorBitField < consentData.count * 8 else { return false @@ -127,24 +128,25 @@ class ConsentString:ConsentStringProtocol { } } else { let consentDataMaxBit = consentData.count * 8 - 1 //1 byte, last bit is 7, for 2 bytes, last is 15 etc... - let defaultConsent = consentData.intValue(fromBit: rangeDefaultConsent, toBit: rangeDefaultConsent) - let numEntries = Int(consentData.intValue(fromBit: 174, toBit: 185)) - var rangeStart = Int64(186) + let defaultConsent = consentData.intValue(for: .defaultConsent) + let numEntries = Int(consentData.intValue(for: .numberOfEntries)) + let vendorIdentifierSize = Int64(ConsentString.vendorIdentifierSize) + var rangeStart = Int64(ConsentString.rangeEntryOffset) for _ in 0..) throws -> String { + let commonBinaryString = try commonConsentBinaryString(created: created, updated: updated, cmpId: cmpId, cmpVersion: cmpVersion, consentScreenId: consentScreenId, consentLanguage: consentLanguage, allowedPurposes: allowedPurposes, vendorListVersion: vendorListVersion, maxVendorId: maxVendorId) + + // we encode by both methods (bit field and ranges) and use whichever is smallest + let encodingUsingBitField = commonBinaryString + .appending(bitFieldBinaryString(allowedVendorIds: allowedVendorIds, maxVendorId: maxVendorId)) + .padRight(toNearestMultipleOf: 8) + .trimmedWebSafeBase64EncodedString() + + let encodingUsingRanges = commonBinaryString + .appending(rangesBinaryString(allowedVendorIds: allowedVendorIds, maxVendorId: maxVendorId, defaultConsent: defaultConsent)) + .padRight(toNearestMultipleOf: 8) + .trimmedWebSafeBase64EncodedString() + + if encodingUsingBitField.count < encodingUsingRanges.count { + return encodingUsingBitField + } else { + return encodingUsingRanges + } + } + + func commonConsentBinaryString(created: Date, updated: Date, cmpId: Int, cmpVersion: Int, consentScreenId: Int, consentLanguage: String, allowedPurposes: Purposes, vendorListVersion: Int, maxVendorId: VendorIdentifier) throws -> String { + var consentString = "" + consentString.append(encode(integer: version, toLength: NSRange.version.length)) + consentString.append(encode(date: created, toLength: NSRange.created.length)) + consentString.append(encode(date: updated, toLength: NSRange.updated.length)) + consentString.append(encode(integer: cmpId, toLength: NSRange.cmpIdentifier.length)) + consentString.append(encode(integer: cmpVersion, toLength: NSRange.cmpVersion.length)) + consentString.append(encode(integer: consentScreenId, toLength: NSRange.consentScreen.length)) + + guard consentLanguage.count == 2, let firstLanguageCharacter = consentLanguage.uppercased()[consentLanguage.index(consentLanguage.startIndex, offsetBy: 0)].asciiValue, firstLanguageCharacter >= asciiOffset, + let secondLanguageCharacter = consentLanguage.uppercased()[consentLanguage.index(consentLanguage.startIndex, offsetBy: 1)].asciiValue, secondLanguageCharacter >= asciiOffset else { + throw Error.invalidLanguageCode(consentLanguage) + } + + consentString.append(encode(integer: firstLanguageCharacter - asciiOffset, toLength: NSRange.consentLanguage.length / 2)) + consentString.append(encode(integer: secondLanguageCharacter - asciiOffset, toLength: NSRange.consentLanguage.length / 2)) + consentString.append(encode(integer: vendorListVersion, toLength: NSRange.vendorListVersion.length)) + consentString.append(encode(purposeBitFieldForPurposes: allowedPurposes)) + consentString.append(encode(integer: maxVendorId, toLength: NSRange.maxVendorIdentifier.length)) + return consentString + } + + func bitFieldBinaryString(allowedVendorIds: Set, maxVendorId: VendorIdentifier) -> String { + var consentString = "" + consentString.append(encode(integer: VendorEncodingType.bitField.rawValue, toLength: NSRange.encodingType.length)) + consentString.append(encode(vendorBitFieldForVendors: allowedVendorIds, maxVendorId: maxVendorId)) + return consentString + } + + func rangesBinaryString(allowedVendorIds: Set, maxVendorId: VendorIdentifier, defaultConsent: Bool) -> String { + var consentString = "" + consentString.append(encode(integer: VendorEncodingType.range.rawValue, toLength: NSRange.encodingType.length)) + consentString.append(encode(integer: defaultConsent ? 1 : 0, toLength: NSRange.defaultConsent.length)) + consentString.append(encode(vendorRanges: ranges(for: allowedVendorIds, in: Set(1...maxVendorId), defaultConsent: defaultConsent))) + return consentString + } + + func encode(integer: UInt8, toLength length: Int) -> String { + return String(integer, radix: 2).padLeft(toLength: length) + } + + func encode(integer: Int16, toLength length: Int) -> String { + return String(integer, radix: 2).padLeft(toLength: length) + } + + func encode(integer: Int, toLength length: Int) -> String { + return String(integer, radix: 2).padLeft(toLength: length) + } + + func encode(date: Date, toLength length: Int) -> String { + return encode(integer: Int(date.timeIntervalSince1970 * 10), toLength: length) + } + + public func encode(purposeBitFieldForPurposes purposes: Purposes) -> String { + return encode(integer: purposes.rawValue, toLength: NSRange.purposes.length) + } + + public func encode(vendorBitFieldForVendors vendors: Set, maxVendorId: VendorIdentifier) -> String { + return (1...maxVendorId).reduce("") { $0 + (vendors.contains($1) ? "1" : "0") } + } + + func encode(vendorRanges ranges: [ClosedRange]) -> String { + var string = "" + string.append(encode(integer: ranges.count, toLength: NSRange.numberOfEntries.length)) + for range in ranges { + if range.count == 1 { + // single entry + string.append(encode(integer: 0, toLength: 1)) + string.append(encode(integer: range.lowerBound, toLength: ConsentString.vendorIdentifierSize)) + } else { + // range entry + string.append(encode(integer: 1, toLength: 1)) + string.append(encode(integer: range.lowerBound, toLength: ConsentString.vendorIdentifierSize)) + string.append(encode(integer: range.upperBound, toLength: ConsentString.vendorIdentifierSize)) + } + } + return string + } + + func ranges(for allowedVendorIds: Set, in allVendorIds: Set, defaultConsent: Bool) -> [ClosedRange] { + let vendorsToEncode = defaultConsent ? allVendorIds.subtracting(allowedVendorIds).sorted() : allowedVendorIds.sorted() + + var ranges = [ClosedRange]() + var currentRangeStart: VendorIdentifier? + for vendorId in allVendorIds.sorted() { + if vendorsToEncode.contains(vendorId) { + if currentRangeStart == nil { + // start a new range + currentRangeStart = vendorId + } + } else if let rangeStart = currentRangeStart { + // close the range + ranges.append(rangeStart...vendorId-1) + currentRangeStart = nil + } + } + + // close any range open at the end + if let rangeStart = currentRangeStart, let last = vendorsToEncode.last { + ranges.append(rangeStart...last) + currentRangeStart = nil + } + return ranges + } + +} + +private extension String { + func padLeft(withCharacter character: String = "0", toLength length: Int) -> String { + let padCount = length - count + guard padCount > 0 else { return self } + return String(repeating: character, count: padCount) + self + } + + func padRight(withCharacter character: String = "0", toLength length: Int) -> String { + let padCount = length - count + guard padCount > 0 else { return self } + return self + String(repeating: character, count: padCount) + } + + func padRight(withCharacter character: String = "0", toNearestMultipleOf multiple: Int) -> String { + let (byteCount, bitRemainder) = count.quotientAndRemainder(dividingBy: multiple) + let totalBytes = byteCount + (bitRemainder > 0 ? 1 : 0) + return padRight(toLength: totalBytes * multiple) + } + + func split(by length: Int) -> [String] { + var startIndex = self.startIndex + var results = [Substring]() + + while startIndex < self.endIndex { + let endIndex = self.index(startIndex, offsetBy: length, limitedBy: self.endIndex) ?? self.endIndex + results.append(self[startIndex.. String { + let data = Data(bytes: split(by: 8).compactMap { UInt8($0, radix: 2) }) + return data.base64EncodedString() + .trimmingCharacters(in: ["="]) + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + } +} + +public extension ConsentString { + convenience init(created: Date = Date(), + updated: Date = Date(), + cmpId: Int, + cmpVersion: Int, + consentScreenId: Int, + consentLanguage: String, + allowedPurposes: Purposes, + vendorListVersion: Int, + maxVendorId: VendorIdentifier, + defaultConsent: Bool = false, + allowedVendorIds: Set) throws { + let builder = ConsentStringBuilder() + try self.init(consentString: try builder.build(created: created, + updated: updated, + cmpId: cmpId, + cmpVersion: cmpVersion, + consentScreenId: consentScreenId, + consentLanguage: consentLanguage, + allowedPurposes: allowedPurposes, + vendorListVersion: vendorListVersion, + maxVendorId: maxVendorId, + defaultConsent: defaultConsent, + allowedVendorIds: allowedVendorIds)) + } +} diff --git a/Consent String SDK Swift/ConsentStringBuilding.swift b/Consent String SDK Swift/ConsentStringBuilding.swift new file mode 100644 index 0000000..1f83cd4 --- /dev/null +++ b/Consent String SDK Swift/ConsentStringBuilding.swift @@ -0,0 +1,21 @@ +// +// ConsentStringBuilding.swift +// Consent String SDK Swift +// +// Created by Alexander Edge on 30/05/2019. +// Copyright © 2019 Guardian News & Media Ltd. All rights reserved. +// + +import Foundation + +public protocol ConsentStringBuilding { + func build(created: Date, updated: Date, cmpId: Int, cmpVersion: Int, consentScreenId: Int, consentLanguage: String, allowedPurposes: Purposes, vendorListVersion: Int, maxVendorId: VendorIdentifier, defaultConsent: Bool, allowedVendorIds: Set) throws -> String + func encode(purposeBitFieldForPurposes purposes: Purposes) -> String + func encode(vendorBitFieldForVendors vendors: Set, maxVendorId: VendorIdentifier) -> String +} + +public extension ConsentStringBuilding { + func build(cmpId: Int, cmpVersion: Int, consentScreenId: Int, consentLanguage: String, allowedPurposes: Purposes, vendorListVersion: Int, maxVendorId: VendorIdentifier, defaultConsent: Bool, allowedVendorIds: Set) throws -> String { + return try build(created: Date(), updated: Date(), cmpId: cmpId, cmpVersion: cmpVersion, consentScreenId: consentScreenId, consentLanguage: consentLanguage, allowedPurposes: allowedPurposes, vendorListVersion: vendorListVersion, maxVendorId: maxVendorId, defaultConsent: defaultConsent, allowedVendorIds: allowedVendorIds) + } +} diff --git a/Consent String SDK Swift/Data.swift b/Consent String SDK Swift/Data.swift index 7ddc252..e9c9e5f 100644 --- a/Consent String SDK Swift/Data.swift +++ b/Consent String SDK Swift/Data.swift @@ -117,6 +117,26 @@ extension Data { let value = UInt64(bigEndian: dataValue.withUnsafeBytes { $0.pointee }) return Int64(value) } + + /// Return data for a range from the lower bound up to, but not including, + /// the upper bound. E.g. a range of location 0 and length 5 will return + /// bits 0,1,2,3, and 4. + /// + /// - Parameter range: Range of bits to read + /// - Returns: Data representing the range of bits + func data(for range: NSRange) -> Data { + return data(fromBit: Int64(range.lowerBound), toBit: Int64(range.upperBound - 1)) + } + + /// Return an integer represented by a range of bits from the lower bound + /// up to, but not including, the upper bound. E.g. a range of location 0 + /// and length 5 will return bits 0,1,2,3, and 4. + /// + /// - Parameter range: Range of bits to read + /// - Returns: Integer representing the range of bits + func intValue(for range: NSRange) -> Int64 { + return intValue(fromBit: Int64(range.lowerBound), toBit: Int64(range.upperBound - 1)) + } } diff --git a/Consent String SDK Swift/NSRange+ConsentString.swift b/Consent String SDK Swift/NSRange+ConsentString.swift new file mode 100644 index 0000000..0b1846f --- /dev/null +++ b/Consent String SDK Swift/NSRange+ConsentString.swift @@ -0,0 +1,25 @@ +// +// NSRange+ConsentString.swift +// Consent String SDK Swift +// +// Created by Alexander Edge on 22/05/2019. +// Copyright © 2019 Guardian News & Media Ltd. All rights reserved. +// + +import Foundation + +extension NSRange { + static let version = NSRange(location: 0, length: 6) + static let created = NSRange(location: 6, length: 36) + static let updated = NSRange(location: 42, length: 36) + static let cmpIdentifier = NSRange(location: 78, length: 12) + static let cmpVersion = NSRange(location: 90, length: 12) + static let consentScreen = NSRange(location: 102, length: 6) + static let consentLanguage = NSRange(location: 108, length: 12) + static let vendorListVersion = NSRange(location: 120, length: 12) + static let purposes = NSRange(location: 132, length: 24) + static let maxVendorIdentifier = NSRange(location: 156, length: 16) + static let encodingType = NSRange(location: 172, length: 1) + static let defaultConsent = NSRange(location: 173, length: 1) + static let numberOfEntries = NSRange(location: 174, length: 12) +} diff --git a/Consent String SDK Swift/Purpose.swift b/Consent String SDK Swift/Purpose.swift new file mode 100644 index 0000000..f9c9ef1 --- /dev/null +++ b/Consent String SDK Swift/Purpose.swift @@ -0,0 +1,26 @@ +// +// Purpose.swift +// Consent String SDK Swift +// +// Created by Alexander Edge on 17/05/2019. +// Copyright © 2019 Guardian News & Media Ltd. All rights reserved. +// + +import Foundation + +/// Purposes are listed in the global Vendor List. Purpose #1 maps to the first (most significant) bit, purpose #24 maps to the last (least significant) bit. +public struct Purposes: OptionSet { + + public static let storageAndAccess = Purposes(rawValue: 1 << 23) + public static let personalization = Purposes(rawValue: 1 << 22) + public static let adSelection = Purposes(rawValue: 1 << 21) + public static let contentDelivery = Purposes(rawValue: 1 << 20) + public static let measurement = Purposes(rawValue: 1 << 19) + public static let all: Purposes = [.storageAndAccess, .personalization, .adSelection, .contentDelivery, .measurement] + + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } +} diff --git a/Consent String SDK Swift/VendorEncodingType.swift b/Consent String SDK Swift/VendorEncodingType.swift new file mode 100644 index 0000000..0428c5e --- /dev/null +++ b/Consent String SDK Swift/VendorEncodingType.swift @@ -0,0 +1,15 @@ +// +// EncodingType.swift +// Consent String SDK Swift +// +// Created by Alexander Edge on 17/05/2019. +// Copyright © 2019 Guardian News & Media Ltd. All rights reserved. +// + +import Foundation + +/// The consent encoding used. Consent string encoding logic should choose the encoding that results in the smaller output. +enum VendorEncodingType: Int { + case bitField + case range +} diff --git a/Consent String SDK Swift/VendorIdentifier.swift b/Consent String SDK Swift/VendorIdentifier.swift new file mode 100644 index 0000000..1620a4f --- /dev/null +++ b/Consent String SDK Swift/VendorIdentifier.swift @@ -0,0 +1,11 @@ +// +// VendorIdentifier.swift +// Consent String SDK Swift +// +// Created by Alexander Edge on 22/05/2019. +// Copyright © 2019 Guardian News & Media Ltd. All rights reserved. +// + +import Foundation + +public typealias VendorIdentifier = Int16 diff --git a/Consent String SDK SwiftTests/BinaryStringTestSupport.swift b/Consent String SDK SwiftTests/BinaryStringTestSupport.swift new file mode 100644 index 0000000..ad641c6 --- /dev/null +++ b/Consent String SDK SwiftTests/BinaryStringTestSupport.swift @@ -0,0 +1,58 @@ +// +// BinaryStringTestSupport.swift +// Consent String SDK SwiftTests +// +// Created by Alexander Edge on 20/05/2019. +// Copyright © 2019 Guardian News & Media Ltd. All rights reserved. +// + +import Foundation + +protocol BinaryStringTestSupport { + func binaryStringRepresenting(data:Data) -> String + func binary(string:String, isEqualToBinaryString string2:String) -> Bool +} + +extension BinaryStringTestSupport { + func binaryStringRepresenting(data:Data) -> String { + return data.reduce("") { (acc, byte) -> String in + let stringRep = String(byte, radix: 2) + let pad = 8 - stringRep.count + let padString = "".padding(toLength: pad, withPad: "0", startingAt: 0) + return acc + padString + stringRep + } + } + + func binary(string:String, isEqualToBinaryString string2:String) -> Bool { + if abs(string.count - string2.count) > 7 { + return false + } + var index = 0 + var max = string.count + if string.count > string2.count { + max = string2.count + } + while index < max { + if string[string.index(string.startIndex, offsetBy: index)] != string2[string2.index(string2.startIndex, offsetBy: index)] { + return false + } + index += 1 + } + if string.count > string2.count { + while index < string.count { + if string[string.index(string.startIndex, offsetBy: index)] != "0" { + return false + } + index += 1 + } + } else { + while index < string2.count { + if string2[string2.index(string2.startIndex, offsetBy: index)] != "0" { + return false + } + index += 1 + } + } + return true + } +} diff --git a/Consent String SDK SwiftTests/ConsentStringBuilderTests.swift b/Consent String SDK SwiftTests/ConsentStringBuilderTests.swift new file mode 100644 index 0000000..669b984 --- /dev/null +++ b/Consent String SDK SwiftTests/ConsentStringBuilderTests.swift @@ -0,0 +1,120 @@ +// +// ConsentStringBuilderTests.swift +// Consent String SDK SwiftTests +// +// Created by Alexander Edge on 17/05/2019. +// Copyright © 2019 Guardian News & Media Ltd. All rights reserved. +// + +import XCTest +@testable import Consent_String_SDK_Swift + +class ConsentStringBuilderTests: XCTestCase, BinaryStringTestSupport { + + var builder: ConsentStringBuilder! + + override func setUp() { + super.setUp() + builder = ConsentStringBuilder() + } + + override func tearDown() { + builder = nil + super.tearDown() + } + + func testEncodingInt() { + XCTAssertEqual(builder.encode(integer: 1, toLength: NSRange.version.length), "000001") + } + + func testEncodingDate() { + let date = Date(timeIntervalSince1970: 1510082155.4) + XCTAssertEqual(builder.encode(date: date, toLength: NSRange.updated.length), "001110000100000101000100000000110010") + } + + func testEncodingBitfield() { + XCTAssertEqual(builder.encode(vendorBitFieldForVendors: [2,4,6,8,10,12,14,16,18,20], maxVendorId: 20), "01010101010101010101") + } + + func testEncodingNoVendorRanges() { + XCTAssertEqual(builder.encode(vendorRanges: []), "000000000000") + } + + func testEncodingSingleVendorIdRange() { + XCTAssertEqual(builder.encode(vendorRanges: [9...9]), "00000000000100000000000001001") + } + + func testEncodingMultipleVendorIdRange() { + XCTAssertEqual(builder.encode(vendorRanges: [1...3]), "000000000001100000000000000010000000000000011") + } + + func testEncodingMixedVendorRanges() { + XCTAssertEqual(builder.encode(vendorRanges: [1...3, 9...9]), "00000000001010000000000000001000000000000001100000000000001001") + } + + func testRangesWithDefaultConsent() { + let allVendorIds = Set(0...10) + let allowedVendorIds = Set(1...3) + XCTAssertEqual(builder.ranges(for: allowedVendorIds, in: allVendorIds, defaultConsent: true), [0...0, 4...10]) + } + + func testRangesWithoutDefaultConsent() { + let allVendorIds = Set(0...10) + let allowedVendorIds = Set(1...3) + XCTAssertEqual(builder.ranges(for: allowedVendorIds, in: allVendorIds, defaultConsent: false), [1...3]) + } + + func testUsesRangesOverBitField() throws { + XCTAssertEqual(try builder.build(created: Date(timeIntervalSince1970: 1510082155.4), updated: Date(timeIntervalSince1970: 1510082155.4), cmpId: 7, cmpVersion: 1, consentScreenId: 3, consentLanguage: "EN", allowedPurposes: [.storageAndAccess, .personalization, .adSelection], vendorListVersion: 8, maxVendorId: 2011, defaultConsent: true, allowedVendorIds: Set(1...2011).subtracting([9])), "BOEFEAyOEFEAyAHABDENAI4AAAB9vABAASA") + } + + func testUsesBitFieldOverRanges() throws { + let vendorIds = ClosedRange(1...234).compactMap { $0.isMultiple(of: 2) ? nil : $0 } + XCTAssertEqual(try builder.build(created: Date(timeIntervalSince1970: 1510082155.4), updated: Date(timeIntervalSince1970: 1510082155.4), cmpId: 7, cmpVersion: 1, consentScreenId: 3, consentLanguage: "EN", allowedPurposes: [.storageAndAccess, .personalization, .adSelection], vendorListVersion: 8, maxVendorId: 2011, defaultConsent: true, allowedVendorIds: Set(vendorIds)), "BOEFEAyOEFEAyAHABDENAI4AAAB9tVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + } + + func testFailsWithInvalidLanguageCode() { + XCTAssertThrowsError(try builder.build(cmpId: 0, cmpVersion: 0, consentScreenId: 0, consentLanguage: "z", allowedPurposes: [], vendorListVersion: 0, maxVendorId: 1, defaultConsent: false, allowedVendorIds: [1])) { error in + guard case ConsentStringBuilder.Error.invalidLanguageCode("z") = error else { + XCTFail("unexpected error: \(error)") + return + } + } + + XCTAssertThrowsError(try builder.build(cmpId: 0, cmpVersion: 0, consentScreenId: 0, consentLanguage: "45", allowedPurposes: [], vendorListVersion: 0, maxVendorId: 1, defaultConsent: false, allowedVendorIds: [1])) { error in + guard case ConsentStringBuilder.Error.invalidLanguageCode("45") = error else { + XCTFail("unexpected error: \(error)") + return + } + } + } + + func testUsesWebSafeBase64EncodedString() { + XCTAssertEqual(try builder.build(created: Date(timeIntervalSince1970: 1510082155.4), updated: Date(timeIntervalSince1970: 1510082155.4), cmpId: 0, cmpVersion: 0, consentScreenId: 0, consentLanguage: "EN", allowedPurposes: .all, vendorListVersion: 1, maxVendorId: 100, defaultConsent: false, allowedVendorIds: [1,2,51,99,100]), "BOEFEAyOEFEAyAAAAAENAB-AAAAGSADgACAAQAM4AxgDIA") + } + + func testConsentStringInitializer() throws { + let created = Date() + let updated = created.addingTimeInterval(50) + let cmpId = 1 + let cmpVersion = 2 + let consentScreenId = 3 + let consentLanguage = "FR" + let allowedPurposes: Purposes = [.adSelection] + let vendorListVersion = 4 + let maxVendorId: VendorIdentifier = 5 + let allowedVendorIds = Set([2, 4]) + let consentString = try ConsentString(created: created, updated: updated, cmpId: cmpId, cmpVersion: cmpVersion, consentScreenId: consentScreenId, consentLanguage: consentLanguage, allowedPurposes: allowedPurposes, vendorListVersion: vendorListVersion, maxVendorId: maxVendorId, allowedVendorIds: allowedVendorIds) + XCTAssertEqual(consentString.cmpId, cmpId) + XCTAssertEqual(consentString.consentLanguage, consentLanguage) + XCTAssertEqual(consentString.consentScreen, consentScreenId) + XCTAssertEqual(consentString.maxVendorId, Int(maxVendorId)) + XCTAssertEqual(consentString.purposesAllowed, [3]) + XCTAssertEqual(consentString.vendorListVersion, vendorListVersion) + XCTAssertFalse(consentString.isVendorAllowed(vendorId: 1)) + XCTAssertTrue(consentString.isVendorAllowed(vendorId: 2)) + XCTAssertFalse(consentString.isVendorAllowed(vendorId: 3)) + XCTAssertTrue(consentString.isVendorAllowed(vendorId: 4)) + XCTAssertFalse(consentString.isVendorAllowed(vendorId: 5)) + } +} diff --git a/Consent String SDK SwiftTests/Consent_String_SDK_SwiftTests.swift b/Consent String SDK SwiftTests/Consent_String_SDK_SwiftTests.swift index d621d3e..eb9122e 100644 --- a/Consent String SDK SwiftTests/Consent_String_SDK_SwiftTests.swift +++ b/Consent String SDK SwiftTests/Consent_String_SDK_SwiftTests.swift @@ -9,7 +9,7 @@ import XCTest @testable import Consent_String_SDK_Swift -class Consent_String_SDK_SwiftTests: XCTestCase { +class Consent_String_SDK_SwiftTests: XCTestCase, BinaryStringTestSupport { let base64 = ["BOMexSfOMexSfAAABAENAA////ABSABgACAAIA", "BOMexSfOMexSfAAABAENAA////ABSABgACBAAA", @@ -39,8 +39,6 @@ class Consent_String_SDK_SwiftTests: XCTestCase { super.tearDown() } - - func testPerformance() { self.measure { // Put the code you want to measure the time of here. @@ -326,47 +324,5 @@ class Consent_String_SDK_SwiftTests: XCTestCase { XCTAssert(consentString.consentScreen == values[index], "Actual screen: \(consentString.consentScreen)") } } - - func binaryStringRepresenting(data:Data) -> String { - return data.reduce("") { (acc, byte) -> String in - let stringRep = String(byte, radix: 2) - let pad = 8 - stringRep.count - let padString = "".padding(toLength: pad, withPad: "0", startingAt: 0) - return acc + padString + stringRep - } - } - - func binary(string:String, isEqualToBinaryString string2:String) -> Bool { - if abs(string.count - string2.count) > 7 { - return false - } - var index = 0 - var max = string.count - if string.count > string2.count { - max = string2.count - } - while index < max { - if string[string.index(string.startIndex, offsetBy: index)] != string2[string2.index(string2.startIndex, offsetBy: index)] { - return false - } - index += 1 - } - if string.count > string2.count { - while index < string.count { - if string[string.index(string.startIndex, offsetBy: index)] != "0" { - return false - } - index += 1 - } - } else { - while index < string2.count { - if string2[string2.index(string2.startIndex, offsetBy: index)] != "0" { - return false - } - index += 1 - } - } - return true - } - + }