diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ee1ae7332f..2873283dda 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -558,6 +558,8 @@ dependencies { implementation(libs.rxjava3.rxkotlin) implementation(libs.rxdogtag) + "playImplementation"(project(":billing")) + "spinnerImplementation"(project(":spinner")) "canaryImplementation"(libs.square.leakcanary) diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index d699f3f131..dd6fe3874c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -196,6 +196,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addNonBlocking(this::beginJobLoop) .addNonBlocking(EmojiSource::refresh) .addNonBlocking(() -> AppDependencies.getGiphyMp4Cache().onAppStart(this)) + .addNonBlocking(AppDependencies::getBillingApi) .addNonBlocking(this::ensureProfileUploaded) .addNonBlocking(() -> AppDependencies.getExpireStoriesManager().scheduleIfNecessary()) .addPostRender(() -> AppDependencies.getDeletedCallEventManager().scheduleIfNecessary()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/billing/GooglePlayBillingApi.kt b/app/src/main/java/org/thoughtcrime/securesms/billing/GooglePlayBillingApi.kt new file mode 100644 index 0000000000..af5671e8ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/billing/GooglePlayBillingApi.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.billing + +/** + * Variant interface for the BillingApi. + */ +interface GooglePlayBillingApi { + fun isApiAvailable(): Boolean = false + suspend fun queryProducts() {} + + /** + * Empty implementation, to be used when play services are available but + * GooglePlayBillingApi is not available. + */ + object Empty : GooglePlayBillingApi +} 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 6c327d37fb..2f1bcba50e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -12,6 +12,7 @@ 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 @@ -210,6 +211,11 @@ object AppDependencies { provider.provideAndroidCallAudioManager() } + @JvmStatic + val billingApi: GooglePlayBillingApi by lazy { + provider.provideBillingApi() + } + private val _webSocketObserver: Subject = BehaviorSubject.create() /** @@ -342,5 +348,6 @@ object AppDependencies { fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations fun provideScheduledMessageManager(): ScheduledMessageManager fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network + fun provideBillingApi(): GooglePlayBillingApi } } 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 2a4aa0bb60..2f5dc7640e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -15,6 +15,8 @@ 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; @@ -69,8 +71,8 @@ import org.thoughtcrime.securesms.util.AlarmSleepTimer; import org.thoughtcrime.securesms.util.AppForegroundObserver; import org.thoughtcrime.securesms.util.ByteUnit; import org.thoughtcrime.securesms.util.EarlyMessageCache; -import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.FrameRateTracker; +import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache; import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool; @@ -92,11 +94,11 @@ import org.whispersystems.signalservice.api.util.SleepTimer; import org.whispersystems.signalservice.api.util.UptimeSleepTimer; import org.whispersystems.signalservice.api.websocket.WebSocketFactory; import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; +import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection; import org.whispersystems.signalservice.internal.websocket.LibSignalNetworkExtensions; +import org.whispersystems.signalservice.internal.websocket.OkHttpWebSocketConnection; import org.whispersystems.signalservice.internal.websocket.ShadowingWebSocketConnection; import org.whispersystems.signalservice.internal.websocket.WebSocketConnection; -import org.whispersystems.signalservice.internal.websocket.LibSignalChatConnection; -import org.whispersystems.signalservice.internal.websocket.OkHttpWebSocketConnection; import org.whispersystems.signalservice.internal.websocket.WebSocketShadowingBridge; import java.util.Optional; @@ -457,6 +459,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { }; } + @Override + public @NonNull GooglePlayBillingApi provideBillingApi() { + return GooglePlayBillingFactory.create(context); + } + @VisibleForTesting static class DynamicCredentialsProvider implements CredentialsProvider { diff --git a/app/src/main/java/org/thoughtcrime/securesms/maps/LocationRetriever.java b/app/src/main/java/org/thoughtcrime/securesms/maps/LocationRetriever.java index bcccc79af3..0183397c63 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/maps/LocationRetriever.java +++ b/app/src/main/java/org/thoughtcrime/securesms/maps/LocationRetriever.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.maps; import android.Manifest; +import android.annotation.SuppressLint; import android.content.Context; import android.content.pm.PackageManager; import android.location.Location; @@ -73,6 +74,7 @@ class LocationRetriever implements DefaultLifecycleObserver, LocationListener { } } + @SuppressLint("MissingPermission") @Override public void onStop(@NonNull LifecycleOwner owner) { Log.i(TAG, "Removing any possible location listeners."); diff --git a/app/src/play/AndroidManifest.xml b/app/src/play/AndroidManifest.xml new file mode 100644 index 0000000000..613e309f7f --- /dev/null +++ b/app/src/play/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/play/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt b/app/src/play/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt new file mode 100644 index 0000000000..5d24ca4363 --- /dev/null +++ b/app/src/play/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.billing + +import android.content.Context +import com.android.billingclient.api.ProductDetailsResult +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 billingApi: BillingApi = BillingApi.getOrCreate(context) + + 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}") + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java index 02e21f158f..2a92c7c198 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.java @@ -6,6 +6,7 @@ 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; @@ -238,4 +239,9 @@ public class MockApplicationDependencyProvider implements AppDependencies.Provid public @NonNull Network provideLibsignalNetwork(@NonNull SignalServiceConfiguration config) { return null; } + + @Override + public @NonNull GooglePlayBillingApi provideBillingApi() { + return null; + } } diff --git a/app/src/website/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt b/app/src/website/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt new file mode 100644 index 0000000000..c71e690c00 --- /dev/null +++ b/app/src/website/java/org/thoughtcrime/securesms/billing/GooglePlayBillingFactory.kt @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000000..de0a343141 --- /dev/null +++ b/billing/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("signal-library") +} + +android { + namespace = "org.signal.billing" +} + +dependencies { + lintChecks(project(":lintchecks")) + + api(libs.android.billing) + implementation(project(":core-util")) +} diff --git a/billing/src/main/AndroidManifest.xml b/billing/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..7277dc362a --- /dev/null +++ b/billing/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/billing/src/main/java/org/signal/billing/BillingApi.kt b/billing/src/main/java/org/signal/billing/BillingApi.kt new file mode 100644 index 0000000000..e6d3c3a33b --- /dev/null +++ b/billing/src/main/java/org/signal/billing/BillingApi.kt @@ -0,0 +1,169 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.billing + +import android.content.Context +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.ProductDetailsResult +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.queryProductDetails +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.retry +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.logging.Log + +/** + * BillingApi serves as the core location for interacting with the Google Billing API. Use of this API is required + * for remote backups paid tier, and will only be available in play store builds. + * + * Care should be taken here to ensure only one instance of this exists at a time. + */ +class BillingApi private constructor( + context: Context +) { + companion object { + private val TAG = Log.tag(BillingApi::class) + + private var instance: BillingApi? = null + + @Synchronized + fun getOrCreate(context: Context): BillingApi { + return instance ?: BillingApi(context).let { + instance = it + it + } + } + } + + 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) + .enablePendingPurchases( + PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + ) + .build() + + init { + coroutineScope.launch { + createConnectionFlow() + .retry { it is RetryException } // TODO [message-backups] - consider a delay here + .collect { newState -> + Log.d(TAG, "Updating Google Play Billing connection state: $newState") + connectionState.update { + newState + } + } + } + } + + suspend fun queryProducts(): ProductDetailsResult { + val productList = listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId("") // TODO [message-backups] where does the product id come from? + .setProductType(BillingClient.ProductType.SUBS) + .build() + ) + + val params = QueryProductDetailsParams.newBuilder() + .setProductList(productList) + .build() + + return withContext(Dispatchers.IO) { + doOnConnectionReady { + billingClient.queryProductDetails(params) + } + } + } + + /** + * 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 doOnConnectionReady(block: suspend () -> T): T { + val state = connectionState + .filter { it == State.Connected || it is State.Failure } + .first() + + return when (state) { + State.Connected -> block() + is State.Failure -> throw state.billingError + else -> error("Unexpected state: $state") + } + } + + private fun createConnectionFlow(): Flow { + return callbackFlow { + Log.d(TAG, "Starting Google Play Billing connection...", true) + trySend(State.Connecting) + + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingServiceDisconnected() { + Log.d(TAG, "Google Play Billing became disconnected.", true) + trySend(State.Disconnected) + cancel(CancellationException("Google Play Billing became disconnected.", RetryException())) + } + + override fun onBillingSetupFinished(billingResult: BillingResult) { + Log.d(TAG, "onBillingSetupFinished: ${billingResult.responseCode}") + if (billingResult.responseCode == BillingResponseCode.OK) { + Log.d(TAG, "Google Play Billing is ready.", true) + trySend(State.Connected) + } else { + Log.d(TAG, "Google Play Billing failed to connect.", true) + val billingError = BillingError( + billingResponseCode = billingResult.responseCode + ) + trySend(State.Failure(billingError)) + cancel(CancellationException("Failed to connect to Google Play Billing", billingError)) + } + } + }) + + awaitClose { + billingClient.endConnection() + } + } + } + + private sealed interface State { + data object Init : State + data object Connecting : State + data object Connected : State + data object Disconnected : State + data class Failure(val billingError: BillingError) : State + } + + private class RetryException : Exception() +} diff --git a/billing/src/main/java/org/signal/billing/BillingError.kt b/billing/src/main/java/org/signal/billing/BillingError.kt new file mode 100644 index 0000000000..8bf51077bd --- /dev/null +++ b/billing/src/main/java/org/signal/billing/BillingError.kt @@ -0,0 +1,10 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.billing + +class BillingError( + val billingResponseCode: Int +) : Exception() diff --git a/dependencies.gradle.kts b/dependencies.gradle.kts index a8325647ba..1ef1154924 100644 --- a/dependencies.gradle.kts +++ b/dependencies.gradle.kts @@ -99,6 +99,7 @@ dependencyResolutionManagement { library("androidx-asynclayoutinflater-appcompat", "androidx.asynclayoutinflater:asynclayoutinflater-appcompat:1.1.0-alpha01") library("androidx-emoji2", "androidx.emoji2:emoji2:1.4.0") library("androidx-documentfile", "androidx.documentfile:documentfile:1.0.0") + library("android-billing", "com.android.billingclient:billing-ktx:7.0.0") // Material library("material-material", "com.google.android.material:material:1.8.0") diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 2b3e54a38b..aa88dd4936 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -3396,6 +3396,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + + + + + + @@ -4871,6 +4881,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4886,6 +4901,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4901,6 +4921,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4911,6 +4936,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -4926,6 +4956,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -8196,6 +8231,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + @@ -8241,6 +8281,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index a4d05b2059..472aea3596 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -56,6 +56,7 @@ include(":benchmark") include(":microbenchmark") include(":video") include(":video-app") +include(":billing") project(":app").name = "Signal-Android" project(":paging").projectDir = file("paging/lib")