From 18f6657323ec89ee2c547f86202c6f6c7a8d0991 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 21 May 2026 12:34:40 +0200 Subject: [PATCH 1/3] Add Claude Code skills for Parse.ly Android SDK integration Adds three skills covering setup, tracking, and testing: - parsely-android-setup: Gradle dependency, Application init, DI wiring - parsely-android-tracking: pageview, engagement, video, conversion, metadata - parsely-android-testing: FakeParselyTracker with typed data classes and test patterns --- skills/parsely-android-setup/SKILL.md | 81 ++++++++++++ skills/parsely-android-testing/SKILL.md | 115 ++++++++++++++++ .../references/FakeParselyTracker.kt | 73 ++++++++++ skills/parsely-android-tracking/SKILL.md | 125 ++++++++++++++++++ 4 files changed, 394 insertions(+) create mode 100644 skills/parsely-android-setup/SKILL.md create mode 100644 skills/parsely-android-testing/SKILL.md create mode 100644 skills/parsely-android-testing/references/FakeParselyTracker.kt create mode 100644 skills/parsely-android-tracking/SKILL.md diff --git a/skills/parsely-android-setup/SKILL.md b/skills/parsely-android-setup/SKILL.md new file mode 100644 index 0000000..29e1d5f --- /dev/null +++ b/skills/parsely-android-setup/SKILL.md @@ -0,0 +1,81 @@ +--- +name: parsely-android-setup +description: > + Add the Parse.ly Android SDK to an existing project: Gradle dependency, Application-class + initialization, and wiring ParselyTracker so it can be injected rather than fetched as a + singleton. Use this skill when the user wants to integrate Parse.ly for the first time, add + the SDK to a project, initialize ParselyTracker, or set up the Parse.ly dependency. Pair with + parsely-android-tracking to add actual tracking calls, and parsely-android-testing for unit tests. +--- + +# Parse.ly Android SDK — Setup + +## Step 1: Probe the project structure + +Before writing any code, read: + +1. The **app-module `build.gradle` or `build.gradle.kts`** — identify existing dependencies and what dependency declaration convention the project uses (direct, version catalog, `buildSrc`, etc.) +2. Any existing **`Application` subclass** — search for `class.*Application` in the main source set +3. **`AndroidManifest.xml`** — confirm whether an Application class is already registered, and check the package name + +From this, determine: +- **Language**: Kotlin, Java, or mixed (generate code in whichever the target file uses; prefer Kotlin for new files in a mixed project) +- **DI setup**: how the project wires dependencies (DI framework, manual construction, service locator, etc.) +- **Application class**: exists or needs creating +- **`siteId`**: ask the user if not visible in existing code — it's the domain shown in Parse.ly dashboard under Settings → API (e.g. `"yoursite.com"`) + +## Step 2: Add the Gradle dependency + +The Maven coordinates are: + +- **Group**: `com.parsely` +- **Artifact**: `parsely` +- **Repository**: Maven Central + +Fetch the latest version by visiting https://api.github.com/repos/Parsely/parsely-android/releases/latest and reading the `tag_name` field from the response. + +Strip any leading `v` from the tag to get the version string. Add the dependency using whatever convention the project already uses. Maven Central is required in the repository list — it is present in most projects by default. + +## Step 3: Initialize in Application.onCreate() + +`ParselyTracker.init()` must be called **exactly once**, **before** any tracking call, in `Application.onCreate()`. Never initialize in an Activity — it may be created after another component has already tried to track. Calling `init()` a second time throws `ParselyAlreadyInitializedException`, so guard against double-initialization if the app already has partial Parse.ly wiring. + +Parameters: +| Parameter | Type | Notes | +|-----------|------|-------| +| `siteId` | `String` | Your Parse.ly site ID, e.g. `"example.com"` | +| `flushInterval` | `Int` | Seconds between event flushes; `60` is a sensible default. Lower values (e.g. `10`) give faster feedback during development | +| `context` | `Context` | Pass the Application `this` | +| `dryRun` | `Boolean` | `true` = events logged but **not** sent to servers. Use `BuildConfig.DEBUG` so production always sends | + +```kotlin +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + ParselyTracker.init( + siteId = "YOUR_SITE_ID", + flushInterval = 60, // consider 10 during development for faster feedback + context = this, + dryRun = BuildConfig.DEBUG, + ) + } +} +``` + +If no `Application` subclass exists, create one and register it in `AndroidManifest.xml`: +```xml + +``` + +## Step 4: Wire up ParselyTracker for injection + +What makes tracking code testable is that every class that tracks receives a `ParselyTracker` **interface** reference — never calls `sharedInstance()` itself. + +Resolve `ParselyTracker.sharedInstance()` once at the app's entry point and wire it into the rest of the project using whatever mechanism the project already uses — DI framework binding, manual constructor injection, service locator, etc. The key rule: `sharedInstance()` belongs at the wiring layer, not inside any class that holds business logic. + +## Common mistakes to avoid + +| Mistake | Why it's a problem | +|---------|-------------------| +| Calling `init()` in an Activity | SDK may be uninitialized when another component fires first | +| Calling `sharedInstance()` inside feature code | Couples logic to singleton, blocks unit testing | diff --git a/skills/parsely-android-testing/SKILL.md b/skills/parsely-android-testing/SKILL.md new file mode 100644 index 0000000..3fe03aa --- /dev/null +++ b/skills/parsely-android-testing/SKILL.md @@ -0,0 +1,115 @@ +--- +name: parsely-android-testing +description: > + Write unit tests for Parse.ly Android tracking using FakeParselyTracker. Provides a ready-to-use + fake implementation of the ParselyTracker interface and test patterns for asserting on pageviews, + engagement, video, and conversion events. Use this skill when the user wants to test Parse.ly + tracking calls, mock ParselyTracker, write unit tests for a tracked screen, or verify that + tracking events are sent correctly. +--- + +# Parse.ly Android SDK — Testing + +## FakeParselyTracker + +This skill bundles a ready-to-use fake at `references/FakeParselyTracker.kt`. Copy it into the +project's **`test` source set** (never `main`) and update the package declaration to match the project. It implements every method of the +`ParselyTracker` interface and records calls so tests can assert on them. + +If the SDK adds new interface methods in a future release, the fake will fail to compile — update +it to match the new interface at that point. + +## Test patterns + +Create a new `FakeParselyTracker` instance per test. Inject it via the class constructor — this +is why all classes that track should receive `ParselyTracker` as a parameter rather than calling +`sharedInstance()` directly. + +### Asserting pageview and engagement + +```kotlin +@Test +fun `when screen becomes visible, then pageview and engagement are tracked`() { + val fakeTracker = FakeParselyTracker() + val viewModel = ArticleViewModel(parselyTracker = fakeTracker) + + viewModel.onScreenVisible("https://example.com/article") + + assertThat(fakeTracker.trackedPageviews).containsExactly("https://example.com/article") + assertThat(fakeTracker.engagementStarts).containsExactly("https://example.com/article") +} + +@Test +fun `when screen is hidden, then engagement is stopped`() { + val fakeTracker = FakeParselyTracker() + val viewModel = ArticleViewModel(parselyTracker = fakeTracker) + viewModel.onScreenVisible("https://example.com/article") + + viewModel.onScreenHidden() + + assertThat(fakeTracker.engagementStopCount).isEqualTo(1) +} +``` + +### Asserting video tracking + +```kotlin +@Test +fun `when video plays, then trackPlay is called with correct metadata`() { + val fakeTracker = FakeParselyTracker() + val viewModel = VideoViewModel(parselyTracker = fakeTracker) + + viewModel.onPlay("https://example.com/post", "video-123", 120) + + assertThat(fakeTracker.videoPlays).hasSize(1) + assertThat(fakeTracker.videoPlays.first().url).isEqualTo("https://example.com/post") +} + +@Test +fun `when video is paused, then trackPause is called`() { + val fakeTracker = FakeParselyTracker() + val viewModel = VideoViewModel(parselyTracker = fakeTracker) + + viewModel.onPause() + + assertThat(fakeTracker.videoPauseCount).isEqualTo(1) +} + +@Test +fun `when video is stopped, then resetVideo is called`() { + val fakeTracker = FakeParselyTracker() + val viewModel = VideoViewModel(parselyTracker = fakeTracker) + + viewModel.onStop() + + assertThat(fakeTracker.videoResetCount).isEqualTo(1) +} +``` + +### Asserting conversions + +```kotlin +@Test +fun `when subscription completes, then conversion is tracked with correct label`() { + val fakeTracker = FakeParselyTracker() + val viewModel = SubscriptionViewModel(parselyTracker = fakeTracker) + + viewModel.onSubscriptionCompleted("https://example.com/subscribe") + + assertThat(fakeTracker.conversions).containsExactly( + FakeParselyTracker.Conversion( + url = "https://example.com/subscribe", + type = ConversionType.SUBSCRIPTION, + label = "monthly_plan", + ) + ) +} +``` + +## Checklist + +- [ ] `FakeParselyTracker` is in the `test` source set, not `main` +- [ ] Each test creates its own `FakeParselyTracker` instance — don't share state between tests +- [ ] At least one test per tracked screen verifies the pageview URL +- [ ] Screens with engagement tracking have a test that verifies `stopEngagement` is called +- [ ] Conversion tracking has a test that verifies both the `ConversionType` and `conversionLabel` diff --git a/skills/parsely-android-testing/references/FakeParselyTracker.kt b/skills/parsely-android-testing/references/FakeParselyTracker.kt new file mode 100644 index 0000000..773b086 --- /dev/null +++ b/skills/parsely-android-testing/references/FakeParselyTracker.kt @@ -0,0 +1,73 @@ +package com.example // replace with the project's test package + +import com.parsely.parselyandroid.ConversionType +import com.parsely.parselyandroid.ParselyMetadata +import com.parsely.parselyandroid.ParselyTracker +import com.parsely.parselyandroid.ParselyVideoMetadata +import com.parsely.parselyandroid.SiteIdSource + +class FakeParselyTracker : ParselyTracker { + data class VideoPlay(val url: String, val metadata: ParselyVideoMetadata) + data class Conversion(val url: String, val type: ConversionType, val label: String) + + val trackedPageviews = mutableListOf() + val engagementStarts = mutableListOf() + var engagementStopCount = 0 + val conversions = mutableListOf() + val videoPlays = mutableListOf() + var videoPauseCount = 0 + var videoResetCount = 0 + + override fun trackPageview( + url: String, + urlRef: String, + urlMetadata: ParselyMetadata?, + extraData: Map?, + siteIdSource: SiteIdSource, + ) { + trackedPageviews += url + } + + override fun startEngagement( + url: String, + urlRef: String, + extraData: Map?, + siteIdSource: SiteIdSource, + ) { + engagementStarts += url + } + + override fun stopEngagement() { + engagementStopCount++ + } + + override fun trackPlay( + url: String, + urlRef: String, + videoMetadata: ParselyVideoMetadata, + extraData: Map?, + siteIdSource: SiteIdSource, + ) { + videoPlays += VideoPlay(url, videoMetadata) + } + + override fun trackPause() { + videoPauseCount++ + } + + override fun resetVideo() { + videoResetCount++ + } + + override fun trackConversion( + url: String, + conversionType: ConversionType, + conversionLabel: String, + urlRef: String, + urlMetadata: ParselyMetadata?, + extraData: Map?, + siteIdSource: SiteIdSource, + ) { + conversions += Conversion(url, conversionType, conversionLabel) + } +} diff --git a/skills/parsely-android-tracking/SKILL.md b/skills/parsely-android-tracking/SKILL.md new file mode 100644 index 0000000..b1437d9 --- /dev/null +++ b/skills/parsely-android-tracking/SKILL.md @@ -0,0 +1,125 @@ +--- +name: parsely-android-tracking +description: > + Add Parse.ly tracking calls to Android screens and features: pageview, engagement time, + video, conversion, and metadata. Use this skill when the user wants to track a screen, article, + video, or conversion event with Parse.ly — even if they just say "track this screen with Parse.ly" + or "add Parse.ly tracking here". Assumes ParselyTracker is already initialized and injectable + (see parsely-android-setup). Pair with parsely-android-testing to write unit tests for the + tracking calls. +--- + +# Parse.ly Android SDK — Tracking + +Assumes `ParselyTracker` is already initialized in `Application.onCreate()` and available for +injection. If not, run `parsely-android-setup` first. + +Pass `ParselyTracker` into whatever class owns the tracking logic — ViewModel, Activity, Fragment, +or any other class. The examples below use a ViewModel but the pattern applies to any class. + +## Page view + engagement tracking + +Call `trackPageview` once when a screen loads. Call `startEngagement` immediately after to begin +measuring time-on-content. Call `stopEngagement` when the user leaves — the right moment depends +on the app's navigation and UX model. + +```kotlin +class ArticleViewModel( + private val parselyTracker: ParselyTracker, +) : ViewModel() { + + fun onScreenVisible(articleUrl: String) { + parselyTracker.trackPageview(url = articleUrl) + parselyTracker.startEngagement(url = articleUrl) + } + + fun onScreenHidden() { + parselyTracker.stopEngagement() + } +} +``` + +Wire `onScreenHidden()` wherever the screen stops being visible — Fragment/Activity `onPause`, +a Compose `DisposableEffect`, a player state callback, etc. + +## Video tracking + +`trackPause` and `resetVideo` behave differently — choose based on the user's intent: +- **`trackPause`**: user paused; calling `trackPlay` again for the same video won't re-send `videostart` — models "resume" +- **`resetVideo`**: user stopped; next `trackPlay` sends a fresh `videostart` — models "stop and restart" + +```kotlin +class VideoViewModel( + private val parselyTracker: ParselyTracker, +) : ViewModel() { + + fun onPlay(postUrl: String, videoId: String, durationSecs: Int) { + parselyTracker.trackPlay( + url = postUrl, + videoMetadata = ParselyVideoMetadata( + videoId = videoId, + durationSeconds = durationSecs, + title = "My Video Title", // optional but recommended + ) + ) + } + + fun onPause() = parselyTracker.trackPause() + + fun onStop() = parselyTracker.resetVideo() +} +``` + +Call `onPause()` / `onStop()` at the right moment for the player — this might be a lifecycle event, +a player state callback, a PiP transition, or anything else that signals the user is no longer watching. + +## Conversion tracking + +```kotlin +class SubscriptionViewModel( + private val parselyTracker: ParselyTracker, +) : ViewModel() { + + fun onSubscriptionCompleted(currentUrl: String) { + parselyTracker.trackConversion( + url = currentUrl, + conversionType = ConversionType.SUBSCRIPTION, + conversionLabel = "monthly_plan", + ) + } +} +``` + +For available `ConversionType` values, inspect the enum using LSP or search the resolved Gradle dependency. + +## Metadata (app-only content) + +Only needed when the URL is not accessible over the internet (i.e. app-only content). Otherwise, +metadata will be gathered by Parse.ly's crawling infrastructure. + +Add `urlMetadata` to the `trackPageview` call from wherever you already call it. Inspect the +`ParselyMetadata` constructor (via LSP or the resolved Gradle dependency) for all available fields. +The example below shows the most commonly used ones: + +```kotlin +parselyTracker.trackPageview( + url = "https://example.com/app-only/article-123", + urlMetadata = ParselyMetadata( + title = "Article Title", + authors = listOf("Jane Doe"), + pubDate = Calendar.getInstance().apply { set(2024, Calendar.JANUARY, 15) }, + // see ParselyMetadata for full list of available fields + ) +) +``` + +## Multi-site tracking + +For projects that serve multiple Parse.ly sites, every tracking method accepts a `siteIdSource` +parameter — pass `SiteIdSource.Custom("other-site.com")` to override the default site ID for that event. + +## Common mistakes to avoid + +| Mistake | Why it's a problem | +|---------|-------------------| +| Forgetting `stopEngagement()` when leaving a screen | Background heartbeats inflate engaged-time metrics | From 735bd08d941e499269dab6797f5c109e16aa4388 Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 21 May 2026 12:43:28 +0200 Subject: [PATCH 2/3] Add Claude Code plugin for Parse.ly Android SDK Restructures skills into a Claude Code plugin under plugins/parsely-android/, adds plugin manifests, and documents installation in the README. --- .claude-plugin/marketplace.json | 13 +++++++++++++ README.md | 17 +++++++++++++++++ .../parsely-android/.claude-plugin/plugin.json | 9 +++++++++ .../skills}/parsely-android-setup/SKILL.md | 0 .../skills}/parsely-android-testing/SKILL.md | 0 .../references/FakeParselyTracker.kt | 0 .../skills}/parsely-android-tracking/SKILL.md | 0 7 files changed, 39 insertions(+) create mode 100644 .claude-plugin/marketplace.json create mode 100644 plugins/parsely-android/.claude-plugin/plugin.json rename {skills => plugins/parsely-android/skills}/parsely-android-setup/SKILL.md (100%) rename {skills => plugins/parsely-android/skills}/parsely-android-testing/SKILL.md (100%) rename {skills => plugins/parsely-android/skills}/parsely-android-testing/references/FakeParselyTracker.kt (100%) rename {skills => plugins/parsely-android/skills}/parsely-android-tracking/SKILL.md (100%) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..b5bc194 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,13 @@ +{ + "name": "parsely-android-marketplace", + "owner": { "name": "Parse.ly" }, + "plugins": [ + { + "name": "parsely-android", + "version": "1.0.0", + "description": "Parse.ly Android SDK guidance covering setup, tracking (pageview, engagement, video, conversion), and unit testing with FakeParselyTracker.", + "author": { "name": "Parse.ly" }, + "source": "./plugins/parsely-android" + } + ] +} diff --git a/README.md b/README.md index 9d008b0..b46d9ca 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,23 @@ implementation("com.parsely:parsely:") Full instructions and documentation can be found on the [Parse.ly help page](https://docs.parse.ly/android-sdk/). +## AI-Assisted Development + +A [Claude Code](https://claude.ai/code) plugin is available to help integrate and use this SDK: + +```sh +claude plugin marketplace add Parsely/parsely-android +claude plugin install parsely-android@parsely-android-marketplace +``` + +The plugin provides three skills: + +| Skill | Description | +|-------|-------------| +| `parsely-android-setup` | Add the SDK to a project: Gradle dependency, Application init, DI wiring | +| `parsely-android-tracking` | Add tracking calls: pageview, engagement, video, conversion, metadata | +| `parsely-android-testing` | Write unit tests using `FakeParselyTracker` | + ## Migration to 4.0.0 Version 4.0.0 of the SDK introduces significant updates and breaking changes that enhance performance and add new features. diff --git a/plugins/parsely-android/.claude-plugin/plugin.json b/plugins/parsely-android/.claude-plugin/plugin.json new file mode 100644 index 0000000..3f4e317 --- /dev/null +++ b/plugins/parsely-android/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "parsely-android", + "version": "1.0.0", + "description": "Parse.ly Android SDK guidance covering setup, tracking (pageview, engagement, video, conversion), and unit testing with FakeParselyTracker.", + "author": { "name": "Parse.ly" }, + "homepage": "https://github.com/Parsely/parsely-android", + "keywords": ["parsely", "android", "sdk", "tracking", "analytics"], + "skills": ["./skills/"] +} diff --git a/skills/parsely-android-setup/SKILL.md b/plugins/parsely-android/skills/parsely-android-setup/SKILL.md similarity index 100% rename from skills/parsely-android-setup/SKILL.md rename to plugins/parsely-android/skills/parsely-android-setup/SKILL.md diff --git a/skills/parsely-android-testing/SKILL.md b/plugins/parsely-android/skills/parsely-android-testing/SKILL.md similarity index 100% rename from skills/parsely-android-testing/SKILL.md rename to plugins/parsely-android/skills/parsely-android-testing/SKILL.md diff --git a/skills/parsely-android-testing/references/FakeParselyTracker.kt b/plugins/parsely-android/skills/parsely-android-testing/references/FakeParselyTracker.kt similarity index 100% rename from skills/parsely-android-testing/references/FakeParselyTracker.kt rename to plugins/parsely-android/skills/parsely-android-testing/references/FakeParselyTracker.kt diff --git a/skills/parsely-android-tracking/SKILL.md b/plugins/parsely-android/skills/parsely-android-tracking/SKILL.md similarity index 100% rename from skills/parsely-android-tracking/SKILL.md rename to plugins/parsely-android/skills/parsely-android-tracking/SKILL.md From f1fde3ff9e93d157dd27cb4065ea2fa5f3a8248d Mon Sep 17 00:00:00 2001 From: Wojtek Zieba Date: Thu, 21 May 2026 14:29:39 +0200 Subject: [PATCH 3/3] Fix ParselyVideoMetadata constructor call in tracking skill durationSeconds is declared internal, so it cannot be used as a named argument from external modules. Use positional arguments instead. --- .../skills/parsely-android-tracking/SKILL.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/parsely-android/skills/parsely-android-tracking/SKILL.md b/plugins/parsely-android/skills/parsely-android-tracking/SKILL.md index b1437d9..35752ef 100644 --- a/plugins/parsely-android/skills/parsely-android-tracking/SKILL.md +++ b/plugins/parsely-android/skills/parsely-android-tracking/SKILL.md @@ -57,9 +57,14 @@ class VideoViewModel( parselyTracker.trackPlay( url = postUrl, videoMetadata = ParselyVideoMetadata( - videoId = videoId, - durationSeconds = durationSecs, - title = "My Video Title", // optional but recommended + /* authors = */ null, + /* videoId = */ videoId, + /* section = */ null, + /* tags = */ null, + /* thumbUrl = */ null, + /* title = */ "My Video Title", + /* pubDate = */ null, + /* duration = */ durationSecs ) ) }