diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2873283dda2..0607e80b8eb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -559,6 +559,7 @@ dependencies { implementation(libs.rxdogtag) "playImplementation"(project(":billing")) + "nightlyImplementation"(project(":billing")) "spinnerImplementation"(project(":spinner")) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 2f1bcba50ec..dff0e61889f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -7,12 +7,12 @@ import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.subjects.BehaviorSubject import io.reactivex.rxjava3.subjects.Subject import okhttp3.OkHttpClient +import org.signal.core.util.billing.BillingApi import org.signal.core.util.concurrent.DeadlockDetector import org.signal.core.util.resettableLazy import org.signal.libsignal.net.Network import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations -import org.thoughtcrime.securesms.billing.GooglePlayBillingApi import org.thoughtcrime.securesms.components.TypingStatusRepository import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl @@ -212,7 +212,7 @@ object AppDependencies { } @JvmStatic - val billingApi: GooglePlayBillingApi by lazy { + val billingApi: BillingApi by lazy { provider.provideBillingApi() } @@ -348,6 +348,6 @@ object AppDependencies { fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations fun provideScheduledMessageManager(): ScheduledMessageManager fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network - fun provideBillingApi(): GooglePlayBillingApi + fun provideBillingApi(): BillingApi } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 2f5dc7640e9..56a19674e09 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -8,15 +8,15 @@ import androidx.annotation.NonNull; import androidx.annotation.VisibleForTesting; +import org.signal.billing.BillingFactory; 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.SignalExecutors; import org.signal.libsignal.net.Network; import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; 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.TypingStatusSender; import org.thoughtcrime.securesms.crypto.ReentrantSessionLock; @@ -460,8 +460,8 @@ public WebSocketConnection createUnidentifiedWebSocket() { } @Override - public @NonNull GooglePlayBillingApi provideBillingApi() { - return GooglePlayBillingFactory.create(context); + public @NonNull BillingApi provideBillingApi() { + return BillingFactory.create(context, RemoteConfig.messageBackups()); } @VisibleForTesting diff --git a/app/src/nightly/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt b/app/src/nightly/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt deleted file mode 100644 index c71e690c002..00000000000 --- a/app/src/nightly/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/play/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt b/app/src/play/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt deleted file mode 100644 index d4994d40dc0..00000000000 --- a/app/src/play/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 31092b10ed9..9dc89e94a95 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -2,11 +2,11 @@ package org.thoughtcrime.securesms.dependencies import io.mockk.mockk import org.mockito.Mockito +import org.signal.core.util.billing.BillingApi import org.signal.core.util.concurrent.DeadlockDetector import org.signal.libsignal.net.Network import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations -import org.thoughtcrime.securesms.billing.GooglePlayBillingApi import org.thoughtcrime.securesms.components.TypingStatusRepository import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl @@ -199,7 +199,7 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk() } - override fun provideBillingApi(): GooglePlayBillingApi { + override fun provideBillingApi(): BillingApi { return mockk() } } diff --git a/app/src/website/java/org/signal/billing/BillingFactory.kt b/app/src/website/java/org/signal/billing/BillingFactory.kt new file mode 100644 index 00000000000..8e7a47306e7 --- /dev/null +++ b/app/src/website/java/org/signal/billing/BillingFactory.kt @@ -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 + } +} diff --git a/app/src/website/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt b/app/src/website/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt deleted file mode 100644 index c71e690c002..00000000000 --- a/app/src/website/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt +++ /dev/null @@ -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 - } -} diff --git a/billing/build.gradle.kts b/billing/build.gradle.kts index de0a3431418..c9005a1f3a9 100644 --- a/billing/build.gradle.kts +++ b/billing/build.gradle.kts @@ -9,6 +9,6 @@ android { dependencies { lintChecks(project(":lintchecks")) - api(libs.android.billing) + implementation(libs.android.billing) implementation(project(":core-util")) } diff --git a/billing/src/main/java/org/signal/billing/BillingApi.kt b/billing/src/main/java/org/signal/billing/BillingApiImpl.kt similarity index 83% rename from billing/src/main/java/org/signal/billing/BillingApi.kt rename to billing/src/main/java/org/signal/billing/BillingApiImpl.kt index 1542810c4bf..79471096919 100644 --- a/billing/src/main/java/org/signal/billing/BillingApi.kt +++ b/billing/src/main/java/org/signal/billing/BillingApiImpl.kt @@ -17,7 +17,6 @@ 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 @@ -37,6 +36,7 @@ import kotlinx.coroutines.flow.retry import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.signal.core.util.billing.BillingApi 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. */ -class BillingApi private constructor( - context: Context, - onPurchaseUpdateListener: PurchasesUpdatedListener -) { +internal class BillingApiImpl( + context: Context +) : BillingApi { + companion object { private val TAG = Log.tag(BillingApi::class) + } - private var instance: BillingApi? = null + private val connectionState = MutableStateFlow(State.Init) + private val coroutineScope = CoroutineScope(Dispatchers.Default) - @Synchronized - fun getOrCreate(context: Context, onPurchaseUpdateListener: PurchasesUpdatedListener): BillingApi { - return instance ?: BillingApi(context, onPurchaseUpdateListener).let { - instance = it - it + 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 connectionState = MutableStateFlow(State.Init) - private val coroutineScope = CoroutineScope(Dispatchers.Default) - private val billingClient: BillingClient = BillingClient.newBuilder(context) - .setListener(onPurchaseUpdateListener) + .setListener(purchasesUpdatedListener) .enablePendingPurchases( PendingPurchasesParams.newBuilder() .enableOneTimeProducts() @@ -88,51 +96,38 @@ class BillingApi private constructor( } } - suspend fun queryProducts(): ProductDetailsResult { - val productList = listOf( - QueryProductDetailsParams.Product.newBuilder() - .setProductId("") // TODO [message-backups] where does the product id come from? - .setProductType(ProductType.SUBS) - .build() - ) - - val params = QueryProductDetailsParams.newBuilder() - .setProductList(productList) - .build() - - return withContext(Dispatchers.IO) { - doOnConnectionReady { - billingClient.queryProductDetails(params) - } - } + override suspend fun queryProducts() { + val products = queryProductsInternal() } - suspend fun queryPurchases(): PurchasesResult { + override suspend fun queryPurchases() { val param = QueryPurchasesParams.newBuilder() .setProductType(ProductType.SUBS) .build() - return doOnConnectionReady { + 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. */ - suspend fun launchBillingFlow(activity: Activity): BillingResult? { - val productDetails = queryProducts().productDetailsList + 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 null + 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 null + return } val productDetailParamsList = listOf( @@ -146,7 +141,7 @@ class BillingApi private constructor( .setProductDetailsParamsList(productDetailParamsList) .build() - return doOnConnectionReady { + doOnConnectionReady { withContext(Dispatchers.Main) { billingClient.launchBillingFlow(activity, billingFlowParams) } @@ -157,10 +152,29 @@ class BillingApi private constructor( * 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 { + override fun isApiAvailable(): Boolean { return billingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS).responseCode == BillingResponseCode.OK } + private suspend fun queryProductsInternal(): ProductDetailsResult { + val productList = listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId("") // TODO [message-backups] where does the product id come from? + .setProductType(ProductType.SUBS) + .build() + ) + + val params = QueryProductDetailsParams.newBuilder() + .setProductList(productList) + .build() + + return withContext(Dispatchers.IO) { + doOnConnectionReady { + billingClient.queryProductDetails(params) + } + } + } + private suspend fun doOnConnectionReady(block: suspend () -> T): T { val state = connectionState .filter { it == State.Connected || it is State.Failure } diff --git a/billing/src/main/java/org/signal/billing/BillingFactory.kt b/billing/src/main/java/org/signal/billing/BillingFactory.kt new file mode 100644 index 00000000000..925826b5ef6 --- /dev/null +++ b/billing/src/main/java/org/signal/billing/BillingFactory.kt @@ -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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/billing/GooglePlayBillingApi.kt b/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt similarity index 84% rename from app/src/main/java/org/thoughtcrime/securesms/billing/GooglePlayBillingApi.kt rename to core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt index 10c4236ba94..51d25f0931a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/billing/GooglePlayBillingApi.kt +++ b/core-util/src/main/java/org/signal/core/util/billing/BillingApi.kt @@ -3,14 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.thoughtcrime.securesms.billing +package org.signal.core.util.billing import android.app.Activity /** * Variant interface for the BillingApi. */ -interface GooglePlayBillingApi { +interface BillingApi { fun isApiAvailable(): Boolean = false suspend fun queryProducts() = Unit @@ -26,5 +26,5 @@ interface GooglePlayBillingApi { * Empty implementation, to be used when play services are available but * GooglePlayBillingApi is not available. */ - object Empty : GooglePlayBillingApi + object Empty : BillingApi }