Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ implementation("com.parsely:parsely:<release_version>")
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.
Expand Down
9 changes: 9 additions & 0 deletions plugins/parsely-android/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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/"]
}
81 changes: 81 additions & 0 deletions plugins/parsely-android/skills/parsely-android-setup/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
<application android:name=".MyApplication" ...>
```

## 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 |
115 changes: 115 additions & 0 deletions plugins/parsely-android/skills/parsely-android-testing/SKILL.md
Original file line number Diff line number Diff line change
@@ -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`
Original file line number Diff line number Diff line change
@@ -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<String>()
val engagementStarts = mutableListOf<String>()
var engagementStopCount = 0
val conversions = mutableListOf<Conversion>()
val videoPlays = mutableListOf<VideoPlay>()
var videoPauseCount = 0
var videoResetCount = 0

override fun trackPageview(
url: String,
urlRef: String,
urlMetadata: ParselyMetadata?,
extraData: Map<String, Any>?,
siteIdSource: SiteIdSource,
) {
trackedPageviews += url
}

override fun startEngagement(
url: String,
urlRef: String,
extraData: Map<String, Any>?,
siteIdSource: SiteIdSource,
) {
engagementStarts += url
}

override fun stopEngagement() {
engagementStopCount++
}

override fun trackPlay(
url: String,
urlRef: String,
videoMetadata: ParselyVideoMetadata,
extraData: Map<String, Any>?,
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<String, Any>?,
siteIdSource: SiteIdSource,
) {
conversions += Conversion(url, conversionType, conversionLabel)
}
}
Loading
Loading