Add billing module and include in play implementation.

This commit is contained in:
Alex Hart 2024-08-16 16:29:39 -03:00 committed by mtang-signal
parent 82443af8f7
commit cda029cd93
17 changed files with 375 additions and 3 deletions

View file

@ -558,6 +558,8 @@ dependencies {
implementation(libs.rxjava3.rxkotlin)
implementation(libs.rxdogtag)
"playImplementation"(project(":billing"))
"spinnerImplementation"(project(":spinner"))
"canaryImplementation"(libs.square.leakcanary)

View file

@ -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())

View file

@ -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
}

View file

@ -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<WebSocketConnectionState> = BehaviorSubject.create()
/**
@ -342,5 +348,6 @@ object AppDependencies {
fun provideClientZkReceiptOperations(signalServiceConfiguration: SignalServiceConfiguration): ClientZkReceiptOperations
fun provideScheduledMessageManager(): ScheduledMessageManager
fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network
fun provideBillingApi(): GooglePlayBillingApi
}
}

View file

@ -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 {

View file

@ -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.");

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION"/>
<application>
<receiver android:name=".apkupdate.ApkUpdateRefreshListener" android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver android:name=".apkupdate.ApkUpdateDownloadManagerReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.DOWNLOAD_COMPLETE"/>
</intent-filter>
</receiver>
<receiver
android:name=".apkupdate.ApkUpdatePackageInstallerReceiver"
android:exported="true" />
<receiver
android:name=".apkupdate.ApkUpdateNotificationReceiver"
android:exported="false" />
</application>
</manifest>

View file

@ -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}")
}
}

View file

@ -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;
}
}

View file

@ -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
}
}

14
billing/build.gradle.kts Normal file
View file

@ -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"))
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View file

@ -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>(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 <T> 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<State> {
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()
}

View file

@ -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()

View file

@ -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")

View file

@ -3396,6 +3396,16 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="81dd485618a509a3235929b9eb13091d884452661de6ce5a45cc38b1c555421c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.android.billingclient" name="billing" version="7.0.0">
<artifact name="billing-7.0.0.aar">
<sha256 value="7d58671c3e56da57befe2798fa60f806a26bb724d326a0865da05bc50827ff87" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.android.billingclient" name="billing-ktx" version="7.0.0">
<artifact name="billing-ktx-7.0.0.aar">
<sha256 value="bfb7416b270b2a15ddb09fe0c3a9f9a4edeadb348c875104c615dce9766924ba" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.android.databinding" name="baseLibrary" version="8.0.2">
<artifact name="baseLibrary-8.0.2.jar">
<sha256 value="530b2113317ff4d0f69ffdfb49387ba4b86aac169e1c77dff943405b79adcf8b" origin="Generated by Gradle"/>
@ -4871,6 +4881,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="2896d76f432be52167295bb9ce45ade25c310aeffc04d28cf8db6a15868e83de" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-base" version="18.3.0">
<artifact name="play-services-base-18.3.0.aar">
<sha256 value="94066a46047e3d593eb652383e7767e0630385ecd75133e68c21124aa96b8dc2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-basement" version="18.0.0">
<artifact name="play-services-basement-18.0.0.aar">
<sha256 value="55c1777467901a2d399f3252384c4976284aa35fddfd5995466dbeacb49f9dd6" origin="Generated by Gradle"/>
@ -4886,6 +4901,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="ef43ebfc641d71481543524f46d126793b4cb57bf466c4df4ce43d0cb5e11b91" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-basement" version="18.3.0">
<artifact name="play-services-basement-18.3.0.aar">
<sha256 value="6c11ae3eb2dd7f17373f919c4c557a70e4cf891bc0c9b66926a0a6445d654352" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-cloud-messaging" version="17.0.1">
<artifact name="play-services-cloud-messaging-17.0.1.aar">
<sha256 value="1e759adcf0350731ce4dc73b035705e4c7d08bdf7db069cc0468eca3e7bb9dc2" origin="Generated by Gradle"/>
@ -4901,6 +4921,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="4c93d05e943d29852d2b3092b467ce8517a222363eb8584b706a47292bddd18a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-location" version="19.0.0">
<artifact name="play-services-location-19.0.0.aar">
<sha256 value="6b205c43ba5df751eca8ce9dae7a58effafac7d637fb4fc708a7522d1b99cf80" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-maps" version="18.0.2">
<artifact name="play-services-maps-18.0.2.aar">
<sha256 value="442a4687f44a266051853986d8e8f694ffc62e5fe752b613b356c66f9bf55d2d" origin="Generated by Gradle"/>
@ -4911,6 +4936,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="44f50655578a21c579e1bb2dacaec4c545ec4df81393d6fd7236d664268056a9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-places-placereport" version="17.0.0">
<artifact name="play-services-places-placereport-17.0.0.aar">
<sha256 value="2c7fd63ad02f28150ae4ffe4615dac7d694d790e2c4667f777aedc8ee054e929" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-stats" version="17.0.2">
<artifact name="play-services-stats-17.0.2.aar">
<sha256 value="dd4314a53f49a378ec146103d36232b96c75454d29526336ccbdf132941764d3" origin="Generated by Gradle"/>
@ -4926,6 +4956,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="03465cf53680da23d3d4a3995c0329b7f73b211d38791ecb36e591febb896664" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-tasks" version="18.1.0">
<artifact name="play-services-tasks-18.1.0.aar">
<sha256 value="d60575eae39350e6234858bc9d7d775375707ae82a684e6caf7f3e41a12e25a2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.android.gms" name="play-services-wallet" version="19.2.1">
<artifact name="play-services-wallet-19.2.1.aar">
<sha256 value="c1625a403df419d08e9da950fb72b04c9430b66bd702231941aa726b39942c1d" origin="Generated by Gradle"/>
@ -8196,6 +8231,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="7b9f6960c6689d1a79d82ca3b00b8347bd57d15fe4070a3dd34e826e76136392" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core" version="1.6.0">
<artifact name="kotlinx-coroutines-core-1.6.0.module">
<sha256 value="512cfab68f00d7363461b5dd02637dd19048b364a89e1960f99784c705a27a7c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core" version="1.6.3">
<artifact name="kotlinx-coroutines-core-1.6.3.module">
<sha256 value="14039719f2100d91e0ab220f834f7e5f3578a81026137e4ec16a5c83ecd1740b" origin="Generated by Gradle"/>
@ -8241,6 +8281,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="c885dd0281076c5843826de317e3cbcdc3d8859dbeef53ae1cfacd1b9c60f96e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core-jvm" version="1.6.0">
<artifact name="kotlinx-coroutines-core-jvm-1.6.0.module">
<sha256 value="2cdc60217b955ce213fca1f14ee960b85dbd30f8a6966361237410d38a602ef1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core-jvm" version="1.6.3">
<artifact name="kotlinx-coroutines-core-jvm-1.6.3.jar">
<sha256 value="58a497ab595d83bbbf28892a8b34ab57d94309a8742ee0eba43cb86408d235bf" origin="Generated by Gradle"/>

View file

@ -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")