Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ dependencies {
standardImplementation(libs.google.firebase.cloud.messaging)
standardImplementation(platform(libs.google.firebase.bom))
standardImplementation(libs.google.firebase.crashlytics)
standardImplementation(libs.google.billing)
standardImplementation(libs.google.play.review)

// Pull in test fixtures from other modules
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.x8bit.bitwarden.data.billing.manager

import android.content.Context
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

/**
* F-Droid implementation of [PlayBillingManager]. Always returns `true` since
* F-Droid users are eligible for the premium upgrade flow.
*/
@OmitFromCoverage
@Suppress("UnusedParameter")
class PlayBillingManagerImpl(
context: Context,
dispatcherManager: DispatcherManager,
) : PlayBillingManager {

override val isInAppBillingSupportedFlow: StateFlow<Boolean> =
MutableStateFlow(true)
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package com.x8bit.bitwarden.data.billing.di

import android.content.Context
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.network.service.BillingService
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManagerImpl
import com.x8bit.bitwarden.data.billing.repository.BillingRepository
import com.x8bit.bitwarden.data.billing.repository.BillingRepositoryImpl
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

Expand All @@ -16,11 +21,23 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
object BillingModule {

@Provides
@Singleton
fun providePlayBillingManager(
@ApplicationContext context: Context,
dispatcherManager: DispatcherManager,
): PlayBillingManager = PlayBillingManagerImpl(
context = context,
dispatcherManager = dispatcherManager,
)

@Provides
@Singleton
fun provideBillingRepository(
playBillingManager: PlayBillingManager,
billingService: BillingService,
): BillingRepository = BillingRepositoryImpl(
playBillingManager = playBillingManager,
billingService = billingService,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.x8bit.bitwarden.data.billing.manager

import kotlinx.coroutines.flow.StateFlow

/**
* Manages interactions with the Google Play Billing system.
*/
interface PlayBillingManager {

/**
* Emits `true` when in-app billing is supported, or `false` otherwise.
*/
val isInAppBillingSupportedFlow: StateFlow<Boolean>
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ package com.x8bit.bitwarden.data.billing.repository

import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
import kotlinx.coroutines.flow.StateFlow

/**
* Provides an API for managing billing operations.
*/
interface BillingRepository {

/**
* Emits `true` when in-app billing is supported, or `false` otherwise.
*/
val isInAppBillingSupportedFlow: StateFlow<Boolean>

/**
* Creates a Stripe checkout session and returns the checkout URL.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
package com.x8bit.bitwarden.data.billing.repository

import com.bitwarden.network.service.BillingService
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
import kotlinx.coroutines.flow.StateFlow

/**
* The default implementation of [BillingRepository].
*/
class BillingRepositoryImpl(
playBillingManager: PlayBillingManager,
private val billingService: BillingService,
) : BillingRepository {

override val isInAppBillingSupportedFlow: StateFlow<Boolean> =
playBillingManager.isInAppBillingSupportedFlow

override suspend fun getCheckoutSessionUrl(): CheckoutSessionResult =
billingService
.createCheckoutSession()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package com.x8bit.bitwarden.data.billing.manager

import android.content.Context
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingConfig
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.GetBillingConfigParams
import com.android.billingclient.api.PendingPurchasesParams
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.manager.dispatcher.DispatcherManager
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume

private const val SUPPORTED_BILLING_COUNTRY = "US"

/**
* Standard implementation of [PlayBillingManager] using the Google Play Billing Library.
*
* Uses a connect-per-call lifecycle: a new [BillingClient] is created, connected, queried,
* and disconnected for each call.
*/
@OmitFromCoverage
class PlayBillingManagerImpl(
private val context: Context,
dispatcherManager: DispatcherManager,
) : PlayBillingManager {

private val unconfinedScope =
CoroutineScope(dispatcherManager.unconfined)

private val mutableIsInAppBillingSupportedFlow = MutableStateFlow(false)

override val isInAppBillingSupportedFlow: StateFlow<Boolean> =
mutableIsInAppBillingSupportedFlow.asStateFlow()

init {
unconfinedScope.launch {
mutableIsInAppBillingSupportedFlow.value =
queryBillingCountry().getOrNull() == SUPPORTED_BILLING_COUNTRY
}
}

private suspend fun queryBillingCountry(): Result<String> {
val billingClient = BillingClient
.newBuilder(context)
.setListener { _, _ ->
// No-op: we don't handle purchases.
}
.enablePendingPurchases(
PendingPurchasesParams
.newBuilder()
.enableOneTimeProducts()
.build(),
)
.build()

return billingClient.useConnection {
if (responseCode != BillingClient.BillingResponseCode.OK) {
return@useConnection BillingException("Connection failed: $debugMessage")
.asFailure()
}
val (configResult, billingConfig) =
billingClient.getBillingConfig()
if (configResult.responseCode != BillingClient.BillingResponseCode.OK ||
billingConfig == null
) {
BillingException("Config query failed: ${configResult.debugMessage}").asFailure()
} else {
billingConfig.countryCode.asSuccess()
}
}
}
}

/**
* Connects to the [BillingClient], executes [block] with the [BillingResult], and guarantees
* [BillingClient.endConnection] is called when finished. Catches [IllegalStateException] thrown
* by [BillingClient.startConnection] if the client is already connected or mid-connection.
*/
private suspend fun <T> BillingClient.useConnection(
block: suspend BillingResult.() -> Result<T>,
): Result<T> = try {
block(
suspendCancellableCoroutine { continuation ->
startConnection(
object : BillingClientStateListener {
override fun onBillingSetupFinished(
billingResult: BillingResult,
) {
if (continuation.isActive) {
continuation.resume(billingResult)
}
}

override fun onBillingServiceDisconnected() {
// No-op: connect-per-call lifecycle, no reconnection.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we don't want to cancel the continuation here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, we don't need it now that we have useConnection. By the time this would fire, we would have already resumed/completed, or have already called endConnection() and it firing is expected.

}
},
)
},
)
} catch (e: IllegalStateException) {
e.asFailure()
} finally {
endConnection()
}

/**
* Wraps [BillingClient.getBillingConfigAsync] in a suspend function using
* [suspendCancellableCoroutine].
*/
private suspend fun BillingClient.getBillingConfig(): Pair<BillingResult, BillingConfig?> =
suspendCancellableCoroutine { continuation ->
val params = GetBillingConfigParams.newBuilder().build()
getBillingConfigAsync(params) { billingResult, billingConfig ->
if (continuation.isActive) {
continuation.resume(billingResult to billingConfig)
}
}
}

/**
* Exception type for billing-specific errors.
*/
private class BillingException(message: String) : Exception(message)
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,43 @@ import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.PortalUrlResponseJson
import com.bitwarden.network.service.BillingService
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult
import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class BillingRepositoryTest {

private val mutableIsInAppBillingSupportedFlow = MutableStateFlow(false)
private val playBillingManager = mockk<PlayBillingManager> {
every {
isInAppBillingSupportedFlow
} returns mutableIsInAppBillingSupportedFlow
}
private val billingService = mockk<BillingService>()
private val repository = BillingRepositoryImpl(
playBillingManager = playBillingManager,
billingService = billingService,
)

@Test
fun `isInAppBillingSupportedFlow should delegate to PlayBillingManager`() =
runTest {
assertFalse(repository.isInAppBillingSupportedFlow.value)

mutableIsInAppBillingSupportedFlow.value = true

assertTrue(repository.isInAppBillingSupportedFlow.value)
}

@Test
fun `getCheckoutSessionUrl when service returns success should return Success`() =
runTest {
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ detekt = "1.23.8"
firebaseBom = "34.10.0"
glide = "5.0.5"
glideCompose = "1.0.0-beta01"
googleBilling = "8.3.0"
googleGuava = "33.5.0-jre"
googleProtoBufJava = "4.34.0"
googleProtoBufPlugin = "0.9.6"
Expand Down Expand Up @@ -104,6 +105,7 @@ bumptech-glide-okhttp = { module = "com.github.bumptech.glide:okhttp3-integratio
bumptech-glide-compiler = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" }
detekt-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" }
detekt-detekt-rules = { module = "io.gitlab.arturbosch.detekt:detekt-rules-libraries", version.ref = "detekt" }
google-billing = { module = "com.android.billingclient:billing", version.ref = "googleBilling" }
google-firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
google-firebase-cloud-messaging = { module = "com.google.firebase:firebase-messaging" }
google-firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" }
Expand Down
Loading