diff --git a/.github/workflows/instrumented-tests.yml b/.github/workflows/instrumented-tests.yml index 12a1b83a6..1d6e3bbd5 100644 --- a/.github/workflows/instrumented-tests.yml +++ b/.github/workflows/instrumented-tests.yml @@ -40,23 +40,8 @@ jobs: java-version: "17" - name: Gradle cache uses: gradle/gradle-build-action@842c587ad8aa4c68eeba24c396e15af4c2e9f30a #v2.9.0 - - name: AVD cache - uses: actions/cache@v5 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-29 - - name: create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b #v2.35.0 - with: - api-level: 28 - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false - script: echo "Generated AVD snapshot for caching." + # AVD cache disabled: causes indefinite hangs on emulator shutdown (see + # ReactiveCircus/android-emulator-runner#373, #385, #362; WordPress/gutenberg#66771) - name: Enable KVM group perms run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules @@ -64,6 +49,8 @@ jobs: sudo udevadm trigger --name-match=kvm - name: "Run Instrumented Tests (${{ inputs.display_name }})" uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b #v2.35.0 + env: + ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL: 60 with: api-level: 28 force-avd-creation: false diff --git a/.gitmodules b/.gitmodules index 35b8d14d7..20ab664aa 100644 --- a/.gitmodules +++ b/.gitmodules @@ -13,9 +13,6 @@ [submodule "kits/foresee-kit"] path = kits/foresee-kit url = git@github.com:mparticle-integrations/mparticle-android-integration-foresee.git -[submodule "kits/onetrust-kit"] - path = kits/onetrust-kit - url = git@github.com:mparticle-integrations/mparticle-android-integration-onetrust.git [submodule "kits/pilgrim-kit"] path = kits/pilgrim-kit url = git@github.com:mparticle-integrations/mparticle-android-integration-pilgrim.git diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml index 9684371cc..d8ddce956 100644 --- a/.trunk/trunk.yaml +++ b/.trunk/trunk.yaml @@ -83,6 +83,7 @@ lint: - kits/kochava/kochava-5/** - kits/leanplum/leanplum-7/** - kits/localytics/localytics-6/** + - kits/onetrust/onetrust/** - kits/optimizely/optimizely-3/** - kits/radar/radar-3/** - kits/rokt/rokt/** diff --git a/kits/onetrust-kit b/kits/onetrust-kit deleted file mode 160000 index e29e1cd27..000000000 --- a/kits/onetrust-kit +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e29e1cd273645db89d94d4c6d809d15d600f3b7f diff --git a/kits/onetrust/onetrust/README.md b/kits/onetrust/onetrust/README.md new file mode 100644 index 000000000..10fb0c5d3 --- /dev/null +++ b/kits/onetrust/onetrust/README.md @@ -0,0 +1,33 @@ +## OneTrust Kit Integration + +[See here for more information](https://github.com/mParticle/mparticle-android-sdk/wiki/Kit-Development) on how to use this example to write a new kit. + +This repository contains the [OneTrust](https://www.onetrust.com) integration for the [mParticle Android SDK](https://github.com/mParticle/mparticle-android-sdk). + +### Adding the integration + +1. Add the kit dependency to your app's build.gradle: + + ```groovy + dependencies { + implementation 'com.mparticle:android-onetrust-kit:5+' + // Implement the SDK version that corresponds to the published version you're using' + implementation 'com.onetrust.cmp:native-sdk:X.X.0.0' + + // Example: + implementation 'com.onetrust.cmp:native-sdk:202308.1.0.0' + } +} + ``` + _Note: OneTrust is unique in their versioning and in that you must specify your version used from a constrained list in their UI. This necessitates that we cannot pin the version of the OneTrust SDK in this kit. Therefore you must pin the correct version in the build.gradle file of your application. For more information on this checkout this [OneTrust Guide for Adding the SDK to an Android App](https://developer.onetrust.com/onetrust/docs/adding-sdk-to-app-android)_ + +2. Follow the mParticle Android SDK [quick-start](https://github.com/mParticle/mparticle-android-sdk), then rebuild and launch your app, and verify that you see `" detected"` in the output of `adb logcat`. +3. Reference mParticle's integration docs below to enable the integration. + +### Documentation + +[OneTrust integration](https://docs.mparticle.com/integrations/onetrust/event/) + +### License + +[Apache License 2.0](http://www.apache.org/licenses/LICENSE-2.0) diff --git a/kits/onetrust/onetrust/build.gradle b/kits/onetrust/onetrust/build.gradle new file mode 100644 index 000000000..20ef431bc --- /dev/null +++ b/kits/onetrust/onetrust/build.gradle @@ -0,0 +1,65 @@ +buildscript { + ext.kotlin_version = '2.0.20' + if (!project.hasProperty('version') || project.version.equals('unspecified')) { + project.version = '+' + } + + repositories { + google() + mavenLocal() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.1.4' + classpath 'com.mparticle:android-kit-plugin:' + project.version + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +plugins { + id "org.sonarqube" version "3.5.0.2730" + id "org.jlleitschuh.gradle.ktlint" version "13.0.0" +} + +sonarqube { + properties { + property "sonar.projectKey", "mparticle-android-integration-onetrust" + property "sonar.organization", "mparticle" + property "sonar.host.url", "https://sonarcloud.io" + } +} + +apply plugin: 'org.jlleitschuh.gradle.ktlint' +apply plugin: 'kotlin-android' +apply plugin: 'com.mparticle.kit' + +android { + namespace 'com.mparticle.kits.onetrust' + buildFeatures { + buildConfig = true + } + defaultConfig { + minSdkVersion 21 + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } + testOptions { + unitTests.all { + jvmArgs += ['--add-opens', 'java.base/java.lang=ALL-UNNAMED'] + } + } +} + +dependencies { + testImplementation fileTree(dir: 'libs', include: ['*.jar']) + //note that compileOnly requires kit users to define the dependency themselves + compileOnly 'com.onetrust.cmp:native-sdk:202411.2.0.0' + // Add as testImplementation so tests can load OneTrustKit class + testImplementation 'com.onetrust.cmp:native-sdk:202411.2.0.0' +} diff --git a/kits/onetrust/onetrust/consumer-proguard.pro b/kits/onetrust/onetrust/consumer-proguard.pro new file mode 100644 index 000000000..2a90e0f4c --- /dev/null +++ b/kits/onetrust/onetrust/consumer-proguard.pro @@ -0,0 +1 @@ +# TODO: Add rules to exclude your SDK classes from proguard in implementing apps \ No newline at end of file diff --git a/kits/onetrust/onetrust/docs/assets/connection-settings.png b/kits/onetrust/onetrust/docs/assets/connection-settings.png new file mode 100755 index 000000000..b9e872cfc Binary files /dev/null and b/kits/onetrust/onetrust/docs/assets/connection-settings.png differ diff --git a/kits/onetrust/onetrust/docs/manual-testing.md b/kits/onetrust/onetrust/docs/manual-testing.md new file mode 100644 index 000000000..78a2c3636 --- /dev/null +++ b/kits/onetrust/onetrust/docs/manual-testing.md @@ -0,0 +1,45 @@ +# OneTrust Kit Manual Test Procedure +## Environments +### mParticle Environment +Has a number of purposes configured and, in the connection to the OneTrust output has them mapped to the different categories/purposes and vendors (of all types) in OneTrusts, that are listed in our UI as: + + - Purpose Consent mapping + - IAB Vendor Consent Mapping + - Google Vendor Consent Mapping + - General (SDK) Vendor Consent Mapping + +##### Sample Configuration +![image](assets/connection-settings.png) + +### OneTrust Environment +Has a IAB TCF 2.2 template with a number of purposes and vendors assigned to the application. + +Those would be mapped to the mParticle purposes as explained above. + +### Testing Process +The kit should be manually tested by following this process: + +1. Connect a test application to a testing environment that has been configured as per above in both mParticle and OneTrust. +1. Start the application. +1. Get the OneTrust banner or preference centre centre to show. +1. Accept all purposes and vendors. +1. Ensure a batch is sent from the application i.e. by triggering an event. +1. Verify: + - In the user profile at mParticle, that you can see via Activity > User Activity > [Search the profile] > Information tab, under "Consent and Compliance", all purposes should be consented (Look under "Consented" column, where all configured purposes should have `true`). +1. Get the OneTrust banner or preference centre centre to show. +1. Reject all purposes and vendors. +1. Ensure a batch is sent from the application i.e. by triggering an event. +1. Verify: + - In the user profile at mParticle, that you can see via Activity > User Activity > [Search the profile] > Information tab, under "Consent and Compliance", all purposes should be not consented (Look under "Consented" column, where all configured purposes should have `false`). +1. Get the OneTrust banner or preference centre centre to show. +1. Alternate accepting and rejecting purposes and vendors e.g. accept the first, reject the second, accept the third, reject the fourth, etc. +1. Ensure a batch is sent from the application i.e. by triggering an event. +1. Verify: + - In the user profile at mParticle, that you can see via Activity > User Activity > [Search the profile] > Information tab, under "Consent and Compliance", ensure that all purposes consent state match those set in OneTrust banner or preference centre. +1. Get the OneTrust banner or preference centre centre to show. +1. Toggle the consent state set previously i.e. reject where accepted and accept where rejected. +1. Ensure a batch is sent from the application i.e. by triggering an event. +1. Verify: + - In the user profile at mParticle, that you can see via Activity > User Activity > [Search the profile] > Information tab, under "Consent and Compliance", ensure that all purposes consent state match those set in OneTrust banner or preference centre. + +The testing is successfull if the "Verify" steps are all positive. \ No newline at end of file diff --git a/kits/onetrust/onetrust/gradle.properties b/kits/onetrust/onetrust/gradle.properties new file mode 100644 index 000000000..edb1202c3 --- /dev/null +++ b/kits/onetrust/onetrust/gradle.properties @@ -0,0 +1,4 @@ +android.enableJetifier=true +android.useAndroidX=true +org.gradle.daemon=true +org.gradle.jvmargs=-Xmx2560m \ No newline at end of file diff --git a/kits/onetrust/onetrust/gradle/wrapper/gradle-wrapper.jar b/kits/onetrust/onetrust/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..13372aef5 Binary files /dev/null and b/kits/onetrust/onetrust/gradle/wrapper/gradle-wrapper.jar differ diff --git a/kits/onetrust/onetrust/gradle/wrapper/gradle-wrapper.properties b/kits/onetrust/onetrust/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..e1bef7e87 --- /dev/null +++ b/kits/onetrust/onetrust/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/kits/onetrust/onetrust/gradlew b/kits/onetrust/onetrust/gradlew new file mode 100755 index 000000000..5afed789c --- /dev/null +++ b/kits/onetrust/onetrust/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null # nosemgrep +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD # nosemgrep + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then # nosemgrep + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then # nosemgrep + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` # nosemgrep + else + eval `echo args$i`="\"$arg\"" # nosemgrep + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS # nosemgrep +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/kits/onetrust/onetrust/gradlew.bat b/kits/onetrust/onetrust/gradlew.bat new file mode 100644 index 000000000..aec99730b --- /dev/null +++ b/kits/onetrust/onetrust/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/kits/onetrust/onetrust/libs/java-json.jar b/kits/onetrust/onetrust/libs/java-json.jar new file mode 100755 index 000000000..2f211e366 Binary files /dev/null and b/kits/onetrust/onetrust/libs/java-json.jar differ diff --git a/kits/onetrust/onetrust/libs/testutils.aar b/kits/onetrust/onetrust/libs/testutils.aar new file mode 100644 index 000000000..cf49b4189 Binary files /dev/null and b/kits/onetrust/onetrust/libs/testutils.aar differ diff --git a/kits/onetrust/onetrust/settings.gradle.kts b/kits/onetrust/onetrust/settings.gradle.kts new file mode 100644 index 000000000..78f0a219e --- /dev/null +++ b/kits/onetrust/onetrust/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "android-onetrust-kit" +include(":") diff --git a/kits/onetrust/onetrust/src/main/AndroidManifest.xml b/kits/onetrust/onetrust/src/main/AndroidManifest.xml new file mode 100644 index 000000000..c4e6c98d7 --- /dev/null +++ b/kits/onetrust/onetrust/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/kits/onetrust/onetrust/src/main/kotlin/com/mparticle/kits/OneTrustKit.kt b/kits/onetrust/onetrust/src/main/kotlin/com/mparticle/kits/OneTrustKit.kt new file mode 100644 index 000000000..002c1ee8a --- /dev/null +++ b/kits/onetrust/onetrust/src/main/kotlin/com/mparticle/kits/OneTrustKit.kt @@ -0,0 +1,193 @@ +package com.mparticle.kits + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import com.mparticle.MParticle +import com.mparticle.consent.CCPAConsent +import com.mparticle.consent.ConsentState +import com.mparticle.consent.GDPRConsent +import com.mparticle.identity.MParticleUser +import com.mparticle.internal.Logger +import com.mparticle.kits.KitIntegration.IdentityListener +import com.onetrust.otpublishers.headless.Public.Keys.OTBroadcastServiceKeys +import com.onetrust.otpublishers.headless.Public.OTPublishersHeadlessSDK +import com.onetrust.otpublishers.headless.Public.OTVendorListMode +import org.json.JSONArray +import org.json.JSONException + +class OneTrustKit : + KitIntegration(), + IdentityListener { + internal enum class ConsentRegulation { GDPR, CCPA } + + internal class OneTrustConsent( + val vendorType: String? = null, + val purpose: String, + val regulation: ConsentRegulation, + ) + + private val consentUpdatedReceiver: BroadcastReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent?, + ) { + processOneTrustConsent() + } + } + + private val oneTrustSdk: OTPublishersHeadlessSDK by lazy { OTPublishersHeadlessSDK(context) } + + private var consentMappings = mutableMapOf() + + companion object { + private var initializedOnce = false + + private const val MOBILE_CONSENT_GROUPS = "mobileConsentGroups" + private const val IAB_CONSENT_GROUPS = "vendorIABConsentGroups" + private const val GOOGLE_CONSENT_GROUPS = "vendorGoogleConsentGroups" + private const val GENERAL_CONSENT_GROUPS = "vendorGeneralConsentGroups" + + internal const val CCPA_PURPOSE_VALUE = "data_sale_opt_out" + } + + override fun getName(): String = "OneTrust" + + override fun setOptOut(optedOut: Boolean): List = listOf() + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + public override fun onKitCreate( + settings: Map, + context: Context, + ): List { + processConsentMappings(settings[MOBILE_CONSENT_GROUPS]) + processConsentMappings(settings[IAB_CONSENT_GROUPS], OTVendorListMode.IAB) + processConsentMappings(settings[GOOGLE_CONSENT_GROUPS], OTVendorListMode.GOOGLE) + processConsentMappings(settings[GENERAL_CONSENT_GROUPS], OTVendorListMode.GENERAL) + + if (!initializedOnce) { + initializedOnce = true + processOneTrustConsent() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver( + consentUpdatedReceiver, + IntentFilter(OTBroadcastServiceKeys.OT_CONSENT_UPDATED), + Context.RECEIVER_NOT_EXPORTED, + ) + } else { + context.registerReceiver(consentUpdatedReceiver, IntentFilter(OTBroadcastServiceKeys.OT_CONSENT_UPDATED)) + } + } + return listOf() + } + + override fun getInstance(): Any = oneTrustSdk + + internal fun processConsentMappings( + setting: String?, + vendorType: String? = null, + ) { + if (!setting.isNullOrEmpty()) { + try { + val settingJSON = JSONArray(setting) + + for (i in 0 until settingJSON.length()) { + val p = settingJSON.getJSONObject(i) + val otPurposeCode = p.optString("value") + val mpPurposeCode = p.optString("map") + val mpRegulation = if (mpPurposeCode == CCPA_PURPOSE_VALUE) ConsentRegulation.CCPA else ConsentRegulation.GDPR + + if (otPurposeCode.isNullOrEmpty()) { + Logger.warning("Consent mapping is missing OneTrust's side: $this") + } else if (mpPurposeCode.isNullOrEmpty()) { + Logger.warning("Consent mapping is missing mParticle's side: $this") + } else { + consentMappings[otPurposeCode] = OneTrustConsent(vendorType, mpPurposeCode, mpRegulation) + } + } + } catch (jse: JSONException) { + Logger.error(jse, "Could not parse consent mapping!") + } + } + } + + internal fun processOneTrustConsent() { + MParticle.getInstance()?.Identity()?.currentUser?.let { user -> + for (consentMapping in consentMappings) { + var status: Int = 0 + try { + if (!consentMapping.value.vendorType.isNullOrEmpty()) { + status = oneTrustSdk.getVendorDetails(consentMapping.value.vendorType!!, consentMapping.key)!!.getInt("consent") + } else { + status = oneTrustSdk.getConsentStatusForGroupId(consentMapping.key) + } + } catch (e: Exception) { + Logger.error(e, "Could not fetch consent from OneTrust!") + continue + } + + // -1 = Consent Not Collected + if (status > -1) { + // 0 = Consent Not Given + // 1 or 2 = Consent Given + val consentGiven: Boolean = status > 0 + setConsentStateEvent(user, consentMapping.value, consentGiven) + } + } + } ?: run { + Logger.warning("OneTrust consent could not be processed as MParticle's Current user is not set") + } + } + + internal fun setConsentStateEvent( + user: MParticleUser, + consentMapping: OneTrustConsent, + consentGiven: Boolean, + ) { + val time = System.currentTimeMillis() + + val consentState = user.consentState.let { ConsentState.withConsentState(it) } + when (consentMapping.regulation) { + ConsentRegulation.GDPR -> { + consentState.addGDPRConsentState(consentMapping.purpose, GDPRConsent.builder(consentGiven).timestamp(time).build()) + } + + ConsentRegulation.CCPA -> { + consentState.setCCPAConsentState(CCPAConsent.builder(consentGiven).timestamp(time).build()) + } + } + user.setConsentState(consentState.build()) + } + + override fun onIdentifyCompleted( + mParticleUser: MParticleUser?, + identityApiRequest: FilteredIdentityApiRequest?, + ) { + } + + override fun onLoginCompleted( + mParticleUser: MParticleUser?, + identityApiRequest: FilteredIdentityApiRequest?, + ) { + } + + override fun onLogoutCompleted( + mParticleUser: MParticleUser?, + identityApiRequest: FilteredIdentityApiRequest?, + ) { + } + + override fun onModifyCompleted( + mParticleUser: MParticleUser?, + identityApiRequest: FilteredIdentityApiRequest?, + ) { + } + + override fun onUserIdentified(mParticleUser: MParticleUser?) { + processOneTrustConsent() + } +} diff --git a/kits/onetrust/onetrust/src/test/kotlin/com/mparticle/kits/KitTests.kt b/kits/onetrust/onetrust/src/test/kotlin/com/mparticle/kits/KitTests.kt new file mode 100644 index 000000000..d33a2a41d --- /dev/null +++ b/kits/onetrust/onetrust/src/test/kotlin/com/mparticle/kits/KitTests.kt @@ -0,0 +1,146 @@ +package com.mparticle.kits + +import com.mparticle.MParticleOptions +import com.mparticle.kits.KitIntegration +import com.mparticle.kits.KitIntegrationFactory +import org.junit.Assert +import org.junit.Assert.assertTrue +import org.junit.Test +import org.mockito.Mockito + +class KitTests { + private val kit: KitIntegration + get() = OneTrustKit() + + @Test + @Throws(Exception::class) + fun testGetName() { + val name = kit.name + Assert.assertTrue(name.isNotEmpty()) + } + +// /** +// * Kit *should* throw an exception when they're initialized with the wrong settings. +// * +// */ +// @Test +// @Throws(Exception::class) +// fun testOnKitCreate() { +// var e: Exception? = null +// try { +// val kit = kit +// val settings: MutableMap = mutableMapOf() +// settings["fake setting"] = "fake" +// kit.onKitCreate(settings, Mockito.mock(Context::class.java)) +// } catch (ex: Exception) { +// e = ex +// } +// Assert.assertNotNull(e) +// } +// +// /** +// * This test should ensure that whatever the consent state is, if a new GDPR consent is created, +// * it should be added to the consent state GDPR map +// */ +// @Test +// fun testCreateConsentEventGDPR() { +// val timestamp = System.currentTimeMillis() +// val user = Mockito.mock(MParticleUser::class.java) +// val gdprConsent = GDPRConsent.builder(false).build() +// var currentConsentState = +// ConsentState.builder().addGDPRConsentState("purpose1", gdprConsent).build() +// `when`(user.consentState).thenReturn(currentConsentState) +// assertEquals(1, currentConsentState.gdprConsentState.size) +// assertEquals(gdprConsent, currentConsentState.gdprConsentState.get("purpose1")) +// assertNull(currentConsentState.ccpaConsentState) +// currentConsentState = +// kit.createOrUpdateConsent( +// user, +// "purpose2", +// 1, +// OneTrustKit.ConsentRegulation.GDPR, +// timestamp +// )!! +// assertEquals(2, currentConsentState.gdprConsentState.size) +// assertEquals(gdprConsent, currentConsentState.gdprConsentState.get("purpose1")) +// assertEquals(currentConsentState.gdprConsentState.get("purpose2")?.timestamp, timestamp) +// assertTrue(currentConsentState.gdprConsentState.containsKey("purpose2")) +// assertEquals(true, currentConsentState.gdprConsentState.get("purpose2")!!.isConsented) +// assertNull(currentConsentState.ccpaConsentState) +// } +// +// /** +// * This test must ensure that any CCPA consent creates is added to the constent state. +// * By design a new CCPA consent overrides the previous one. +// */ +// @Test +// fun testCreateConsentEventCCPA() { +// val timestamp = System.currentTimeMillis() +// val user = Mockito.mock(MParticleUser::class.java) +// val ccpaConsent = CCPAConsent.builder(false).location("loc1").build() +// var currentConsentState = ConsentState.builder().setCCPAConsentState(ccpaConsent).build() +// `when`(user.consentState).thenReturn(currentConsentState) +// assertEquals(0, currentConsentState.gdprConsentState.size) +// assertEquals(ccpaConsent, currentConsentState.ccpaConsentState) +// assertEquals("loc1", currentConsentState.ccpaConsentState?.location) +// assertEquals(false, currentConsentState.ccpaConsentState?.isConsented) +// +// currentConsentState = +// kit.createOrUpdateConsent( +// user, +// "ccpa", +// 1, +// OneTrustKit.ConsentRegulation.CCPA, +// timestamp +// )!! +// assertEquals(0, currentConsentState.gdprConsentState.size) +// assertEquals(currentConsentState.ccpaConsentState?.timestamp, timestamp) +// assertEquals(true, currentConsentState.ccpaConsentState?.isConsented) +// assertNull(currentConsentState.ccpaConsentState?.location) +// } +// +// @Test +// fun testCreateUpdate() { +// val timestamp = System.currentTimeMillis() +// val user = Mockito.mock(MParticleUser::class.java) +// val ccpaConsent = CCPAConsent.builder(false).timestamp(timestamp).build() +// var currentConsentStateBuilder = ConsentState.builder().setCCPAConsentState(ccpaConsent) +// +// val gdprConsent = GDPRConsent.builder(false).timestamp(timestamp).build() +// var currentConsentState = currentConsentStateBuilder.addGDPRConsentState("purpose1", gdprConsent).build() +// `when`(user.consentState).thenReturn(currentConsentState) +// assertEquals(1, currentConsentState.gdprConsentState.size) +// assertEquals(gdprConsent, currentConsentState.gdprConsentState.get("purpose1")) +// currentConsentState = +// kit.createOrUpdateConsent( +// user, +// "purpose2", +// 1, +// OneTrustKit.ConsentRegulation.GDPR, +// timestamp +// )!! +// assertEquals(2, currentConsentState.gdprConsentState.size) +// assertEquals(gdprConsent, currentConsentState.gdprConsentState.get("purpose1")) +// assertEquals(gdprConsent.timestamp, currentConsentState.gdprConsentState.get("purpose1")?.timestamp) +// assertEquals(currentConsentState.gdprConsentState.get("purpose2")?.timestamp, timestamp) +// assertTrue(currentConsentState.gdprConsentState.containsKey("purpose2")) +// assertEquals(true, currentConsentState.gdprConsentState.get("purpose2")!!.isConsented) +// assertEquals(currentConsentState.ccpaConsentState?.timestamp, timestamp) +// assertEquals(false, currentConsentState.ccpaConsentState?.isConsented) +// } + + @Test + @Throws(Exception::class) + fun testClassName() { + val options = Mockito.mock(MParticleOptions::class.java) + val factory = KitIntegrationFactory(options) + val integrations = factory.supportedKits.values + val className = kit.javaClass.name + for (integration in integrations) { + if (integration.name == className) { + return + } + } + Assert.fail("$className not found as a known integration.") + } +} diff --git a/settings-kits.gradle b/settings-kits.gradle index 4b1b33407..7f42c673f 100644 --- a/settings-kits.gradle +++ b/settings-kits.gradle @@ -23,7 +23,7 @@ include ( ':kits:localytics:localytics-6', ':kits:leanplum:leanplum-7', //Neura hosts kit - ':kits:onetrust-kit', + ':kits:onetrust:onetrust', ':kits:optimizely:optimizely-3', // ':kits:pilgrim-kit', ':kits:radar:radar-3',