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
11 changes: 3 additions & 8 deletions app/src/main/java/com/appsci/billingktx/sample/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.QueryProductDetailsParams
import com.appsci.billingktx.client.BillingKtx
import com.appsci.billingktx.client.BillingKtxImpl
import com.appsci.billingktx.connection.BillingKtxFactory
import com.appsci.billingktx.lifecycle.keepConnection
import com.appsci.billingktx.sample.theme.BillingKtxTheme
import kotlinx.coroutines.launch
Expand All @@ -36,11 +34,9 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)

billingKtx = BillingKtxImpl(
billingFactory = BillingKtxFactory(
context = this,
enableOneTimeProducts = true,
)
billingKtx = BillingKtx(
context = this,
enableOneTimeProducts = true,
)
billingKtx.keepConnection(this)

Expand Down Expand Up @@ -118,7 +114,6 @@ class MainActivity : ComponentActivity() {
}
setContent {
BillingKtxTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
Expand Down
4 changes: 2 additions & 2 deletions billingKtx/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ afterEvaluate {
from(components["release"])
groupId = "com.github.AppSci"
artifactId = "billing-ktx"
version = "1.0.0"
version = "1.1.0-RC2"
}
}

Expand Down Expand Up @@ -78,7 +78,7 @@ dependencies {
implementation(libs.android.material)
implementation(libs.android.lifecycle)

implementation(libs.google.billing)
api(libs.google.billing)

implementation(libs.utils.timber)
}
246 changes: 42 additions & 204 deletions billingKtx/src/main/java/com/appsci/billingktx/client/BillingKtx.kt
Original file line number Diff line number Diff line change
@@ -1,236 +1,74 @@
package com.appsci.billingktx.client

import android.app.Activity
import android.content.Context
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClient.FeatureType
import com.android.billingclient.api.BillingConfig
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.ConsumeParams
import com.android.billingclient.api.GetBillingConfigParams
import com.android.billingclient.api.InAppMessageParams
import com.android.billingclient.api.InAppMessageResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.consumePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import com.appsci.billingktx.client.connection.BillingConnection
import com.appsci.billingktx.client.connection.BillingConnectionImpl
import com.appsci.billingktx.client.repository.BillingApi
import com.appsci.billingktx.client.ui.BillingFlowLauncher
import com.appsci.billingktx.connection.BillingKtxFactory
import com.appsci.billingktx.exception.BillingException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

interface BillingKtx {
class BillingKtx(
context: Context,
enableOneTimeProducts: Boolean = false,
enablePrepaidPlans: Boolean = false,
enableAutoServiceReconnection: Boolean = false,
) : BillingConnection {

fun connect(): Flow<BillingClient>
private val connection = BillingConnectionImpl(
BillingKtxFactory(
context = context,
enableOneTimeProducts = enableOneTimeProducts,
enablePrepaidPlans = enablePrepaidPlans,
enableAutoServiceReconnection = enableAutoServiceReconnection,
),
)

suspend fun isFeatureSupported(@FeatureType feature: String): Boolean
private val api = BillingApi(connection)
private val flowLauncher = BillingFlowLauncher(connection)

fun observeUpdates(): Flow<PurchasesUpdate>
override fun connect(): Flow<BillingClient> = connection.connect()

suspend fun getPurchases(@BillingClient.ProductType productType: String): List<Purchase>
override fun observeUpdates(): Flow<PurchasesUpdate> = connection.observeUpdates()

/**
* do not mix subs and inapp types in the same params object
*/
suspend fun getProductDetails(params: QueryProductDetailsParams): List<ProductDetails>

suspend fun launchFlow(activity: Activity, params: BillingFlowParams)

suspend fun showInappMessages(
activity: Activity,
params: InAppMessageParams,
): InAppMessageResult

suspend fun consumeProduct(params: ConsumeParams)

suspend fun acknowledge(params: AcknowledgePurchaseParams)

suspend fun getBillingConfig(): BillingConfig
}

class BillingKtxImpl(
billingFactory: BillingKtxFactory,
scope: CoroutineScope = CoroutineScope(SupervisorJob()),
) : BillingKtx {

private val updatesFlow = MutableSharedFlow<PurchasesUpdate>()

private val updatedListener = PurchasesUpdatedListener { result, purchases ->
val event = when (val responseCode = result.responseCode) {
BillingClient.BillingResponseCode.OK -> PurchasesUpdate.Success(
responseCode,
purchases.orEmpty()
)
suspend fun isFeatureSupported(@FeatureType feature: String): Boolean =
api.isFeatureSupported(feature)

BillingClient.BillingResponseCode.USER_CANCELED -> PurchasesUpdate.Canceled(
responseCode,
purchases.orEmpty()
)
suspend fun getPurchases(@BillingClient.ProductType productType: String): List<Purchase> =
api.getPurchases(productType)

else -> PurchasesUpdate.Failed(responseCode, purchases.orEmpty())
}
scope.launch {
updatesFlow.emit(event)
}
}

private val connectionFlow = billingFactory
.createBillingClientFlow(updatedListener)

override fun connect(): Flow<BillingClient> {
return connectionFlow
}

override suspend fun isFeatureSupported(@FeatureType feature: String): Boolean {
return withConnectedClient {
val result = it.isFeatureSupported(feature)
result.responseCode == BillingClient.BillingResponseCode.OK
}
}
/**
* Do not mix subs and inapp types in the same params object.
*/
suspend fun getProductDetails(params: QueryProductDetailsParams): List<ProductDetails> =
api.getProductDetails(params)

@OptIn(ExperimentalCoroutinesApi::class)
override fun observeUpdates(): Flow<PurchasesUpdate> {
return connectionFlow.flatMapLatest {
updatesFlow
}
}
suspend fun getBillingConfig(): BillingConfig =
api.getBillingConfig()

override suspend fun getPurchases(@BillingClient.ProductType productType: String): List<Purchase> {
return getBoughtItems(productType)
}
suspend fun consumeProduct(params: ConsumeParams) =
api.consumeProduct(params)

override suspend fun getProductDetails(params: QueryProductDetailsParams): List<ProductDetails> {
return withConnectedClient { client ->
val detailsResult = client.queryProductDetails(params)
val billingResult = detailsResult.billingResult
val productDetails = detailsResult.productDetailsList
val responseCode = billingResult.responseCode
if (isSuccess(responseCode)) {
productDetails.orEmpty()
} else {
throw BillingException.fromResult(billingResult)
}
}
}
suspend fun acknowledge(params: AcknowledgePurchaseParams) =
api.acknowledge(params)

override suspend fun launchFlow(activity: Activity, params: BillingFlowParams) {
return withConnectedClient {
val billingResult = withContext(Dispatchers.Main) {
it.launchBillingFlow(activity, params)
}
if (isSuccess(billingResult.responseCode)) {
Unit
} else {
throw BillingException.fromResult(billingResult)
}
}
}
suspend fun launchFlow(activity: Activity, params: BillingFlowParams) =
flowLauncher.launchFlow(activity, params)

override suspend fun showInappMessages(
suspend fun showInappMessages(
activity: Activity,
params: InAppMessageParams,
): InAppMessageResult {
return withConnectedClient { client ->
suspendCoroutine {
client.showInAppMessages(activity, params) { result: InAppMessageResult ->
val responseCode = result.responseCode
if (isSuccess(responseCode)) {
it.resume(result)
} else {
it.resumeWithException(
BillingException.fromResponseCode(responseCode)
)
}
}
}
}
}

override suspend fun consumeProduct(params: ConsumeParams) {
return withConnectedClient { client ->
val consumePurchase = client.consumePurchase(params)
val billingResult = consumePurchase.billingResult
val responseCode = billingResult.responseCode
if (isSuccess(responseCode)) {
Unit
} else {
throw BillingException.fromResult(billingResult)
}
}
}

override suspend fun acknowledge(params: AcknowledgePurchaseParams) {
return withConnectedClient { client ->
val billingResult = client.acknowledgePurchase(params)
val responseCode = billingResult.responseCode
if (isSuccess(responseCode)) {
Unit
} else {
throw BillingException.fromResult(billingResult)
}
}
}

override suspend fun getBillingConfig(): BillingConfig {
return withConnectedClient { client ->
val params = GetBillingConfigParams
.newBuilder()
.build()
suspendCoroutine {
client.getBillingConfigAsync(params) { billingResult, config ->
if (isSuccess(billingResult.responseCode) && config != null) {
it.resume(config)
} else {
it.resumeWithException(BillingException.fromResult(billingResult))
}
}
}
}
}

private suspend fun getBoughtItems(@BillingClient.ProductType type: String): List<Purchase> {
return withConnectedClient {
val params = QueryPurchasesParams.newBuilder()
.setProductType(type)
.build()
val purchasesResult = it.queryPurchasesAsync(params)
val billingResult = purchasesResult.billingResult
val purchasesList = purchasesResult.purchasesList

if (isSuccess(billingResult.responseCode)) {
purchasesList
} else {
throw BillingException.fromResult(billingResult)
}
}
}

private suspend fun <R> withConnectedClient(
block: suspend (BillingClient) -> R,
): R {
return connectionFlow.map {
block(it)
}.first()
}

private fun isSuccess(@BillingClient.BillingResponseCode responseCode: Int): Boolean {
return responseCode == BillingClient.BillingResponseCode.OK
}
): InAppMessageResult = flowLauncher.showInappMessages(activity, params)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.appsci.billingktx.client.connection

import com.android.billingclient.api.BillingClient
import com.appsci.billingktx.client.PurchasesUpdate
import kotlinx.coroutines.flow.Flow

interface BillingConnection {

fun connect(): Flow<BillingClient>

fun observeUpdates(): Flow<PurchasesUpdate>
Comment on lines +9 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To allow BillingRepository and BillingFlowLauncher to depend on this interface (as suggested in other comments), the withConnectedClient function should be part of the public contract of BillingConnection.

    fun connect(): Flow<BillingClient>

    fun observeUpdates(): Flow<PurchasesUpdate>

    suspend fun <R> withConnectedClient(block: suspend (BillingClient) -> R): R

}
Loading