Skip to content

Commit

Permalink
Improve Billing API communication (#3441)
Browse files Browse the repository at this point in the history
  • Loading branch information
MiSikora authored Jan 15, 2025
1 parent 59f79c7 commit 41a965a
Show file tree
Hide file tree
Showing 8 changed files with 417 additions and 249 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -256,12 +256,11 @@ class PocketCastsApplication : Application(), Configuration.Provider {
// init the stats engine
statsManager.initStatsEngine()

subscriptionManager.connectToGooglePlay(this@PocketCastsApplication)

sleepTimerRestartWhenShakingDevice.init() // Begin detecting when the device has been shaken to restart the sleep timer.
}
}

applicationScope.launch { subscriptionManager.initializeBillingConnection() }
applicationScope.launch(Dispatchers.IO) { fileStorage.fixBrokenFiles(episodeManager) }

userEpisodeManager.monitorUploads(applicationContext)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -585,7 +585,9 @@ class MainActivity :
source = PocketCastsShortcuts.Source.REFRESH_APP,
)

subscriptionManager.refreshPurchases()
lifecycleScope.launch {
subscriptionManager.refreshPurchases()
}

// Schedule next refresh in the background
RefreshPodcastsTask.scheduleOrCancel(this@MainActivity, settings)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package au.com.shiftyjelly.pocketcasts.account.viewmodel

import android.app.Activity
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand Down Expand Up @@ -101,7 +101,7 @@ class OnboardingUpgradeBottomSheetViewModel @Inject constructor(
}

