Skip to content

Commit

Permalink
Add new WebView Interface (#700)
Browse files Browse the repository at this point in the history
* Add new webview classes

* Add tests

* Add another test

* Start adding support for self-desc events

* Use MockNetworkConnection in tests

* Allow self-describing event data

* Add another test

* Subscribe using new interface

* Remove unused imports

* Remove spaces

* Add more test sleep

* Fix small review comments

* Improve the API

* Update tests to use EventSink where possible

* Update test

* Add test for default eventname and trackerversion
  • Loading branch information
mscwilson authored Jan 16, 2025
1 parent 05fdaa6 commit 693f638
Show file tree
Hide file tree
Showing 6 changed files with 471 additions and 13 deletions.
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) {
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

0 comments on commit 693f638

Please sign in to comment.