From f7ab7bf93c0b7e7eda0052ae42bb353dd7fbd01d Mon Sep 17 00:00:00 2001 From: Randy Date: Thu, 14 May 2026 20:38:14 +0200 Subject: [PATCH 1/4] Add trackConversion for sending conversion events Exposes a new public method on ParselyTracker for firing conversion events (newsletter signups, subscriptions, purchases, link clicks, lead capture, and arbitrary custom conversions) using the existing event queue, flush, and mobileproxy pipeline. - New ConversionType enum mirrors the categories accepted by the Parse.ly conversions backend (NEWSLETTER_SIGNUP, LEAD_CAPTURE, LINK_CLICK, SUBSCRIPTION, PURCHASE, CUSTOM). Each case carries the wire-format string the backend expects in `_conversion_type`. - trackConversion(url, conversionType, conversionLabel, ...) merges `_conversion_type` and `_conversion_label` into the event's extra_data alongside caller-supplied keys. Reserved keys cannot be overridden by caller extraData (caller values are applied first, reserved keys last). - The event is dispatched through the existing EventsBuilder / enqueueEvent / FlushQueue / mobileproxy pipeline. No changes to EventsBuilder, the buffer, the flush manager, or the API connection. - Tests: EventsBuilderTest gains "when building conversion event, then build the correct one" covering the conversion event shape (action, idsite, url, merged extra_data, absence of pvid/vsid). ParselyTrackerTest gains a smoke test that the public entry point doesn't throw when called after init. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../parsely/parselyandroid/ConversionType.kt | 15 ++++++++ .../parsely/parselyandroid/ParselyTracker.kt | 29 +++++++++++++++ .../parselyandroid/ParselyTrackerInternal.kt | 32 ++++++++++++++++ .../parselyandroid/EventsBuilderTest.kt | 37 +++++++++++++++++++ .../parselyandroid/ParselyTrackerTest.kt | 11 ++++++ 5 files changed, 124 insertions(+) create mode 100644 parsely/src/main/java/com/parsely/parselyandroid/ConversionType.kt diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ConversionType.kt b/parsely/src/main/java/com/parsely/parselyandroid/ConversionType.kt new file mode 100644 index 0000000..fcfffab --- /dev/null +++ b/parsely/src/main/java/com/parsely/parselyandroid/ConversionType.kt @@ -0,0 +1,15 @@ +package com.parsely.parselyandroid + +/** + * The category of a conversion event. The [wireValue] of each case is the string sent to + * Parse.ly over the wire and must match the values accepted by the Parse.ly conversions + * backend. Use [CUSTOM] for conversions that don't fit one of the named categories. + */ +public enum class ConversionType(internal val wireValue: String) { + NEWSLETTER_SIGNUP("newsletter_signup"), + LEAD_CAPTURE("lead_capture"), + LINK_CLICK("link_click"), + SUBSCRIPTION("subscription"), + PURCHASE("purchase"), + CUSTOM("custom"), +} diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.kt index b8155bc..c75a752 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTracker.kt @@ -135,6 +135,35 @@ public interface ParselyTracker { */ public fun resetVideo() + /** + * Track a conversion event (e.g. newsletter signup, subscription, purchase). + * + * The category and label are merged into the event's `extra_data` under the reserved keys + * `_conversion_type` and `_conversion_label`, which the Parse.ly conversions backend uses to + * identify the goal. Reserved keys cannot be overridden via [extraData]. + * + * @param url The URL at which the conversion occurred. + * @param conversionType The category of conversion. Use [ConversionType.CUSTOM] for + * conversions that don't fit the named categories. + * @param conversionLabel A customer-defined identifier for this conversion (e.g. + * "weekly_plan", "homepage_cta"). Events without a label are dropped + * by the Parse.ly conversions backend. + * @param urlRef The url of the page that linked to the conversion page. Analogous to HTTP referer. + * @param urlMetadata Optional metadata for the URL. + * @param extraData A map of additional information to send with the event. Reserved keys + * `_conversion_type` and `_conversion_label` will be overwritten. + * @param siteIdSource The source of the site ID to use for the event. + */ + public fun trackConversion( + url: String, + conversionType: ConversionType, + conversionLabel: String, + urlRef: String = "", + urlMetadata: ParselyMetadata? = null, + extraData: Map? = null, + siteIdSource: SiteIdSource = SiteIdSource.Default, + ) + public companion object { private const val DEFAULT_FLUSH_INTERVAL_SECS = 60 private var instance: ParselyTrackerInternal? = null diff --git a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTrackerInternal.kt b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTrackerInternal.kt index 2a18855..c062d4c 100644 --- a/parsely/src/main/java/com/parsely/parselyandroid/ParselyTrackerInternal.kt +++ b/parsely/src/main/java/com/parsely/parselyandroid/ParselyTrackerInternal.kt @@ -206,6 +206,38 @@ internal class ParselyTrackerInternal internal constructor( videoEngagementManager = null } + override fun trackConversion( + url: String, + conversionType: ConversionType, + conversionLabel: String, + urlRef: String, + urlMetadata: ParselyMetadata?, + extraData: Map?, + siteIdSource: SiteIdSource, + ) { + if (url.isBlank()) { + Log.e("url cannot be empty") + return + } + + val mergedExtraData = (extraData ?: emptyMap()) + mapOf( + "_conversion_type" to conversionType.wireValue, + "_conversion_label" to conversionLabel, + ) + + enqueueEvent( + eventsBuilder.buildEvent( + url, + urlRef, + "conversion", + urlMetadata, + mergedExtraData, + generatePixelId(), + siteIdSource + ) + ) + } + /** * Add an event Map to the queue. * Place a data structure representing the event into the in-memory queue for later use. diff --git a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt index f5ee6de..ebff1ec 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/EventsBuilderTest.kt @@ -207,6 +207,43 @@ internal class EventsBuilderTest { } + @Test + fun `when building conversion event, then build the correct one`() { + // given + val extraData: Map = mapOf( + "_conversion_type" to "subscription", + "_conversion_label" to "weekly_plan", + "plan" to "Active", + ) + + // when + val event: Map = sut.buildEvent( + TEST_URL, + "", + "conversion", + null, + extraData, + TEST_UUID, + SiteIdSource.Default, + ) + + // then + assertThat(event) + .doesNotContainKey("pvid") + .doesNotContainKey("vsid") + .containsEntry("action", "conversion") + .containsEntry("url", TEST_URL) + .containsEntry("idsite", TEST_SITE_ID) + .hasEntrySatisfying("data") { + @Suppress("UNCHECKED_CAST") + it as Map + assertThat(it) + .containsEntry("_conversion_type", "subscription") + .containsEntry("_conversion_label", "weekly_plan") + .containsEntry("plan", "Active") + } + } + @Test fun `given custom site id is provided, when creating a pixel, then use the custom site id`() { // given diff --git a/parsely/src/test/java/com/parsely/parselyandroid/ParselyTrackerTest.kt b/parsely/src/test/java/com/parsely/parselyandroid/ParselyTrackerTest.kt index e179d0e..ef25c82 100644 --- a/parsely/src/test/java/com/parsely/parselyandroid/ParselyTrackerTest.kt +++ b/parsely/src/test/java/com/parsely/parselyandroid/ParselyTrackerTest.kt @@ -33,6 +33,17 @@ class ParselyTrackerTest { ParselyTracker.sharedInstance().startEngagement("url") } + @Test + fun `given tracker initialized, when calling trackConversion, do not throw any exception`() { + ParselyTracker.init(siteId = "example.com", context = RuntimeEnvironment.getApplication()) + + ParselyTracker.sharedInstance().trackConversion( + url = "https://example.com/path/test-conversion", + conversionType = ConversionType.SUBSCRIPTION, + conversionLabel = "weekly_plan", + ) + } + @After fun tearDown() { ParselyTracker.tearDown() From 32dd42547e8d61a96af73e76f58d7ded1aa1d9c1 Mon Sep 17 00:00:00 2001 From: Randy Date: Thu, 14 May 2026 20:38:28 +0200 Subject: [PATCH 2/4] Example: add sandbox buttons for trackConversion smoke testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two buttons on the main screen — "Track Pageview (sandbox)" and "Track Conversion (sandbox)" — that fire against the `sandbox.joshhanson.io` apikey on a shared test URL. The pageview lets a session accrue history before the conversion fires so the conversions topology has something to attribute to. NOTE: the example app initializes the SDK with `dryRun=true`, which suppresses real network sends. To exercise these buttons end-to-end against mobileproxy, temporarily flip the fourth argument of `ParselyTracker.init(...)` to `false`. Used during the manual end-to-end verification of trackConversion on the Android emulator: with logcat filtered to `Parsely:V`, confirm the emitted "POST Data {...}" JSON includes `action: "conversion"` and the `_conversion_type` / `_conversion_label` keys, then watch dash.parsely.com for the conversion in the conversions report. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/com/example/MainActivity.java | 29 +++++++++++++++++++ example/src/main/res/layout/activity_main.xml | 20 ++++++++++++- example/src/main/res/values/strings.xml | 2 ++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/example/src/main/java/com/example/MainActivity.java b/example/src/main/java/com/example/MainActivity.java index 1000362..74efc08 100644 --- a/example/src/main/java/com/example/MainActivity.java +++ b/example/src/main/java/com/example/MainActivity.java @@ -9,6 +9,7 @@ import android.widget.EditText; import android.widget.TextView; +import com.parsely.parselyandroid.ConversionType; import com.parsely.parselyandroid.ParselyTracker; import com.parsely.parselyandroid.SiteIdSource; import com.parsely.parselyandroid.ParselyTrackerInternal; @@ -140,6 +141,34 @@ public void trackReset(View view) { ParselyTracker.sharedInstance().resetVideo(); } + // --- Sandbox buttons for trackConversion smoke testing against sandbox.joshhanson.io. + // Both fire against the same URL so the conversions topology has a pageview to attribute to. + private static final String SANDBOX_SITE_ID = "sandbox.joshhanson.io"; + private static final String SANDBOX_URL = "https://sandbox.joshhanson.io/path/test-conversion2"; + + public void trackSandboxPageview(View view) { + final Map extraData = new HashMap<>(); + extraData.put("source", "android_demo_app"); + ParselyTracker.sharedInstance().trackPageview( + SANDBOX_URL, "", null, extraData, new SiteIdSource.Custom(SANDBOX_SITE_ID) + ); + } + + public void trackSandboxConversion(View view) { + final Map extraData = new HashMap<>(); + extraData.put("plan", "weekly"); + extraData.put("source", "android_demo_app"); + ParselyTracker.sharedInstance().trackConversion( + SANDBOX_URL, + ConversionType.SUBSCRIPTION, + "android_sdk_smoke_test", + "", + null, + extraData, + new SiteIdSource.Custom(SANDBOX_SITE_ID) + ); + } + private SiteIdSource getSiteId() { Editable fromEditText = ((EditText) findViewById(R.id.custom_site_id)).getText(); if (fromEditText == null || fromEditText.toString().isEmpty()) { diff --git a/example/src/main/res/layout/activity_main.xml b/example/src/main/res/layout/activity_main.xml index bae2bae..7011fbf 100644 --- a/example/src/main/res/layout/activity_main.xml +++ b/example/src/main/res/layout/activity_main.xml @@ -76,11 +76,29 @@ android:onClick="trackReset" android:text="@string/button_reset_video" /> +