diff --git a/app/src/main/java/org/thoughtcrime/securesms/billing/GooglePlayBillingApi.kt b/app/src/main/java/org/thoughtcrime/securesms/billing/GooglePlayBillingApi.kt index af5671e8ab..10c4236ba9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/billing/GooglePlayBillingApi.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/billing/GooglePlayBillingApi.kt @@ -5,12 +5,22 @@ package org.thoughtcrime.securesms.billing +import android.app.Activity + /** * Variant interface for the BillingApi. */ interface GooglePlayBillingApi { fun isApiAvailable(): Boolean = false - suspend fun queryProducts() {} + suspend fun queryProducts() = Unit + + /** + * Queries the user's current purchases. This enqueues a check and will + * propagate it to the normal callbacks in the api. + */ + suspend fun queryPurchases() = Unit + + suspend fun launchBillingFlow(activity: Activity) = Unit /** * Empty implementation, to be used when play services are available but diff --git a/app/src/play/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt b/app/src/play/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt index 5d24ca4363..d4994d40dc 100644 --- a/app/src/play/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt +++ b/app/src/play/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt @@ -1,7 +1,10 @@ package org.thoughtcrime.securesms.billing +import android.app.Activity import android.content.Context +import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.ProductDetailsResult +import com.android.billingclient.api.PurchasesUpdatedListener import org.signal.billing.BillingApi import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.util.RemoteConfig @@ -29,7 +32,25 @@ private class GooglePlayBillingApiImpl(context: Context) : GooglePlayBillingApi val TAG = Log.tag(GooglePlayBillingApiImpl::class) } - private val billingApi: BillingApi = BillingApi.getOrCreate(context) + private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> + when { + billingResult.responseCode == BillingResponseCode.OK && purchases != null -> { + Log.d(TAG, "purchasesUpdatedListener: ${purchases.size} purchases.") + purchases.forEach { + // Handle purchases. + } + } + billingResult.responseCode == BillingResponseCode.USER_CANCELED -> { + // Handle user cancelled + Log.d(TAG, "purchasesUpdatedListener: User cancelled.") + } + else -> { + Log.d(TAG, "purchasesUpdatedListener: No purchases.") + } + } + } + + private val billingApi: BillingApi = BillingApi.getOrCreate(context, purchasesUpdatedListener) override fun isApiAvailable(): Boolean = billingApi.areSubscriptionsSupported() @@ -38,4 +59,15 @@ private class GooglePlayBillingApiImpl(context: Context) : GooglePlayBillingApi Log.d(TAG, "queryProducts: ${products.billingResult.responseCode}, ${products.billingResult.debugMessage}") } + + override suspend fun queryPurchases() { + Log.d(TAG, "queryPurchases") + + val purchaseResult = billingApi.queryPurchases() + purchasesUpdatedListener.onPurchasesUpdated(purchaseResult.billingResult, purchaseResult.purchasesList) + } + + override suspend fun launchBillingFlow(activity: Activity) { + billingApi.launchBillingFlow(activity) + } } diff --git a/billing/src/main/java/org/signal/billing/BillingApi.kt b/billing/src/main/java/org/signal/billing/BillingApi.kt index e6d3c3a33b..1542810c4b 100644 --- a/billing/src/main/java/org/signal/billing/BillingApi.kt +++ b/billing/src/main/java/org/signal/billing/BillingApi.kt @@ -5,16 +5,24 @@ package org.signal.billing +import android.app.Activity import android.content.Context import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetailsResult +import com.android.billingclient.api.PurchasesResult import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.queryProductDetails +import com.android.billingclient.api.queryPurchasesAsync import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -38,7 +46,8 @@ import org.signal.core.util.logging.Log * Care should be taken here to ensure only one instance of this exists at a time. */ class BillingApi private constructor( - context: Context + context: Context, + onPurchaseUpdateListener: PurchasesUpdatedListener ) { companion object { private val TAG = Log.tag(BillingApi::class) @@ -46,8 +55,8 @@ class BillingApi private constructor( private var instance: BillingApi? = null @Synchronized - fun getOrCreate(context: Context): BillingApi { - return instance ?: BillingApi(context).let { + fun getOrCreate(context: Context, onPurchaseUpdateListener: PurchasesUpdatedListener): BillingApi { + return instance ?: BillingApi(context, onPurchaseUpdateListener).let { instance = it it } @@ -57,13 +66,8 @@ class BillingApi private constructor( private val connectionState = MutableStateFlow(State.Init) private val coroutineScope = CoroutineScope(Dispatchers.Default) - private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> - Log.d(TAG, "purchasesUpdatedListener: ${billingResult.responseCode}") - Log.d(TAG, "purchasesUpdatedListener: Detected ${purchases?.size ?: 0} purchases.") - } - private val billingClient: BillingClient = BillingClient.newBuilder(context) - .setListener(purchasesUpdatedListener) + .setListener(onPurchaseUpdateListener) .enablePendingPurchases( PendingPurchasesParams.newBuilder() .enableOneTimeProducts() @@ -88,7 +92,7 @@ class BillingApi private constructor( val productList = listOf( QueryProductDetailsParams.Product.newBuilder() .setProductId("") // TODO [message-backups] where does the product id come from? - .setProductType(BillingClient.ProductType.SUBS) + .setProductType(ProductType.SUBS) .build() ) @@ -103,6 +107,52 @@ class BillingApi private constructor( } } + suspend fun queryPurchases(): PurchasesResult { + val param = QueryPurchasesParams.newBuilder() + .setProductType(ProductType.SUBS) + .build() + + return doOnConnectionReady { + billingClient.queryPurchasesAsync(param) + } + } + + /** + * Launches the Google Play billing flow. + * Returns a billing result if we launched the flow, null otherwise. + */ + suspend fun launchBillingFlow(activity: Activity): BillingResult? { + val productDetails = queryProducts().productDetailsList + if (productDetails.isNullOrEmpty()) { + Log.w(TAG, "No products are available! Cancelling billing flow launch.") + return null + } + + val subscriptionDetails: ProductDetails = productDetails[0] + val offerToken = subscriptionDetails.subscriptionOfferDetails?.firstOrNull() + if (offerToken == null) { + Log.w(TAG, "No offer tokens available on subscription product! Cancelling billing flow launch.") + return null + } + + val productDetailParamsList = listOf( + ProductDetailsParams.newBuilder() + .setProductDetails(subscriptionDetails) + .setOfferToken(offerToken.offerToken) + .build() + ) + + val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(productDetailParamsList) + .build() + + return doOnConnectionReady { + withContext(Dispatchers.Main) { + billingClient.launchBillingFlow(activity, billingFlowParams) + } + } + } + /** * Returns whether or not subscriptions are supported by a user's device. Lack of subscription support is generally due * to out-of-date Google Play API