diff --git a/android/src/main/java/com/sourcepoint/reactnativecmp/RNSourcepointCmpModule.kt b/android/src/main/java/com/sourcepoint/reactnativecmp/RNSourcepointCmpModule.kt index d0dab21..237cf0d 100644 --- a/android/src/main/java/com/sourcepoint/reactnativecmp/RNSourcepointCmpModule.kt +++ b/android/src/main/java/com/sourcepoint/reactnativecmp/RNSourcepointCmpModule.kt @@ -21,6 +21,7 @@ import com.sourcepoint.cmplibrary.model.ConsentAction import com.sourcepoint.cmplibrary.model.exposed.SPConsents import com.sourcepoint.cmplibrary.util.clearAllData import com.sourcepoint.cmplibrary.util.userConsents +import com.sourcepoint.reactnativecmp.consents.RNSPUserData import org.json.JSONObject class RNSourcepointCmpModule internal constructor(context: ReactApplicationContext) : @@ -137,41 +138,5 @@ class RNSourcepointCmpModule internal constructor(context: ReactApplicationConte sendEvent(SDKEvent.onSPUIReady) } - // TODO: standardise SPConsents interface on the JS side - private fun userConsentsToWriteableMap(consents: SPConsents) = createMap().apply { - consents.usNat?.let { usnat -> - putMap("usnat", createMap().apply { - putMap("consents", createMap().apply { - putString("uuid", usnat.consent.uuid) - putArray("consentSections", createArray().apply { - usnat.consent.consentStrings?.map { cString -> - pushMap(createMap().apply { - cString.sectionId?.let { id -> putInt("id", id) } - putString("name", cString.sectionName) - putString("string", cString.consentString) - }) - } - }) - putMap("consentStatus", createMap().apply { - usnat.consent.statuses.consentedToAll?.let { putBoolean("consentedAll", it) } - }) - putString("legacyUSPString", usnat.consent.gppData["IABUSPrivacy_String"].toString()) - }) - putBoolean("applies", usnat.consent.applies) - }) - } - - consents.gdpr?.let { gdpr -> - putMap("gdpr", createMap().apply { - putMap("consents", createMap().apply { - putString("uuid", gdpr.consent.uuid) - putString("euconsent", gdpr.consent.euconsent) - putMap("consentStatus", createMap().apply { - gdpr.consent.consentStatus?.consentedAll?.let { putBoolean("consentedAll", it) } - }) - }) - putBoolean("applies", gdpr.consent.applies) - }) - } - } + private fun userConsentsToWriteableMap(consents: SPConsents) = RNSPUserData(consents).toRN() } diff --git a/android/src/main/java/com/sourcepoint/reactnativecmp/arguments/Arguments.kt b/android/src/main/java/com/sourcepoint/reactnativecmp/arguments/Arguments.kt new file mode 100644 index 0000000..06c971a --- /dev/null +++ b/android/src/main/java/com/sourcepoint/reactnativecmp/arguments/Arguments.kt @@ -0,0 +1,160 @@ +package com.sourcepoint.reactnativecmp.arguments + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.double +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.float +import kotlinx.serialization.json.floatOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull + +fun WritableArray.pushAny(value: Any?) { + when (value) { + is Int -> pushInt(value) + is Long -> pushInt(value.toInt()) + is Double -> pushDouble(value) + is Float -> pushDouble(value.toDouble()) + is Boolean -> pushBoolean(value) + is String -> pushString(value) + is JsonElement -> pushJsonElement(value) + is Map<*, *> -> pushMap(value) + is Iterable<*> -> pushArray(value) + else -> {} + } +} + +fun WritableArray.pushJsonElement(value: JsonElement) { + try { + pushJsonPrimitive(value.jsonPrimitive) + } catch (_: IllegalArgumentException) { + try { + pushJsonObject(value.jsonObject) + } catch (_: IllegalArgumentException) { + try { + pushJsonArray(value.jsonArray) + } catch (_: IllegalArgumentException) { + + } + } + } +} + +fun WritableArray.pushJsonPrimitive(value: JsonPrimitive) { + when { + value.isString -> pushString(value.content) + (value.booleanOrNull != null) -> pushBoolean(value.boolean) + (value.longOrNull != null) -> pushInt(value.long.toInt()) + (value.intOrNull != null) -> pushInt(value.int) + (value.doubleOrNull != null) -> pushDouble(value.double) + (value.floatOrNull != null) -> pushDouble(value.float.toDouble()) + else -> {} + } +} + +fun WritableArray.pushJsonArray(value: JsonArray) { + pushArray(Arguments.createArray().apply { + value.forEach { this.pushJsonElement(it) } + }) +} + +fun WritableArray.pushJsonObject(value: JsonObject) { + pushMap(Arguments.createMap().apply { + value.keys.forEach { key -> value[key]?.let { putJsonElement(key, it) } } + }) +} + +fun WritableArray.pushMap(value: Map<*,*>) { + pushMap(Arguments.createMap().apply { + value.keys.forEach { key -> + (key as? String)?.let { putAny(it, value[key]) } + } + }) +} + +fun WritableArray.pushArray(value: Iterable<*>) { + pushArray(Arguments.createArray().apply { + value.forEach { pushAny(it) } + }) +} + +fun WritableMap.putAny(name: String, value: Any?) { + when (value) { + is Int -> putInt(name, value) + is Long -> putInt(name, value.toInt()) + is Double -> putDouble(name, value) + is Float -> putDouble(name, value.toDouble()) + is Boolean -> putBoolean(name, value) + is String -> putString(name, value) + is JsonElement -> putJsonElement(name, value) + is Map<*, *> -> putMap(name, value) + is Iterable<*> -> putArray(name, value) + else -> {} + } +} + +fun WritableMap.putJsonElement(name: String, value: JsonElement) { + try { + putJsonPrimitive(name, value.jsonPrimitive) + } catch (_: IllegalArgumentException) { + try { + putJsonObject(name, value.jsonObject) + } catch (_: IllegalArgumentException) { + try { + putJsonArray(name, value.jsonArray) + } catch (_: IllegalArgumentException) { + + } + } + } +} + +fun WritableMap.putJsonPrimitive(name: String, value: JsonPrimitive) { + when { + value.isString -> putString(name, value.content) + (value.booleanOrNull != null) -> putBoolean(name, value.boolean) + (value.longOrNull != null) -> putInt(name, value.long.toInt()) + (value.intOrNull != null) -> putInt(name, value.int) + (value.doubleOrNull != null) -> putDouble(name, value.double) + (value.floatOrNull != null) -> putDouble(name, value.float.toDouble()) + else -> {} + } +} + +fun WritableMap.putJsonArray(name: String, value: JsonArray) { + putArray(name, Arguments.createArray().apply { + value.forEach { this.pushJsonElement(it) } + }) +} + +fun WritableMap.putJsonObject(name: String, value: JsonObject) { + putMap(name, Arguments.createMap().apply { + value.keys.forEach { key -> value[key]?.let { putJsonElement(key, it) } } + }) +} + +fun WritableMap.putMap(name: String, value: Map<*, *>) { + putMap(name, Arguments.createMap().apply { + value.keys.forEach { key -> + (key as? String)?.let { putAny(it, value[key]) } + } + }) +} + +fun WritableMap.putArray(name: String, value: Iterable<*>) { + putArray(name, Arguments.createArray().apply { + value.forEach { this.pushAny(it) } + }) +} diff --git a/android/src/main/java/com/sourcepoint/reactnativecmp/consents/RNSPGDPRConsent.kt b/android/src/main/java/com/sourcepoint/reactnativecmp/consents/RNSPGDPRConsent.kt new file mode 100644 index 0000000..a859554 --- /dev/null +++ b/android/src/main/java/com/sourcepoint/reactnativecmp/consents/RNSPGDPRConsent.kt @@ -0,0 +1,67 @@ +package com.sourcepoint.reactnativecmp.consents + +import com.facebook.react.bridge.Arguments.createMap +import com.facebook.react.bridge.ReadableMap +import com.sourcepoint.cmplibrary.data.network.model.optimized.ConsentStatus +import com.sourcepoint.cmplibrary.model.exposed.GDPRConsent +import com.sourcepoint.cmplibrary.model.exposed.GDPRPurposeGrants +import com.sourcepoint.reactnativecmp.arguments.putAny + +data class RNSPGDPRConsent ( + override val uuid: String?, + override val createdDate: String?, + override val expirationDate: String?, + val euconsent: String?, + val vendorGrants: Map, + val statuses: Statuses, + val tcfData: Map +) : RNSPConsent { + data class Statuses(val consentedAll: Boolean?, val consentedAny: Boolean?, val rejectedAny: Boolean?): RNMappable { + constructor(status: ConsentStatus?): this( + consentedAll = status?.consentedAll, + consentedAny = status?.consentedToAny, // TODO: verify if this should be `consentedAny` instead + rejectedAny = status?.rejectedAny + ) + + override fun toRN(): ReadableMap = createMap().apply { + consentedAll?.let { putBoolean("consentedAll", it) } + consentedAny?.let { putBoolean("consentedAny", it) } + rejectedAny?.let { putBoolean("rejectedAny", it) } + } + } + + constructor(gdpr: GDPRConsent) : this( + uuid = gdpr.uuid, + createdDate = null, + expirationDate = null, + euconsent = gdpr.euconsent, + vendorGrants = gdpr.grants, + statuses = Statuses(status = gdpr.consentStatus), + tcfData = gdpr.tcData + ) + + override fun toRN(): ReadableMap = createMap().apply { + putString("uuid", uuid) + putString("createdDate", createdDate) + putString("expirationDate", expirationDate) + putString("euconsent", euconsent) + putMap("vendorGrants", vendorGrants.toRN()) + putMap("statuses", statuses.toRN()) + putAny("tcfData", tcfData) + } +} + +fun Map.toRN(): ReadableMap = createMap().apply { + keys.forEach { vendorId -> + this@toRN[vendorId]?.let { putMap(vendorId, it.toRN()) } + } +} + +fun GDPRPurposeGrants.toRN(): ReadableMap = createMap().apply { + putBoolean("granted", granted) + putMap("purposes", createMap().apply { + purposeGrants.keys.forEach { purposeId -> + purposeGrants[purposeId]?.let { putBoolean(purposeId, it) } + } + }) +} diff --git a/android/src/main/java/com/sourcepoint/reactnativecmp/consents/RNSPUSNatConsent.kt b/android/src/main/java/com/sourcepoint/reactnativecmp/consents/RNSPUSNatConsent.kt new file mode 100644 index 0000000..5ad8432 --- /dev/null +++ b/android/src/main/java/com/sourcepoint/reactnativecmp/consents/RNSPUSNatConsent.kt @@ -0,0 +1,101 @@ +package com.sourcepoint.reactnativecmp.consents + +import com.facebook.react.bridge.Arguments.createArray +import com.facebook.react.bridge.Arguments.createMap +import com.facebook.react.bridge.ReadableMap +import com.sourcepoint.cmplibrary.data.network.model.optimized.USNatConsentData.ConsentString +import com.sourcepoint.cmplibrary.model.exposed.Consentable +import com.sourcepoint.cmplibrary.model.exposed.UsNatConsent +import com.sourcepoint.cmplibrary.model.exposed.UsNatStatuses +import com.sourcepoint.reactnativecmp.arguments.putAny + +typealias SPConsentable = Consentable + +data class RNSPUSNatConsent( + override val uuid: String?, + override val createdDate: String?, + override val expirationDate: String?, + val consentSections: List, + val statuses: Statuses, + val gppData: Map, + val vendors: List, + val categories: List +): RNSPConsent { + data class ConsentSection(val id: Int?, val name: String?, val consentString: String?) { + constructor(section: ConsentString): this( + id = section.sectionId, + name = section.sectionName, + consentString = section.consentString + ) + + fun toRN(): ReadableMap = createMap().apply { + id?.let { putInt("id", it) } + putString("name", name) + putString("consentString", consentString) + } + } + + data class Statuses( + val consentedAll: Boolean?, + val consentedAny: Boolean?, + val rejectedAny: Boolean?, + val sellStatus: Boolean?, + val shareStatus: Boolean?, + val sensitiveDataStatus: Boolean?, + val gpcStatus: Boolean?, + ): RNMappable { + constructor(status: UsNatStatuses) : this( + consentedAll = status.consentedToAll, + consentedAny = status.consentedToAny, + rejectedAny = status.rejectedAny, + sellStatus = status.sellStatus, + shareStatus = status.shareStatus, + sensitiveDataStatus = status.sensitiveDataStatus, + gpcStatus = status.gpcStatus, + ) + + override fun toRN(): ReadableMap = createMap().apply { + consentedAll?.let { putBoolean("consentedAll", it) } + consentedAny?.let { putBoolean("consentedAny", it) } + rejectedAny?.let { putBoolean("rejectedAny", it) } + sellStatus?.let { putBoolean("sellStatus", it) } + shareStatus?.let { putBoolean("shareStatus", it) } + sensitiveDataStatus?.let { putBoolean("sensitiveDataStatus", it) } + gpcStatus?.let { putBoolean("gpcStatus", it) } + } + } + + data class Consentable(override val id: String, override val consented: Boolean): SPConsentable, RNMappable { + constructor(spConsentable: SPConsentable): this( + id = spConsentable.id, + consented = spConsentable.consented + ) + + override fun toRN(): ReadableMap = createMap().apply { + putString("id", id) + putBoolean("consented", consented) + } + } + + constructor(usnat: UsNatConsent) : this( + uuid = usnat.uuid, + createdDate = usnat.dateCreated, + expirationDate = null, + consentSections = usnat.consentStrings?.map { ConsentSection(section = it) } ?: emptyList(), + statuses = Statuses(status = usnat.statuses), + gppData = usnat.gppData, + vendors = usnat.vendors?.map { Consentable(it) } ?: emptyList(), + categories = usnat.categories?.map { Consentable(it) } ?: emptyList(), + ) + + override fun toRN(): ReadableMap = createMap().apply { + putString("uuid", uuid) + putString("createdDate", createdDate) + putString("expirationDate", expirationDate) + putArray("consentSections", createArray().apply { consentSections.forEach { pushMap(it.toRN()) } }) + putMap("statuses", statuses.toRN()) + putAny("gppData", gppData) + putArray("vendors", createArray().apply { vendors.forEach { pushMap(it.toRN()) } }) + putArray("categories", createArray().apply { categories.forEach { pushMap(it.toRN()) } }) + } +} diff --git a/android/src/main/java/com/sourcepoint/reactnativecmp/consents/RNSPUserData.kt b/android/src/main/java/com/sourcepoint/reactnativecmp/consents/RNSPUserData.kt new file mode 100644 index 0000000..a0d5f0b --- /dev/null +++ b/android/src/main/java/com/sourcepoint/reactnativecmp/consents/RNSPUserData.kt @@ -0,0 +1,46 @@ +package com.sourcepoint.reactnativecmp.consents + +import com.facebook.react.bridge.Arguments.createMap +import com.facebook.react.bridge.ReadableMap +import com.sourcepoint.cmplibrary.model.exposed.SPConsents + +interface RNMappable { + fun toRN(): ReadableMap +} + +interface RNSPConsent: RNMappable { + val uuid: String? + val expirationDate: String? + val createdDate: String? +} + +data class RNSPCampaignData( + val applies: Boolean, + val consents: Consent +): RNMappable { + override fun toRN(): ReadableMap = createMap().apply { + putBoolean("applies", applies) + putMap("consents", consents.toRN()) + } +} + +data class RNSPUserData( + val gdpr: RNSPCampaignData?, + val usnat: RNSPCampaignData? +): RNMappable { + constructor(spData: SPConsents): this( + gdpr = spData.gdpr?.let { RNSPCampaignData( + applies = it.consent.applies, + consents = RNSPGDPRConsent(gdpr = it.consent) + )}, + usnat = spData.usNat?.let { RNSPCampaignData( + applies = it.consent.applies, + consents = RNSPUSNatConsent(usnat = it.consent) + )} + ) + + override fun toRN(): ReadableMap = createMap().apply { + gdpr?.let { putMap("gdpr", it.toRN()) } + usnat?.let { putMap("usnat", it.toRN()) } + } +} diff --git a/example/android/build.gradle b/example/android/build.gradle index c635c66..bb1c907 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -5,7 +5,7 @@ buildscript { compileSdkVersion = 34 targetSdkVersion = 34 ndkVersion = "26.1.10909125" - kotlinVersion = "1.9.22" + kotlinVersion = "1.9.24" } repositories { google() diff --git a/example/src/App.tsx b/example/src/App.tsx index 70423cf..45bf7e0 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -6,7 +6,7 @@ import { SPConsentManager, SPCampaignEnvironment, } from '@sourcepoint/react-native-cmp'; -import type { SPCampaigns } from '@sourcepoint/react-native-cmp'; +import type { SPCampaigns, SPUserData } from '@sourcepoint/react-native-cmp'; import type { LaunchArgs } from './LaunchArgs'; import UserDataView from './UserDataView'; @@ -36,7 +36,7 @@ const config = { }; export default function App() { - const [userData, setUserData] = useState>({}); + const [userData, setUserData] = useState({}); const [sdkStatus, setSDKStatus] = useState(SDKStatus.NotStarted); const consentManager = useRef(); diff --git a/example/src/UserDataView.tsx b/example/src/UserDataView.tsx index 7264c89..f106581 100644 --- a/example/src/UserDataView.tsx +++ b/example/src/UserDataView.tsx @@ -2,13 +2,14 @@ import * as React from 'react'; import { ScrollView, View, Text, StyleSheet } from 'react-native'; import { TestableText } from './TestableText'; +import type { SPUserData } from '@sourcepoint/react-native-cmp'; export default ({ data }: UserDataViewProps) => ( Local User Data {data?.gdpr?.consents?.uuid} - {data?.gdpr?.consents?.consentStatus?.consentedAll + {data?.gdpr?.consents?.statuses?.consentedAll ? 'consentedAll' : 'rejectedAll'} @@ -16,37 +17,20 @@ export default ({ data }: UserDataViewProps) => ( {data?.usnat?.consents?.uuid} - {/* TODO: cleanup below once consent classes are standardised */} - {data?.usnat?.consents?.consentStatus?.consentedToAll || - data?.usnat?.consents?.consentStatus?.consentedAll + {data?.usnat?.consents?.statuses?.consentedAll ? 'consentedAll' : 'rejectedAll'} - {JSON.stringify( - { - gdpr: { - uuid: data?.gdpr?.consents?.uuid, - consentStatus: data?.gdpr?.consents?.consentStatus, - applies: data?.gdpr?.applies, - }, - usnat: { - uuid: data?.usnat?.consents?.uuid, - consentStatus: data?.usnat?.consents?.consentStatus, - applies: data?.usnat?.applies, - }, - }, - null, - 2 - )} + {JSON.stringify(data, null, 2)} ); type UserDataViewProps = { - data: Record; + data: SPUserData; }; const styles = StyleSheet.create({ diff --git a/ios/RNSPUserData/RNGDPRConsent.swift b/ios/RNSPUserData/RNGDPRConsent.swift new file mode 100644 index 0000000..2a230e6 --- /dev/null +++ b/ios/RNSPUserData/RNGDPRConsent.swift @@ -0,0 +1,54 @@ +// +// RNGDPRConsent.swift +// sourcepoint-react-native-cmp +// +// Created by Andre Herculano on 22/5/24. +// + +import Foundation +import ConsentViewController + +// encapsulates GDPR consent +struct RNSPGDPRConsent: RNSPConsent { + struct Statuses: Encodable { + let consentedAll, consentedAny, rejectedAny: Bool? + } + + // the uuid given to a consent profile (user) it can be nil if the profile is not yet created + let uuid: String? + + // the TCF consent string representing this user's consent. this can be nil if the user doesn't yet have consent. + let euconsent: String? + let expirationDate, createdDate: SPDate? + let vendorGrants: SPGDPRVendorGrants + let statuses: Statuses + + // this data is stored at the "root level" of the `UserDefaults` as specified by + // https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md#in-app-details + let tcfData: SPJson? +} + +extension RNSPGDPRConsent.Statuses { + init(from status: ConsentStatus) { + consentedAll = status.consentedAll + consentedAny = status.consentedToAny + rejectedAny = status.rejectedAny + } +} + +extension RNSPGDPRConsent { + init?(from gdpr: SPGDPRConsent?) { + guard let gdpr = gdpr else { return nil } + + self.init( + uuid: gdpr.uuid, + euconsent: gdpr.euconsent, + expirationDate: nil, + createdDate: gdpr.dateCreated, + vendorGrants: gdpr.vendorGrants, + statuses: Statuses(from: gdpr.consentStatus), + tcfData: gdpr.tcfData + ) + } +} + diff --git a/ios/RNSPUserData/RNSPUSNatConsent.swift b/ios/RNSPUserData/RNSPUSNatConsent.swift new file mode 100644 index 0000000..7cf2919 --- /dev/null +++ b/ios/RNSPUserData/RNSPUSNatConsent.swift @@ -0,0 +1,90 @@ +// +// RNSPUSNatConsent.swift +// sourcepoint-react-native-cmp +// +// Created by Andre Herculano on 22/5/24. +// + +import Foundation +import ConsentViewController + +// Encapsulates USNat (sometimes called MSPS) consent. +// This is an adapter of `SPUSNatConsent` +struct RNSPUSNatConsent: RNSPConsent { + struct Statuses: Encodable { + let consentedAll, consentedAny, rejectedAny, + sellStatus, shareStatus, + sensitiveDataStatus, gpcStatus: Bool? + } + + struct Consentable: Encodable { + let id: String + let consented: Bool + } + + struct ConsentSection: Encodable { + let id: Int + let name, consentString: String + } + + // the uuid given to a consent profile (user) it can be nil if the profile is not yet created + let uuid: String? + + let expirationDate, createdDate: SPDate? + + // a list of consent sections (id, name and consent string) applicable to that user + let consentSections: [ConsentSection] + + let statuses: Statuses? + + // this data is stored at the "root level" of the `UserDefaults` as specified by + // https://github.com/InteractiveAdvertisingBureau/Global-Privacy-Platform/blob/main/Core/CMP%20API%20Specification.md#in-app-details + let gppData: SPJson? + + // a list of vendors/categories identified by id, indicating if they are consented or rejected based on the boolean `consented` + let vendors, categories: [Consentable] +} + +extension RNSPUSNatConsent.Statuses { + init(from status: SPUSNatConsent.Statuses) { + consentedAll = status.consentedToAll + consentedAny = status.consentedToAny + rejectedAny = status.rejectedAny + sellStatus = status.sellStatus + shareStatus = status.shareStatus + sensitiveDataStatus = status.sensitiveDataStatus + gpcStatus = status.gpcStatus + } +} + +extension RNSPUSNatConsent.Consentable { + init(from consentable: SPConsentable) { + id = consentable.id + consented = consentable.consented + } +} + +extension RNSPUSNatConsent.ConsentSection { + init(from section: SPUSNatConsent.ConsentString) { + id = section.sectionId + name = section.sectionName + consentString = section.consentString + } +} + +extension RNSPUSNatConsent { + init?(from usnat: SPUSNatConsent?) { + guard let usnat = usnat else { return nil } + + self.init( + uuid: usnat.uuid, + expirationDate: nil, + createdDate: nil, + consentSections: usnat.consentStrings.map { ConsentSection(from: $0) }, + statuses: Statuses(from: usnat.statuses), + gppData: usnat.GPPData, + vendors: usnat.vendors.map { Consentable(from: $0) }, + categories: usnat.categories.map { Consentable(from: $0) } + ) + } +} diff --git a/ios/RNSPUserData/RNSPUserData.swift b/ios/RNSPUserData/RNSPUserData.swift new file mode 100644 index 0000000..635ec7e --- /dev/null +++ b/ios/RNSPUserData/RNSPUserData.swift @@ -0,0 +1,67 @@ +// +// RNSPUserData.swift +// sourcepoint-react-native-cmp +// +// Created by Andre Herculano on 21/5/24. +// + +import Foundation +import ConsentViewController + +protocol RNSPConsent: Encodable { + var uuid: String? { get } + var expirationDate: SPDate? { get } + var createdDate: SPDate? { get } +} + +// Represents the consent data for a given campaign type. +struct RNSPCampaignData: Encodable { + // Whether a campaign "applies", based on the "applies" scope on the campaigns + // vendor list, setup in Sourcepoint's web dashboard. + let applies: Bool + let consents: Consent +} + +extension RNSPCampaignData { + init?(applies: Bool?, consents: Consent?) { + guard let consents = consents, let applies = applies else { return nil } + + self.applies = applies + self.consents = consents + } +} + +// Represents the consent data associated with an user. +// It's an interface on top of native SDKs SPUserData +struct RNSPUserData: Encodable { + let gdpr: RNSPCampaignData? + let usnat: RNSPCampaignData? + + init( + gdpr: RNSPCampaignData? = nil, + usnat: RNSPCampaignData? = nil + ) { + self.gdpr = gdpr + self.usnat = usnat + } +} + +extension RNSPUserData { + init(from sdkConsents: SPUserData?) { + guard let sdkConsents = sdkConsents else { + self.init() + return + } + + self.init( + gdpr: RNSPCampaignData( + applies: sdkConsents.gdpr?.applies, + consents: RNSPGDPRConsent(from: sdkConsents.gdpr?.consents) + ), + usnat: RNSPCampaignData( + applies: sdkConsents.gdpr?.applies, + consents: RNSPUSNatConsent(from: sdkConsents.usnat?.consents) + ) + ) + } +} diff --git a/ios/RNSourcepointCmp.swift b/ios/RNSourcepointCmp.swift index aabfb20..2db8021 100644 --- a/ios/RNSourcepointCmp.swift +++ b/ios/RNSourcepointCmp.swift @@ -25,7 +25,7 @@ import React } func getUserData(_ resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { - resolve(consentManager?.userData.toDictionary() ?? [:]) + resolve(RNSPUserData(from: consentManager?.userData).toDictionary()) } func build(_ accountId: Int, propertyId: Int, propertyName: String, campaigns: SPCampaigns) { @@ -66,8 +66,12 @@ extension RNSourcepointCmp: SPDelegate { UIApplication.shared.delegate?.window??.rootViewController } + // TODO: standardize action names func onAction(_ action: SPAction, from controller: UIViewController) { - RNSourcepointCmp.shared?.sendEvent(withName: "onAction", body: ["actionType": action.type.description]) + RNSourcepointCmp.shared?.sendEvent( + withName: "onAction", + body: ["actionType": action.type.description] + ) } func onSPUIReady(_ controller: UIViewController) { diff --git a/ios/Utils.swift b/ios/Utils.swift index 7b95721..8b72c6f 100644 --- a/ios/Utils.swift +++ b/ios/Utils.swift @@ -6,47 +6,12 @@ // import Foundation -import ConsentViewController -extension JSONEncoder { - func encodeResult(_ value: T) -> Result { - Result { try self.encode(value) } - } -} - -extension JSONDecoder { - func decode(_ type: T.Type, from: Data) -> Result { - Result { try self.decode(type, from: from) } - } -} - -extension UserDefaults { - func setObject(_ value: T, forKey defaultName: String) { - let encoder = JSONEncoder() - if let encoded = try? encoder.encodeResult(value).get() { - self.set(encoded, forKey: defaultName) - } - } - - func object(ofType type: T.Type, forKey defaultName: String) -> T? where T: Decodable { - let decoder = JSONDecoder() - if let data = self.data(forKey: defaultName), - let object = try? decoder.decode(type, from: data).get() { - return object - } - return nil - } - - func removeObjects(forKeys keys: [String]) { - keys.forEach { removeObject(forKey: $0) } - } -} - -extension SPUserData { - func toDictionary() -> [String: Any]? { +extension Encodable { + func toDictionary() -> [String: Any] { guard let jsonData = try? JSONEncoder().encode(self) else { - return nil + return [:] } - return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] + return (try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]) ?? [:] } } diff --git a/src/NativeSourcepointCmp.ts b/src/NativeSourcepointCmp.ts index a1e9563..daa2e33 100644 --- a/src/NativeSourcepointCmp.ts +++ b/src/NativeSourcepointCmp.ts @@ -1,42 +1,5 @@ -import type { TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; -export type SPCampaign = { - targetingParams?: Object; - supportLegacyUSPString?: boolean; -}; - -export const enum SPCampaignEnvironment { - Public = 'Public', - Stage = 'Stage', -} - -export type SPCampaigns = { - gdpr?: SPCampaign; - usnat?: SPCampaign; - environment?: SPCampaignEnvironment; -}; - -export interface Spec extends TurboModule { - build( - accountId: number, - propertyId: number, - propertyName: string, - campaigns: SPCampaigns - ): void; - getUserData(): Promise>; - loadMessage(): void; - clearLocalData(): void; - loadGDPRPrivacyManager(pmId: string): void; - loadUSNatPrivacyManager(pmId: string): void; - - onFinished(callback: () => void): void; - onAction(callback: (action: string) => void): void; - onSPUIReady(callback: () => void): void; - onSPUIFinished(callback: () => void): void; - onError(callback: (description: string) => void): void; - - dispose(): void; -} +import type { Spec } from './types'; export default TurboModuleRegistry.getEnforcing('RNSourcepointCmp'); diff --git a/src/index.tsx b/src/index.ts similarity index 86% rename from src/index.tsx rename to src/index.ts index 1e42cb2..6a1f92f 100644 --- a/src/index.tsx +++ b/src/index.ts @@ -1,6 +1,5 @@ import { NativeModules, Platform, NativeEventEmitter } from 'react-native'; -import type { Spec, SPCampaigns, SPCampaign } from './NativeSourcepointCmp'; -import { SPCampaignEnvironment } from './NativeSourcepointCmp'; +import type { Spec, SPCampaigns, SPUserData } from './types'; const LINKING_ERROR = `The package '@sourcepoint/react-native-cmp' doesn't seem to be linked. Make sure: \n\n` + @@ -26,11 +25,9 @@ const RNSourcepointCmp = RNSourcepointCmpModule } ); -// TODO: standardize consent classes across platforms +export * from './types'; -export type { SPCampaign, SPCampaigns }; - -export { SPCampaignEnvironment }; +export type * from './types'; export class SPConsentManager implements Spec { emitter = new NativeEventEmitter(RNSourcepointCmp); @@ -44,7 +41,7 @@ export class SPConsentManager implements Spec { RNSourcepointCmp.build(accountId, propertyId, propertyName, campaigns); } - getUserData(): Promise> { + getUserData(): Promise { return RNSourcepointCmp.getUserData(); } @@ -79,7 +76,7 @@ export class SPConsentManager implements Spec { this.emitter.addListener('onSPUIFinished', callback); } - onFinished(callback: () => void) { + onFinished(callback: (userData: SPUserData) => void) { this.emitter.removeAllListeners('onSPFinished'); this.emitter.addListener('onSPFinished', callback); } diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e1db949 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,111 @@ +import type { TurboModule } from 'react-native'; + +export type SPCampaign = { + targetingParams?: Object; + supportLegacyUSPString?: boolean; +}; + +export const enum SPCampaignEnvironment { + Public = 'Public', + Stage = 'Stage', +} + +export type SPCampaigns = { + gdpr?: SPCampaign; + usnat?: SPCampaign; + environment?: SPCampaignEnvironment; +}; + +export interface CampaignConsent { + applies: boolean; + consents: Consent; +} + +export interface ConcreteConsent { + uuid?: String; + // TODO: uncomment once both GDPR and USNAT expose those two attributes + // expirationDate?: String; + // createdDate?: String; +} + +export type GDPRConsentStatus = { + consentedAll?: Boolean; + consentedAny?: Boolean; + rejectedAny?: Boolean; +}; + +export type USNatConsentStatus = { + consentedAll?: Boolean; + consentedAny?: Boolean; + rejectedAny?: Boolean; + sellStatus?: Boolean; + shareStatus?: Boolean; + sensitiveDataStatus?: Boolean; + gpcStatus?: Boolean; +}; + +export type GDPRVendorGrant = { + granted: Boolean; + purposes: Record; +}; + +export type GDPRConsent = { + uuid?: String; + // expirationDate?: String; + // createdDate?: String; + euconsent?: String; + vendorGrants: Record; + statuses?: GDPRConsentStatus; + tcfData?: Object; +}; + +export type Consentable = { + consented: Boolean; + id: String; +}; + +export type ConsentSection = { + id: Number; + name: String; + consentString: String; +}; + +export type USNatConsent = { + uuid?: String; + // expirationDate?: String; + // createdDate?: String; + consentSections: Array; + statuses?: USNatConsentStatus; + gppData?: Object; + vendors: [Consentable]; + categories: [Consentable]; +}; + +export type SPUserData = { + gdpr?: CampaignConsent; + usnat?: CampaignConsent; +}; + +export interface Spec extends TurboModule { + build( + accountId: number, + propertyId: number, + propertyName: string, + campaigns: SPCampaigns + ): void; + getUserData(): Promise; + loadMessage(): void; + clearLocalData(): void; + loadGDPRPrivacyManager(pmId: string): void; + loadUSNatPrivacyManager(pmId: string): void; + + onFinished(callback: () => void): void; + + // TODO: change action from string to enum + onAction(callback: (action: string) => void): void; + onSPUIReady(callback: () => void): void; + onSPUIFinished(callback: () => void): void; + onError(callback: (description: string) => void): void; + + dispose(): void; +}