diff --git a/app/src/main/java/com/appsci/billingktx/sample/MainActivity.kt b/app/src/main/java/com/appsci/billingktx/sample/MainActivity.kt index da6a9f8..ca679be 100644 --- a/app/src/main/java/com/appsci/billingktx/sample/MainActivity.kt +++ b/app/src/main/java/com/appsci/billingktx/sample/MainActivity.kt @@ -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 @@ -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) @@ -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 diff --git a/billingKtx/build.gradle.kts b/billingKtx/build.gradle.kts index e18da20..d56deda 100644 --- a/billingKtx/build.gradle.kts +++ b/billingKtx/build.gradle.kts @@ -49,7 +49,7 @@ afterEvaluate { from(components["release"]) groupId = "com.github.AppSci" artifactId = "billing-ktx" - version = "1.0.0" + version = "1.1.0-RC2" } } @@ -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) } diff --git a/billingKtx/src/main/java/com/appsci/billingktx/client/BillingKtx.kt b/billingKtx/src/main/java/com/appsci/billingktx/client/BillingKtx.kt index 7fe643e..87ea5f1 100644 --- a/billingKtx/src/main/java/com/appsci/billingktx/client/BillingKtx.kt +++ b/billingKtx/src/main/java/com/appsci/billingktx/client/BillingKtx.kt @@ -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 + 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 + override fun connect(): Flow = connection.connect() - suspend fun getPurchases(@BillingClient.ProductType productType: String): List + override fun observeUpdates(): Flow = connection.observeUpdates() - /** - * do not mix subs and inapp types in the same params object - */ - suspend fun getProductDetails(params: QueryProductDetailsParams): List - - 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() - - 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 = + api.getPurchases(productType) - else -> PurchasesUpdate.Failed(responseCode, purchases.orEmpty()) - } - scope.launch { - updatesFlow.emit(event) - } - } - - private val connectionFlow = billingFactory - .createBillingClientFlow(updatedListener) - - override fun connect(): Flow { - 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 = + api.getProductDetails(params) - @OptIn(ExperimentalCoroutinesApi::class) - override fun observeUpdates(): Flow { - return connectionFlow.flatMapLatest { - updatesFlow - } - } + suspend fun getBillingConfig(): BillingConfig = + api.getBillingConfig() - override suspend fun getPurchases(@BillingClient.ProductType productType: String): List { - return getBoughtItems(productType) - } + suspend fun consumeProduct(params: ConsumeParams) = + api.consumeProduct(params) - override suspend fun getProductDetails(params: QueryProductDetailsParams): List { - 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 { - 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 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) } diff --git a/billingKtx/src/main/java/com/appsci/billingktx/client/connection/BillingConnection.kt b/billingKtx/src/main/java/com/appsci/billingktx/client/connection/BillingConnection.kt new file mode 100644 index 0000000..00a10ff --- /dev/null +++ b/billingKtx/src/main/java/com/appsci/billingktx/client/connection/BillingConnection.kt @@ -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 + + fun observeUpdates(): Flow +} diff --git a/billingKtx/src/main/java/com/appsci/billingktx/client/connection/BillingConnectionImpl.kt b/billingKtx/src/main/java/com/appsci/billingktx/client/connection/BillingConnectionImpl.kt new file mode 100644 index 0000000..d4e7614 --- /dev/null +++ b/billingKtx/src/main/java/com/appsci/billingktx/client/connection/BillingConnectionImpl.kt @@ -0,0 +1,65 @@ +package com.appsci.billingktx.client.connection + +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.PurchasesUpdatedListener +import com.appsci.billingktx.client.PurchasesUpdate +import com.appsci.billingktx.connection.BillingKtxFactory +import kotlinx.coroutines.CoroutineScope +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 + +internal class BillingConnectionImpl( + billingFactory: BillingKtxFactory, + scope: CoroutineScope = CoroutineScope(SupervisorJob()), +) : BillingConnection { + + private val updatesFlow = MutableSharedFlow() + + private val updatedListener = PurchasesUpdatedListener { result, purchases -> + val event = when (val responseCode = result.responseCode) { + BillingClient.BillingResponseCode.OK -> PurchasesUpdate.Success( + responseCode, + purchases.orEmpty() + ) + + BillingClient.BillingResponseCode.USER_CANCELED -> PurchasesUpdate.Canceled( + responseCode, + purchases.orEmpty() + ) + + else -> PurchasesUpdate.Failed(responseCode, purchases.orEmpty()) + } + scope.launch { + updatesFlow.emit(event) + } + } + + private val connectionFlow = billingFactory + .createBillingClientFlow(updatedListener) + + override fun connect(): Flow { + return connectionFlow + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun observeUpdates(): Flow { + return connectionFlow.flatMapLatest { + updatesFlow + } + } + + internal suspend fun withConnectedClient( + block: suspend (BillingClient) -> R, + ): R { + return connectionFlow.map { + block(it) + }.first() + } + +} diff --git a/billingKtx/src/main/java/com/appsci/billingktx/client/connection/BillingUtils.kt b/billingKtx/src/main/java/com/appsci/billingktx/client/connection/BillingUtils.kt new file mode 100644 index 0000000..e21995b --- /dev/null +++ b/billingKtx/src/main/java/com/appsci/billingktx/client/connection/BillingUtils.kt @@ -0,0 +1,7 @@ +package com.appsci.billingktx.client.connection + +import com.android.billingclient.api.BillingClient + +internal fun isSuccess(@BillingClient.BillingResponseCode responseCode: Int): Boolean { + return responseCode == BillingClient.BillingResponseCode.OK +} diff --git a/billingKtx/src/main/java/com/appsci/billingktx/client/repository/BillingApi.kt b/billingKtx/src/main/java/com/appsci/billingktx/client/repository/BillingApi.kt new file mode 100644 index 0000000..c28a180 --- /dev/null +++ b/billingKtx/src/main/java/com/appsci/billingktx/client/repository/BillingApi.kt @@ -0,0 +1,107 @@ +package com.appsci.billingktx.client.repository + +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.ConsumeParams +import com.android.billingclient.api.GetBillingConfigParams +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +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.BillingConnectionImpl +import com.appsci.billingktx.client.connection.isSuccess +import com.appsci.billingktx.exception.BillingException +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal class BillingApi( + private val connection: BillingConnectionImpl, +) { + + suspend fun isFeatureSupported(@FeatureType feature: String): Boolean { + return connection.withConnectedClient { + val result = it.isFeatureSupported(feature) + isSuccess(result.responseCode) + } + } + + suspend fun getPurchases(@BillingClient.ProductType productType: String): List { + return connection.withConnectedClient { + val params = QueryPurchasesParams.newBuilder() + .setProductType(productType) + .build() + val purchasesResult = it.queryPurchasesAsync(params) + val billingResult = purchasesResult.billingResult + val purchasesList = purchasesResult.purchasesList + + if (isSuccess(billingResult.responseCode)) { + purchasesList + } else { + throw BillingException.fromResult(billingResult) + } + } + } + + suspend fun getProductDetails(params: QueryProductDetailsParams): List { + return connection.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 getBillingConfig(): BillingConfig { + return connection.withConnectedClient { client -> + val params = GetBillingConfigParams + .newBuilder() + .build() + suspendCancellableCoroutine { + client.getBillingConfigAsync(params) { billingResult, config -> + if (isSuccess(billingResult.responseCode) && config != null) { + it.resume(config) + } else { + it.resumeWithException(BillingException.fromResult(billingResult)) + } + } + } + } + } + + suspend fun consumeProduct(params: ConsumeParams) { + return connection.withConnectedClient { client -> + val consumePurchase = client.consumePurchase(params) + val billingResult = consumePurchase.billingResult + val responseCode = billingResult.responseCode + if (isSuccess(responseCode)) { + Unit + } else { + throw BillingException.fromResult(billingResult) + } + } + } + + suspend fun acknowledge(params: AcknowledgePurchaseParams) { + return connection.withConnectedClient { client -> + val billingResult = client.acknowledgePurchase(params) + val responseCode = billingResult.responseCode + if (isSuccess(responseCode)) { + Unit + } else { + throw BillingException.fromResult(billingResult) + } + } + } +} diff --git a/billingKtx/src/main/java/com/appsci/billingktx/client/ui/BillingFlowLauncher.kt b/billingKtx/src/main/java/com/appsci/billingktx/client/ui/BillingFlowLauncher.kt new file mode 100644 index 0000000..45e2bae --- /dev/null +++ b/billingKtx/src/main/java/com/appsci/billingktx/client/ui/BillingFlowLauncher.kt @@ -0,0 +1,52 @@ +package com.appsci.billingktx.client.ui + +import android.app.Activity +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.InAppMessageParams +import com.android.billingclient.api.InAppMessageResult +import com.appsci.billingktx.client.connection.BillingConnectionImpl +import com.appsci.billingktx.client.connection.isSuccess +import com.appsci.billingktx.exception.BillingException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +internal class BillingFlowLauncher( + private val connection: BillingConnectionImpl, +) { + + suspend fun launchFlow(activity: Activity, params: BillingFlowParams) { + return connection.withConnectedClient { + val billingResult = withContext(Dispatchers.Main) { + it.launchBillingFlow(activity, params) + } + if (isSuccess(billingResult.responseCode)) { + Unit + } else { + throw BillingException.fromResult(billingResult) + } + } + } + + suspend fun showInappMessages( + activity: Activity, + params: InAppMessageParams, + ): InAppMessageResult { + return connection.withConnectedClient { client -> + suspendCancellableCoroutine { + client.showInAppMessages(activity, params) { result: InAppMessageResult -> + val responseCode = result.responseCode + if (isSuccess(responseCode)) { + it.resume(result) + } else { + it.resumeWithException( + BillingException.fromResponseCode(responseCode) + ) + } + } + } + } + } +} diff --git a/billingKtx/src/main/java/com/appsci/billingktx/connection/BillingKtxFactory.kt b/billingKtx/src/main/java/com/appsci/billingktx/connection/BillingKtxFactory.kt index fa30346..da8a04a 100644 --- a/billingKtx/src/main/java/com/appsci/billingktx/connection/BillingKtxFactory.kt +++ b/billingKtx/src/main/java/com/appsci/billingktx/connection/BillingKtxFactory.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.retryWhen import kotlinx.coroutines.flow.shareIn import timber.log.Timber -class BillingKtxFactory( +internal class BillingKtxFactory( private val context: Context, private val transform: (Flow) -> Flow = DefaultTransform( sharingScope = CoroutineScope(SupervisorJob()), @@ -93,7 +93,7 @@ class BillingKtxFactory( } } -class DefaultTransform( +internal class DefaultTransform( private val sharingScope: CoroutineScope, private val timeOut: Long = 3_000, ) : (Flow) -> Flow { diff --git a/billingKtx/src/main/java/com/appsci/billingktx/lifecycle/BillingKtxLifecycle.kt b/billingKtx/src/main/java/com/appsci/billingktx/lifecycle/BillingKtxLifecycle.kt index c745a77..414292e 100644 --- a/billingKtx/src/main/java/com/appsci/billingktx/lifecycle/BillingKtxLifecycle.kt +++ b/billingKtx/src/main/java/com/appsci/billingktx/lifecycle/BillingKtxLifecycle.kt @@ -4,13 +4,13 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import com.appsci.billingktx.client.BillingKtx +import com.appsci.billingktx.client.connection.BillingConnection import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import timber.log.Timber -fun BillingKtx.keepConnection( +fun BillingConnection.keepConnection( lifecycleOwner: LifecycleOwner, state: Lifecycle.State = Lifecycle.State.STARTED, ) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8cdc45a..278e59e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,18 +5,18 @@ sdk-compile = "35" sdk-target = "35" sdk-min = "24" -android-gp = "8.2.2" -kotlin = "2.0.21" +android-gp = "8.9.1" +kotlin = "2.2.0" -kotlin-ksp = "2.0.21-1.0.26" +kotlin-ksp = "2.2.0-2.0.2" kotlin-coroutines = "1.7.3" -android-core = "1.15.0" +android-core = "1.16.0" android-compose = "1.9.3" -android-appcompat = "1.7.0" +android-appcompat = "1.7.1" android-material = "1.12.0" androidTools = "31.7.2" -android-lifecycle = "2.8.7" +android-lifecycle = "2.9.1" google-billing = "8.0.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a5ff921..db71250 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Jan 25 14:57:44 EET 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists