Move billing code to shared module.

This commit is contained in:
Alex Hart 2024-08-22 15:12:24 -03:00 committed by mtang-signal
parent 4447433ffe
commit 244a81ef24
12 changed files with 136 additions and 183 deletions

View file

@ -559,6 +559,7 @@ dependencies {
implementation(libs.rxdogtag) implementation(libs.rxdogtag)
"playImplementation"(project(":billing")) "playImplementation"(project(":billing"))
"nightlyImplementation"(project(":billing"))
"spinnerImplementation"(project(":spinner")) "spinnerImplementation"(project(":spinner"))

View file

@ -7,12 +7,12 @@ import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.subjects.BehaviorSubject import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.Subject import io.reactivex.rxjava3.subjects.Subject
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.signal.core.util.billing.BillingApi
import org.signal.core.util.concurrent.DeadlockDetector import org.signal.core.util.concurrent.DeadlockDetector
import org.signal.core.util.resettableLazy import org.signal.core.util.resettableLazy
import org.signal.libsignal.net.Network import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.thoughtcrime.securesms.billing.GooglePlayBillingApi
import org.thoughtcrime.securesms.components.TypingStatusRepository import org.thoughtcrime.securesms.components.TypingStatusRepository
import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.components.TypingStatusSender
import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl
@ -212,7 +212,7 @@ object AppDependencies {
} }
@JvmStatic @JvmStatic
val billingApi: GooglePlayBillingApi by lazy { val billingApi: BillingApi by lazy {
provider.provideBillingApi() provider.provideBillingApi()
} }
@ -348,6 +348,6 @@ object AppDependencies {
fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations
fun provideScheduledMessageManager(): ScheduledMessageManager fun provideScheduledMessageManager(): ScheduledMessageManager
fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network
fun provideBillingApi(): GooglePlayBillingApi fun provideBillingApi(): BillingApi
} }
} }

View file

@ -8,15 +8,15 @@ import android.os.HandlerThread;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting; import androidx.annotation.VisibleForTesting;
import org.signal.billing.BillingFactory;
import org.signal.core.util.ThreadUtil; import org.signal.core.util.ThreadUtil;
import org.signal.core.util.billing.BillingApi;
import org.signal.core.util.concurrent.DeadlockDetector; import org.signal.core.util.concurrent.DeadlockDetector;
import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.libsignal.net.Network; import org.signal.libsignal.net.Network;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.billing.GooglePlayBillingApi;
import org.thoughtcrime.securesms.billing.GooglePlayBillingFactory;
import org.thoughtcrime.securesms.components.TypingStatusRepository; import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.TypingStatusSender; import org.thoughtcrime.securesms.components.TypingStatusSender;
import org.thoughtcrime.securesms.crypto.ReentrantSessionLock; import org.thoughtcrime.securesms.crypto.ReentrantSessionLock;
@ -460,8 +460,8 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
} }
@Override @Override
public @NonNull GooglePlayBillingApi provideBillingApi() { public @NonNull BillingApi provideBillingApi() {
return GooglePlayBillingFactory.create(context); return BillingFactory.create(context, RemoteConfig.messageBackups());
} }
@VisibleForTesting @VisibleForTesting

View file

@ -1,13 +0,0 @@
package org.thoughtcrime.securesms.billing
import android.content.Context
/**
* Website builds do not support google play billing.
*/
object GooglePlayBillingFactory {
@JvmStatic
fun create(context: Context): GooglePlayBillingApi {
return GooglePlayBillingApi.Empty
}
}

View file

@ -1,73 +0,0 @@
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
/**
* Play billing factory. Returns empty implementation if message backups are not enabled.
*/
object GooglePlayBillingFactory {
@JvmStatic
fun create(context: Context): GooglePlayBillingApi {
return if (RemoteConfig.messageBackups) {
GooglePlayBillingApiImpl(context)
} else {
GooglePlayBillingApi.Empty
}
}
}
/**
* Play Store implementation
*/
private class GooglePlayBillingApiImpl(context: Context) : GooglePlayBillingApi {
private companion object {
val TAG = Log.tag(GooglePlayBillingApiImpl::class)
}
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()
override suspend fun queryProducts() {
val products: ProductDetailsResult = billingApi.queryProducts()
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)
}
}

View file

@ -2,11 +2,11 @@ package org.thoughtcrime.securesms.dependencies
import io.mockk.mockk import io.mockk.mockk
import org.mockito.Mockito import org.mockito.Mockito
import org.signal.core.util.billing.BillingApi
import org.signal.core.util.concurrent.DeadlockDetector import org.signal.core.util.concurrent.DeadlockDetector
import org.signal.libsignal.net.Network import org.signal.libsignal.net.Network
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations
import org.thoughtcrime.securesms.billing.GooglePlayBillingApi
import org.thoughtcrime.securesms.components.TypingStatusRepository import org.thoughtcrime.securesms.components.TypingStatusRepository
import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.components.TypingStatusSender
import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl
@ -199,7 +199,7 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
return mockk() return mockk()
} }
override fun provideBillingApi(): GooglePlayBillingApi { override fun provideBillingApi(): BillingApi {
return mockk() return mockk()
} }
} }

View file

@ -0,0 +1,14 @@
package org.signal.billing
import android.content.Context
import org.signal.core.util.billing.BillingApi
/**
* Website builds do not support google play billing.
*/
object BillingFactory {
@JvmStatic
fun create(context: Context): BillingApi {
return BillingApi.Empty
}
}

View file

