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:
parent
ce15843313
commit
0930206d50
22 changed files with 856 additions and 150 deletions
|
@ -188,6 +188,7 @@ dependencies {
|
|||
implementation libs.pickT
|
||||
implementation libs.emojiCompat
|
||||
implementation libs.navigationCompose
|
||||
implementation libs.playBilling
|
||||
|
||||
implementation libs.lifecycleViewModel
|
||||
implementation libs.lifecycleRuntime
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -79,7 +79,8 @@ import javax.inject.Singleton
|
|||
CrashReportingModule::class,
|
||||
TemplatesModule::class,
|
||||
NetworkModeModule::class,
|
||||
NotificationsModule::class
|
||||
NotificationsModule::class,
|
||||
BillingModule::class
|
||||
]
|
||||
)
|
||||
interface MainComponent :
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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) {
|
|
@ -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)
|
|
@ -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
|
|
@ -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(
|
|
@ -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()
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue