diff --git a/example/src/main/java/com/example/MainActivity.java b/example/src/main/java/com/example/MainActivity.java index 0eed11a1..09a3cb7e 100644 --- a/example/src/main/java/com/example/MainActivity.java +++ b/example/src/main/java/com/example/MainActivity.java @@ -25,7 +25,7 @@ protected void onCreate(Bundle savedInstanceState) { setContentView(R.layout.activity_main); // initialize the Parsely tracker with your site id and the current Context - ParselyTracker.sharedInstance("example.com", 30, this, true); + ParselyTracker.init("example.com", 30, this, true); final TextView intervalView = (TextView) findViewById(R.id.interval); @@ -62,7 +62,7 @@ public void run() { private void updateEngagementStrings() { StringBuilder eMsg = new StringBuilder("Engagement is "); - if (ParselyTracker.sharedInstance().engagementIsActive() == true) { + if (ParselyTracker.sharedInstance().engagementIsActive()) { eMsg.append("active."); } else { eMsg.append("inactive."); @@ -73,7 +73,7 @@ private void updateEngagementStrings() { eView.setText(eMsg.toString()); StringBuilder vMsg = new StringBuilder("Video is "); - if (ParselyTracker.sharedInstance().videoIsActive() == true) { + if (ParselyTracker.sharedInstance().videoIsActive()) { vMsg.append("active."); } else { vMsg.append("inactive."); diff --git a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt index 6bda073e..4d555df3 100644 --- a/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt +++ b/parsely/src/androidTest/java/com/parsely/parselyandroid/FunctionalTests.kt @@ -60,7 +60,7 @@ class FunctionalTests { scenario.onActivity { activity: Activity -> beforeEach(activity) server.enqueue(MockResponse().setResponseCode(200)) - parselyTracker = initializeTracker(activity) + initializeTracker(activity) repeat(51) { parselyTracker.trackPageview("url") @@ -91,7 +91,7 @@ class FunctionalTests { scenario.onActivity { activity: Activity -> beforeEach(activity) server.enqueue(MockResponse().setResponseCode(200)) - parselyTracker = initializeTracker(activity) + initializeTracker(activity) parselyTracker.trackPageview("url") } @@ -135,7 +135,7 @@ class FunctionalTests { beforeEach(activity) server.enqueue(MockResponse().setResponseCode(200)) server.enqueue(MockResponse().setResponseCode(200)) - parselyTracker = initializeTracker(activity, flushInterval = 1.hours) + initializeTracker(activity, flushInterval = 1.hours) repeat(20) { parselyTracker.trackPageview("url") @@ -169,7 +169,7 @@ class FunctionalTests { scenario.onActivity { activity: Activity -> beforeEach(activity) server.enqueue(MockResponse().setResponseCode(200)) - parselyTracker = initializeTracker(activity) + initializeTracker(activity) repeat(eventsToSend) { parselyTracker.trackPageview("url") @@ -217,7 +217,7 @@ class FunctionalTests { scenario.onActivity { activity: Activity -> beforeEach(activity) server.enqueue(MockResponse().setResponseCode(200)) - parselyTracker = initializeTracker(activity, flushInterval = 30.seconds) + initializeTracker(activity, flushInterval = 30.seconds) // when startTimestamp = System.currentTimeMillis().milliseconds @@ -314,13 +314,14 @@ class FunctionalTests { private fun initializeTracker( activity: Activity, flushInterval: Duration = defaultFlushInterval - ): ParselyTracker { + ) { val field: Field = ParselyTrackerInternal::class.java.getDeclaredField("ROOT_URL") field.isAccessible = true field.set(this, url) - return ParselyTracker.sharedInstance( + ParselyTracker.init( siteId, flushInterval.inWholeSeconds.toInt(), activity.application ) + parselyTracker = ParselyTracker.sharedInstance() } private companion object { diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyAlreadyInitializedException.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAlreadyInitializedException.kt new file mode 100644 index 00000000..dced4a57 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyAlreadyInitializedException.kt @@ -0,0 +1,4 @@ +package com.parsely.parselyandroid + +public class ParselyAlreadyInitializedException() : + Exception("Parse.ly SDK has been already initialized. Reinitialization is not supported.") diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyNotInitializedException.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyNotInitializedException.kt new file mode 100644 index 00000000..1d3d10b0 --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyNotInitializedException.kt @@ -0,0 +1,4 @@ +package com.parsely.parselyandroid + +public class ParselyNotInitializedException() : + Exception("Parse.ly client has not been initialized. Call ParselyTracker#init before using the SDK.") diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.kt index 7e57efae..79a99089 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.kt @@ -16,22 +16,58 @@ package com.parsely.parselyandroid import android.content.Context +import org.jetbrains.annotations.TestOnly /** * Tracks Parse.ly app views in Android apps * - * * Accessed as a singleton. Maintains a queue of pageview events in memory and periodically * flushes the queue to the Parse.ly pixel proxy server. */ public interface ParselyTracker { + + /** + * Get the heartbeat interval + * + * @return The base engagement tracking interval. + */ public val engagementInterval: Double? + public val videoEngagementInterval: Double? + + /** + * Returns the interval at which the event queue is flushed to Parse.ly. + * + * @return The interval at which the event queue is flushed to Parse.ly. + */ public val flushInterval: Long + /** + * Returns whether the engagement tracker is running. + * + * @return Whether the engagement tracker is running. + */ public fun engagementIsActive(): Boolean + + /** + * Returns whether video tracking is active. + * + * @return Whether video tracking is active. + */ public fun videoIsActive(): Boolean + /** + * Register a pageview event using a URL and optional metadata. + * + * @param url The URL of the article being tracked + * (eg: "http://example.com/some-old/article.html") + * @param urlRef Referrer URL associated with this video view. + * @param urlMetadata Optional metadata for the URL -- not used in most cases. Only needed + * when `url` isn't accessible over the Internet (i.e. app-only + * content). Do not use this for **content also hosted on** URLs Parse.ly + * would normally crawl. + * @param extraData A Map of additional information to send with the event. + */ public fun trackPageview( url: String, urlRef: String = "", @@ -39,37 +75,103 @@ public interface ParselyTracker { extraData: Map? = null, ) + /** + * Start engaged time tracking for the given URL. + * + * + * This starts a timer which will send events to Parse.ly on a regular basis + * to capture engaged time for this URL. The value of `url` should be a URL for + * which `trackPageview` has been called. + * + * @param url The URL to track engaged time for. + * @param urlRef Referrer URL associated with this video view. + */ public fun startEngagement( url: String, urlRef: String = "", extraData: Map? = null ) + /** + * Stop engaged time tracking. + * + * + * Stops the engaged time tracker, sending any accumulated engaged time to Parse.ly. + * NOTE: This **must** be called in your `MainActivity` during various Android lifecycle events + * like `onPause` or `onStop`. Otherwise, engaged time tracking may keep running in the background + * and Parse.ly values may be inaccurate. + */ public fun stopEngagement() + /** + * Start video tracking. + * + * + * Starts tracking view time for a video being viewed at a given url. Will send a `videostart` + * event unless the same url/videoId had previously been paused. + * Video metadata must be provided, specifically the video ID and video duration. + * + * + * The `url` value is *not* the URL of a video, but the post which contains the video. If the video + * is not embedded in a post, then this should contain a well-formatted URL on the customer's + * domain (e.g. http:///app-videos). This URL doesn't need to return a 200 status + * when crawled, but must but well-formatted so Parse.ly systems recognize it as belonging to + * the customer. + * + * @param url URL of post the video is embedded in. If videos is not embedded, a + * valid URL for the customer should still be provided. + * (e.g. http:///app-videos) + * @param urlRef Referrer URL associated with this video view. + * @param videoMetadata Metadata about the video being tracked. + * @param extraData A Map of additional information to send with the event. + */ public fun trackPlay( url: String, urlRef: String = "", videoMetadata: ParselyVideoMetadata, extraData: Map? = null, ) + + /** + * Pause video tracking. + * + * + * Pauses video tracking for an ongoing video. If [.trackPlay] is immediately called again for + * the same video, a new video start event will not be sent. This models a user pausing a + * playing video. + * + * + * NOTE: This or [.resetVideo] **must** be called in your `MainActivity` during various Android lifecycle events + * like `onPause` or `onStop`. Otherwise, engaged time tracking may keep running in the background + * and Parse.ly values may be inaccurate. + */ public fun trackPause() + + /** + * Reset tracking on a video. + * + * + * Stops video tracking and resets internal state for the video. If [.trackPlay] is immediately + * called for the same video, a new video start event is set. This models a user stopping a + * video and (on [.trackPlay] being called again) starting it over. + * + * + * NOTE: This or [.trackPause] **must** be called in your `MainActivity` during various Android lifecycle events + * like `onPause` or `onStop`. Otherwise, engaged time tracking may keep running in the background + * and Parse.ly values may be inaccurate. + */ public fun resetVideo() - public fun flushEventQueue() + public fun flushTimerIsActive(): Boolean public companion object { private const val DEFAULT_FLUSH_INTERVAL_SECS = 60 private var instance: ParselyTrackerInternal? = null - /** - * Singleton instance accessor. Note: This must be called after [.sharedInstance] - * - * @return The singleton instance - */ - @JvmStatic - public fun sharedInstance(): ParselyTracker? { - return instance + private fun ensureInitialized(): ParselyTracker { + return instance ?: run { + throw ParselyNotInitializedException() + } } /** @@ -83,17 +185,24 @@ public interface ParselyTracker { */ @JvmStatic @JvmOverloads - public fun sharedInstance( + public fun init( siteId: String, flushInterval: Int = DEFAULT_FLUSH_INTERVAL_SECS, context: Context, dryRun: Boolean = false, - ): ParselyTracker { - return instance ?: run { - val newInstance = ParselyTrackerInternal(siteId, flushInterval, context, dryRun) - instance = newInstance - return newInstance + ) { + if (instance != null) { + throw ParselyAlreadyInitializedException() } + instance = ParselyTrackerInternal(siteId, flushInterval, context, dryRun) + } + + @JvmStatic + public fun sharedInstance(): ParselyTracker = ensureInitialized() + + @TestOnly + internal fun tearDown() { + instance = null } } } diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTrackerInternal.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTrackerInternal.kt index 05cec30a..8cf6696c 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTrackerInternal.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTrackerInternal.kt @@ -66,55 +66,23 @@ internal class ParselyTrackerInternal internal constructor( ) } - /** - * Get the heartbeat interval - * - * @return The base engagement tracking interval. - */ override val engagementInterval: Double? get() = engagementManager?.intervalMillis override val videoEngagementInterval: Double? get() = videoEngagementManager?.intervalMillis - /** - * Returns whether the engagement tracker is running. - * - * @return Whether the engagement tracker is running. - */ override fun engagementIsActive(): Boolean { return engagementManager?.isRunning ?: false } - /** - * Returns whether video tracking is active. - * - * @return Whether video tracking is active. - */ override fun videoIsActive(): Boolean { return videoEngagementManager?.isRunning ?: false } - /** - * Returns the interval at which the event queue is flushed to Parse.ly. - * - * @return The interval at which the event queue is flushed to Parse.ly. - */ override val flushInterval: Long get() = flushManager.intervalMillis / 1000 - /** - * Register a pageview event using a URL and optional metadata. - * - * @param url The URL of the article being tracked - * (eg: "http://example.com/some-old/article.html") - * @param urlRef Referrer URL associated with this video view. - * @param urlMetadata Optional metadata for the URL -- not used in most cases. Only needed - * when `url` isn't accessible over the Internet (i.e. app-only - * content). Do not use this for **content also hosted on** URLs Parse.ly - * would normally crawl. - * @param extraData A Map of additional information to send with the event. - */ override fun trackPageview( url: String, urlRef: String, @@ -141,17 +109,6 @@ internal class ParselyTrackerInternal internal constructor( ) } - /** - * Start engaged time tracking for the given URL. - * - * - * This starts a timer which will send events to Parse.ly on a regular basis - * to capture engaged time for this URL. The value of `url` should be a URL for - * which `trackPageview` has been called. - * - * @param url The URL to track engaged time for. - * @param urlRef Referrer URL associated with this video view. - */ override fun startEngagement( url: String, urlRef: String, @@ -183,15 +140,6 @@ internal class ParselyTrackerInternal internal constructor( ).also { it.start() } } - /** - * Stop engaged time tracking. - * - * - * Stops the engaged time tracker, sending any accumulated engaged time to Parse.ly. - * NOTE: This **must** be called in your `MainActivity` during various Android lifecycle events - * like `onPause` or `onStop`. Otherwise, engaged time tracking may keep running in the background - * and Parse.ly values may be inaccurate. - */ override fun stopEngagement() { engagementManager?.let { it.stop() @@ -200,28 +148,6 @@ internal class ParselyTrackerInternal internal constructor( engagementManager = null } - /** - * Start video tracking. - * - * - * Starts tracking view time for a video being viewed at a given url. Will send a `videostart` - * event unless the same url/videoId had previously been paused. - * Video metadata must be provided, specifically the video ID and video duration. - * - * - * The `url` value is *not* the URL of a video, but the post which contains the video. If the video - * is not embedded in a post, then this should contain a well-formatted URL on the customer's - * domain (e.g. http:///app-videos). This URL doesn't need to return a 200 status - * when crawled, but must but well-formatted so Parse.ly systems recognize it as belonging to - * the customer. - * - * @param url URL of post the video is embedded in. If videos is not embedded, a - * valid URL for the customer should still be provided. - * (e.g. http:///app-videos) - * @param urlRef Referrer URL associated with this video view. - * @param videoMetadata Metadata about the video being tracked. - * @param extraData A Map of additional information to send with the event. - */ override fun trackPlay( url: String, urlRef: String, @@ -266,36 +192,10 @@ internal class ParselyTrackerInternal internal constructor( ).also { it.start() } } - /** - * Pause video tracking. - * - * - * Pauses video tracking for an ongoing video. If [.trackPlay] is immediately called again for - * the same video, a new video start event will not be sent. This models a user pausing a - * playing video. - * - * - * NOTE: This or [.resetVideo] **must** be called in your `MainActivity` during various Android lifecycle events - * like `onPause` or `onStop`. Otherwise, engaged time tracking may keep running in the background - * and Parse.ly values may be inaccurate. - */ override fun trackPause() { videoEngagementManager?.stop() } - /** - * Reset tracking on a video. - * - * - * Stops video tracking and resets internal state for the video. If [.trackPlay] is immediately - * called for the same video, a new video start event is set. This models a user stopping a - * video and (on [.trackPlay] being called again) starting it over. - * - * - * NOTE: This or [.trackPause] **must** be called in your `MainActivity` during various Android lifecycle events - * like `onPause` or `onStop`. Otherwise, engaged time tracking may keep running in the background - * and Parse.ly values may be inaccurate. - */ override fun resetVideo() { videoEngagementManager?.stop() videoEngagementManager = null @@ -311,15 +211,6 @@ internal class ParselyTrackerInternal internal constructor( inMemoryBuffer.add(event) } - /** - * Deprecated since 3.1.1. The SDK now automatically flushes the queue on app lifecycle events. - * Any usage of this method is safe to remove and will have no effect. Keeping for backwards compatibility. - */ - @Deprecated("The SDK now automatically flushes the queue on app lifecycle events. Any usage of this method is safe to remove and will have no effect") - override fun flushEventQueue() { - // no-op - } - /** * Start the timer to flush events to Parsely. * @@ -349,7 +240,7 @@ internal class ParselyTrackerInternal internal constructor( flushQueue.invoke(dryRun) } - companion object { + internal companion object { private const val DEFAULT_ENGAGEMENT_INTERVAL_MILLIS = 10500 @JvmField val ROOT_URL: String = "https://p1.parsely.com/".intern() } diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyTrackerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyTrackerTest.kt new file mode 100644 index 00000000..4621e847 --- /dev/null +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyTrackerTest.kt @@ -0,0 +1,40 @@ +package com.parsely.parselyandroid + +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class ParselyTrackerTest { + + @Test(expected = ParselyNotInitializedException::class) + fun `given no prior initialization, when executing a method, throw the exception`() { + ParselyTracker.sharedInstance().engagementIsActive() + } + + @Test(expected = ParselyAlreadyInitializedException::class) + fun `given prior initialization, when initializing, throw an exception`() { + ParselyTracker.init(siteId = "", context = RuntimeEnvironment.getApplication()) + + ParselyTracker.init(siteId = "", context = RuntimeEnvironment.getApplication()) + } + + @Test + fun `given no prior initialization, when initializing, do not throw any exception`() { + ParselyTracker.init(siteId = "", context = RuntimeEnvironment.getApplication()) + } + + @Test + fun `given tracker initialized, when calling a method, do not throw any exception`() { + ParselyTracker.init(siteId = "", context = RuntimeEnvironment.getApplication()) + + ParselyTracker.sharedInstance().engagementIsActive() + } + + @After + fun tearDown() { + ParselyTracker.tearDown() + } +}