Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.

## 2.7.19

## Enhancements

- Added `EventTrackingBehavior` enum and `SuperwallOptions.eventTrackingBehavior` property for GDPR-compliant event collection control. Use `ALL` (default) to track everything, `SUPERWALL_ONLY` to suppress user-initiated tracking, trigger fires, and user-attribute updates while keeping internal SDK events, or `NONE` to stop all event collection entirely. The behavior can also be changed at runtime via `Superwall.instance.eventTrackingBehavior`.
- Deprecated `SuperwallOptions.isExternalDataCollectionEnabled`. Setting it to `false` now maps to `SUPERWALL_ONLY`; setting it back to `true` maps to `ALL`.

## 2.7.18

## Enhancements
Expand Down
34 changes: 34 additions & 0 deletions superwall/src/main/java/com/superwall/sdk/Superwall.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import com.superwall.sdk.analytics.superwall.SuperwallEventInfo
import com.superwall.sdk.billing.toInternalResult
import com.superwall.sdk.config.models.ConfigState
import com.superwall.sdk.config.models.ConfigurationStatus
import com.superwall.sdk.config.options.EventTrackingBehavior
import com.superwall.sdk.config.options.SuperwallOptions
import com.superwall.sdk.deeplinks.DeepLinkRouter
import com.superwall.sdk.delegate.InternalPurchaseResult
Expand Down Expand Up @@ -147,6 +148,39 @@ class Superwall(
}
}

/**
* Controls which events are sent to the Superwall servers at runtime.
*
* Defaults to [EventTrackingBehavior.ALL]. Update this at any point after
* [configure] to change event collection dynamically — for example, toggling to
* [EventTrackingBehavior.NONE] after the user declines data collection in a GDPR
* consent flow.
*
* You can also set the initial value via [SuperwallOptions.eventTrackingBehavior]
* before calling [configure].
*/
var eventTrackingBehavior: EventTrackingBehavior
get() = options.eventTrackingBehavior
set(newValue) {
options.eventTrackingBehavior = newValue

dependencyContainer.eventsQueue.setTrackingBehavior(newValue)

mainScope.launch {
paywallView?.webView?.messageHandler?.passEventTrackingBehaviorToWebView(newValue)
}

// When opting out entirely, don't emit the config-attributes event —
// it would otherwise transmit attributes right after the opt-out.
if (newValue == EventTrackingBehavior.NONE) {
return
}

ioScope.launch {
track(dependencyContainer.makeConfigAttributes())
}
}