fun onClickSubscribe(
activity: Activity,
activity: AppCompatActivity,
flow: OnboardingFlow,
source: OnboardingUpgradeSource,
onComplete: () -> Unit,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package au.com.shiftyjelly.pocketcasts.account.viewmodel

import android.app.Activity
import android.app.Application
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
Expand Down Expand Up @@ -211,7 +211,7 @@ class OnboardingUpgradeFeaturesViewModel @Inject constructor(
}

fun onClickSubscribe(
activity: Activity,
activity: AppCompatActivity,
flow: OnboardingFlow,
source: OnboardingUpgradeSource,
onComplete: () -> Unit,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package au.com.shiftyjelly.pocketcasts.referrals

import android.app.Activity
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent
Expand Down Expand Up @@ -147,7 +147,7 @@ class ReferralsClaimGuestPassViewModel @Inject constructor(
}

fun launchBillingFlow(
activity: Activity,
activity: AppCompatActivity,
subscriptionWithOffer: Subscription.WithOffer,
) {
analyticsTracker.track(AnalyticsEvent.REFERRAL_PURCHASE_SHOWN)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
package au.com.shiftyjelly.pocketcasts.repositories.subscription

import android.app.Activity
import android.content.Context
import androidx.lifecycle.AtomicReference
import au.com.shiftyjelly.pocketcasts.repositories.subscription.ClientConnection.ClientConnectionState.Connected
import au.com.shiftyjelly.pocketcasts.repositories.subscription.ClientConnection.ClientConnectionState.Connecting
import au.com.shiftyjelly.pocketcasts.repositories.subscription.ClientConnection.ClientConnectionState.Disconnected
import au.com.shiftyjelly.pocketcasts.repositories.subscription.ClientConnection.ClientConnectionState.Uninitialized
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchaseHistoryRecord
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchaseHistoryParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchaseHistory
import com.android.billingclient.api.queryPurchasesAsync
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

@Singleton
class BillingClientWrapper @Inject constructor(
@ApplicationContext context: Context,
private val productDetailsInterceptor: ProductDetailsInterceptor,
) {
private val _purchaseUpdates = MutableSharedFlow<Pair<BillingResult, List<Purchase>>>(
extraBufferCapacity = 100, // Arbitrarily large number
)
val purchaseUpdates = _purchaseUpdates.asSharedFlow()

private val connection = ClientConnection(
context,
listener = PurchasesUpdatedListener { billingResult, purchases ->
logSubscriptionInfo("Purchase results updated")
_purchaseUpdates.tryEmit(billingResult to purchases.orEmpty())
},
)

suspend fun loadProducts(
params: QueryProductDetailsParams,
): Pair<BillingResult, List<ProductDetails>> {
logSubscriptionInfo("Loading products")
return connection.withConnectedClient { client ->
val productDetailsResult = client.queryProductDetails(params)
val result = productDetailsInterceptor.intercept(
productDetailsResult.billingResult,
productDetailsResult.productDetailsList.orEmpty(),
)
if (result.first.isOk()) {
logSubscriptionInfo("Products loaded")
} else {
logSubscriptionWarning("Failed to load products: ${result.first.debugMessage}")
}
result
}
}

suspend fun loadPurchaseHistory(
params: QueryPurchaseHistoryParams,
): Pair<BillingResult, List<PurchaseHistoryRecord>> {
logSubscriptionInfo("Loading purchase history")
return connection.withConnectedClient { client ->
val result = client.queryPurchaseHistory(params)
if (result.billingResult.isOk()) {
logSubscriptionInfo("Purchase history loaded")
} else {
logSubscriptionWarning("Failed to load purchase history: ${result.billingResult.debugMessage}")
}
result.billingResult to result.purchaseHistoryRecordList.orEmpty()
}
}

suspend fun loadPurchases(
params: QueryPurchasesParams,
): Pair<BillingResult, List<Purchase>> {
logSubscriptionInfo("Loading purchases")
return connection.withConnectedClient { client ->
val result = client.queryPurchasesAsync(params)
if (result.billingResult.isOk()) {
logSubscriptionInfo("Purchases loaded")
} else {
logSubscriptionWarning("Failed to load purchases: ${result.billingResult.debugMessage}")
}
result.billingResult to result.purchasesList
}
}

suspend fun acknowledgePurchase(
params: AcknowledgePurchaseParams,
): BillingResult {
logSubscriptionInfo("Acknowledging purchase: ${params.purchaseToken}")
return connection.withConnectedClient { client ->
val result = client.acknowledgePurchase(params)
if (result.isOk()) {
logSubscriptionInfo("Purchase acknowledge: ${params.purchaseToken}")
} else {
logSubscriptionWarning("Failed to acknowledge purchase: ${params.purchaseToken}, ${result.debugMessage}")
}
result
}
}

suspend fun launchBillingFlow(
activity: Activity,
params: BillingFlowParams,
): BillingResult {
logSubscriptionInfo("Launching billing flow")
return connection.withConnectedClient { client ->
val result = client.launchBillingFlow(activity, params)
if (result.isOk()) {
logSubscriptionInfo("Launched billing flow")
} else {
logSubscriptionWarning("Failed to launch billing flow: ${result.debugMessage}")
}
result
}
}
}

private class ClientConnection(
context: Context,
listener: PurchasesUpdatedListener,
) {
private val connectionState = AtomicReference<ClientConnectionState>(Uninitialized)
private val connectionMutex = Mutex()

private val billingClient = run {
val params = PendingPurchasesParams.newBuilder()
.enablePrepaidPlans()
.enableOneTimeProducts()
.build()
BillingClient.newBuilder(context)
.enablePendingPurchases(params)
.setListener(listener)
.build()
}

suspend fun <T> withConnectedClient(block: suspend (BillingClient) -> T): T {
connect()
return block(billingClient)
}

private suspend fun connect() = connectionMutex.withLock {
val state = connectionState.getAndUpdate { currentState ->
if (currentState != Connected) Connecting else currentState
}
logSubscriptionInfo("Billing client connection: $state")
if (state == Disconnected || state == Uninitialized) {
val isConnectionEstablished = setupBillingClient()
if (!isConnectionEstablished) {
billingClient.endConnection()
}
connectionState.updateAndGet { currentState ->
if (isConnectionEstablished && currentState == Connecting) Connected else Disconnected
}
}
}

private suspend fun setupBillingClient(): Boolean {
logSubscriptionInfo("Connecting to billing client")
return suspendCancellableCoroutine<Boolean> { continuation ->
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
logSubscriptionInfo("Billing setup finished: $billingResult")
continuation.resume(billingResult.responseCode == BillingClient.BillingResponseCode.OK)
}

override fun onBillingServiceDisconnected() {
logSubscriptionWarning("Billing client disconnected")

// Emitting disconnected state here as well as this an ongoing listener
// And we want to update the status if this changes
billingClient.endConnection()
connectionState.set(Disconnected)

if (continuation.isActive) {
continuation.resume(false)
}
}
})
}
}

private enum class ClientConnectionState {
Uninitialized,
Disconnected,
Connecting,
Connected,
}
}

