1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-2273 Payment | Google Play Billing Library (#1062)

This commit is contained in:
Konstantin Ivanov 2024-04-05 12:49:34 +02:00 committed by GitHub
parent ce15843313
commit 0930206d50
Signed by: github
GPG key ID: B5690EEEBB952194
22 changed files with 856 additions and 150 deletions

View file

@ -188,6 +188,7 @@ dependencies {
implementation libs.pickT
implementation libs.emojiCompat
implementation libs.navigationCompose
implementation libs.playBilling
implementation libs.lifecycleViewModel
implementation libs.lifecycleRuntime

View file

@ -1,14 +1,20 @@
package com.anytypeio.anytype.di.feature.payments
import android.content.Context
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.core_utils.di.scope.PerScreen
import com.anytypeio.anytype.di.common.ComponentDependencies
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.domain.auth.repo.AuthRepository
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import com.anytypeio.anytype.ui.payments.PaymentsFragment
import com.anytypeio.anytype.viewmodel.PaymentsViewModelFactory
import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModelFactory
import dagger.Binds
import dagger.Component
import dagger.Module
import dagger.Provides
@Component(
dependencies = [PaymentsComponentDependencies::class],
@ -31,6 +37,14 @@ interface PaymentsComponent {
@Module
object PaymentsModule {
@JvmStatic
@Provides
@PerScreen
fun provideGetAccountUseCase(
repo: AuthRepository,
dispatchers: AppCoroutineDispatchers
): GetAccount = GetAccount(repo = repo, dispatcher = dispatchers)
@Module
interface Declarations {
@ -45,4 +59,8 @@ object PaymentsModule {
interface PaymentsComponentDependencies : ComponentDependencies {
fun analytics(): Analytics
fun context(): Context
fun billingListener(): BillingClientLifecycle
fun authRepository(): AuthRepository
fun appCoroutineDispatchers(): AppCoroutineDispatchers
}

View file

@ -0,0 +1,28 @@
package com.anytypeio.anytype.di.main
import android.content.Context
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import dagger.Module
import dagger.Provides
import javax.inject.Named
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
@Module
object BillingModule {
@Singleton
@Provides
fun provideBillingLifecycle(
context: Context,
dispatchers: AppCoroutineDispatchers,
@Named(ConfigModule.DEFAULT_APP_COROUTINE_SCOPE) scope: CoroutineScope
): BillingClientLifecycle {
return BillingClientLifecycle(
dispatchers = dispatchers,
applicationContext = context,
scope = scope
)
}
}

View file

@ -79,7 +79,8 @@ import javax.inject.Singleton
CrashReportingModule::class,
TemplatesModule::class,
NetworkModeModule::class,
NotificationsModule::class
NotificationsModule::class,
BillingModule::class
]
)
interface MainComponent :

View file

@ -3,11 +3,8 @@ package com.anytypeio.anytype.ui.main
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import android.provider.OpenableColumns
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.core.os.bundleOf
@ -37,7 +34,6 @@ import com.anytypeio.anytype.presentation.main.MainViewModel
import com.anytypeio.anytype.presentation.main.MainViewModel.Command
import com.anytypeio.anytype.presentation.main.MainViewModelFactory
import com.anytypeio.anytype.presentation.navigation.AppNavigation
import com.anytypeio.anytype.presentation.util.getExternalFilesDirTemp
import com.anytypeio.anytype.presentation.wallpaper.WallpaperColor
import com.anytypeio.anytype.ui.editor.CreateObjectFragment
import com.anytypeio.anytype.ui.notifications.NotificationsFragment
@ -45,8 +41,6 @@ import com.anytypeio.anytype.ui.sharing.SharingFragment
import com.anytypeio.anytype.ui_settings.appearance.ThemeApplicator
import com.github.javiersantos.appupdater.AppUpdater
import com.github.javiersantos.appupdater.enums.UpdateFrom
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

View file

@ -17,14 +17,15 @@ import com.anytypeio.anytype.core_ui.common.ComposeDialogView
import com.anytypeio.anytype.core_utils.ext.subscribe
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.screens.CodeScreen
import com.anytypeio.anytype.screens.MainPaymentsScreen
import com.anytypeio.anytype.screens.PaymentWelcomeScreen
import com.anytypeio.anytype.screens.TierScreen
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import com.anytypeio.anytype.payments.screens.CodeScreen
import com.anytypeio.anytype.payments.screens.MainPaymentsScreen
import com.anytypeio.anytype.payments.screens.PaymentWelcomeScreen
import com.anytypeio.anytype.payments.screens.TierScreen
import com.anytypeio.anytype.ui.settings.typography
import com.anytypeio.anytype.viewmodel.PaymentsNavigation
import com.anytypeio.anytype.viewmodel.PaymentsViewModel
import com.anytypeio.anytype.viewmodel.PaymentsViewModelFactory
import com.anytypeio.anytype.payments.viewmodel.PaymentsNavigation
import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModel
import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModelFactory
import com.google.accompanist.navigation.material.BottomSheetNavigator
import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi
import com.google.accompanist.navigation.material.ModalBottomSheetLayout
@ -39,6 +40,14 @@ class PaymentsFragment : BaseBottomSheetComposeFragment() {
private val vm by viewModels<PaymentsViewModel> { factory }
private lateinit var navController: NavHostController
@Inject
lateinit var billingClientLifecycle: BillingClientLifecycle
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(billingClientLifecycle)
}
@OptIn(ExperimentalMaterialNavigationApi::class)
override fun onCreateView(
inflater: LayoutInflater,
@ -71,6 +80,12 @@ class PaymentsFragment : BaseBottomSheetComposeFragment() {
else -> {}
}
}
jobs += subscribe(vm.launchBillingCommand) { event ->
billingClientLifecycle.launchBillingFlow(
activity = requireActivity(),
params = event
)
}
}
@OptIn(ExperimentalMaterialNavigationApi::class)

View file

@ -34,8 +34,8 @@ import com.anytypeio.anytype.ui.auth.account.DeleteAccountWarning
import com.anytypeio.anytype.ui.profile.KeychainPhraseDialog
import com.anytypeio.anytype.ui_settings.account.ProfileSettingsScreen
import com.anytypeio.anytype.ui_settings.account.ProfileSettingsViewModel
import com.anytypeio.anytype.viewmodel.PaymentsViewModel
import com.anytypeio.anytype.viewmodel.PaymentsViewModelFactory
import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModel
import com.anytypeio.anytype.payments.viewmodel.PaymentsViewModelFactory
import javax.inject.Inject
import timber.log.Timber

View file

@ -168,6 +168,7 @@ sentryTimber = { module = "io.sentry:sentry-android-timber", version.ref = "sent
navigationCompose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationComposeVersion" }
appUpdater = { module = "com.github.PLPsiSoft:AndroidAppUpdater", version = "9913ce80da7871c84af24b9adc2bf2414ca294f0" }
composeQrCode = { module = "com.lightspark:compose-qr-code", version.ref = "composeQrCodeVersion" }
playBilling = { module = "com.android.billingclient:billing", version = "6.2.0" }
[bundles]

View file

@ -4,9 +4,6 @@ plugins {
}
android {
def config = rootProject.extensions.getByName("ext")
buildFeatures {
compose true
}
@ -14,7 +11,7 @@ android {
composeOptions {
kotlinCompilerExtensionVersion libs.versions.composeKotlinCompilerVersion.get()
}
namespace 'com.anytypeio.anytype.peyments'
namespace 'com.anytypeio.anytype.payments'
}
dependencies {
@ -27,6 +24,8 @@ dependencies {
implementation project(':presentation')
implementation project(':library-emojifier')
implementation libs.playBilling
compileOnly libs.javaxInject
implementation libs.lifecycleViewModel

View file

@ -0,0 +1,9 @@
package com.anytypeio.anytype.payments.constants
object BillingConstants {
//Tiers IDs
const val SUBSCRIPTION_BUILDER = "builder_subscription"
val suscriptionTiers = listOf(SUBSCRIPTION_BUILDER)
}

View file

@ -1,6 +1,6 @@
package com.anytypeio.anytype.models
package com.anytypeio.anytype.payments.models
import com.anytypeio.anytype.viewmodel.TierId
import com.anytypeio.anytype.payments.viewmodel.TierId
sealed class Tier {
abstract val id: TierId

View file

@ -0,0 +1,334 @@
package com.anytypeio.anytype.payments.playbilling
import android.app.Activity
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.MutableLiveData
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.ProductDetailsResponseListener
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesResponseListener
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.payments.constants.BillingConstants
import com.anytypeio.anytype.payments.constants.BillingConstants.suscriptionTiers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
class BillingClientLifecycle(
private val dispatchers: AppCoroutineDispatchers,
private val applicationContext: Context,
private val scope: CoroutineScope
) : DefaultLifecycleObserver, PurchasesUpdatedListener, BillingClientStateListener,
ProductDetailsResponseListener, PurchasesResponseListener {
private val _subscriptionPurchases = MutableStateFlow<List<Purchase>>(emptyList())
/**
* Purchases are collectable. This list will be updated when the Billing Library
* detects new or existing purchases.
*/
val subscriptionPurchases = _subscriptionPurchases.asStateFlow()
/**
* Cached in-app product purchases details.
*/
private var cachedPurchasesList: List<Purchase>? = null
/**
* ProductDetails for all known products.
*/
val builderSubProductWithProductDetails = MutableLiveData<ProductDetails?>()
/**
* Instantiate a new BillingClient instance.
*/
private lateinit var billingClient: BillingClient
override fun onCreate(owner: LifecycleOwner) {
Timber.d("ON_CREATE")
// Create a new BillingClient in onCreate().
// Since the BillingClient can only be used once, we need to create a new instance
// after ending the previous connection to the Google Play Store in onDestroy().
billingClient = BillingClient.newBuilder(applicationContext)
.setListener(this)
.enablePendingPurchases() // Not used for subscriptions.
.build()
if (!billingClient.isReady) {
Timber.d("BillingClient: Start connection...")
billingClient.startConnection(this)
}
}
override fun onDestroy(owner: LifecycleOwner) {
Timber.d("ON_DESTROY")
if (billingClient.isReady) {
Timber.d("BillingClient can only be used once -- closing connection")
// BillingClient can only be used once.
// After calling endConnection(), we must create a new BillingClient.
billingClient.endConnection()
}
}
override fun onBillingSetupFinished(billingResult: BillingResult) {
val responseCode = billingResult.responseCode
val debugMessage = billingResult.debugMessage
Timber.d("onBillingSetupFinished: $responseCode $debugMessage")
if (responseCode == BillingClient.BillingResponseCode.OK) {
// The billing client is ready.
// You can query product details and purchases here.
querySubscriptionProductDetails()
querySubscriptionPurchases()
}
}
override fun onBillingServiceDisconnected() {
Timber.d("onBillingServiceDisconnected")
// TODO: Try connecting again with exponential backoff.
// billingClient.startConnection(this)
}
/**
* In order to make purchases, you need the [ProductDetails] for the item or subscription.
* This is an asynchronous call that will receive a result in [onProductDetailsResponse].
*
* querySubscriptionProductDetails uses method calls from GPBL 5.0.0. PBL5, released in May 2022,
* is backwards compatible with previous versions.
* To learn more about this you can read:
* https://developer.android.com/google/play/billing/compatibility
*/
private fun querySubscriptionProductDetails() {
Timber.d("querySubscriptionProductDetails")
val params = QueryProductDetailsParams.newBuilder()
val productList: MutableList<QueryProductDetailsParams.Product> = arrayListOf()
for (product in suscriptionTiers) {
productList.add(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(product)
.setProductType(BillingClient.ProductType.SUBS)
.build()
)
}
params.setProductList(productList).let { productDetailsParams ->
billingClient.queryProductDetailsAsync(productDetailsParams.build(), this)
}
}
/**
* Receives the result from [querySubscriptionProductDetails].
*
* Store the ProductDetails and post them in the [explorerSubProductWithProductDetails] and
* [builderSubProductWithProductDetails]. This allows other parts of the app to use the
* [ProductDetails] to show product information and make purchases.
*
* onProductDetailsResponse() uses method calls from GPBL 5.0.0. PBL5, released in May 2022,
* is backwards compatible with previous versions.
* To learn more about this you can read:
* https://developer.android.com/google/play/billing/compatibility
*/
override fun onProductDetailsResponse(
billingResult: BillingResult,
productDetailsList: MutableList<ProductDetails>
) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
Timber.d("onProductDetailsResponse: ${productDetailsList.size} product(s)")
processProductDetails(productDetailsList)
} else {
Timber.e("onProductDetailsResponse: ${billingResult.responseCode}")
}
}
/**
* This method is used to process the product details list returned by the [BillingClient]and
* post the details to the [explorerSubProductWithProductDetails] and
* [builderSubProductWithProductDetails] live data.
*
* @param productDetailsList The list of product details.
*
*/
private fun processProductDetails(productDetailsList: MutableList<ProductDetails>) {
val expectedProductDetailsCount = suscriptionTiers.size
if (productDetailsList.isEmpty()) {
Timber.e("Expected ${expectedProductDetailsCount}, Found null ProductDetails.")
postProductDetails(emptyList())
} else {
postProductDetails(productDetailsList)
}
}
/**
* This method is used to post the product details to the [explorerSubProductWithProductDetails]
* and [builderSubProductWithProductDetails] live data.
*
* @param productDetailsList The list of product details.
*
*/
private fun postProductDetails(productDetailsList: List<ProductDetails>) {
productDetailsList.forEach { productDetails ->
when (productDetails.productType) {
BillingClient.ProductType.SUBS -> {
when (productDetails.productId) {
BillingConstants.SUBSCRIPTION_BUILDER -> {
Timber.d("Builder Subscription ProductDetails: $productDetails")
builderSubProductWithProductDetails.postValue(productDetails)
}
}
}
}
}
}
/**
* Query Google Play Billing for existing subscription purchases.
*
* New purchases will be provided to the PurchasesUpdatedListener.
* You still need to check the Google Play Billing API to know when purchase tokens are removed.
*/
fun querySubscriptionPurchases() {
if (!billingClient.isReady) {
Timber.w("querySubscriptionPurchases: BillingClient is not ready")
billingClient.startConnection(this)
}
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build(), this
)
}
/**
* Callback from the billing library when queryPurchasesAsync is called.
*/
override fun onQueryPurchasesResponse(
billingResult: BillingResult,
purchasesList: MutableList<Purchase>
) {
processPurchases(purchasesList)
}
/**
* Called by the Billing Library when new purchases are detected.
*/
override fun onPurchasesUpdated(
billingResult: BillingResult,
purchases: MutableList<Purchase>?
) {
val responseCode = billingResult.responseCode
val debugMessage = billingResult.debugMessage
Timber.d("onPurchasesUpdated: $responseCode $debugMessage")
when (responseCode) {
BillingClient.BillingResponseCode.OK -> {
if (purchases == null) {
Timber.d("onPurchasesUpdated: null purchase list")
processPurchases(null)
} else {
processPurchases(purchases)
}
}
BillingClient.BillingResponseCode.USER_CANCELED -> {
Timber.i("onPurchasesUpdated: User canceled the purchase")
}
BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> {
Timber.i("onPurchasesUpdated: The user already owns this item")
}
BillingClient.BillingResponseCode.DEVELOPER_ERROR -> {
Timber.e(
"onPurchasesUpdated: Developer error means that Google Play does " +
"not recognize the configuration."
)
}
}
}
/**
* Send purchase to StateFlow, which will trigger network call to verify the subscriptions
* on the sever.
*/
private fun processPurchases(purchasesList: List<Purchase>?) {
Timber.d( "processPurchases: ${purchasesList?.size} purchase(s)")
purchasesList?.let { list ->
if (isUnchangedPurchaseList(list)) {
Timber.d("processPurchases: Purchase list has not changed")
return
}
scope.launch(dispatchers.io) {
val subscriptionPurchaseList = list.filter { purchase ->
purchase.products.any { product ->
product in suscriptionTiers
}
}
_subscriptionPurchases.emit(subscriptionPurchaseList)
}
logAcknowledgementStatus(list)
}
}
/**
* Check whether the purchases have changed before posting changes.
*/
private fun isUnchangedPurchaseList(purchasesList: List<Purchase>): Boolean {
val isUnchanged = purchasesList == cachedPurchasesList
if (!isUnchanged) {
cachedPurchasesList = purchasesList
}
return isUnchanged
}
/**
* Log the number of purchases that are acknowledge and not acknowledged.
*
* https://developer.android.com/google/play/billing/billing_library_releases_notes#2_0_acknowledge
*
* When the purchase is first received, it will not be acknowledge.
* This application sends the purchase token to the server for registration. After the
* purchase token is registered to an account, the Android app acknowledges the purchase token.
* The next time the purchase list is updated, it will contain acknowledged purchases.
*/
private fun logAcknowledgementStatus(purchasesList: List<Purchase>) {
var acknowledgedCounter = 0
var unacknowledgedCounter = 0
for (purchase in purchasesList) {
if (purchase.isAcknowledged) {
acknowledgedCounter++
} else {
unacknowledgedCounter++
}
}
Timber.d(
"logAcknowledgementStatus: acknowledged=$acknowledgedCounter " +
"unacknowledged=$unacknowledgedCounter"
)
}
/**
* Launching the billing flow.
*
* Launching the UI to make a purchase requires a reference to the Activity.
*/
fun launchBillingFlow(activity: Activity, params: BillingFlowParams): Int {
if (!billingClient.isReady) {
Timber.e("launchBillingFlow: BillingClient is not ready")
}
val billingResult = billingClient.launchBillingFlow(activity, params)
val responseCode = billingResult.responseCode
val debugMessage = billingResult.debugMessage
Timber.d("launchBillingFlow: BillingResponse $responseCode $debugMessage")
return responseCode
}
}

View file

@ -1,4 +1,4 @@
package com.anytypeio.anytype.screens
package com.anytypeio.anytype.payments.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@ -50,9 +50,9 @@ import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.views.BodyBold
import com.anytypeio.anytype.core_ui.views.HeadlineTitle
import com.anytypeio.anytype.core_ui.views.PreviewTitle1Regular
import com.anytypeio.anytype.peyments.R
import com.anytypeio.anytype.viewmodel.PaymentsCodeState
import com.anytypeio.anytype.viewmodel.TierId
import com.anytypeio.anytype.payments.R
import com.anytypeio.anytype.payments.viewmodel.PaymentsCodeState
import com.anytypeio.anytype.payments.viewmodel.TierId
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View file

@ -1,4 +1,4 @@
package com.anytypeio.anytype.screens
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background

View file

@ -1,4 +1,4 @@
package com.anytypeio.anytype.screens
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
@ -53,9 +53,9 @@ import com.anytypeio.anytype.core_ui.views.BodyRegular
import com.anytypeio.anytype.core_ui.views.Caption1Regular
import com.anytypeio.anytype.core_ui.views.Relations2
import com.anytypeio.anytype.core_ui.views.fontRiccioneRegular
import com.anytypeio.anytype.models.Tier
import com.anytypeio.anytype.viewmodel.PaymentsMainState
import com.anytypeio.anytype.viewmodel.TierId
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.payments.viewmodel.PaymentsMainState
import com.anytypeio.anytype.payments.viewmodel.TierId
@Composable
fun MainPaymentsScreen(state: PaymentsMainState, tierClicked: (TierId) -> Unit) {

View file

@ -1,4 +1,4 @@
package com.anytypeio.anytype.screens
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@ -57,10 +57,10 @@ import com.anytypeio.anytype.core_ui.views.ButtonSize
import com.anytypeio.anytype.core_ui.views.HeadlineTitle
import com.anytypeio.anytype.core_ui.views.Relations1
import com.anytypeio.anytype.core_ui.views.Relations2
import com.anytypeio.anytype.models.Tier
import com.anytypeio.anytype.peyments.R
import com.anytypeio.anytype.viewmodel.PaymentsTierState
import com.anytypeio.anytype.viewmodel.TierId
import com.anytypeio.anytype.payments.R
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.payments.viewmodel.PaymentsTierState
import com.anytypeio.anytype.payments.viewmodel.TierId
@OptIn(ExperimentalMaterial3Api::class)

View file

@ -1,4 +1,4 @@
package com.anytypeio.anytype.screens
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -26,10 +26,10 @@ import com.anytypeio.anytype.core_ui.views.BodyRegular
import com.anytypeio.anytype.core_ui.views.ButtonSecondary
import com.anytypeio.anytype.core_ui.views.ButtonSize
import com.anytypeio.anytype.core_ui.views.HeadlineHeading
import com.anytypeio.anytype.models.Tier
import com.anytypeio.anytype.peyments.R
import com.anytypeio.anytype.viewmodel.PaymentsWelcomeState
import com.anytypeio.anytype.viewmodel.TierId
import com.anytypeio.anytype.payments.R
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.payments.viewmodel.PaymentsWelcomeState
import com.anytypeio.anytype.payments.viewmodel.TierId
@OptIn(ExperimentalMaterial3Api::class)
@Composable

View file

@ -1,4 +1,4 @@
package com.anytypeio.anytype.screens
package com.anytypeio.anytype.payments.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -37,7 +37,7 @@ import com.anytypeio.anytype.core_ui.views.ButtonSize
import com.anytypeio.anytype.core_ui.views.Caption1Regular
import com.anytypeio.anytype.core_ui.views.Relations3
import com.anytypeio.anytype.core_ui.views.fontInterSemibold
import com.anytypeio.anytype.models.Tier
import com.anytypeio.anytype.payments.models.Tier
@Composable
fun TierView(

View file

@ -1,6 +1,7 @@
package com.anytypeio.anytype.viewmodel
package com.anytypeio.anytype.payments.viewmodel
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.models.Tier
sealed class PaymentsMainState {
object Loading : PaymentsMainState()

View file

@ -0,0 +1,402 @@
package com.anytypeio.anytype.payments.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.payments.constants.BillingConstants
import com.anytypeio.anytype.payments.models.Tier
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import timber.log.Timber
class PaymentsViewModel(
private val analytics: Analytics,
private val billingClientLifecycle: BillingClientLifecycle,
private val getAccount: GetAccount
) : ViewModel() {
val viewState = MutableStateFlow<PaymentsMainState>(PaymentsMainState.Loading)
val codeState = MutableStateFlow<PaymentsCodeState>(PaymentsCodeState.Hidden)
val tierState = MutableStateFlow<PaymentsTierState>(PaymentsTierState.Hidden)
val welcomeState = MutableStateFlow<PaymentsWelcomeState>(PaymentsWelcomeState.Hidden)
val command = MutableStateFlow<PaymentsNavigation?>(null)
private val _tiers = mutableListOf<Tier>()
var activeTierName: MutableStateFlow<String?> = MutableStateFlow(null)
/**
* Local billing purchase data.
*/
private val purchases = billingClientLifecycle.subscriptionPurchases
/**
* ProductDetails for all known Products.
*/
private val builderSubProductWithProductDetails =
billingClientLifecycle.builderSubProductWithProductDetails
private val _launchBillingCommand = MutableSharedFlow<BillingFlowParams>()
val launchBillingCommand = _launchBillingCommand.asSharedFlow()
init {
Timber.d("PaymentsViewModel init")
_tiers.addAll(gertTiers())
setupActiveTierName()
viewState.value = PaymentsMainState.Default(_tiers)
}
fun onTierClicked(tierId: TierId) {
Timber.d("onTierClicked: tierId:$tierId")
tierState.value = PaymentsTierState.Visible.Initial(tier = _tiers.first { it.id == tierId })
command.value = PaymentsNavigation.Tier
}
fun onActionCode(code: String, tierId: TierId) {
Timber.d("onActionCode: tierId:$tierId, code:$code, _tiers:${_tiers}")
viewModelScope.launch {
codeState.value = PaymentsCodeState.Visible.Loading(tierId = tierId)
welcomeState.value =
PaymentsWelcomeState.Initial(tier = _tiers.first { it.id == tierId })
val updatedTiers = _tiers.map {
val isCurrent = it.id == tierId
when (it) {
is Tier.Builder -> it.copy(isCurrent = isCurrent)
is Tier.CoCreator -> it.copy(isCurrent = isCurrent)
is Tier.Custom -> it.copy(isCurrent = isCurrent)
is Tier.Explorer -> it.copy(isCurrent = isCurrent)
}
}
_tiers.clear()
_tiers.addAll(updatedTiers)
viewState.value = PaymentsMainState.PaymentSuccess(_tiers)
command.value = PaymentsNavigation.Welcome
}
}
fun onSubmitEmailButtonClicked(tierId: TierId, email: String) {
Timber.d("onSubmitEmailButtonClicked: email:$email")
codeState.value = PaymentsCodeState.Visible.Initial(tierId = tierId)
command.value = PaymentsNavigation.Code
}
fun onPayButtonClicked(tierId: TierId) {
Timber.d("onPayButtonClicked: tierId:$tierId")
buyBasePlans(product = tierId.value, upDowngrade = false)
}
fun onDismissTier() {
Timber.d("onDismissTier")
command.value = PaymentsNavigation.Dismiss
}
fun onDismissCode() {
Timber.d("onDismissCode")
command.value = PaymentsNavigation.Dismiss
}
fun onDismissWelcome() {
Timber.d("onDismissWelcome")
command.value = PaymentsNavigation.Dismiss
}
private fun setupActiveTierName() {
activeTierName.value = _tiers.firstOrNull { it.isCurrent }?.prettyName
}
private fun gertTiers(): List<Tier> {
return listOf(
Tier.Explorer(id = TierId("explorer_subscription"), isCurrent = false, validUntil = "Forever"),
Tier.Builder(id = TierId("builder_subscription"), isCurrent = false, validUntil = "2022-12-31"),
Tier.CoCreator(id = TierId("cocreator_subscription"), isCurrent = false, validUntil = "2022-12-31"),
Tier.Custom(id = TierId("idCustom"), isCurrent = false, validUntil = "2022-12-31")
)
}
//region Google Play Billing
/**
* Use the Google Play Billing Library to make a purchase.
*
* @param tag String representing tags associated with offers and base plans.
* @param product Product being purchased.
* @param upDowngrade Boolean indicating if the purchase is an upgrade or downgrade and
* when converting from one base plan to another.
*
*/
private fun buyBasePlans(tag: String = "", product: String, upDowngrade: Boolean) {
//todo check if the user has already purchased the product
val isProductOnServer = false//serverHasSubscription(subscriptions.value, product)
val isProductOnDevice = deviceHasGooglePlaySubscription(purchases.value, product)
Timber.d(
"Billing", "$product - isProductOnServer: $isProductOnServer," +
" isProductOnDevice: $isProductOnDevice"
)
when {
isProductOnDevice && isProductOnServer -> {
Timber.d("User is trying to top up prepaid subscription: $product. ")
}
isProductOnDevice && !isProductOnServer -> {
Timber.d(
"The Google Play Billing Library APIs indicate that " +
"this Product is already owned, but the purchase token is not " +
"registered with the server."
)
}
!isProductOnDevice && isProductOnServer -> {
Timber.w(
"WHOA! The server says that the user already owns " +
"this item: $product. This could be from another Google account. " +
"You should warn the user that they are trying to buy something " +
"from Google Play that they might already have access to from " +
"another purchase, possibly from a different Google account " +
"on another device.\n" +
"You can choose to block this purchase.\n" +
"If you are able to cancel the existing subscription on the server, " +
"you should allow the user to subscribe with Google Play, and then " +
"cancel the subscription after this new subscription is complete. " +
"This will allow the user to seamlessly transition their payment " +
"method from an existing payment method to this Google Play account."
)
return
}
}
val builderSubProductDetails = builderSubProductWithProductDetails.value ?: run {
Timber.e( "Could not find Basic product details.")
return
}
val builderOffers =
builderSubProductDetails.subscriptionOfferDetails?.let { offerDetailsList ->
retrieveEligibleOffers(
offerDetails = offerDetailsList,
tag = tag
)
}
val offerToken: String
when (product) {
BillingConstants.SUBSCRIPTION_BUILDER -> {
offerToken = builderOffers?.let { leastPricedOfferToken(it) }.toString()
launchFlow(upDowngrade, offerToken, builderSubProductDetails)
}
}
}
/**
* Calculates the lowest priced offer amongst all eligible offers.
* In this implementation the lowest price of all offers' pricing phases is returned.
* It's possible the logic can be implemented differently.
* For example, the lowest average price in terms of month could be returned instead.
*
* @param offerDetails List of of eligible offers and base plans.
*
* @return the offer id token of the lowest priced offer.
*
*/
private fun leastPricedOfferToken(
offerDetails: List<ProductDetails.SubscriptionOfferDetails>
): String {
var offerToken = String()
var leastPricedOffer: ProductDetails.SubscriptionOfferDetails
var lowestPrice = Int.MAX_VALUE
if (offerDetails.isNotEmpty()) {
for (offer in offerDetails) {
for (price in offer.pricingPhases.pricingPhaseList) {
if (price.priceAmountMicros < lowestPrice) {
lowestPrice = price.priceAmountMicros.toInt()
leastPricedOffer = offer
offerToken = leastPricedOffer.offerToken
}
}
}
}
return offerToken
TODO("Replace this with least average priced offer implementation")
}
/**
* Retrieves all eligible base plans and offers using tags from ProductDetails.
*
* @param offerDetails offerDetails from a ProductDetails returned by the library.
* @param tag string representing tags associated with offers and base plans.
*
* @return the eligible offers and base plans in a list.
*
*/
private fun retrieveEligibleOffers(
offerDetails: MutableList<ProductDetails.SubscriptionOfferDetails>, tag: String
):
List<ProductDetails.SubscriptionOfferDetails> {
val eligibleOffers = emptyList<ProductDetails.SubscriptionOfferDetails>().toMutableList()
offerDetails.forEach { offerDetail ->
if (offerDetail.offerTags.contains(tag)) {
eligibleOffers.add(offerDetail)
}
}
return eligibleOffers
}
/**
* BillingFlowParams Builder for normal purchases.
*
* @param productDetails ProductDetails object returned by the library.
* @param offerToken the least priced offer's offer id token returned by
* [leastPricedOfferToken].
*
* @return [BillingFlowParams] builder.
*/
private suspend fun billingFlowParamsBuilder(
productDetails: ProductDetails,
offerToken: String
):
BillingFlowParams {
val anyId = getAccount.async(Unit).getOrNull()
return BillingFlowParams.newBuilder()
.setObfuscatedAccountId(anyId?.id.orEmpty())
.setObfuscatedProfileId("testobfuscatedProfileId")
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
)
).build()
}
/**
* BillingFlowParams Builder for upgrades and downgrades.
*
* @param productDetails ProductDetails object returned by the library.
* @param offerToken the least priced offer's offer id token returned by
* [leastPricedOfferToken].
* @param oldToken the purchase token of the subscription purchase being upgraded or downgraded.
*
* @return [BillingFlowParams] builder.
*/
private fun upDowngradeBillingFlowParamsBuilder(
productDetails: ProductDetails, offerToken: String, oldToken: String
): BillingFlowParams {
return BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
)
).setSubscriptionUpdateParams(
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldToken)
.setSubscriptionReplacementMode(
BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE
).build()
).build()
}
/**
* Launches the billing flow for a subscription product purchase.
* A user can only have one subscription purchase on the device at a time. If the user
* has more than one subscription purchase on the device, the app should not allow the
* user to purchase another subscription.
*
* @param upDowngrade Boolean indicating if the purchase is an upgrade or downgrade and
* when converting from one base plan to another.
* @param offerToken String representing the offer token of the lowest priced offer.
* @param productDetails ProductDetails of the product being purchased.
*
*/
private fun launchFlow(
upDowngrade: Boolean,
offerToken: String,
productDetails: ProductDetails
) {
val currentSubscriptionPurchaseCount = purchases.value.count {
it.products.contains(BillingConstants.SUBSCRIPTION_BUILDER)
}
if (currentSubscriptionPurchaseCount > EXPECTED_SUBSCRIPTION_PURCHASE_LIST_SIZE) {
Timber.e("There are more than one subscription purchases on the device.")
return
TODO(
"Handle this case better, such as by showing a dialog to the user or by " +
"programmatically getting the correct purchase token."
)
}
val oldToken = purchases.value.filter {
it.products.contains(BillingConstants.SUBSCRIPTION_BUILDER)
}.firstOrNull { it.purchaseToken.isNotEmpty() }?.purchaseToken ?: ""
viewModelScope.launch {
val billingParams: BillingFlowParams = if (upDowngrade) {
upDowngradeBillingFlowParamsBuilder(
productDetails = productDetails,
offerToken = offerToken,
oldToken = oldToken
)
} else {
billingFlowParamsBuilder(
productDetails = productDetails,
offerToken = offerToken
)
}
_launchBillingCommand.emit(billingParams)
}
}
/**
* This will return true if the Google Play Billing APIs have a record for the subscription.
* This will not always match the server's record of the subscription for this app user.
*
* Example: App user buys the subscription on a different device with a different Google
* account. The server will show that this app user has the subscription, even if the
* Google account on this device has not purchased the subscription.
* In this example, the method will return false.
*
* Example: The app user changes by signing out and signing into the app with a different
* email address. The server will show that this app user does not have the subscription,
* even if the Google account on this device has purchased the subscription.
* In this example, the method will return true.
*/
private fun deviceHasGooglePlaySubscription(purchases: List<Purchase>?, product: String) =
purchaseForProduct(purchases, product) != null
/**
* Return purchase for the provided Product, if it exists.
*/
private fun purchaseForProduct(purchases: List<Purchase>?, product: String): Purchase? {
purchases?.let {
for (purchase in it) {
if (purchase.products[0] == product) {
return purchase
}
}
}
return null
}
companion object {
const val EXPECTED_SUBSCRIPTION_PURCHASE_LIST_SIZE = 1
}
}

View file

@ -1,17 +1,23 @@
package com.anytypeio.anytype.viewmodel
package com.anytypeio.anytype.payments.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.domain.auth.interactor.GetAccount
import com.anytypeio.anytype.payments.playbilling.BillingClientLifecycle
import javax.inject.Inject
class PaymentsViewModelFactory @Inject constructor(
private val analytics: Analytics,
private val billingClientLifecycle: BillingClientLifecycle,
private val getAccount: GetAccount,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PaymentsViewModel(
analytics = analytics,
billingClientLifecycle = billingClientLifecycle,
getAccount = getAccount
) as T
}
}

View file

@ -1,103 +0,0 @@
package com.anytypeio.anytype.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.models.Tier
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
class PaymentsViewModel(
private val analytics: Analytics,
) : ViewModel() {
val viewState = MutableStateFlow<PaymentsMainState>(PaymentsMainState.Loading)
val codeState = MutableStateFlow<PaymentsCodeState>(PaymentsCodeState.Hidden)
val tierState = MutableStateFlow<PaymentsTierState>(PaymentsTierState.Hidden)
val welcomeState = MutableStateFlow<PaymentsWelcomeState>(PaymentsWelcomeState.Hidden)
val command = MutableStateFlow<PaymentsNavigation?>(null)
private val _tiers = mutableListOf<Tier>()
var activeTierName: MutableStateFlow<String?> = MutableStateFlow(null)
init {
Timber.d("PaymentsViewModel init")
_tiers.addAll(gertTiers())
setupActiveTierName()
viewState.value = PaymentsMainState.Default(_tiers)
}
fun onTierClicked(tierId: TierId) {
Timber.d("onTierClicked: tierId:$tierId")
tierState.value = PaymentsTierState.Visible.Initial(tier = _tiers.first { it.id == tierId })
command.value = PaymentsNavigation.Tier
}
fun onActionCode(code: String, tierId: TierId) {
Timber.d("onActionCode: tierId:$tierId, code:$code, _tiers:${_tiers}")
viewModelScope.launch {
codeState.value = PaymentsCodeState.Visible.Loading(tierId = tierId)
delay(2000)
welcomeState.value =
PaymentsWelcomeState.Initial(tier = _tiers.first { it.id == tierId })
val updatedTiers = _tiers.map {
val isCurrent = it.id == tierId
when (it) {
is Tier.Builder -> it.copy(isCurrent = isCurrent)
is Tier.CoCreator -> it.copy(isCurrent = isCurrent)
is Tier.Custom -> it.copy(isCurrent = isCurrent)
is Tier.Explorer -> it.copy(isCurrent = isCurrent)
}
}
_tiers.clear()
_tiers.addAll(updatedTiers)
viewState.value = PaymentsMainState.PaymentSuccess(_tiers)
command.value = PaymentsNavigation.Welcome
}
}
fun onSubmitEmailButtonClicked(tierId: TierId, email: String) {
Timber.d("onSubmitEmailButtonClicked: email:$email")
codeState.value = PaymentsCodeState.Visible.Initial(tierId = tierId)
command.value = PaymentsNavigation.Code
}
fun onPayButtonClicked(tierId: TierId) {
Timber.d("onPayButtonClicked: tierId:$tierId")
codeState.value = PaymentsCodeState.Visible.Initial(tierId = tierId)
command.value = PaymentsNavigation.Code
}
fun onDismissTier() {
Timber.d("onDismissTier")
command.value = PaymentsNavigation.Dismiss
}
fun onDismissCode() {
Timber.d("onDismissCode")
command.value = PaymentsNavigation.Dismiss
}
fun onDismissWelcome() {
Timber.d("onDismissWelcome")
command.value = PaymentsNavigation.Dismiss
}
private fun setupActiveTierName() {
activeTierName.value = _tiers.firstOrNull { it.isCurrent }?.prettyName
}
private fun gertTiers(): List<Tier> {
return listOf(
Tier.Explorer(id = TierId("idExplorer"), isCurrent = false, validUntil = "Forever"),
Tier.Builder(id = TierId("idBuilder"), isCurrent = false, validUntil = "2022-12-31"),
Tier.CoCreator(id = TierId("idCoCreator"), isCurrent = false, validUntil = "2022-12-31"),
Tier.Custom(id = TierId("idCustom"), isCurrent = false, validUntil = "2022-12-31")
)
}
}