diff --git a/snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/LinkDecoratorTest.kt b/snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/LinkDecoratorTest.kt new file mode 100644 index 000000000..fdf1fb014 --- /dev/null +++ b/snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/LinkDecoratorTest.kt @@ -0,0 +1,179 @@ +package com.snowplowanalytics.snowplow.tracker + + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.snowplowanalytics.core.tracker.CrossDeviceParameter +import com.snowplowanalytics.snowplow.Snowplow +import com.snowplowanalytics.snowplow.configuration.NetworkConfiguration +import com.snowplowanalytics.snowplow.configuration.SessionConfiguration +import com.snowplowanalytics.snowplow.configuration.SubjectConfiguration +import com.snowplowanalytics.snowplow.configuration.TrackerConfiguration +import com.snowplowanalytics.snowplow.controller.SessionController +import com.snowplowanalytics.snowplow.controller.TrackerController +import com.snowplowanalytics.snowplow.event.ScreenView +import com.snowplowanalytics.snowplow.network.HttpMethod +import com.snowplowanalytics.snowplow.util.TimeMeasure +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.TimeUnit + + +@RunWith(AndroidJUnit4::class) +class LinkDecoratorTest { + private lateinit var tracker: TrackerController + private lateinit var session: SessionController + private lateinit var userId: String + private val testLink = Uri.parse("http://example.com") + private fun matchesRegex(pattern: Regex, result: Uri) { + Assert.assertTrue( + "$result\ndoes not match expected: $pattern", + pattern.matches(result.toString()) + ) + } + + + @Before + fun before() { + tracker = getTracker() + session = tracker.session!! + userId = session.userId + } + + @Test + fun testWithoutSession() { + val tracker = getTrackerNoSession() + val result = tracker.decorateLink(testLink) + Assert.assertEquals(null, result) + } + + @Test + fun testDecorateUriWithNoParams() { + tracker.track(ScreenView("test")) + + val pattern = + Regex("""http://example\.com\?_sp=$userId\.\d{13}\.${session.sessionId}\.decoratorTest\.mob\.subjectUserId""") + val result = tracker.decorateLink(testLink) + + matchesRegex(pattern, result!!) + } + + @Test + fun testDecorateUriWithExistingSpParam() { + tracker.track(ScreenView("test")) + + val pattern = + Regex("""http://example\.com\?_sp=$userId\.\d{13}\.${session.sessionId}\.decoratorTest\.mob\.subjectUserId""") + val result = tracker.decorateLink(testLink.buildUpon().appendQueryParameter("_sp", "test").build()) + + matchesRegex(pattern, result!!) + } + + @Test + fun testDecorateUriWithOtherParam() { + tracker.track(ScreenView("test")) + + val pattern = + Regex("""http://example\.com\?a=b&_sp=$userId\.\d{13}\.${session.sessionId}\.decoratorTest\.mob\.subjectUserId""") + val result = + tracker.decorateLink(testLink.buildUpon().appendQueryParameter("a", "b").build()) + + matchesRegex(pattern, result!!) + } + + @Test + fun testDecorateUriWithParameters() { + tracker.track(ScreenView("test")) + + val sessionId = session.sessionId + val expectedParams = hashMapOf( + listOf(CrossDeviceParameter.SESSION_ID) to ".$sessionId", + + listOf( + CrossDeviceParameter.SESSION_ID, + CrossDeviceParameter.SOURCE_ID + ) to ".$sessionId.decoratorTest", + + listOf( + CrossDeviceParameter.SESSION_ID, + CrossDeviceParameter.SOURCE_ID, + CrossDeviceParameter.SOURCE_PLATFORM + ) to ".$sessionId.decoratorTest.mob", + + listOf( + CrossDeviceParameter.SESSION_ID, + CrossDeviceParameter.SOURCE_ID, + CrossDeviceParameter.SOURCE_PLATFORM, + CrossDeviceParameter.USER_ID + ) to ".$sessionId.decoratorTest.mob.subjectUserId", + + listOf( + CrossDeviceParameter.SESSION_ID, + CrossDeviceParameter.SOURCE_PLATFORM, + ) to ".$sessionId..mob", + + listOf( + CrossDeviceParameter.SOURCE_ID, + CrossDeviceParameter.USER_ID + ) to "..decoratorTest..subjectUserId", + + listOf( + CrossDeviceParameter.USER_ID + ) to "....subjectUserId", + + emptyList() to "", + ) + + for ((param, spVal) in expectedParams.entries) { + val pattern = + Regex("""http://example\.com\?_sp=$userId\.\d{13}$spVal""") + val result = tracker.decorateLink(testLink, param) + + matchesRegex(pattern, result!!) + } + } + + fun getTracker(): TrackerController { + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val networkConfiguration = NetworkConfiguration("fake-url", HttpMethod.POST) + + val trackerConfiguration = TrackerConfiguration("decoratorTest") + .sessionContext(true) + + val subjectConfig = SubjectConfiguration().userId("subjectUserId") + + val sessionConfiguration = SessionConfiguration( + TimeMeasure(6, TimeUnit.SECONDS), + TimeMeasure(30, TimeUnit.SECONDS), + ) + + return Snowplow.createTracker( + context, + "namespace" + Math.random(), + networkConfiguration, + trackerConfiguration, + sessionConfiguration, + subjectConfig + ) + } + + fun getTrackerNoSession(): TrackerController { + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val networkConfiguration = NetworkConfiguration("fake-url", HttpMethod.POST) + + val trackerConfiguration = TrackerConfiguration("decoratorTest") + .sessionContext(false) + + return Snowplow.createTracker( + context, + "namespace" + Math.random(), + networkConfiguration, + trackerConfiguration, + ) + } +} diff --git a/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/CrossDeviceParameter.kt b/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/CrossDeviceParameter.kt new file mode 100644 index 000000000..6459742c1 --- /dev/null +++ b/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/CrossDeviceParameter.kt @@ -0,0 +1,29 @@ +package com.snowplowanalytics.core.tracker + +import com.snowplowanalytics.snowplow.controller.SessionController +import com.snowplowanalytics.snowplow.controller.TrackerController + +/** + * The optional parameters to include in the query string added by [TrackerController.decorateLink] + */ +enum class CrossDeviceParameter { + /** + * Value of [SessionController.sessionId] + */ + SESSION_ID, + + /** + * Value of [Tracker.appId] + */ + SOURCE_ID, + + /** + * Value of [Tracker.platform] + */ + SOURCE_PLATFORM, + + /** + * Value of [Subject.userId] + */ + USER_ID, +} diff --git a/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/TrackerControllerImpl.kt b/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/TrackerControllerImpl.kt index 51e4614ae..0cbdefa99 100644 --- a/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/TrackerControllerImpl.kt +++ b/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/TrackerControllerImpl.kt @@ -12,6 +12,7 @@ */ package com.snowplowanalytics.core.tracker +import android.net.Uri import androidx.annotation.RestrictTo import com.snowplowanalytics.core.Controller import com.snowplowanalytics.core.ecommerce.EcommerceControllerImpl @@ -70,6 +71,42 @@ class TrackerControllerImpl // Constructors return tracker.track(event) } + + override fun decorateLink(uri: Uri, parameters: List): Uri? { + // UserId is a required parameter of `_sp` + if (this.session?.userId == null) { + return null + } + + val values = hashMapOf( + CrossDeviceParameter.SESSION_ID to (this.session?.sessionId ?: ""), + CrossDeviceParameter.SOURCE_ID to this.appId, + CrossDeviceParameter.SOURCE_PLATFORM to this.devicePlatform.value, + CrossDeviceParameter.USER_ID to (this.subject.userId ?: "") + ) + + // Create our list of values in the required order + val spVals = listOf( + this.session?.userId, System.currentTimeMillis() + ) + CrossDeviceParameter.values().map { + if (it in parameters) values[it] else "" + } + + // Remove any existing `_sp` param if present + val builder = uri.buildUpon() + if (!uri.getQueryParameter(crossDeviceQueryParameterKey).isNullOrBlank()) { + builder.clearQuery() + uri.queryParameterNames.forEach { + if (it != crossDeviceQueryParameterKey) builder.appendQueryParameter(it, uri.getQueryParameter(it)) + } + } + + return builder.appendQueryParameter( + crossDeviceQueryParameterKey, + spVals.joinToString(".").trimEnd('.') + ).build() + } + override val version: String get() = BuildConfig.TRACKER_LABEL override val isTracking: Boolean @@ -219,6 +256,8 @@ class TrackerControllerImpl // Constructors private val dirtyConfig: TrackerConfiguration get() = serviceProvider.trackerConfiguration + private val crossDeviceQueryParameterKey = "_sp" + companion object { private val TAG = TrackerControllerImpl::class.java.simpleName } diff --git a/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/controller/TrackerController.kt b/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/controller/TrackerController.kt index f32a80b89..f3c76737a 100644 --- a/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/controller/TrackerController.kt +++ b/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/controller/TrackerController.kt @@ -12,7 +12,11 @@ */ package com.snowplowanalytics.snowplow.controller +import android.net.Uri +import com.snowplowanalytics.core.session.Session +import com.snowplowanalytics.core.tracker.CrossDeviceParameter import com.snowplowanalytics.core.tracker.TrackerConfigurationInterface +import com.snowplowanalytics.snowplow.configuration.TrackerConfiguration import com.snowplowanalytics.snowplow.ecommerce.EcommerceController import com.snowplowanalytics.snowplow.event.Event import com.snowplowanalytics.snowplow.media.controller.MediaController @@ -116,4 +120,23 @@ interface TrackerController : TrackerConfigurationInterface { * The tracker will start tracking again. */ fun resume() + + /** + * Adds user and session information to a URI. + * + * For example, calling decorateLink on `appSchema://path/to/page` will return: + * + * `appSchema://path/to/page?_sp=userId.timestamp.sessionId.appId.platform.domainUserId` + * + * @param uri The URI to add the query string to + * @param parameters The parameters to include in the query string (defaults to all) + * + * @return Optional Uri: + * - null if [Session.userId] is null from `sessionContext(false)` being passed in [TrackerConfiguration] + * - otherwise, decorated Uri + */ + fun decorateLink( + uri: Uri, + parameters: List = CrossDeviceParameter.values().toList() + ): Uri? }