Skip to content

Commit e92d352

Browse files
authored
feat: add device-based consent to override MPID-scoped consent (#726)
Enables Inspire-style flows where consent is collected before MPID changes at checkout, so kit forwarding rules and uploads keep the correct consent state.
1 parent cbaa623 commit e92d352

9 files changed

Lines changed: 235 additions & 87 deletions

File tree

CHANGELOG.md

Lines changed: 62 additions & 83 deletions
Large diffs are not rendered by default.

android-core/src/main/java/com/mparticle/MParticle.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import androidx.annotation.RequiresApi;
2121

2222
import com.mparticle.commerce.CommerceEvent;
23+
import com.mparticle.consent.ConsentState;
2324
import com.mparticle.identity.IdentityApi;
2425
import com.mparticle.identity.IdentityApiRequest;
2526
import com.mparticle.identity.IdentityApiResult;
@@ -911,6 +912,48 @@ public void setOptOut(@NonNull Boolean optOutStatus) {
911912
}
912913
}
913914

915+
/**
916+
* Query the device-level consent state.
917+
* <p>
918+
* Device-level consent, when set, overrides MPID-based consent when applying consent forwarding
919+
* rules and uploading events.
920+
*
921+
* @return the device-level consent state, or an empty state if none has been set
922+
*/
923+
@NonNull
924+
public ConsentState getDeviceConsentState() {
925+
return mConfigManager.getDeviceConsentState();
926+
}
927+
928+
/**
929+
* Set the device-level consent state.
930+
* <p>
931+
* Device-level consent overrides MPID-based consent when applying consent forwarding rules
932+
* and uploading events. Pass {@code null} to clear the device-level override and fall back to
933+
* MPID-based consent.
934+
*
935+
* @param state the device-level consent state, or {@code null} to clear the override
936+
*/
937+
public void setDeviceConsentState(@Nullable ConsentState state) {
938+
ConsentState oldState = mConfigManager.getEffectiveConsentState(mConfigManager.getMpid());
939+
mConfigManager.setDeviceConsentState(state);
940+
ConsentState newState = mConfigManager.getEffectiveConsentState(mConfigManager.getMpid());
941+
mKitManager.onConsentStateUpdated(oldState, newState, mConfigManager.getMpid());
942+
}
943+
944+
/**
945+
* Query whether device-based consent is enabled.
946+
* <p>
947+
* When enabled, {@link com.mparticle.identity.MParticleUser#setConsentState(ConsentState)}
948+
* also persists consent at the device level.
949+
*
950+
* @return true if device-based consent is enabled
951+
*/
952+
@NonNull
953+
public Boolean isDeviceBasedConsentEnabled() {
954+
return mConfigManager.isDeviceBasedConsentEnabled();
955+
}
956+
914957
/**
915958
* Retrieve a URL to be loaded within a {@link WebView} to show the user a survey
916959
* or feedback form.

android-core/src/main/java/com/mparticle/MParticleOptions.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public class MParticleOptions {
3838
private String mApiSecret;
3939
private IdentityApiRequest mIdentifyRequest;
4040
private Boolean mDevicePerformanceMetricsDisabled = false;
41+
private Boolean mDeviceBasedConsentEnabled = false;
4142
private Boolean mAndroidIdEnabled = false;
4243
private Integer mUploadInterval = ConfigManager.DEFAULT_UPLOAD_INTERVAL; //seconds
4344
private Integer mSessionTimeout = ConfigManager.DEFAULT_SESSION_TIMEOUT_SECONDS; //seconds
@@ -93,6 +94,9 @@ public MParticleOptions(@NonNull Builder builder) {
9394
if (builder.devicePerformanceMetricsDisabled != null) {
9495
this.mDevicePerformanceMetricsDisabled = builder.devicePerformanceMetricsDisabled;
9596
}
97+
if (builder.deviceBasedConsentEnabled != null) {
98+
this.mDeviceBasedConsentEnabled = builder.deviceBasedConsentEnabled;
99+
}
96100
if (builder.androidIdEnabled != null) {
97101
this.mAndroidIdEnabled = builder.androidIdEnabled;
98102
}
@@ -254,6 +258,20 @@ public Boolean isDevicePerformanceMetricsDisabled() {
254258
return mDevicePerformanceMetricsDisabled;
255259
}
256260

261+
/**
262+
* Query whether device-based consent is enabled.
263+
* <p>
264+
* When enabled, {@link com.mparticle.identity.MParticleUser#setConsentState(ConsentState)}
265+
* will persist consent at the device level in addition to the current MPID. Device-level
266+
* consent overrides MPID-based consent when applying consent forwarding rules and uploading events.
267+
*
268+
* @return true if device-based consent is enabled
269+
*/
270+
@NonNull
271+
public Boolean isDeviceBasedConsentEnabled() {
272+
return mDeviceBasedConsentEnabled;
273+
}
274+
257275
/**
258276
* @return true if collection is disabled, false if it is enabled
259277
* @deprecated This method has been replaced as the behavior has been inverted - Android ID collection is now disabled by default.
@@ -423,6 +441,7 @@ public static class Builder {
423441
private MParticle.Environment environment;
424442
private IdentityApiRequest identifyRequest;
425443
private Boolean devicePerformanceMetricsDisabled = null;
444+
private Boolean deviceBasedConsentEnabled = null;
426445
private Boolean androidIdEnabled = null;
427446
private Integer uploadInterval = null;
428447
private Integer sessionTimeout = null;
@@ -570,6 +589,23 @@ public Builder devicePerformanceMetricsDisabled(boolean disabled) {
570589
return this;
571590
}
572591

592+
/**
593+
* Enable device-based consent.
594+
* <p>
595+
* When enabled, consent set via {@link com.mparticle.identity.MParticleUser#setConsentState(ConsentState)}
596+
* is stored at the device level and overrides MPID-based consent when applying consent forwarding
597+
* rules and uploading events. This is useful when consent is collected before the user's MPID is known
598+
* or when the MPID changes during a flow such as checkout.
599+
*
600+
* @param enabled true to enable device-based consent
601+
* @return the instance of the builder, for chaining calls
602+
*/
603+
@NonNull
604+
public Builder deviceBasedConsentEnabled(boolean enabled) {
605+
this.deviceBasedConsentEnabled = enabled;
606+
return this;
607+
}
608+
573609
/**
574610
* @param disabled false to enable collection (true by default)
575611
* @return the instance of the builder, for chaining calls

android-core/src/main/java/com/mparticle/identity/MParticleUserDelegate.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,13 +276,17 @@ boolean setUser(Context context, long previousMpid, long newMpid, Map<MParticle.
276276
}
277277

278278
public ConsentState getConsentState(long mpid) {
279-
return mConfigManager.getConsentState(mpid);
279+
return mConfigManager.getEffectiveConsentState(mpid);
280280
}
281281

282282
public void setConsentState(ConsentState state, long mpid) {
283283
ConsentState oldState = getConsentState(mpid);
284284
mConfigManager.setConsentState(state, mpid);
285-
mKitManager.onConsentStateUpdated(oldState, state, mpid);
285+
if (mConfigManager.isDeviceBasedConsentEnabled()) {
286+
mConfigManager.setDeviceConsentState(state);
287+
}
288+
ConsentState newState = getConsentState(mpid);
289+
mKitManager.onConsentStateUpdated(oldState, newState, mpid);
286290
}
287291

288292
public boolean isLoggedIn(Long mpid) {

android-core/src/main/java/com/mparticle/internal/ConfigManager.java

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ public class ConfigManager {
9191
private UserStorage mUserStorage;
9292
private String mLogUnhandledExceptions = VALUE_APP_DEFINED;
9393
private boolean audienceAPIFlag = false;
94+
private boolean mDeviceBasedConsentEnabled = false;
9495

9596
private boolean mSendOoEvents;
9697
private JSONObject mProviderPersistence;
@@ -134,11 +135,13 @@ public static ConfigManager getInstance(Context context) {
134135
public ConfigManager(Context context) {
135136
mContext = context;
136137
sPreferences = getPreferences(mContext);
138+
mDeviceBasedConsentEnabled = sPreferences.getBoolean(Constants.PrefKeys.DEVICE_BASED_CONSENT_ENABLED, false);
137139
}
138140

139141
public ConfigManager(@NonNull MParticleOptions options) {
140142
this(options.getContext(), options.getEnvironment(), options.getApiKey(), options.getApiSecret(), options.getDataplanOptions(), options.getDataplanId(), options.getDataplanVersion(), options.getConfigMaxAge(), options.getConfigurationsForTarget(ConfigManager.class), options.getSideloadedKits());
141143
mPersistenceMaxAgeSeconds = options.getPersistenceMaxAgeSeconds();
144+
setDeviceBasedConsentEnabled(options.isDeviceBasedConsentEnabled());
142145
}
143146

144147
/**
@@ -177,6 +180,7 @@ public ConfigManager(@NonNull Context context, @Nullable MParticle.Environment e
177180
configuration.apply(this);
178181
}
179182
}
183+
mDeviceBasedConsentEnabled = sPreferences.getBoolean(Constants.PrefKeys.DEVICE_BASED_CONSENT_ENABLED, false);
180184
}
181185

182186
public void onMParticleStarted() {
@@ -1335,6 +1339,50 @@ public ConsentState getConsentState(long mpid) {
13351339
return ConsentState.withConsentState(serializedConsent).build();
13361340
}
13371341

1342+
public boolean isDeviceBasedConsentEnabled() {
1343+
return mDeviceBasedConsentEnabled;
1344+
}
1345+
1346+
public void setDeviceBasedConsentEnabled(boolean deviceBasedConsentEnabled) {
1347+
mDeviceBasedConsentEnabled = deviceBasedConsentEnabled;
1348+
sPreferences.edit()
1349+
.putBoolean(Constants.PrefKeys.DEVICE_BASED_CONSENT_ENABLED, deviceBasedConsentEnabled)
1350+
.apply();
1351+
}
1352+
1353+
public boolean hasDeviceConsentOverride() {
1354+
return sPreferences.contains(Constants.PrefKeys.DEVICE_CONSENT_STATE);
1355+
}
1356+
1357+
@NonNull
1358+
public ConsentState getDeviceConsentState() {
1359+
if (!hasDeviceConsentOverride()) {
1360+
return ConsentState.withConsentState((String) null).build();
1361+
}
1362+
String serializedConsent = sPreferences.getString(Constants.PrefKeys.DEVICE_CONSENT_STATE, null);
1363+
return ConsentState.withConsentState(serializedConsent).build();
1364+
}
1365+
1366+
public void setDeviceConsentState(@Nullable ConsentState state) {
1367+
if (state != null) {
1368+
sPreferences.edit()
1369+
.putString(Constants.PrefKeys.DEVICE_CONSENT_STATE, state.toString())
1370+
.apply();
1371+
} else {
1372+
sPreferences.edit()
1373+
.remove(Constants.PrefKeys.DEVICE_CONSENT_STATE)
1374+
.apply();
1375+
}
1376+
}
1377+
1378+
@NonNull
1379+
public ConsentState getEffectiveConsentState(long mpid) {
1380+
if (hasDeviceConsentOverride()) {
1381+
return getDeviceConsentState();
1382+
}
1383+
return getConsentState(mpid);
1384+
}
1385+
13381386
public boolean isDirectUrlRoutingEnabled() {
13391387
return directUrlRouting;
13401388
}

android-core/src/main/java/com/mparticle/internal/MessageBatch.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ public static MessageBatch create(boolean history, ConfigManager configManager,
5454
uploadMessage.put(Constants.MessageKey.COOKIES, cookies);
5555
uploadMessage.put(Constants.MessageKey.PROVIDER_PERSISTENCE, configManager.getProviderPersistence());
5656
uploadMessage.put(Constants.MessageKey.INTEGRATION_ATTRIBUTES, configManager.getIntegrationAttributes());
57-
uploadMessage.addConsentState(configManager.getConsentState(batchId.getMpid()));
57+
uploadMessage.addConsentState(configManager.getEffectiveConsentState(batchId.getMpid()));
5858
uploadMessage.addDataplanContext(batchId.getDataplanId(), batchId.getDataplanVersion());
5959
return uploadMessage;
6060
}

android-core/src/main/kotlin/com/mparticle/internal/Constants.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,8 @@ object Constants {
608608
const val IF_MODIFIED: String = "mp::ifmodified"
609609
const val IDENTITY_API_CONTEXT: String = "mp::identity::api::context"
610610
const val DEVICE_APPLICATION_STAMP: String = "mp::device-app-stamp"
611+
const val DEVICE_CONSENT_STATE: String = "mp::device::consent"
612+
const val DEVICE_BASED_CONSENT_ENABLED: String = "mp::device::consent::enabled"
611613
const val PREVIOUS_ANDROID_ID: String = "mp::previous::android::id"
612614
const val DISPLAY_PUSH_NOTIFICATIONS: String = "mp::displaypushnotifications"
613615
const val IDENTITY_CONNECTION_TIMEOUT: String = "mp::connection:timeout:identity"

android-core/src/test/kotlin/com/mparticle/external/ApiVisibilityTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class ApiVisibilityTest {
1717
publicMethodCount++
1818
}
1919
}
20-
Assert.assertEquals(66, publicMethodCount)
20+
Assert.assertEquals(69, publicMethodCount)
2121
}
2222

2323
@Test

android-core/src/test/kotlin/com/mparticle/internal/ConfigManagerTest.kt

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package com.mparticle.internal
22

33
import com.mparticle.MParticle
44
import com.mparticle.MockMParticle
5+
import com.mparticle.consent.ConsentState
6+
import com.mparticle.consent.GDPRConsent
57
import com.mparticle.internal.KitManager.KitStatus
68
import com.mparticle.internal.PushRegistrationHelper.PushRegistration
79
import com.mparticle.internal.messages.BaseMPMessage
@@ -729,6 +731,40 @@ class ConfigManagerTest {
729731
Assert.assertNotNull(manager.configTimestamp)
730732
}
731733

734+
@Test
735+
fun testDeviceConsentOverridesMpidConsent() {
736+
val mpid = ran.nextLong()
737+
val mpidConsent = ConsentState.builder()
738+
.addGDPRConsentState("mpid-purpose", GDPRConsent.builder(false).build())
739+
.build()
740+
val deviceConsent = ConsentState.builder()
741+
.addGDPRConsentState("device-purpose", GDPRConsent.builder(true).build())
742+
.build()
743+
744+
manager.setConsentState(mpidConsent, mpid)
745+
Assert.assertFalse(manager.hasDeviceConsentOverride())
746+
Assert.assertTrue(manager.getEffectiveConsentState(mpid).gdprConsentState.containsKey("mpid-purpose"))
747+
748+
manager.setDeviceConsentState(deviceConsent)
749+
Assert.assertTrue(manager.hasDeviceConsentOverride())
750+
Assert.assertTrue(manager.getEffectiveConsentState(mpid).gdprConsentState.containsKey("device-purpose"))
751+
Assert.assertFalse(manager.getEffectiveConsentState(mpid).gdprConsentState.containsKey("mpid-purpose"))
752+
753+
manager.setDeviceConsentState(null)
754+
Assert.assertFalse(manager.hasDeviceConsentOverride())
755+
Assert.assertTrue(manager.getEffectiveConsentState(mpid).gdprConsentState.containsKey("mpid-purpose"))
756+
}
757+
758+
@Test
759+
fun testDeviceBasedConsentEnabledPersists() {
760+
Assert.assertFalse(manager.isDeviceBasedConsentEnabled())
761+
manager.setDeviceBasedConsentEnabled(true)
762+
Assert.assertTrue(manager.isDeviceBasedConsentEnabled())
763+
764+
val reloadedManager = ConfigManager(context)
765+
Assert.assertTrue(reloadedManager.isDeviceBasedConsentEnabled())
766+
}
767+
732768
companion object {
733769
private const val SAMPLE_CONFIG =
734770
"{ \"dt\":\"ac\", \"id\":\"5b7b8073-852b-47c2-9b89-c4bc66e3bd55\", \"ct\":1428030730685, \"dbg\":false, \"cue\":\"appdefined\", \"pmk\":[ \"mp_message\", \"com.urbanairship.push.ALERT\", \"alert\", \"a\", \"message\" ], \"cnp\":\"appdefined\", \"soc\":0, \"oo\":false, \"tri\" : { \"mm\" : [{ \"dt\" : \"x\", \"eh\" : true } ], \"evts\" : [1217787541, 2, 3] }, \"eks\":[ { \"id\":64, \"as\":{ \"clientId\":\"8FMBElARYl9ZtgwYIN5sZA==\", \"surveyId\":\"android_app\", \"sendAppVersion\":\"True\", \"rootUrl\":\"http://survey.foreseeresults.com/survey/display\" }, \"hs\":{ \"et\":{ \"57\":0, \"49\":0, \"55\":0, \"52\":0, \"53\":0, \"50\":0, \"56\":0, \"51\":0, \"54\":0, \"48\":0 }, \"ec\":{ \"609391310\":0, \"-1282670145\":0, \"2138942058\":0, \"-1262630649\":0, \"-877324321\":0, \"1700497048\":0, \"1611158813\":0, \"1900204162\":0, \"-998867355\":0, \"-1758179958\":0, \"-994832826\":0, \"1598473606\":0, \"-2106320589\":0 }, \"ea\":{ \"343635109\":0, \"1162787110\":0, \"-427055400\":0, \"-1285822129\":0, \"1699530232\":0 }, \"svec\":{ \"-725356351\":0, \"-1992427723\":0, \"751512662\":0, \"-118381281\":0, \"-171137512\":0, \"-2036479142\":0, \"-1338304551\":0, \"1003167705\":0, \"1046650497\":0, \"1919407518\":0, \"-1326325184\":0, \"480870493\":0, \"-1087232483\":0, \"-725540438\":0, \"-461793000\":0, \"1935019626\":0, \"76381608\":0, \"273797382\":0, \"-948909976\":0, \"-348193740\":0, \"-685370074\":0, \"-849874419\":0, \"2074021738\":0, \"-767572488\":0, \"-1091433459\":0, \"1671688881\":0, \"1304651793\":0, \"1299738196\":0, \"326063875\":0, \"296835202\":0, \"268236000\":0, \"1708308839\":0, \"101093345\":0, \"-652558691\":0, \"-1613021771\":0, \"1106318256\":0, \"-473874363\":0, \"-1267780435\":0, \"486732621\":0, \"1855792002\":0, \"-881258627\":0, \"698731249\":0, \"1510155838\":0, \"1119638805\":0, \"479337352\":0, \"1312099430\":0, \"1712783405\":0, \"-459721027\":0, \"-214402990\":0, \"617910950\":0, \"428901717\":0, \"-201124647\":0, \"940674176\":0, \"1632668193\":0, \"338835860\":0, \"879890181\":0, \"1667730064\":0 } } } ], \"lsv\":\"2.1.4\", \"pio\":30 }"

0 commit comments

Comments
 (0)