Skip to content

Commit

Permalink
Merge branch 'release/3.3.0'
Browse files Browse the repository at this point in the history
  • Loading branch information
mvojtkovszky committed Nov 11, 2024
2 parents 7218adf + daad549 commit 7c7ab63
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 107 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# CHANGELOG

## 3.3.0 (2024-11-11)
* Added optional `subscriptionPurchaseParams` to `launchPurchaseFlow` method to unify subscription purchase definitions.
Added optional parameters `basePlanId: String?` and `offerId: String?`, which replaced old index parameters.
Similarly with `ProductDetails.getFormattedPrice`, which now take optional `subscriptionBasePlanId` and `subscriptionOfferId`.
* Removed `PriceUtil` object, use extension methods `ProductDetails.getFormattedPrice` and `ProductDetails.getFormattedPriceDivided`.

## 3.2.0 (2024-11-05)
* Support subscription updates or replacements: Added optional parameters `subscriptionUpdateOldToken`,
`subscriptionUpdateExternalTransactionId` and `subscriptionUpdateReplacementMode` to `launchPurchaseFlow()`
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ enum class BillingEvent {
}
```

<br/>You can also make use of provided `PriceUtil` object to format prices in various ways
``` kotlin
// Get formatted price for a product
val formattedPrice = getProductDetails(yourSkuName).getFormattedPrice() // formattedPrice: "16.80 EUR"
val dividedPrice = getProductDetails(yourSkuName).getFormattedPriceDivided(4) // formattedPrice: "4.20 EUR"
```

## Best practices
Since library caches latest state of products and purchases as they are known to an instance based
on the data requested, it's suggested to rely on a **single instance** of BillingHelper in your app.
Expand Down
2 changes: 1 addition & 1 deletion billinghelper/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ buildTypes=debug,release
groupId=com.github.mvojtkovszky
artifactId=BillingHelper
moduleId=billinghelper
versionName=3.2.0
versionName=3.3.0
Original file line number Diff line number Diff line change
Expand Up @@ -259,44 +259,33 @@ class BillingHelper(
*
* @param activity An activity reference from which the billing flow will be launched.
* @param productName Name of the IAP or Subscription we intend to purchase.
* @param subscriptionUpdateOldToken Google Play Billing purchase token that the user is upgrading or downgrading from.
* See [https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams].
* Note that [productName] must also be a subscription for this to take effect.
* @param subscriptionUpdateExternalTransactionId If the originating transaction for the subscription
* that the user is upgrading or downgrading from was processed via alternative billing.
* See [https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setOriginalExternalTransactionId(java.lang.String)].
* @param subscriptionUpdateReplacementMode Supported replacement modes to replace an existing subscription with a new one.
* See [https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.ReplacementMode].
* @param subscriptionParams Additional parameters often required for subscription purchases.
* @param obfuscatedAccountId See
* [setObfuscatedAccountId](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedAccountId(java.lang.String)).
* @param obfuscatedProfileId See
* [setObfuscatedProfileId](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setObfuscatedProfileId(java.lang.String)).
* @param isOfferPersonalized See
* [setIsOfferPersonalized](https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.Builder#setIsOfferPersonalized(boolean)).
* @param selectedOfferIndex See [ProductDetails.SubscriptionOfferDetails].
*/
fun launchPurchaseFlow(activity: Activity,
productName: String,
subscriptionUpdateOldToken: String? = null,
subscriptionUpdateExternalTransactionId: String? = null,
subscriptionUpdateReplacementMode: Int? = null,
obfuscatedAccountId: String? = null,
obfuscatedProfileId: String? = null,
isOfferPersonalized: Boolean? = null,
selectedOfferIndex: Int = 0
fun launchPurchaseFlow(
activity: Activity,
productName: String,
subscriptionParams: SubscriptionPurchaseParams? = null,
obfuscatedAccountId: String? = null,
obfuscatedProfileId: String? = null,
isOfferPersonalized: Boolean? = null
) {
val productDetailsForPurchase = getProductDetails(productName)

if (billingClient.isReady && productDetailsForPurchase != null) {
val offerToken = selectedOfferIndex.let {
productDetailsForPurchase.subscriptionOfferDetails?.get(it)?.offerToken
}

val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder().apply {
setProductDetails(productDetailsForPurchase)
// offer token required for subscriptions
// offer token required for subscription
if (productDetailsForPurchase.isSubscription()) {
offerToken?.let { setOfferToken(offerToken) }
(subscriptionParams ?: SubscriptionPurchaseParams())
.getOfferToken(productDetailsForPurchase)?.let { token ->
setOfferToken(token)
}
}
}.build()

Expand All @@ -305,24 +294,9 @@ class BillingHelper(
obfuscatedAccountId?.let { setObfuscatedAccountId(it) }
obfuscatedProfileId?.let { setObfuscatedProfileId(it) }
isOfferPersonalized?.let { setIsOfferPersonalized(it) }
// subscription update
if (subscriptionUpdateOldToken != null || subscriptionUpdateExternalTransactionId != null) {
setSubscriptionUpdateParams(
BillingFlowParams.SubscriptionUpdateParams
.newBuilder()
.apply {
subscriptionUpdateOldToken?.let { token ->
setOldPurchaseToken(token)
}
subscriptionUpdateExternalTransactionId?.let { externalTransactionId ->
setOriginalExternalTransactionId(externalTransactionId)
}
subscriptionUpdateReplacementMode?.let { mode ->
setSubscriptionReplacementMode(mode)
}
}
.build()
)
// subscription update params
(subscriptionParams ?: SubscriptionPurchaseParams()).getSubscriptionUpdateParams()?.let {
setSubscriptionUpdateParams(it)
}
}.build()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,94 +2,94 @@

package com.vojtkovszky.billinghelper

import android.annotation.SuppressLint
import android.util.Log
import com.android.billingclient.api.ProductDetails
import java.util.regex.Pattern
import kotlin.math.roundToLong

object PriceUtil {
internal const val TAG = "BillingHelper/PriceUtil"

internal object PriceUtil {
var enableLogging: Boolean = false

/**
* Convert a formatted price to a formatted price with a given divider.
* For example: "12.80 EUR" with a divider of 4 will return "4.20 EUR"
*
* @param price formatted price input
* @param divider divide the extracted price
* @return formatted divided price or null on error
*/
fun convertFullPriceToDivided(
price: String,
divider: Int
): String? {
var fullPrice = price
if (divider == 1) {
return price
} else try {
// Must use javaSpaceChar and not a typed space
fullPrice = fullPrice.replace("(?<=\\d)\\p{javaSpaceChar}+(?=\\d)".toRegex(), "").trim()
fullPrice =
if (fullPrice.contains(",") && fullPrice.contains(".") && fullPrice.last() != '.')
fullPrice.replace(",", "")
else fullPrice.replace(",", ".")

var digit: String? = null
val currency: String
val regex = Pattern.compile("(\\d+(?:\\.\\d+)?)")
val matcher = regex.matcher(fullPrice)
while (matcher.find()) {
digit = matcher.group(1)
}
if (digit != null) {
currency = fullPrice.replace(digit, "")
val digitValue = digit.toDouble() / divider

return if (fullPrice.startsWith(currency)) currency + roundDigitString(digitValue)
else roundDigitString(digitValue) + currency
}
} catch (e: Exception) {
if (enableLogging) Log.e(TAG, e.message ?: "")
}

return null
}

private fun roundDigitString(digitValue: Double): String {
val priceValueString =
if (digitValue > 1000.0) digitValue.roundToLong().toString() else
String.format("%.2f", digitValue)
return priceValueString.replace(".", ",")
}
const val TAG = "BillingHelper/PriceUtil"
}


/**
* A helper method making it easier to retrieve a formatted price from product details.
* Parameters only apply to subscription products.
*
* @param subscriptionOfferIndex index of [ProductDetails.getSubscriptionOfferDetails].
* @param subscriptionPricingPhaseIndex index of [ProductDetails.SubscriptionOfferDetails.pricingPhases]
* @param subscriptionBasePlanId applies to a subscription, define base plan id to get price for. If no value is applied, first offer will be used. Can be combined with [subscriptionOfferId]
* @param subscriptionOfferId applies to a subscription, define offer id to get price for. If no value is applied, first offer will be used. Can be combined with [subscriptionBasePlanId]
* @param subscriptionPricingPhaseIndex in case your offer has multiple phases, retrieve price for a phase with a given index.
* @return formatted price or null on error
*/
fun ProductDetails.getFormattedPrice(
subscriptionOfferIndex: Int = 0,
subscriptionBasePlanId: String? = null,
subscriptionOfferId: String? = null,
subscriptionPricingPhaseIndex: Int = 0
): String? {
return if (isInAppPurchase()) {
oneTimePurchaseOfferDetails?.formattedPrice
} else try {
if (isSubscription()) {
subscriptionOfferDetails?.getOrNull(subscriptionOfferIndex)
?.pricingPhases
?.pricingPhaseList?.getOrNull(subscriptionPricingPhaseIndex)
?.formattedPrice
// Find the first matching offer based on the base plan ID and offer ID, or default to the first
val offer = subscriptionOfferDetails?.firstOrNull { offer ->
(subscriptionBasePlanId == null || offer.basePlanId == subscriptionBasePlanId) &&
(subscriptionOfferId == null || offer.offerId == subscriptionOfferId)
}
return offer?.pricingPhases?.pricingPhaseList?.getOrNull(subscriptionPricingPhaseIndex)?.formattedPrice
} else {
null
}
} catch (e: Exception) {
if (PriceUtil.enableLogging) Log.e(PriceUtil.TAG, e.message ?: "")
null
}
}

/**
* Same as [getFormattedPrice], but apply divider to the actual price.
* For example: "16.80 EUR" with a divider of 4 will return "4.20 EUR".
* Formatted price will be rounded to two decimal places.
*
* @param subscriptionBasePlanId see [getFormattedPrice]
* @param subscriptionOfferId see [getFormattedPrice]
* @param subscriptionPricingPhaseIndex see [getFormattedPrice]
* @param divider price divider
* @param dividerFormat divider format used for divided price when represented as String.
* Defaults to two decimal places (as "%.2f") but use other format if needed.
*/
@SuppressLint("DefaultLocale")
fun ProductDetails.getFormattedPriceDivided(
subscriptionBasePlanId: String? = null,
subscriptionOfferId: String? = null,
subscriptionPricingPhaseIndex: Int = 0,
divider: Int,
dividerFormat: String = "%.2f"
): String? {
return getFormattedPrice(
subscriptionBasePlanId,
subscriptionOfferId,
subscriptionPricingPhaseIndex
)?.let { price ->
// Regex to match currency before or after the numeric part, with comma or dot as decimal separator
val regex = """(\D*)\s*([\d,.]+)\s*(\D*)""".toRegex()
val matchResult = regex.matchEntire(price)

return if (matchResult != null) {
val (currencyPrefix, numericPart, currencySuffix) = matchResult.destructured

// Clean numeric part by replacing comma with dot if needed and converting to Double
val normalizedNumber = numericPart.replace(",", ".").toDoubleOrNull()
if (normalizedNumber != null) {
// Divide and format the result with two decimal places
val dividedPrice = normalizedNumber / divider
val formattedPrice = String.format(dividerFormat, dividedPrice)

// Construct the new price string with currency in the original position
"${currencyPrefix.trim()}$formattedPrice${currencySuffix.trim()}"
} else {
null // Return null if numeric conversion fails
}
} else {
null // Return null if the price format is not as expected
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.vojtkovszky.billinghelper

import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.ProductDetails

/**
* Additional parameters often required for subscription purchases.
*
* @param basePlanId define base plan id to initiate a purchase with. If no value is provided, first offer will be used. Can be combined with [offerId]
* @param offerId define offer id to initiate a purchase with. If no value is provided, first offer will be used. Can be combined with [basePlanId]
* @param updateOldToken Google Play Billing purchase token that the user is upgrading or downgrading from.
* See [https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams].
* Note that [productName] must also be a subscription for this to take effect.
* @param updateExternalTransactionId If the originating transaction for the subscription
* that the user is upgrading or downgrading from was processed via alternative billing.
* See [https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.Builder#setOriginalExternalTransactionId(java.lang.String)].
* @param updateReplacementMode Supported replacement modes to replace an existing subscription with a new one.
* See [https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.ReplacementMode].
*/
data class SubscriptionPurchaseParams(
val basePlanId: String? = null,
val offerId: String? = null,
val updateOldToken: String? = null,
val updateExternalTransactionId: String? = null,
val updateReplacementMode: Int? = null
) {
// return offer token for given product details
internal fun getOfferToken(productDetails: ProductDetails): String? {
// set if found, or apply first found by default.
return productDetails.subscriptionOfferDetails?.firstOrNull { offer ->
(basePlanId == null || offer.basePlanId == basePlanId) &&
(offerId == null || offer.offerId == offerId)
}?.offerToken
}

// return SubscriptionUpdateParams if we can build it
internal fun getSubscriptionUpdateParams(): BillingFlowParams.SubscriptionUpdateParams? {
if (updateOldToken != null || updateExternalTransactionId != null) {
return BillingFlowParams.SubscriptionUpdateParams
.newBuilder()
.apply {
updateOldToken?.let { token ->
setOldPurchaseToken(token)
}
updateExternalTransactionId?.let { id ->
setOriginalExternalTransactionId(id)
}
updateReplacementMode?.let { mode ->
setSubscriptionReplacementMode(mode)
}
}
.build()
}
return null
}
}

0 comments on commit 7c7ab63

Please sign in to comment.