@ -1,13 +0,0 @@
package org.thoughtcrime.securesms.billing
import android.content.Context
/**
* Website builds do not support google play billing.
*/
object GooglePlayBillingFactory {
@JvmStatic
fun create(context: Context): GooglePlayBillingApi {
return GooglePlayBillingApi.Empty
}
}

View file

@ -9,6 +9,6 @@ android {
dependencies { dependencies {
lintChecks(project(":lintchecks")) lintChecks(project(":lintchecks"))
api(libs.android.billing) implementation(libs.android.billing)
implementation(project(":core-util")) implementation(project(":core-util"))
} }

View file

@ -17,7 +17,6 @@ import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.ProductDetailsResult import com.android.billingclient.api.ProductDetailsResult
import com.android.billingclient.api.PurchasesResult
import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.QueryPurchasesParams
@ -37,6 +36,7 @@ import kotlinx.coroutines.flow.retry
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.signal.core.util.billing.BillingApi
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
/** /**
@ -45,29 +45,37 @@ import org.signal.core.util.logging.Log
* *
* Care should be taken here to ensure only one instance of this exists at a time. * Care should be taken here to ensure only one instance of this exists at a time.
*/ */
class BillingApi private constructor( internal class BillingApiImpl(
context: Context, context: Context
onPurchaseUpdateListener: PurchasesUpdatedListener ) : BillingApi {
) {
companion object { companion object {
private val TAG = Log.tag(BillingApi::class) private val TAG = Log.tag(BillingApi::class)
private var instance: BillingApi? = null
@Synchronized
fun getOrCreate(context: Context, onPurchaseUpdateListener: PurchasesUpdatedListener): BillingApi {
return instance ?: BillingApi(context, onPurchaseUpdateListener).let {
instance = it
it
}
}
} }
private val connectionState = MutableStateFlow<State>(State.Init) private val connectionState = MutableStateFlow<State>(State.Init)
private val coroutineScope = CoroutineScope(Dispatchers.Default) private val coroutineScope = CoroutineScope(Dispatchers.Default)
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 billingClient: BillingClient = BillingClient.newBuilder(context) private val billingClient: BillingClient = BillingClient.newBuilder(context)
.setListener(onPurchaseUpdateListener) .setListener(purchasesUpdatedListener)
.enablePendingPurchases( .enablePendingPurchases(
PendingPurchasesParams.newBuilder() PendingPurchasesParams.newBuilder()
.enableOneTimeProducts() .enableOneTimeProducts()
@ -88,7 +96,67 @@ class BillingApi private constructor(
} }
} }
suspend fun queryProducts(): ProductDetailsResult { override suspend fun queryProducts() {
val products = queryProductsInternal()
}
override suspend fun queryPurchases() {
val param = QueryPurchasesParams.newBuilder()
.setProductType(ProductType.SUBS)
.build()
val purchases = doOnConnectionReady {
billingClient.queryPurchasesAsync(param)
}
purchasesUpdatedListener.onPurchasesUpdated(purchases.billingResult, purchases.purchasesList)
}
/**
* Launches the Google Play billing flow.
* Returns a billing result if we launched the flow, null otherwise.
*/
override suspend fun launchBillingFlow(activity: Activity) {
val productDetails = queryProductsInternal().productDetailsList
if (productDetails.isNullOrEmpty()) {
Log.w(TAG, "No products are available! Cancelling billing flow launch.")
return
}
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
}
val productDetailParamsList = listOf(
ProductDetailsParams.newBuilder()
.setProductDetails(subscriptionDetails)
.setOfferToken(offerToken.offerToken)
.build()
)
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailParamsList)
.build()
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
*/
override fun isApiAvailable(): Boolean {
return billingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS).responseCode == BillingResponseCode.OK
}
private suspend fun queryProductsInternal(): ProductDetailsResult {
val productList = listOf( val productList = listOf(
QueryProductDetailsParams.Product.newBuilder() QueryProductDetailsParams.Product.newBuilder()
.setProductId("") // TODO [message-backups] where does the product id come from? .setProductId("") // TODO [message-backups] where does the product id come from?
@ -107,60 +175,6 @@ 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
*/
fun areSubscriptionsSupported(): Boolean {
return billingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS).responseCode == BillingResponseCode.OK
}
private suspend fun <T> doOnConnectionReady(block: suspend () -> T): T { private suspend fun <T> doOnConnectionReady(block: suspend () -> T): T {
val state = connectionState val state = connectionState
.filter { it == State.Connected || it is State.Failure } .filter { it == State.Connected || it is State.Failure }

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.billing
import android.content.Context
import org.signal.core.util.billing.BillingApi
/**
* Play billing factory. Returns empty implementation if message backups are not enabled.
*/
object BillingFactory {
@JvmStatic
fun create(context: Context, isBackupsAvailable: Boolean): BillingApi {
return if (isBackupsAvailable) {
BillingApiImpl(context)
} else {
BillingApi.Empty
}
}
}

View file

@ -3,14 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
package org.thoughtcrime.securesms.billing package org.signal.core.util.billing
import android.app.Activity import android.app.Activity
/** /**
* Variant interface for the BillingApi. * Variant interface for the BillingApi.
*/ */
interface GooglePlayBillingApi { interface BillingApi {
fun isApiAvailable(): Boolean = false fun isApiAvailable(): Boolean = false
suspend fun queryProducts() = Unit suspend fun queryProducts() = Unit
@ -26,5 +26,5 @@ interface GooglePlayBillingApi {
* Empty implementation, to be used when play services are available but * Empty implementation, to be used when play services are available but
* GooglePlayBillingApi is not available. * GooglePlayBillingApi is not available.
*/ */
object Empty : GooglePlayBillingApi object Empty : BillingApi
} }