Skip to content

Commit 84a6751

Browse files
authored
Merge pull request #86 from TelemetryDeck/feat/google-services
Purchases, Free Trials, and Trial Conversions: Google Play Billing Integration
2 parents 6e04121 + 06f0e47 commit 84a6751

File tree

9 files changed

+331
-7
lines changed

9 files changed

+331
-7
lines changed

README.md

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,14 @@ The Kotlin SDK for TelemetryDeck is available from Maven Central at the followin
3434
```groovy
3535
// `build.gradle`
3636
dependencies {
37-
implementation 'com.telemetrydeck:kotlin-sdk:6.1.0'
37+
implementation 'com.telemetrydeck:kotlin-sdk:6.2.0'
3838
}
3939
```
4040

4141
```kotlin
4242
// `build.gradle.kts`
4343
dependencies {
44-
implementation("com.telemetrydeck:kotlin-sdk:6.1.0")
44+
implementation("com.telemetrydeck:kotlin-sdk:6.2.0")
4545
}
4646
```
4747

@@ -478,6 +478,51 @@ The following parameters are also included with the signal:
478478
| `TelemetryDeck.Purchase.offerID` | The specific offer identifier for subscription products or customized pricing |
479479

480480

481+
### Google Play
482+
483+
When integrating with Google Play Billing Library, you can adopt the TelemetryDeck SDK with Google Play Services in order to let us determine the exact purchase parameters.
484+
485+
1. Add the following package as a dependency to your app:
486+
487+
```kotlin
488+
// `build.gradle.kts`
489+
dependencies {
490+
implementation("com.telemetrydeck:kotlin-sdk-google-services:6.2.0")
491+
}
492+
```
493+
494+
2. You can now use the `purchaseCompleted` function optimized for Google Play Billing:
495+
496+
```kotlin
497+
fun purchaseHandlerInYourApp(
498+
billingConfig: BillingConfig,
499+
purchase: Purchase,
500+
productDetails: ProductDetails
501+
) {
502+
TelemetryDeck.purchaseCompleted(
503+
billingConfig = billingConfig,
504+
purchase = purchase,
505+
productDetails = productDetails
506+
)
507+
}
508+
```
509+
510+
By default, this method assumes the purchase is a `PAID_PURCHASE`. To determine if the user is converting or starting a trial, you will have to implement your own [server-side validation](https://developer.android.com/google/play/billing/integrate) and inspect the `paymentState` of a subscription.
511+
512+
To make it easier to get started, the TelemetryDeck SDK offers a helper method which attempts to guess the purchase origin based on locally available data, so you could:
513+
514+
```kotlin
515+
TelemetryDeck.purchaseCompleted(
516+
billingConfig = billingConfig,
517+
purchase = purchase,
518+
productDetails = productDetails,
519+
purchaseOrigin = purchase.toTelemetryDeckPurchaseEvent(setOf("TRIAL_SKU"))
520+
)
521+
```
522+
523+
Note that this approach is not exact and comes with a certain number of limitations, please check the doc notes on `com.telemetrydeck.sdk.googleservices.toTelemetryDeckPurchaseEvent` for more details.
524+
525+
481526
## Custom Telemetry
482527

483528
Another way to send signals is to implement a custom `TelemetryDeckProvider`.

google-services/build.gradle.kts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
2+
import com.vanniktech.maven.publish.SonatypeHost
3+
4+
plugins {
5+
alias(libs.plugins.androidLibrary)
6+
alias(libs.plugins.kotlin.android)
7+
alias(libs.plugins.vanniktech.publish)
8+
}
9+
10+
android {
11+
compileSdk = 35
12+
13+
defaultConfig {
14+
minSdk = 21
15+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
16+
consumerProguardFiles("consumer-rules.pro")
17+
}
18+
19+
lint {
20+
targetSdk = 34
21+
}
22+
23+
@Suppress("UnstableApiUsage")
24+
testOptions {
25+
targetSdk = 34
26+
}
27+
28+
buildTypes {
29+
release {
30+
isMinifyEnabled = false
31+
proguardFiles(
32+
getDefaultProguardFile("proguard-android-optimize.txt"),
33+
"proguard-rules.pro"
34+
)
35+
}
36+
}
37+
compileOptions {
38+
sourceCompatibility = JavaVersion.VERSION_11
39+
targetCompatibility = JavaVersion.VERSION_11
40+
}
41+
kotlinOptions {
42+
jvmTarget = "11"
43+
}
44+
buildFeatures {
45+
compose = false
46+
buildConfig = true
47+
}
48+
49+
// https://github.com/Kotlin/kotlinx.coroutines/blob/master/README.md#avoiding-including-the-debug-infrastructure-in-the-resulting-apk
50+
packaging {
51+
resources.excludes += "DebugProbesKt.bin"
52+
resources.merges.addAll(
53+
listOf(
54+
"META-INF/LICENSE.md",
55+
"META-INF/LICENSE-notice.md",
56+
)
57+
)
58+
}
59+
namespace = "com.telemetrydeck.sdk"
60+
}
61+
62+
kotlin {
63+
jvmToolchain {
64+
languageVersion.set(JavaLanguageVersion.of(17))
65+
}
66+
}
67+
68+
dependencies {
69+
implementation(libs.google.play.billing)
70+
implementation(project(":lib"))
71+
72+
}
73+
74+
mavenPublishing {
75+
coordinates("com.telemetrydeck", "kotlin-sdk-google-services", "6.2.0")
76+
77+
pom {
78+
name = "TelemetryDeck SDK Google Services"
79+
description =
80+
"Google Services facilities for Kotlin SDK for TelemetryDeck, a privacy-conscious analytics service for apps and websites"
81+
url = "https://telemetrydeck.com"
82+
83+
licenses {
84+
license {
85+
name = "MIT License"
86+
url = "https://raw.githubusercontent.com/TelemetryDeck/KotlinSDK/main/LICENSE"
87+
}
88+
}
89+
90+
developers {
91+
developer {
92+
id = "winsmith"
93+
name = "Daniel Jilg"
94+
url = "https://github.com/winsmith"
95+
organization = "TelemetryDeck GmbH"
96+
}
97+
}
98+
99+
scm {
100+
url = "https://github.com/TelemetryDeck/KotlinSDK"
101+
}
102+
}
103+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package com.telemetrydeck.sdk.googleservices
2+
3+
import com.android.billingclient.api.BillingConfig
4+
import com.android.billingclient.api.ProductDetails
5+
import com.android.billingclient.api.Purchase
6+
import com.telemetrydeck.sdk.PurchaseEvent
7+
import com.telemetrydeck.sdk.PurchaseType
8+
import com.telemetrydeck.sdk.TelemetryDeck
9+
10+
11+
/**
12+
* Tracks the completion of a purchase and sends the associated telemetry data.
13+
*
14+
* @param billingConfig Google Play Billing Configuration details for the billing process.
15+
* @param purchase The Google Play Billing purchase object containing details about the completed transaction.
16+
* @param productDetails Google Play Billing Details about the product that was purchased.
17+
* @param purchaseOrigin The origin event of the purchase. Defaults to a paid purchase. Use server-side validation to calculate this value. TelemetryDeck provides a simple helper [toTelemetryDeckPurchaseEvent] to try to determine the value locally.
18+
* @param params A map of additional parameters to include with the telemetry data.
19+
* @param customUserID An optional custom user identifier.
20+
*
21+
*
22+
* * You can obtain billing configuration from the Google Play Billing library:
23+
*
24+
* ```kotlin
25+
* // Obtaining the Google Play Country code
26+
* val getBillingConfigParams = GetBillingConfigParams.newBuilder().build()
27+
* billingClient.getBillingConfigAsync(getBillingConfigParams,
28+
* object : BillingConfigResponseListener {
29+
* override fun onBillingConfigResponse(
30+
* billingResult: BillingResult,
31+
* billingConfig: BillingConfig?
32+
* ) {
33+
* if (billingResult.responseCode == BillingResponseCode.OK
34+
* && billingConfig != null) {
35+
* // keep billingConfig around until the purchase is completed:
36+
* TelemetryDeck.purchaseCompleted(billingConfig = billingConfig, purchase, productDetails)
37+
* }
38+
* }
39+
* })
40+
*
41+
* ```
42+
*/
43+
fun TelemetryDeck.Companion.purchaseCompleted(
44+
billingConfig: BillingConfig,
45+
purchase: Purchase,
46+
productDetails: ProductDetails,
47+
purchaseOrigin: PurchaseEvent = PurchaseEvent.PAID_PURCHASE,
48+
params: Map<String, String> = emptyMap(),
49+
customUserID: String? = null
50+
) {
51+
getInstance()?.purchaseCompleted(
52+
billingConfig = billingConfig,
53+
purchase = purchase,
54+
productDetails = productDetails,
55+
purchaseOrigin = purchaseOrigin,
56+
params = params,
57+
customUserID = customUserID
58+
)
59+
}
60+
61+
internal fun TelemetryDeck.purchaseCompleted(
62+
billingConfig: BillingConfig,
63+
purchase: Purchase,
64+
productDetails: ProductDetails,
65+
purchaseOrigin: PurchaseEvent,
66+
params: Map<String, String>,
67+
customUserID: String?
68+
) {
69+
val productId = purchase.products.firstOrNull() ?: ""
70+
val countryCode = billingConfig.countryCode
71+
72+
val isSubscription = productDetails.subscriptionOfferDetails != null
73+
val purchaseType =
74+
if (isSubscription) PurchaseType.SUBSCRIPTION else PurchaseType.ONE_TIME_PURCHASE
75+
val oneTimeOffer = productDetails.oneTimePurchaseOfferDetails
76+
77+
when (purchaseType) {
78+
PurchaseType.SUBSCRIPTION -> {
79+
// subscription
80+
val pricePhase = productDetails.subscriptionOfferDetails
81+
?.firstOrNull()
82+
?.pricingPhases
83+
?.pricingPhaseList
84+
?.firstOrNull()
85+
val priceAmountMicros = pricePhase?.priceAmountMicros ?: 0L
86+
val currencyCode = pricePhase?.priceCurrencyCode ?: "USD"
87+
val offerId = productDetails.subscriptionOfferDetails
88+
?.firstOrNull()
89+
?.offerId
90+
TelemetryDeck.purchaseCompleted(
91+
event = purchaseOrigin,
92+
countryCode = countryCode,
93+
productID = productId,
94+
purchaseType = purchaseType,
95+
priceAmountMicros = priceAmountMicros,
96+
currencyCode = currencyCode,
97+
offerID = offerId,
98+
params = params,
99+
customUserID = customUserID
100+
)
101+
}
102+
103+
PurchaseType.ONE_TIME_PURCHASE -> {
104+
// one time purchase
105+
val priceAmountMicros = oneTimeOffer?.priceAmountMicros ?: 0L
106+
val currencyCode = oneTimeOffer?.priceCurrencyCode ?: "USD"
107+
TelemetryDeck.purchaseCompleted(
108+
event = purchaseOrigin,
109+
countryCode = countryCode,
110+
productID = productId,
111+
purchaseType = purchaseType,
112+
priceAmountMicros = priceAmountMicros,
113+
currencyCode = currencyCode,
114+
params = params,
115+
customUserID = customUserID
116+
)
117+
}
118+
}
119+
}
120+
121+
/**
122+
* Converts a `Purchase` object into a corresponding `PurchaseEvent` based on the purchase's SKU
123+
* and its trial or paid purchase status.
124+
*
125+
* This method attempts to guess if a purchase corresponds to a trial conversion by checking the locally available information.
126+
* We check if:
127+
* 1) If the product was part of a known trial SKU and
128+
* 2) If the purchase was recent (within the default trial window).
129+
*
130+
*
131+
* - This does not detect free trials used on another device/account.
132+
* - You must manually maintain the list of trial SKUs or base plans locally.
133+
* - Does not work for Play offers that use multiple offer tokens under the same product ID.
134+
*
135+
*
136+
* * It is best to implement server-side validation in order to query the Google Play API and inspect the paymentState.
137+
*
138+
* @param knownTrialSkus a set of SKUs that are recognized as free trial SKUs. List of product IDs (or SKUs) that you know include free trials. This should be manually maintained based on how you set up offers in the Play Console.
139+
* @param trialWindowMs the duration (in milliseconds) considered as the trial period, defaulting to 7 days.
140+
* @return a `PurchaseEvent` indicating the type of purchase: either a free trial start, trial conversion, or a standard paid purchase.
141+
*/
142+
fun Purchase.toTelemetryDeckPurchaseEvent(
143+
knownTrialSkus: Set<String>,
144+
trialWindowMs: Long = 7 * 24 * 60 * 60 * 1000L // 7 days
145+
): PurchaseEvent {
146+
val sku = products.firstOrNull() ?: return PurchaseEvent.PAID_PURCHASE
147+
148+
return when {
149+
// If SKU is one of our known free trial SKUs and the purchase is recent
150+
sku in knownTrialSkus && isWithinTrialWindow(purchaseTime, trialWindowMs) -> {
151+
PurchaseEvent.STARTED_FREE_TRIAL
152+
}
153+
154+
// If SKU is a known trial SKU but purchase is outside trial window
155+
sku in knownTrialSkus && !isWithinTrialWindow(purchaseTime, trialWindowMs) -> {
156+
PurchaseEvent.CONVERTED_FROM_TRIAL
157+
}
158+
159+
// Otherwise it's a standard paid purchase
160+
else -> PurchaseEvent.PAID_PURCHASE
161+
}
162+
}
163+
164+
private fun isWithinTrialWindow(purchaseTimeMillis: Long, trialWindowMs: Long): Boolean {
165+
val currentTime = System.currentTimeMillis()
166+
return currentTime - purchaseTimeMillis <= trialWindowMs
167+
}

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mockk = "1.13.13"
1818
robolectric = "4.7.3"
1919
androidxTest = "1.6.1"
2020
datetime = "0.6.2"
21+
google-play-billing = "7.1.1"
2122

2223
[libraries]
2324
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -49,6 +50,7 @@ mockk = {group = "io.mockk", name = "mockk", version.ref = "mockk" }
4950
mockk-agent = {group = "io.mockk", name = "mockk-agent", version.ref = "mockk" }
5051
mockk-android = {group = "io.mockk", name = "mockk-android", version.ref = "mockk" }
5152
androidx-jUnitTestRules = { module = "androidx.test:rules", version.ref = "androidxTest" }
53+
google-play-billing = { module = "com.android.billingclient:billing", version.ref = "google-play-billing"}
5254

5355
[plugins]
5456
androidLibrary = { id = "com.android.library", version.ref = "agp" }

lib/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ dependencies {
107107
}
108108

109109
mavenPublishing {
110-
coordinates("com.telemetrydeck", "kotlin-sdk", "6.1.0")
110+
coordinates("com.telemetrydeck", "kotlin-sdk", "6.2.0")
111111

112112
pom {
113113
name = "TelemetryDeck SDK"

lib/src/main/java/com/telemetrydeck/sdk/TelemetryDeck.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,13 @@ class TelemetryDeck(
369369
}
370370
}
371371

372-
private fun getInstance(): TelemetryDeck? {
372+
/**
373+
* Retrieves the current instance of the `TelemetryDeck` singleton if available.
374+
* If no instance exists, this method returns `null`.
375+
*
376+
* @return The current `TelemetryDeck` instance, or `null` if no instance is available.
377+
*/
378+
fun getInstance(): TelemetryDeck? {
373379
val knownInstance = instance
374380
if (knownInstance != null) {
375381
return knownInstance

lib/src/main/java/com/telemetrydeck/sdk/TelemetryDeckClient.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ interface TelemetryDeckClient {
154154

155155

156156
/**
157-
* Logs the completion of a purchase event.
157+
* Tracks the completion of a purchase event.
158158
*
159159
*
160160
* @param event A `PurchaseEvent` instance representing the type of purchase action being tracked. Instances can include purchase completion, free trial start, or conversion from trial.
@@ -170,7 +170,7 @@ interface TelemetryDeckClient {
170170
*
171171
*
172172
*
173-
* Once a purchase is completed, you can obtain purchase detail information from the billing library:
173+
* Once a purchase is completed, you can obtain purchase detail information from the Google Play Billing library:
174174
*
175175
* ```kotlin
176176
* // For one-time purchases (ProductDetails from Billing Library 5.0+), [PurchaseType.ONE_TIME_PURCHASE]

0 commit comments

Comments
 (0)