Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new WebView Interface #700

Merged
merged 16 commits into from
Jan 16, 2025
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/*
* Copyright (c) 2015-present Snowplow Analytics Ltd. All rights reserved.
*
* This program is licensed to you under the Apache License Version 2.0,
* and you may not use this file except in compliance with the Apache License Version 2.0.
* You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the Apache License Version 2.0 is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the Apache License Version 2.0 for the specific language governing permissions and limitations there under.
*/

package com.snowplowanalytics.snowplow.tracker

import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.snowplowanalytics.core.constants.Parameters
import com.snowplowanalytics.core.constants.TrackerConstants
import com.snowplowanalytics.core.emitter.Executor
import com.snowplowanalytics.core.tracker.TrackerWebViewInterfaceV2
import com.snowplowanalytics.snowplow.Snowplow.createTracker
import com.snowplowanalytics.snowplow.Snowplow.removeAllTrackers
import com.snowplowanalytics.snowplow.configuration.NetworkConfiguration
import com.snowplowanalytics.snowplow.configuration.TrackerConfiguration
import com.snowplowanalytics.snowplow.controller.TrackerController
import com.snowplowanalytics.snowplow.network.HttpMethod
import com.snowplowanalytics.snowplow.util.EventSink
import org.json.JSONException
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class TrackerWebViewInterfaceV2Test {
private var webInterface: TrackerWebViewInterfaceV2? = null

@Before
fun setUp() {
webInterface = TrackerWebViewInterfaceV2()
}

@After
fun tearDown() {
removeAllTrackers()
Executor.shutdown()
}

@Test
@Throws(JSONException::class, InterruptedException::class)
fun tracksEventWithAllOptions() {
val networkConnection = MockNetworkConnection(HttpMethod.GET, 200)
createTracker(
context,
"ns${Math.random()}",
NetworkConfiguration(networkConnection),
TrackerConfiguration("appId").base64encoding(false)
)

val data = "{\"schema\":\"iglu:etc\",\"data\":{\"key\":\"val\"}}"
val atomic = "{\"eventName\":\"pv\",\"trackerVersion\":\"webview\"," +
"\"useragent\":\"Chrome\",\"pageUrl\":\"http://snowplow.com\"," +
"\"pageTitle\":\"Snowplow\",\"referrer\":\"http://google.com\"," +
"\"pingXOffsetMin\":10,\"pingXOffsetMax\":20,\"pingYOffsetMin\":30," +
"\"pingYOffsetMax\":40,\"category\":\"cat\",\"action\":\"act\"," +
"\"property\":\"prop\",\"label\":\"lbl\",\"value\":10.0}"

webInterface!!.trackWebViewEvent(
selfDescribingEventData = data,
atomicProperties = atomic
)

waitForEvents(networkConnection)
assertEquals(1, networkConnection.countRequests())

val request = networkConnection.allRequests[0]
val payload = request.payload.map

assertEquals("pv", payload[Parameters.EVENT])
assertEquals("webview", payload[Parameters.TRACKER_VERSION])
assertEquals("Chrome", payload[Parameters.USERAGENT])
assertEquals("http://snowplow.com", payload[Parameters.PAGE_URL])
assertEquals("Snowplow", payload[Parameters.PAGE_TITLE])
assertEquals("http://google.com", payload[Parameters.PAGE_REFR])
assertEquals("10", payload[Parameters.PING_XOFFSET_MIN])
assertEquals("20", payload[Parameters.PING_XOFFSET_MAX])
assertEquals("30", payload[Parameters.PING_YOFFSET_MIN])
assertEquals("40", payload[Parameters.PING_YOFFSET_MAX])
assertEquals("cat", payload[Parameters.SE_CATEGORY])
assertEquals("act", payload[Parameters.SE_ACTION])
assertEquals("prop", payload[Parameters.SE_PROPERTY])
assertEquals("lbl", payload[Parameters.SE_LABEL])
assertEquals("10.0", payload[Parameters.SE_VALUE])

assertTrue(payload.containsKey(Parameters.UNSTRUCTURED))
val selfDescJson = JSONObject(payload[Parameters.UNSTRUCTURED] as String)
assertEquals(TrackerConstants.SCHEMA_UNSTRUCT_EVENT, selfDescJson.getString("schema"))
assertEquals(data, selfDescJson.getString("data"))
}

@Test
@Throws(JSONException::class, InterruptedException::class)
fun addsDefaultPropertiesIfNotProvided() {
val networkConnection = MockNetworkConnection(HttpMethod.GET, 200)
createTracker(
context,
"ns${Math.random()}",
NetworkConfiguration(networkConnection),
TrackerConfiguration("appId").base64encoding(false)
)

webInterface!!.trackWebViewEvent(atomicProperties = "{}")

waitForEvents(networkConnection)
assertEquals(1, networkConnection.countRequests())

val request = networkConnection.allRequests[0]
val payload = request.payload.map

assertEquals("ue", payload[Parameters.EVENT])

val trackerVersion = payload[Parameters.TRACKER_VERSION] as String?
assertTrue(trackerVersion?.startsWith("andr") ?: false)
}

@Test
@Throws(JSONException::class, InterruptedException::class)
fun tracksEventWithCorrectTracker() {
val eventSink1 = EventSink()
val eventSink2 = EventSink()

createTracker("ns1", eventSink1)
createTracker("ns2", eventSink2)
Thread.sleep(200)

// track an event using the second tracker
webInterface!!.trackWebViewEvent(
atomicProperties = "{}",
trackers = arrayOf("ns2")
)
Thread.sleep(200)

assertEquals(0, eventSink1.trackedEvents.size)
assertEquals(1, eventSink2.trackedEvents.size)

// tracks using default tracker if not specified
webInterface!!.trackWebViewEvent(atomicProperties = "{}")
Thread.sleep(200)

assertEquals(1, eventSink1.trackedEvents.size)
assertEquals(1, eventSink2.trackedEvents.size)
}

@Test
@Throws(JSONException::class, InterruptedException::class)
fun tracksEventWithEntity() {
val namespace = "ns" + Math.random().toString()
val eventSink = EventSink()
createTracker(namespace, eventSink)

webInterface!!.trackWebViewEvent(
atomicProperties = "{}",
entities = "[{\"schema\":\"iglu:com.example/etc\",\"data\":{\"key\":\"val\"}}]",
trackers = arrayOf(namespace)
)
Thread.sleep(200)
val events = eventSink.trackedEvents
assertEquals(1, events.size)

val relevantEntities = events[0].entities.filter { it.map["schema"] == "iglu:com.example/etc" }
assertEquals(1, relevantEntities.size)

val entityData = relevantEntities[0].map["data"] as HashMap<*, *>?
assertEquals("val", entityData?.get("key"))
}

@Test
@Throws(JSONException::class, InterruptedException::class)
fun addsEventNameAndSchemaForInspection() {
val namespace = "ns" + Math.random().toString()
val eventSink = EventSink()
createTracker(namespace, eventSink)

webInterface!!.trackWebViewEvent(
atomicProperties = "{\"eventName\":\"se\"}",
selfDescribingEventData = "{\"schema\":\"iglu:etc\",\"data\":{\"key\":\"val\"}}",
trackers = arrayOf(namespace)
)

Thread.sleep(200)
val events = eventSink.trackedEvents

assertEquals(1, events.size)
assertEquals("se", events[0].name)
assertEquals("iglu:etc", events[0].schema)
}

// --- PRIVATE
private val context: Context
get() = InstrumentationRegistry.getInstrumentation().targetContext

private fun createTracker(namespace: String, eventSink: EventSink): TrackerController {
val networkConfig = NetworkConfiguration(MockNetworkConnection(HttpMethod.POST, 200))
return createTracker(
context,
namespace = namespace,
network = networkConfig,
configurations = arrayOf(eventSink)
)
}

private fun waitForEvents(networkConnection: MockNetworkConnection) {
var i = 0
while (i < 10 && networkConnection.countRequests() == 0) {
Thread.sleep(1000)
i++
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -259,4 +259,11 @@ object Parameters {
const val DIAGNOSTIC_ERROR_STACK = "stackTrace"
const val DIAGNOSTIC_ERROR_CLASS_NAME = "className"
const val DIAGNOSTIC_ERROR_EXCEPTION_NAME = "exceptionName"

// Page Pings (for WebView tracking)
const val PING_XOFFSET_MIN = "pp_mix"
const val PING_XOFFSET_MAX = "pp_max"
const val PING_YOFFSET_MIN = "pp_miy"
const val PING_YOFFSET_MAX = "pp_may"
const val WEBVIEW_EVENT_DATA = "selfDescribingEventData"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2015-present Snowplow Analytics Ltd. All rights reserved.
*
* This program is licensed to you under the Apache License Version 2.0,
* and you may not use this file except in compliance with the Apache License Version 2.0.
* You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the Apache License Version 2.0 is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the Apache License Version 2.0 for the specific language governing permissions and limitations there under.
*/
package com.snowplowanalytics.core.event

import com.snowplowanalytics.core.constants.Parameters
import com.snowplowanalytics.snowplow.event.AbstractEvent
import com.snowplowanalytics.snowplow.payload.SelfDescribingJson

/**
* Allows the tracking of JavaScript events from WebViews.
*/
class WebViewReader(
val selfDescribingEventData: SelfDescribingJson? = null,
val eventName: String? = null,
val trackerVersion: String? = null,
val useragent: String? = null,
val pageUrl: String? = null,
val pageTitle: String? = null,
val referrer: String? = null,
val category: String? = null,
val action: String? = null,
val label: String? = null,
val property: String? = null,
val value: Double? = null,
val pingXOffsetMin: Int? = null,
val pingXOffsetMax: Int? = null,
val pingYOffsetMin: Int? = null,
val pingYOffsetMax: Int? = null
) : AbstractEvent() {

// Public methods
override val dataPayload: Map<String, Any?>
get() {
val payload = HashMap<String, Any?>()
if (selfDescribingEventData != null) payload[Parameters.WEBVIEW_EVENT_DATA] = selfDescribingEventData
if (eventName != null) payload[Parameters.EVENT] = eventName
if (trackerVersion != null) payload[Parameters.TRACKER_VERSION] = trackerVersion
if (useragent != null) payload[Parameters.USERAGENT] = useragent
if (pageUrl != null) payload[Parameters.PAGE_URL] = pageUrl
if (pageTitle != null) payload[Parameters.PAGE_TITLE] = pageTitle
if (referrer != null) payload[Parameters.PAGE_REFR] = referrer
if (category != null) payload[Parameters.SE_CATEGORY] = category
if (action != null) payload[Parameters.SE_ACTION] = action
if (label != null) payload[Parameters.SE_LABEL] = label
if (property != null) payload[Parameters.SE_PROPERTY] = property
if (value != null) payload[Parameters.SE_VALUE] = value
if (pingXOffsetMin != null) payload[Parameters.PING_XOFFSET_MIN] = pingXOffsetMin
if (pingXOffsetMax != null) payload[Parameters.PING_XOFFSET_MAX] = pingXOffsetMax
if (pingYOffsetMin != null) payload[Parameters.PING_YOFFSET_MIN] = pingYOffsetMin
if (pingYOffsetMax != null) payload[Parameters.PING_YOFFSET_MAX] = pingYOffsetMax
return payload
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ package com.snowplowanalytics.core.tracker

import com.snowplowanalytics.core.constants.Parameters
import com.snowplowanalytics.core.constants.TrackerConstants
import com.snowplowanalytics.core.event.WebViewReader
import com.snowplowanalytics.core.statemachine.StateMachineEvent
import com.snowplowanalytics.core.statemachine.TrackerState
import com.snowplowanalytics.core.statemachine.TrackerStateSnapshot
Expand All @@ -39,6 +40,7 @@ class TrackerEvent @JvmOverloads constructor(event: Event, state: TrackerStateSn
var trueTimestamp: Long?
var isPrimitive = false
var isService: Boolean
var isWebView = false

init {
entities = event.entities.toMutableList()
Expand All @@ -56,12 +58,20 @@ class TrackerEvent @JvmOverloads constructor(event: Event, state: TrackerStateSn
}

isService = event is TrackerError
if (event is AbstractPrimitive) {
name = event.name
isPrimitive = true
} else {
schema = (event as? AbstractSelfDescribing)?.schema
isPrimitive = false
when (event) {
is WebViewReader -> {
name = payload[Parameters.EVENT]?.toString()
schema = getWebViewSchema()
isWebView = true
}
is AbstractPrimitive -> {
name = event.name
isPrimitive = true
}
else -> {
schema = (event as? AbstractSelfDescribing)?.schema
isPrimitive = false
}
}
}

Expand Down Expand Up @@ -100,16 +110,19 @@ class TrackerEvent @JvmOverloads constructor(event: Event, state: TrackerStateSn
}

fun wrapPropertiesToPayload(toPayload: Payload, base64Encoded: Boolean) {
if (isPrimitive) {
toPayload.addMap(payload)
} else {
wrapSelfDescribingToPayload(toPayload, base64Encoded)
when {
isWebView -> wrapWebViewToPayload(toPayload, base64Encoded)
isPrimitive -> toPayload.addMap(payload)
else -> wrapSelfDescribingEventToPayload(toPayload, base64Encoded)
}
}

private fun getWebViewSchema(): String? {
val selfDescribingData = payload[Parameters.WEBVIEW_EVENT_DATA] as SelfDescribingJson?
return selfDescribingData?.map?.get(Parameters.SCHEMA)?.toString()
}

private fun wrapSelfDescribingToPayload(toPayload: Payload, base64Encoded: Boolean) {
val schema = schema ?: return
val data = SelfDescribingJson(schema, payload)
private fun addSelfDescribingDataToPayload(toPayload: Payload, base64Encoded: Boolean, data: SelfDescribingJson) {
val unstructuredEventPayload = HashMap<String?, Any?>()
unstructuredEventPayload[Parameters.SCHEMA] = TrackerConstants.SCHEMA_UNSTRUCT_EVENT
unstructuredEventPayload[Parameters.DATA] = data.map
Expand All @@ -120,4 +133,17 @@ class TrackerEvent @JvmOverloads constructor(event: Event, state: TrackerStateSn
Parameters.UNSTRUCTURED
)
}

private fun wrapWebViewToPayload(toPayload: Payload, base64Encoded: Boolean) {
mscwilson marked this conversation as resolved.
Show resolved Hide resolved
val selfDescribingData = payload[Parameters.WEBVIEW_EVENT_DATA] as SelfDescribingJson?
if (selfDescribingData != null) {
addSelfDescribingDataToPayload(toPayload, base64Encoded, selfDescribingData)
}
toPayload.addMap(payload.filterNot { it.key == Parameters.WEBVIEW_EVENT_DATA })
}

private fun wrapSelfDescribingEventToPayload(toPayload: Payload, base64Encoded: Boolean) {
val schema = schema ?: return
addSelfDescribingDataToPayload(toPayload, base64Encoded, SelfDescribingJson(schema, payload))
}
}
Loading
Loading