|
| 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 | +} |
0 commit comments