/**
* The presented paywall view.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.superwall.sdk.config.options

/**
* Controls which events are sent to the Superwall servers.
*
* Use [SuperwallOptions.eventTrackingBehavior] or set [com.superwall.sdk.Superwall.eventTrackingBehavior]
* at runtime to change event collection at any time.
*
* - [ALL]: All events are tracked (default).
* - [SUPERWALL_ONLY]: Only internal Superwall events are tracked. User-initiated
* `Superwall.track` calls, trigger fires, and user-attribute updates are suppressed.
* Equivalent to the deprecated `isExternalDataCollectionEnabled = false`.
* - [NONE]: No events are sent to the Superwall servers.
*/
enum class EventTrackingBehavior(
val description: String,
) {
/** All events are tracked. This is the default. */
ALL("all"),

/**
* Only internal Superwall events are tracked.
*
* User-initiated tracking calls, trigger-fire events, and user-attribute
* updates are suppressed. All other internal SDK events continue to be sent.
*/
SUPERWALL_ONLY("superwallOnly"),

/** No events are sent to the Superwall servers. */
NONE("none"),
;

override fun toString(): String = description
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,36 @@ class SuperwallOptions() {
// received the go-ahead from the Superwall team.
var networkEnvironment: NetworkEnvironment = NetworkEnvironment.Release()

// Controls which events are sent to the Superwall servers.
//
// Defaults to [EventTrackingBehavior.ALL]. Set this to [EventTrackingBehavior.SUPERWALL_ONLY]
// to suppress user-initiated tracking, trigger fires, and user-attribute updates, or to
// [EventTrackingBehavior.NONE] to stop all event collection (e.g. for GDPR compliance).
//
// You can also change this at runtime via [com.superwall.sdk.Superwall.eventTrackingBehavior].
var eventTrackingBehavior: EventTrackingBehavior = EventTrackingBehavior.ALL

// Enables the sending of non-Superwall tracked events and properties back to the Superwall servers.
// Defaults to `true`.
//
// Set this to `false` to stop external data collection. This will not affect
// your ability to create triggers based on properties.
var isExternalDataCollectionEnabled: Boolean = true
// **WARNING**: Deprecated. Use [eventTrackingBehavior] instead.
// Setting this to `false` maps to [EventTrackingBehavior.SUPERWALL_ONLY] unless the current
// value is already [EventTrackingBehavior.NONE], in which case `NONE` is preserved.
// Setting it back to `true` maps to [EventTrackingBehavior.ALL].
@Deprecated(
"Use eventTrackingBehavior instead.",
ReplaceWith("eventTrackingBehavior"),
)
var isExternalDataCollectionEnabled: Boolean
get() = eventTrackingBehavior == EventTrackingBehavior.ALL
set(value) {
eventTrackingBehavior =
when {
value -> EventTrackingBehavior.ALL
eventTrackingBehavior != EventTrackingBehavior.NONE -> EventTrackingBehavior.SUPERWALL_ONLY
else -> eventTrackingBehavior
}
}

// Sets the device locale identifier to use when evaluating rules.
//
Expand Down Expand Up @@ -143,7 +167,11 @@ internal fun SuperwallOptions.toMap(): Map<String, Any> =
listOfNotNull(
"paywalls" to paywalls.toMap(),
"network_environment" to networkEnvironment.toMap(),
"is_external_data_collection_enabled" to isExternalDataCollectionEnabled,
"event_tracking_behavior" to eventTrackingBehavior.description,
// Keep emitting the deprecated `is_external_data_collection_enabled` boolean so
// backends/dashboards still reading it don't treat opted-out clients as the
// default. Mirrors the deprecated property (true only for `ALL`).
"is_external_data_collection_enabled" to (eventTrackingBehavior == EventTrackingBehavior.ALL),
localeIdentifier?.let { "locale_identifier" to it },
"is_game_controller_enabled" to isGameControllerEnabled,
"logging" to logging.toMap(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.webkit.JavascriptInterface
import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent
import com.superwall.sdk.analytics.superwall.SuperwallEvents
import com.superwall.sdk.config.options.EventTrackingBehavior
import com.superwall.sdk.dependencies.OptionsFactory
import com.superwall.sdk.dependencies.VariablesFactory
import com.superwall.sdk.logger.LogLevel
Expand Down Expand Up @@ -316,6 +317,31 @@ class PaywallMessageHandler(
passMessageToWebView(base64String = encodeToB64(jsonString))
}

// Informs the web paywall of the current event tracking behavior so it can
// honor opt-outs (e.g. suppressing first-party collector sends for GDPR).
suspend fun passEventTrackingBehaviorToWebView(behavior: EventTrackingBehavior) {
val eventList =
listOf(
mapOf(
"event_name" to "event_tracking_behavior",
"behavior" to behavior.description,
),
)
val jsonString =
try {
json.encodeToString(eventList.convertToJsonElement())
} catch (e: Throwable) {
Logger.debug(
LogLevel.error,
LogScope.superwallCore,
"Error encoding event_tracking_behavior: ${e.message}",
error = e,
)
return
}
passMessageToWebView(base64String = encodeToB64(jsonString))
}

// Passes the templated variables and params to the webview.
// This is called every paywall open incase variables like user attributes have changed.
private suspend fun passTemplatesToWebView(paywall: Paywall) {
Expand Down Expand Up @@ -380,6 +406,9 @@ class PaywallMessageHandler(
paywallInfo = paywallInfo,
)
track(trackedEvent)

val behavior = options.makeSuperwallOptions().eventTrackingBehavior
passEventTrackingBehaviorToWebView(behavior)
}
}

Expand Down
42 changes: 30 additions & 12 deletions superwall/src/main/java/com/superwall/sdk/storage/EventsQueue.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import com.superwall.sdk.Superwall
import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent
import com.superwall.sdk.analytics.internal.trackable.Trackable
import com.superwall.sdk.analytics.internal.trackable.UserInitiatedEvent
import com.superwall.sdk.config.ConfigManager
import com.superwall.sdk.config.options.EventTrackingBehavior
import com.superwall.sdk.config.options.SuperwallOptions
import com.superwall.sdk.misc.IOScope
import com.superwall.sdk.misc.MainScope
Expand All @@ -31,6 +31,10 @@ class EventsQueue(
private var elements = mutableListOf<EventData>()
private val timer = MutableSharedFlow<Long>()
private var job: Job? = null

// Capture the configured behavior up front; updated at runtime via [setTrackingBehavior].
private var trackingBehavior: EventTrackingBehavior =
configManager.options?.eventTrackingBehavior ?: EventTrackingBehavior.ALL
override val coroutineContext: CoroutineContext = Dispatchers.IO.limitedParallelism(1)

init {
Expand Down Expand Up @@ -67,28 +71,42 @@ class EventsQueue(
data: EventData,
event: Trackable,
) {
if (!externalDataCollectionAllowed(event)) return
if (!trackingAllowed(event)) return

launch {
elements.add(data)
}
}

private fun externalDataCollectionAllowed(event: Trackable): Boolean {
if (Superwall.instance.options.isExternalDataCollectionEnabled) {
return true
}
return when (event) {
is InternalSuperwallEvent.TriggerFire,
is InternalSuperwallEvent.Attributes,
is UserInitiatedEvent.Track,
-> false
else -> true
fun setTrackingBehavior(behavior: EventTrackingBehavior) {
launch {
trackingBehavior = behavior
if (behavior != EventTrackingBehavior.ALL) {
elements.clear()
}
}
}

private fun trackingAllowed(event: Trackable): Boolean =
when (trackingBehavior) {
EventTrackingBehavior.ALL -> true
EventTrackingBehavior.SUPERWALL_ONLY ->
when (event) {
is InternalSuperwallEvent.TriggerFire,
is InternalSuperwallEvent.Attributes,
is UserInitiatedEvent.Track,
-> false
else -> true
}
EventTrackingBehavior.NONE -> false
}

suspend fun flushInternal(depth: Int = 10) {
launch {
if (trackingBehavior == EventTrackingBehavior.NONE) {
elements.clear()
return@launch
}
val eventsToSend = mutableListOf<EventData>()
var i = 0
while (i < maxEventCount && elements.isNotEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.superwall.sdk.config.options

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

@Suppress("DEPRECATION")
class EventTrackingBehaviorTest {
@Test
fun `default event tracking behavior is ALL`() {
val options = SuperwallOptions()
assertEquals(EventTrackingBehavior.ALL, options.eventTrackingBehavior)
}

@Test
fun `descriptions match the wire format`() {
assertEquals("all", EventTrackingBehavior.ALL.description)
assertEquals("superwallOnly", EventTrackingBehavior.SUPERWALL_ONLY.description)
assertEquals("none", EventTrackingBehavior.NONE.description)
}

@Test
fun `deprecated flag getter reflects the behavior`() {
val options = SuperwallOptions()
assertTrue(options.isExternalDataCollectionEnabled)

options.eventTrackingBehavior = EventTrackingBehavior.SUPERWALL_ONLY
assertFalse(options.isExternalDataCollectionEnabled)

options.eventTrackingBehavior = EventTrackingBehavior.NONE
assertFalse(options.isExternalDataCollectionEnabled)
}

@Test
fun `setting deprecated flag to false maps to superwallOnly`() {
val options = SuperwallOptions()
options.isExternalDataCollectionEnabled = false
assertEquals(EventTrackingBehavior.SUPERWALL_ONLY, options.eventTrackingBehavior)
}

@Test
fun `setting deprecated flag to false preserves none`() {
val options = SuperwallOptions()
options.eventTrackingBehavior = EventTrackingBehavior.NONE
options.isExternalDataCollectionEnabled = false
assertEquals(EventTrackingBehavior.NONE, options.eventTrackingBehavior)
}

@Test
fun `setting deprecated flag to true maps to all`() {
val options = SuperwallOptions()
options.eventTrackingBehavior = EventTrackingBehavior.NONE
options.isExternalDataCollectionEnabled = true
assertEquals(EventTrackingBehavior.ALL, options.eventTrackingBehavior)
}

@Test
fun `toMap encodes the behavior and the legacy flag`() {
val options = SuperwallOptions()

val allMap = options.toMap()
assertEquals("all", allMap["event_tracking_behavior"])
assertEquals(true, allMap["is_external_data_collection_enabled"])

options.eventTrackingBehavior = EventTrackingBehavior.SUPERWALL_ONLY
val superwallOnlyMap = options.toMap()
assertEquals("superwallOnly", superwallOnlyMap["event_tracking_behavior"])
assertEquals(false, superwallOnlyMap["is_external_data_collection_enabled"])

options.eventTrackingBehavior = EventTrackingBehavior.NONE
val noneMap = options.toMap()
assertEquals("none", noneMap["event_tracking_behavior"])
assertEquals(false, noneMap["is_external_data_collection_enabled"])
}
}
2 changes: 1 addition & 1 deletion version.env
Original file line number Diff line number Diff line change
@@ -1 +1 @@
SUPERWALL_VERSION=2.7.18
SUPERWALL_VERSION=2.7.19
Loading