Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,29 @@ Kits are deployed as individual artifacts in Maven Central, and each has a dedic
| [Urban Airship](https://github.com/mparticle-integrations/mparticle-android-integration-urbanairship) | [`android-urbanairship-kit`](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.mparticle%22%20AND%20a%3A%22android-urbanairship-kit%22) |
| [Wootric](https://github.com/mparticle-integrations/mparticle-android-integration-wootric) | [`android-wootric-kit`](http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22com.mparticle%22%20AND%20a%3A%22android-wootric-kit%22) |

### Consent and Kit Initialization

If you need to prevent integration kits (and any embedded third-party SDKs) from initializing until a user has granted consent, configure `MParticleOptions` with `hasConsent(false)` (default). Kits will not start until consent is granted.

```java
MParticleOptions options = MParticleOptions.builder(context)
.credentials("apiKey", "apiSecret")
.hasConsent(false) // default
.build();

MParticle.start(options);

// Later, once consent is granted:
MParticle.getInstance().setHasConsent(true);
```

Notes:

- This gate only affects **kit initialization and kit forwarding**. Core SDK behavior (server-side integrations, identity, event batching/upload) continues to operate normally.
- Events and attribute calls made before consent is granted are **not replayed to kits** after consent. If you want kits to start immediately, set `.hasConsent(true)` at startup.
- If you never grant consent (via `.hasConsent(true)` or `MParticle.getInstance().setHasConsent(true)`), kits will remain stopped.
- `hasConsent` is separate from `ConsentState` and kit consent forwarding rules configured in the mParticle UI. Use `ConsentState` for per-purpose/per-regulation consent, and `hasConsent` as a global "do not start any kits yet" switch.

### Google Play Services Ads

The Google Play Services Ads framework is necessary to collect the Android Advertisting ID. AAID collection is required by all attribution and audience integrations, and many other integrations. Include the `-ads` artifact, a subset of [Google Play Services](https://developers.google.com/android/guides/setup):
Expand Down
15 changes: 15 additions & 0 deletions android-core/src/main/java/com/mparticle/MParticle.java
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,21 @@ public void setOptOut(@NonNull Boolean optOutStatus) {
}
}

/**
* Control whether integration kits may be started. If {@code false}, the SDK will prevent kits
* from starting until consent is granted.
*/
public void setHasConsent(boolean hasConsent) {
mKitManager.setHasConsent(hasConsent);
}

/**
* @return true if the SDK is currently allowed to start integration kits.
*/
public boolean hasConsent() {
return mKitManager.hasConsent();
}

/**
* Retrieve a URL to be loaded within a {@link WebView} to show the user a survey
* or feedback form.
Expand Down
22 changes: 22 additions & 0 deletions android-core/src/main/java/com/mparticle/MParticleOptions.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public class MParticleOptions {
private BaseIdentityTask mIdentityTask;

private Context mContext;
// If false, kit initialization can be delayed until the host app grants consent.
private boolean hasConsent = false;
private MParticle.InstallType mInstallType = MParticle.InstallType.AutoDetect;
private MParticle.Environment mEnvironment = MParticle.Environment.AutoDetect;
private String mApiKey;
Expand Down Expand Up @@ -64,6 +66,7 @@ private MParticleOptions() {

public MParticleOptions(@NonNull Builder builder) {
this.mContext = builder.context;
this.hasConsent = builder.hasConsent;
if (builder.apiKey != null) {
this.mApiKey = builder.apiKey;
}
Expand Down Expand Up @@ -168,6 +171,13 @@ public Context getContext() {
return mContext;
}

/**
* If false, the SDK will prevent integration kits from starting until consent is granted.
*/
public boolean hasConsent() {
return hasConsent;
}

/**
* Query the InstallType.
*/
Expand Down Expand Up @@ -395,6 +405,7 @@ public static class Builder {
private Context context;
String apiKey;
String apiSecret;
private boolean hasConsent = false;
private MParticle.InstallType installType;
private MParticle.Environment environment;
private IdentityApiRequest identifyRequest;
Expand Down Expand Up @@ -427,6 +438,17 @@ private Builder(Context context) {
this.context = context;
}

/**
* Control whether integration kits may be started. If set to {@code false}, the SDK will
* prevent integration kits from starting until consent is granted (via {@link #hasConsent(boolean)}
* on the builder, or a runtime setter).
*/
@NonNull
public Builder hasConsent(boolean hasConsent) {
this.hasConsent = hasConsent;
return this;
}

/**
* Register an Api Key and Secret to be used for the SDK. This is a required field, and your
* app will not function properly if you do not provide a valid Key and Secret.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public class KitFrameworkWrapper implements KitManager {
private final MParticleOptions mOptions;
private volatile boolean frameworkLoadAttempted = false;
private static volatile boolean kitsLoaded = false;
private volatile boolean hasConsent = false;

private Queue eventQueue;
private Queue<AttributeChange> attributeQueue;
Expand All @@ -68,9 +69,32 @@ public KitFrameworkWrapper(Context context, ReportingManager reportingManager, C
this.mContext = testing ? context : new KitContext(context);
this.mReportingManager = reportingManager;
this.mCoreCallbacks = new CoreCallbacksImpl(this, configManager, appStateManager);
this.hasConsent = options != null && options.hasConsent();
kitsLoaded = false;
}

@Override
public void setHasConsent(boolean hasConsent) {
boolean changed = this.hasConsent != hasConsent;
this.hasConsent = hasConsent;

if (!changed) {
return;
}

if (mKitManager != null) {
mKitManager.setHasConsent(hasConsent);
} else if (hasConsent && !frameworkLoadAttempted) {
// Avoid blocking callers; kit loading can be expensive.
new Thread(this::loadKitLibrary, "mParticle_kit_loader").start();
Comment on lines +87 to +89
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard consent-triggered kit loading with a single-flight lock

When consent flips to true before the first config update, this branch starts a new loader thread while UploadHandler can simultaneously call loadKitLibrary() during UPDATE_CONFIG; because loadKitLibrary() still uses an unsynchronized if (!frameworkLoadAttempted) check, both threads can enter initialization and create/configure kits twice. That can double-initialize third-party SDKs and emit duplicate kit lifecycle side effects in production startup races.

Useful? React with 👍 / 👎.

}
}

@Override
public boolean hasConsent() {
return hasConsent;
}

@WorkerThread
public void loadKitLibrary() {
if (!frameworkLoadAttempted) {
Expand All @@ -80,6 +104,7 @@ public void loadKitLibrary() {
Class clazz = Class.forName("com.mparticle.kits.KitManagerImpl");
Constructor<KitFrameworkWrapper> constructor = clazz.getConstructor(Context.class, ReportingManager.class, CoreCallbacks.class, MParticleOptions.class);
KitManager kitManager = constructor.newInstance(mContext, mReportingManager, mCoreCallbacks, mOptions);
kitManager.setHasConsent(hasConsent);
JSONArray configuration = mCoreCallbacks.getLatestKitConfiguration();
Logger.debug("Kit Framework loaded.");
this.mKitManager = kitManager;
Expand Down Expand Up @@ -136,7 +161,13 @@ public void addKitsLoadedListener(KitsLoadedListener listener) {
void setKitsLoaded(boolean kitsLoaded) {
this.kitsLoaded = kitsLoaded;
if (kitsLoaded) {
replayAndDisableQueue();
// If consent hasn't been granted yet, drop any queued kit calls rather than replaying
// them after consent (privacy expectation).
if (hasConsent) {
replayAndDisableQueue();
} else {
disableQueuing();
}
} else {
disableQueuing();
}
Expand Down Expand Up @@ -852,4 +883,4 @@ public void onKitApiCalled(String methodName, int kitId, Boolean used, Object...
}
};
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@

public interface KitManager {

/**
* Control whether integration kits may be started. When {@code false}, implementations should
* prevent kits from initializing/starting.
*/
void setHasConsent(boolean hasConsent);

/**
* @return true if integration kits may be started.
*/
boolean hasConsent();

WeakReference<Activity> getCurrentActivity();

void logEvent(BaseEvent event);
Expand Down Expand Up @@ -170,4 +181,4 @@ enum KitStatus {
STOPPED,
ACTIVE
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class ApiVisibilityTest {
publicMethodCount++
}
}
Assert.assertEquals(66, publicMethodCount)
Assert.assertEquals(68, publicMethodCount)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,38 @@ class KitFrameworkWrapperTest {
Assert.assertTrue(wrapper.kitsLoaded)
}

@Test
@Throws(Exception::class)
fun testQueuedCallsAreDroppedWhenNoConsent() {
val options = Mockito.mock(MParticleOptions::class.java)
Mockito.`when`(options.hasConsent()).thenReturn(false)
val wrapper =
KitFrameworkWrapper(
Mockito.mock(Context::class.java),
Mockito.mock(ReportingManager::class.java),
Mockito.mock(ConfigManager::class.java),
Mockito.mock(AppStateManager::class.java),
true,
options,
)
val mockKitManager = Mockito.mock(KitManager::class.java)
wrapper.setKitManager(mockKitManager)

wrapper.kitsLoaded = false
val event = MPEvent.Builder("example").build()
wrapper.logEvent(event)
wrapper.setUserAttribute("a key", "a value", 1)

// Simulate "kits loaded" while consent is false: queued calls should be cleared and not replayed.
wrapper.setKitsLoaded(true)

Assert.assertNull(wrapper.eventQueue)
Assert.assertNull(wrapper.attributeQueue)
verify(mockKitManager, times(0)).onSessionStart()
verify(mockKitManager, times(0)).logEvent(Mockito.any(MPEvent::class.java))
verify(mockKitManager, times(0)).setUserAttribute(Mockito.anyString(), Mockito.anyString(), Mockito.anyLong())
}

@Test
@Throws(Exception::class)
fun testDisableQueuing() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ public class KitManagerImpl implements KitManager, AttributionListener, UserAttr

ConcurrentHashMap<Integer, KitIntegration> providers = new ConcurrentHashMap<Integer, KitIntegration>();
private final Context mContext;
private volatile boolean hasConsent = false;

public KitManagerImpl(Context context, ReportingManager reportingManager, CoreCallbacks coreCallbacks, MParticleOptions options) {
mContext = context;
Expand All @@ -105,6 +106,7 @@ public KitManagerImpl(Context context, ReportingManager reportingManager, CoreCa
mKitIntegrationFactory = new KitIntegrationFactory(options);
if (options != null) {
mRoktOptions = options.getRoktOptions();
hasConsent = options.hasConsent();
}
MParticle instance = MParticle.getInstance();
if (instance != null) {
Expand Down Expand Up @@ -189,6 +191,25 @@ public void reloadKits() {
configureKits(kitConfigurations);
}

@Override
public void setHasConsent(boolean hasConsent) {
this.hasConsent = hasConsent;
runOnMainThread(() -> {
reloadKits();
Comment on lines +197 to +198
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid dropping post-consent events on background-thread grants

This posts kit reconfiguration to the main thread, so if setHasConsent(true) is called from a background callback there is a window where consent is true but kits are still not reloaded; during that window wrapper queueing is already disabled and kit calls are forwarded into an empty providers map, so the first post-consent events/attributes can be silently lost. The issue is limited to off-main consent updates, but those are common in async consent SDK callbacks.

Useful? React with 👍 / 👎.

// If consent is granted mid-session, best-effort signal current app state so kits
// don't have to wait for the next session boundary.
if (hasConsent && !isBackgrounded()) {
onApplicationForeground();
onSessionStart();
}
});
}

@Override
public boolean hasConsent() {
return hasConsent;
}

@Override
public void updateDataplan(@Nullable MParticleOptions.DataplanOptions dataplanOptions) {
if (dataplanOptions != null) {
Expand Down Expand Up @@ -230,6 +251,31 @@ protected synchronized void configureKits(@NonNull List<KitConfiguration> kitCon
HashSet<Integer> activeIds = new HashSet<Integer>();
HashMap<Integer, KitIntegration> previousKits = new HashMap<>(providers);

// Global gate: don't start any kits until the host app grants consent.
if (!hasConsent) {
// If any kits are already active, tear them down.
if (!providers.isEmpty()) {
for (Map.Entry<Integer, KitIntegration> entry : new HashMap<>(providers).entrySet()) {
Integer id = entry.getKey();
KitIntegration integration = entry.getValue();
if (integration != null) {
try {
Logger.debug("De-initializing kit (no consent): " + integration.getName());
clearIntegrationAttributes(integration);
integration.onKitDestroy();
integration.onKitCleanup();
} catch (Exception ignored) {
}
}
providers.remove(id);
Intent intent = new Intent(MParticle.ServiceProviders.BROADCAST_DISABLED + id);
getContext().sendBroadcast(intent);
}
}
onKitsLoaded(new HashMap<>(providers), previousKits, new ArrayList<>(kitConfigurations));
return;
}

if (kitConfigurations != null) {
for (KitConfiguration configuration : kitConfigurations) {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,43 @@ class KitManagerImplTest {
Assert.assertEquals(factory, manager.mKitIntegrationFactory)
}

@Test
@Throws(Exception::class)
fun testKitsDoNotStartUntilConsentGranted() {
val mockUser = mock(MParticleUser::class.java)
`when`(mockUser.consentState).thenReturn(ConsentState.builder().build())
`when`(mockIdentity!!.currentUser).thenReturn(mockUser)

val options = mock(MParticleOptions::class.java)
`when`(options.hasConsent()).thenReturn(false)
val manager: KitManagerImpl = MockKitManagerImpl(options)

val factory = mock(KitIntegrationFactory::class.java)
manager.setKitFactory(factory)
`when`(factory.getSupportedKits()).thenReturn(createKitsMap(listOf(1)).keys)
`when`(factory.isSupported(1)).thenReturn(true)

val kit = com.mparticle.mock.MockKit()
`when`(factory.createInstance(any(KitManagerImpl::class.java), any(KitConfiguration::class.java))).thenAnswer { invocation ->
val config = invocation.arguments[1] as KitConfiguration
kit.setConfiguration(config)
kit
}

val kitConfiguration = JSONArray().put(JSONObject("{\"id\":1}"))

manager.updateKits(kitConfiguration)

// Consent is false: nothing should start.
verify(factory, never()).createInstance(any(KitManagerImpl::class.java), any(KitConfiguration::class.java))
assertTrue(manager.providers.isEmpty())

// Grant consent: kits can start.
manager.setHasConsent(true)
verify(factory).createInstance(any(KitManagerImpl::class.java), any(KitConfiguration::class.java))
assertTrue(manager.providers.isNotEmpty())
}

private fun createKitsMap(
ids: List<Int>,
type: Class<*> = KitIntegration::class.java,
Expand Down Expand Up @@ -613,6 +650,7 @@ class KitManagerImplTest {
MParticleOptions
.builder(MockContext())
.sideloadedKits(mutableListOf(sideloadedKit) as List<SideloadedKit>)
.hasConsent(true)
.build()
val manager: KitManagerImpl = MockKitManagerImpl(options)
val factory = mock(KitIntegrationFactory::class.java)
Expand Down Expand Up @@ -647,6 +685,7 @@ class KitManagerImplTest {
MParticleOptions
.builder(MockContext())
.sideloadedKits(mutableListOf(sideloadedKit) as List<SideloadedKit>)
.hasConsent(true)
.build()
val manager: KitManagerImpl = MockKitManagerImpl(options)
val factory = mock(KitIntegrationFactory::class.java)
Expand Down
Loading