internal fun BillingResult.isOk() = responseCode == BillingClient.BillingResponseCode.OK
Original file line number Diff line number Diff line change
@@ -1,39 +1,32 @@
package au.com.shiftyjelly.pocketcasts.repositories.subscription

import android.app.Activity
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import au.com.shiftyjelly.pocketcasts.models.to.SubscriptionStatus
import au.com.shiftyjelly.pocketcasts.models.type.Subscription
import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionFrequency
import au.com.shiftyjelly.pocketcasts.models.type.SubscriptionTier
import au.com.shiftyjelly.pocketcasts.utils.Optional
import com.android.billingclient.api.BillingResult
import au.com.shiftyjelly.pocketcasts.utils.log.LogBuffer
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesResult
import io.reactivex.Flowable
import io.reactivex.Single
import kotlinx.coroutines.flow.Flow
import timber.log.Timber

interface SubscriptionManager {
suspend fun initializeBillingConnection(): Nothing
suspend fun refreshPurchases()
fun launchBillingFlow(activity: AppCompatActivity, productDetails: ProductDetails, offerToken: String)

fun signOut()
fun observeSubscriptionChangeEvents(): Flowable<SubscriptionChangedEvent>

fun observeProductDetails(): Flowable<ProductDetailsState>
fun observePurchaseEvents(): Flowable<PurchaseEvent>
fun observeSubscriptionStatus(): Flowable<Optional<SubscriptionStatus>>
fun subscriptionTier(): Flow<SubscriptionTier>
fun getSubscriptionStatusRxSingle(allowCache: Boolean = true): Single<SubscriptionStatus>
suspend fun getSubscriptionStatus(allowCache: Boolean = true): SubscriptionStatus
fun connectToGooglePlay(context: Context)
fun loadProducts()
fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList<Purchase>?)
fun onAcknowledgePurchaseResponse(billingResult: BillingResult)
fun handlePurchase(purchase: Purchase)
suspend fun sendPurchaseToServer(purchase: Purchase)
fun refreshPurchases()
suspend fun getPurchases(): PurchasesResult?
fun launchBillingFlow(activity: Activity, productDetails: ProductDetails, offerToken: String)

fun getCachedStatus(): SubscriptionStatus?
fun clearCachedStatus()
fun isOfferEligible(tier: SubscriptionTier): Boolean
Expand All @@ -45,3 +38,17 @@ interface SubscriptionManager {
): Subscription?
fun freeTrialForSubscriptionTierFlow(subscriptionTier: SubscriptionTier): Flow<FreeTrial>
}

internal fun logSubscriptionInfo(message: String) {
Timber.tag(LogBuffer.TAG_SUBSCRIPTIONS).i(message)
}

internal fun logSubscriptionWarning(message: String) {
Timber.tag(LogBuffer.TAG_SUBSCRIPTIONS).w(message)
LogBuffer.w(LogBuffer.TAG_SUBSCRIPTIONS, message)
}

internal fun logSubscriptionError(e: Throwable, message: String) {
Timber.tag(LogBuffer.TAG_SUBSCRIPTIONS).e(e, message)
LogBuffer.e(LogBuffer.TAG_SUBSCRIPTIONS, e, message)
}
Loading

0 comments on commit 41a965a

Please sign in to comment.