diff --git a/gradle.properties b/gradle.properties index ed491c88..66c0ed97 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,4 @@ android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official -sdkVersion=1.0.0 \ No newline at end of file +sdkVersion=1.1.0 \ No newline at end of file diff --git a/sdk/src/androidTest/java/com/apphud/sdk/ApphudServiceTest.kt b/sdk/src/androidTest/java/com/apphud/sdk/ApphudServiceTest.kt index 4d4c5221..378da92e 100644 --- a/sdk/src/androidTest/java/com/apphud/sdk/ApphudServiceTest.kt +++ b/sdk/src/androidTest/java/com/apphud/sdk/ApphudServiceTest.kt @@ -17,7 +17,7 @@ class ApphudServiceTest { private val userId = "cleaner_303" private val deviceId = "cleaner_303" - private val API_KEY = "app_oBcXz2z9j8spKPL2T7sZwQaQN5Jzme" + private val API_KEY = "app_4sY9cLggXpMDDQMmvc5wXUPGReMp8G" private lateinit var service: ApphudService @Before diff --git a/sdk/src/main/java/com/apphud/sdk/Apphud.kt b/sdk/src/main/java/com/apphud/sdk/Apphud.kt index 1f0d5620..73d297e4 100644 --- a/sdk/src/main/java/com/apphud/sdk/Apphud.kt +++ b/sdk/src/main/java/com/apphud/sdk/Apphud.kt @@ -3,8 +3,7 @@ package com.apphud.sdk import android.app.Activity import android.content.Context import com.android.billingclient.api.SkuDetails -import com.apphud.sdk.domain.ApphudNonRenewingPurchase -import com.apphud.sdk.domain.ApphudSubscription +import com.apphud.sdk.domain.* object Apphud { @@ -125,6 +124,21 @@ object Apphud { @kotlin.jvm.JvmStatic fun syncPurchases() = ApphudInternal.syncPurchases() + /** + * Fetches paywalls configured in Apphud dashboard. Paywalls are automatically cached on device. + */ + fun getPaywalls(callback: (paywalls: List?, error: ApphudError?) -> Unit) { + ApphudInternal.getPaywalls(callback = callback) + } + + /** + * Permission groups configured in Apphud dashboard. Groups are cached on device. + * Note that this method may be `null` at first launch of the app. + */ + fun permissionGroups(): List { + return ApphudInternal.productGroups + } + /** * Implements `Restore Purchases` mechanism. Basically it just sends current Play Market Purchase Tokens to Apphud and returns subscriptions info. * @@ -133,9 +147,7 @@ object Apphud { * @param callback: Required. Returns array of subscription (or subscriptions in case you have more than one subscription group), array of standard in-app purchases and an error. All of three parameters are optional. */ @kotlin.jvm.JvmStatic - fun restorePurchases(callback: (subscriptions: List?, - purchases: List?, - error: ApphudError?) -> Unit) { + fun restorePurchases(callback: ApphudPurchasesRestoreCallback) { ApphudInternal.restorePurchases(callback) } @@ -143,6 +155,8 @@ object Apphud { * Returns an array of **SkuDetails** objects that you added in Apphud. * Note that this method will return **null** if products are not yet fetched. */ + @Deprecated("Use \"getPaywalls\" method instead.", + ReplaceWith("getPaywalls(callback: (paywalls: List?, error: ApphudError?) -> Unit)")) @kotlin.jvm.JvmStatic fun products(): List? { return ApphudInternal.getSkuDetailsList() @@ -154,7 +168,8 @@ object Apphud { * You can use `productsDidFetchCallback` callback * or implement `apphudFetchSkuDetailsProducts` listener method. Use whatever you like most. */ - + @Deprecated("Use \"getPaywalls\" method instead.", + ReplaceWith("getPaywalls(callback: (paywalls: List?, error: ApphudError?) -> Unit)")) @kotlin.jvm.JvmStatic fun productsFetchCallback(callback: (List) -> Unit) { ApphudInternal.productsFetchCallback(callback) @@ -165,6 +180,8 @@ object Apphud { * Note that you have to add this product identifier in Apphud. * Will return **null** if product is not yet fetched from Google Play Billing. */ + @Deprecated("Use \"getPaywalls\" method instead.", + ReplaceWith("getPaywalls(callback: (paywalls: List?, error: ApphudError?) -> Unit)")) @kotlin.jvm.JvmStatic fun product(productIdentifier: String): SkuDetails? { return ApphudInternal.getSkuDetailsByProductId(productIdentifier) @@ -177,9 +194,11 @@ object Apphud { * @param productId: The identifier of the product you wish to purchase * @param block: Optional. Returns `ApphudPurchaseResult` object. */ + @Deprecated("Purchase product by product identifier", + ReplaceWith("purchase(activity: Activity, product: ApphudProduct, block: ((ApphudPurchaseResult) -> Unit)?)")) @kotlin.jvm.JvmStatic fun purchase(activity: Activity, productId: String, block: ((ApphudPurchaseResult) -> Unit)?) = - ApphudInternal.purchase(activity, productId, true, block) + ApphudInternal.purchase(activity, productId, null, null, true, block) /** * Purchase sku product and automatically submit Google Play purchase token to Apphud @@ -188,9 +207,22 @@ object Apphud { * @param details The SkuDetails of the product you wish to purchase * @param block Optional. Returns `ApphudPurchaseResult` object. */ + @Deprecated("Purchase product by product identifier", + ReplaceWith("purchase(activity: Activity, product: ApphudProduct, block: ((ApphudPurchaseResult) -> Unit)?)")) @kotlin.jvm.JvmStatic fun purchase(activity: Activity, details: SkuDetails, block: ((ApphudPurchaseResult) -> Unit)?) = - ApphudInternal.purchase(activity, details, true, block) + ApphudInternal.purchase(activity, null, details, null, true, block) + + /** + * Purchase sku product and automatically submit Google Play purchase token to Apphud + * + * @param activity current Activity for use + * @param details The SkuDetails of the product you wish to purchase + * @param block Optional. Returns `ApphudPurchaseResult` object. + */ + @kotlin.jvm.JvmStatic + fun purchase(activity: Activity, product: ApphudProduct, block: ((ApphudPurchaseResult) -> Unit)?) = + ApphudInternal.purchase(activity, null, null, product, true, block) /** * Purchase product by id and automatically submit Google Play purchase token to Apphud @@ -206,7 +238,7 @@ object Apphud { */ @kotlin.jvm.JvmStatic fun purchaseWithoutValidation(activity: Activity, productId: String, block: ((ApphudPurchaseResult) -> Unit)?) = - ApphudInternal.purchase(activity, productId, false, block) + ApphudInternal.purchase(activity, productId, null,null,false, block) /** * Purchase sku product and automatically submit Google Play purchase token to Apphud @@ -222,7 +254,7 @@ object Apphud { */ @kotlin.jvm.JvmStatic fun purchaseWithoutValidation(activity: Activity, details: SkuDetails, block: ((ApphudPurchaseResult) -> Unit)?) = - ApphudInternal.purchase(activity, details, false, block) + ApphudInternal.purchase(activity,null, details, null, false, block) /** * Set custom user property. diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudExtensions.kt b/sdk/src/main/java/com/apphud/sdk/ApphudExtensions.kt new file mode 100644 index 00000000..60077ded --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/ApphudExtensions.kt @@ -0,0 +1,13 @@ +package com.apphud.sdk + +import android.content.Context +import android.content.pm.ApplicationInfo +import java.util.concurrent.atomic.AtomicInteger + +internal fun Context.isDebuggable(): Boolean { + return 0 != this.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE +} + +internal fun AtomicInteger.isBothSkuLoaded(): Boolean { + return this.get() == 2 +} \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudInternal.kt b/sdk/src/main/java/com/apphud/sdk/ApphudInternal.kt index 84e06f0a..56ff6268 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudInternal.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudInternal.kt @@ -3,7 +3,6 @@ package com.apphud.sdk import android.annotation.SuppressLint import android.app.Activity import android.content.Context -import android.content.pm.ApplicationInfo import android.os.AsyncTask import android.os.Build import android.os.Handler @@ -14,16 +13,14 @@ import com.android.billingclient.api.SkuDetails import com.apphud.sdk.body.* import com.apphud.sdk.client.ApphudClient import com.apphud.sdk.domain.* -import com.apphud.sdk.internal.BillingWrapper -import com.apphud.sdk.internal.PurchaseCallbackStatus -import com.apphud.sdk.internal.PurchaseRestoredCallbackStatus -import com.apphud.sdk.internal.PurchaseUpdatedCallbackStatus +import com.apphud.sdk.internal.* import com.apphud.sdk.parser.GsonParser import com.apphud.sdk.parser.Parser import com.apphud.sdk.storage.SharedPreferencesStorage import com.apphud.sdk.tasks.advertisingId import com.google.gson.GsonBuilder import java.util.* +import java.util.concurrent.atomic.AtomicInteger @SuppressLint("StaticFieldLeak") internal object ApphudInternal { @@ -43,6 +40,9 @@ internal object ApphudInternal { private val storage by lazy { SharedPreferencesStorage(context, parser) } private var generatedUUID = UUID.randomUUID().toString() private var prevPurchases = mutableSetOf() + private var tempPrevPurchases = mutableSetOf() + internal var paywalls: MutableList = mutableListOf() + internal var productGroups: MutableList = mutableListOf() private var advertisingId: String? = null get() = storage.advertisingId @@ -72,11 +72,19 @@ internal object ApphudInternal { internal var apphudListener: ApphudListener? = null private val skuDetails = mutableListOf() + /** + * 0 - we at start point without any skuDetails + * 1 - we have only one loaded SkuType SUBS or INAPP + * 2 - we have both loaded SkuType SUBS and INAPP + * */ + private var skuDetailsIsLoaded: AtomicInteger = AtomicInteger(0) + private var skuDetailsForFetchIsLoaded: AtomicInteger = AtomicInteger(0) + private var skuDetailsForRestoreIsLoaded: AtomicInteger = AtomicInteger(0) - private var customProductsFetchedBlock : ((List) -> Unit)? = null + private var customProductsFetchedBlock: ((List) -> Unit)? = null private val pendingUserProperties = mutableMapOf() - private val userPropertiesRunnable = Runnable { if(isRegistered) updateUserProperties() } + private val userPropertiesRunnable = Runnable { if (isRegistered) updateUserProperties() } private var setNeedsToUpdateUserProperties: Boolean = false set(value) { @@ -89,6 +97,23 @@ internal object ApphudInternal { } } + private var paywallsDelayedCallback: PaywallCallback? = null + + private val paywallsRunnable = Runnable { + tryInvokePaywallsDelayedCallback() + } + + private var setNeedsToUpdatePaywalls: Boolean = false + set(value) { + field = value + if (value) { + handler.removeCallbacks(paywallsRunnable) + handler.postDelayed(paywallsRunnable, 200L) + } else { + handler.removeCallbacks(paywallsRunnable) + } + } + private fun loadAdsId() { if (ApphudUtils.adTracking) { AdvertisingTask().execute() @@ -103,7 +128,7 @@ internal object ApphudInternal { } internal fun updateUserId(userId: UserId) { - ApphudLog.log("Start updateUserId userId=$userId" ) + ApphudLog.log("Start updateUserId userId=$userId") val id = updateUser(id = userId) this.userId = id @@ -111,7 +136,7 @@ internal object ApphudInternal { client.registrationUser(body) { customer -> handler.post { storage.customer = customer - ApphudLog.log("End updateUserId customer=${customer.toString()}" ) + ApphudLog.log("End updateUserId customer=$customer") } } } @@ -120,20 +145,22 @@ internal object ApphudInternal { userId: UserId?, deviceId: DeviceId?, isFetchProducts: Boolean = true - ){ - if(!allowIdentifyUser){ + ) { + if (!allowIdentifyUser) { ApphudLog.logE("=============================================================" + - "\nAbort initializing, because Apphud SDK already initialized." + - "\nYou can only call `Apphud.start()` once per app lifecycle." + - "\nOr if `Apphud.logout()` was called previously." + - "\n=============================================================") + "\nAbort initializing, because Apphud SDK already initialized." + + "\nYou can only call `Apphud.start()` once per app lifecycle." + + "\nOr if `Apphud.logout()` was called previously." + + "\n=============================================================") return } allowIdentifyUser = false + ApphudLog.log("try restore cachedPaywalls") + this.paywalls = cachedPaywalls() // try to continue anyway, because maybe already has cached data, try to fetch play market products fetchProducts() - ApphudLog.log("Start initialize with userId=$userId, deviceId=$deviceId" ) + ApphudLog.log("Start initialize with userId=$userId, deviceId=$deviceId") this.userId = updateUser(id = userId) this.deviceId = updateDevice(id = deviceId) ApphudLog.log("Start initialize with saved userId=${this.userId}, saved deviceId=${this.deviceId}") @@ -143,143 +170,223 @@ internal object ApphudInternal { registration(this.userId, this.deviceId) } + private fun fetchProducts() { + billing.skuCallback = { details -> + ApphudLog.log("fetchProducts: details from Google Billing: $details") + skuDetailsIsLoaded.incrementAndGet() + if (details.isNotEmpty()) { + skuDetails.addAll(details) + } + if(skuDetailsIsLoaded.isBothSkuLoaded()) { + paywalls = cachedPaywalls() + productGroups = cachedGroups() + customProductsFetchedBlock?.invoke(skuDetails) + apphudListener?.apphudFetchSkuDetailsProducts(skuDetails) + } + } + client.allProducts { groups -> + ApphudLog.log("fetchProducts: products from Apphud server: $groups") + cacheGroups(groups) + val ids = groups.map { it -> it.products?.map { it.productId }!! }.flatten() + billing.details(BillingClient.SkuType.SUBS, ids) + billing.details(BillingClient.SkuType.INAPP, ids) + } + } + private fun registration( userId: UserId?, deviceId: DeviceId? ) { - ApphudLog.log("Start registration userId=$userId, deviceId=$deviceId" ) + ApphudLog.log("Start registration userId=$userId, deviceId=$deviceId") val body = mkRegistrationBody(userId!!, this.deviceId) client.registrationUser(body) { customer -> isRegistered = true handler.post { - ApphudLog.log("registration: registrationUser customer=${customer.toString()}" ) + ApphudLog.log("registration: registrationUser customer=$customer") storage.customer = customer apphudListener?.apphudSubscriptionsUpdated(customer.subscriptions) apphudListener?.apphudNonRenewingPurchasesUpdated(customer.purchases) // try to resend purchases, if prev requests was fail if (storage.isNeedSync) { - ApphudLog.log("registration: syncPurchases" ) + ApphudLog.log("registration: syncPurchases") syncPurchases() } - if(pendingUserProperties.isNotEmpty() && setNeedsToUpdateUserProperties) { - ApphudLog.log("registration: we should update UserProperties" ) + if (pendingUserProperties.isNotEmpty() && setNeedsToUpdateUserProperties) { + ApphudLog.log("registration: we should update UserProperties") updateUserProperties() } } } - ApphudLog.log("End registration" ) + ApphudLog.log("End registration") } - internal fun productsFetchCallback(callback: (List) -> Unit){ + internal fun productsFetchCallback(callback: (List) -> Unit) { customProductsFetchedBlock = callback - if(skuDetails.isNotEmpty()) { + if (skuDetails.isNotEmpty()) { customProductsFetchedBlock?.invoke(skuDetails) } } + /** + * This is main purchase fun + * At start we should fill only **ONE** of this parameters: **productId** or **skuDetails** or **product** + * */ internal fun purchase( activity: Activity, - productId: String, + productId: String?, + skuDetails: SkuDetails?, + product: ApphudProduct?, withValidation: Boolean = true, callback: ((ApphudPurchaseResult) -> Unit)? ) { - val sku = getSkuDetailsByProductId(productId) - if (sku != null) { - purchase(activity, sku, withValidation, callback) + if (!productId.isNullOrEmpty()) { + //if we have productId + val sku = getSkuDetailsByProductId(productId) + if (sku != null) { + purchaseInternal(activity, sku, null, withValidation, callback) + } else { + fetchDetails(activity, productId, null, withValidation, callback) + } + } else if (skuDetails !=null ){ + //if we have SkuDetails + purchaseInternal(activity, skuDetails, null, withValidation, callback) } else { - ApphudLog.log("Could not find SkuDetails for product id: $productId in memory") - ApphudLog.log("Now try fetch it from Google Billing") - billing.details(BillingClient.SkuType.SUBS, listOf(productId)) { skuList -> - ApphudLog.log("Google Billing (SUBS) return this info for product id = $productId :") - skuList.forEach { ApphudLog.log("$it") } - skuList.takeIf { it.isNotEmpty() }?.let { - skuDetails.addAll(it) - purchase(activity, it.first(), withValidation, callback) - } ?: run { - val message = - "Unable to fetch product (SkuType.SUBS) with given product id: $productId" - callback?.invoke(ApphudPurchaseResult(null, null, null, ApphudError(message))) - } + //if we have ApphudProduct + product?.skuDetails?.let { + purchaseInternal(activity, null, product, withValidation, callback) + } ?: run { + fetchDetails(activity, null, product, withValidation, callback) } - billing.details(BillingClient.SkuType.INAPP, listOf(productId)) { skuList -> - ApphudLog.log("Google Billing (INAPP) return this info for product id = $productId :") + } + } + + private fun fetchDetails( + activity: Activity, + productId: String?, + product: ApphudProduct?, + withValidation: Boolean, + callback: ((ApphudPurchaseResult) -> Unit)? + ) { + skuDetailsForFetchIsLoaded.set(0) + val productName: String = productId ?: product?.productId!! + ApphudLog.log("Could not find SkuDetails for product id: $productId in memory") + ApphudLog.log("Now try fetch it from Google Billing") + val fetchDetailsCallback: ApphudSkuDetailsCallback = { skuList -> + skuDetailsForFetchIsLoaded.incrementAndGet() + if (skuList.isNotEmpty()) { + skuDetails.addAll(skuList) + ApphudLog.log("Google Billing return this info for product id = $productId :") skuList.forEach { ApphudLog.log("$it") } - skuList.takeIf { it.isNotEmpty() }?.let { - skuDetails.addAll(it) - purchase(activity, it.first(), withValidation, callback) + } + //if we have successfully fetched SkuDetails with target productId + getSkuDetailsByProductId(productName)?.let { sku -> + //we have SkuDetails and we don't need a callback anymore + billing.skuCallback = null + //if we have not empty ApphudProduct + product?.let { + paywalls = cachedPaywalls() + it.skuDetails = sku + purchaseInternal(activity, null, it, withValidation, callback) } ?: run { - val message = - "Unable to fetch product (SkuType.INAPP) with given product id: $productId" - callback?.invoke(ApphudPurchaseResult(null, null, null, ApphudError(message))) + purchaseInternal(activity, sku, null, withValidation, callback) + } + } ?: run { + //if we booth SkuType already loaded and we still haven't any SkuDetails + if (skuDetailsForFetchIsLoaded.isBothSkuLoaded()) { + val message = "Unable to fetch product with given product id: $productId" + callback?.invoke(ApphudPurchaseResult(null, + null, + null, + ApphudError(message))) } } } + billing.details(BillingClient.SkuType.SUBS, listOf(productName), fetchDetailsCallback) + billing.details(BillingClient.SkuType.INAPP, listOf(productName), fetchDetailsCallback) } - internal fun purchase( + private fun purchaseInternal( activity: Activity, - details: SkuDetails, + details: SkuDetails?, + product: ApphudProduct?, withValidation: Boolean, callback: ((ApphudPurchaseResult) -> Unit)? ) { billing.acknowledgeCallback = { status, purchase -> - when(status){ + when (status) { is PurchaseCallbackStatus.Error -> { val message = "After purchase acknowledge is failed with code: ${status.error}" ApphudLog.log(message) - callback?.invoke(ApphudPurchaseResult(null, null, purchase, ApphudError(message))) + callback?.invoke(ApphudPurchaseResult(null, + null, + purchase, + ApphudError(message))) } is PurchaseCallbackStatus.Success -> { ApphudLog.log("acknowledge success") when { - withValidation -> ackPurchase(purchase, details, callback) + withValidation -> ackPurchase(purchase, details , product, callback) else -> { callback?.invoke(ApphudPurchaseResult(null, null, purchase, null)) - ackPurchase(purchase, details, null) + ackPurchase(purchase, details, product, null) } } } } } billing.consumeCallback = { status, purchase -> - when(status){ + when (status) { is PurchaseCallbackStatus.Error -> { val message = "After purchase consume is failed with value: ${status.error}" ApphudLog.log(message) - callback?.invoke(ApphudPurchaseResult(null, null, purchase, ApphudError(message))) + callback?.invoke(ApphudPurchaseResult(null, + null, + purchase, + ApphudError(message))) } is PurchaseCallbackStatus.Success -> { ApphudLog.log("consume callback value: ${status.message}") when { - withValidation -> ackPurchase(purchase, details, callback) + withValidation -> ackPurchase(purchase, details,product, callback) else -> { callback?.invoke(ApphudPurchaseResult(null, null, purchase, null)) - ackPurchase(purchase, details, null) + ackPurchase(purchase, details, product, null) } } } } } billing.purchasesCallback = { purchasesResult -> - when(purchasesResult){ + when (purchasesResult) { is PurchaseUpdatedCallbackStatus.Error -> { - val error = ApphudError(message = "Unable to buy product with given product id: ${details.sku} ", - secondErrorMessage = purchasesResult.result.debugMessage, - errorCode = purchasesResult.result.responseCode - ) + val message = if(details!=null) { + "Unable to buy product with given product id: ${details.sku} " + } + else { + "Unable to buy product with given product id: ${product?.skuDetails?.sku} " + } + val error = + ApphudError(message = message, + secondErrorMessage = purchasesResult.result.debugMessage, + errorCode = purchasesResult.result.responseCode + ) callback?.invoke(ApphudPurchaseResult(null, null, null, error)) } is PurchaseUpdatedCallbackStatus.Success -> { ApphudLog.log("purchases: $purchasesResult") - + val detailsType = if(details!=null) { + details.type + } else { + product?.skuDetails?.type + } purchasesResult.purchases.forEach { when (it.purchaseState) { Purchase.PurchaseState.PURCHASED -> - when (details.type) { + when (detailsType) { BillingClient.SkuType.SUBS -> { if (!it.isAcknowledged) { billing.acknowledge(it) @@ -310,71 +417,114 @@ internal object ApphudInternal { } } } - billing.purchase(activity, details) + when { + details!=null -> { + billing.purchase(activity, details) + } + product?.skuDetails != null -> { + billing.purchase(activity, product.skuDetails!!) + } + else -> { + val message = "Unable to buy product with coz SkuDetails is null" + ApphudLog.log(message) + callback?.invoke(ApphudPurchaseResult(null, + null, + null, + ApphudError(message))) + } + } } - private fun ackPurchase(purchase: Purchase, details: SkuDetails?, callback: ((ApphudPurchaseResult) -> Unit)?){ - client.purchased(makePurchaseBody(purchase, details)) { customer -> + private fun ackPurchase( + purchase: Purchase, + details: SkuDetails?, + product: ApphudProduct?, + callback: ((ApphudPurchaseResult) -> Unit)? + ) { + val purchaseBody = details?.let { makePurchaseBody(purchase, it) } + ?: product?.let { makePurchaseBodyV2(purchase, it) } + if (purchaseBody == null) { + val message = + "Error!!! SkuDetails and ApphudProduct cannot be null at the same time !!!" + ApphudLog.logE(message) + callback?.invoke(ApphudPurchaseResult(null, + null, + null, + ApphudError(message))) + } + storage.isNeedSync = true + client.purchased(purchaseBody!!) { customer, errors -> handler.post { - ApphudLog.log("client.purchased: $customer") - - val newSubscriptions = storage.customer?.subscriptions?.let { - customer.subscriptions.minus(it) - } ?: customer.subscriptions - - val newPurchases = storage.customer?.purchases?.let { - customer.purchases.minus(it) - } ?: customer.purchases - - storage.customer = customer - - takeIf { newSubscriptions.isNotEmpty() }?.let { - apphudListener?.apphudSubscriptionsUpdated(customer.subscriptions) - callback?.invoke(ApphudPurchaseResult(newSubscriptions.first(), - null, - purchase, - null)) - } - takeIf { newPurchases.isNotEmpty() }?.let { - apphudListener?.apphudNonRenewingPurchasesUpdated(customer.purchases) - callback?.invoke(ApphudPurchaseResult(null, - newPurchases.first(), - purchase, - null)) + when(errors){ + null -> { + ApphudLog.log("client.purchased: $customer") + val newSubscriptions = storage.customer?.subscriptions?.let { + customer?.subscriptions?.minus(it) + } ?: customer?.subscriptions + + val newPurchases = storage.customer?.purchases?.let { + customer?.purchases?.minus(it) + } ?: customer?.purchases + + storage.customer = customer + storage.isNeedSync = false + + takeIf { !newSubscriptions.isNullOrEmpty() }?.let { + apphudListener?.apphudSubscriptionsUpdated(customer?.subscriptions!!) + callback?.invoke(ApphudPurchaseResult(newSubscriptions?.first(), + null, + purchase, + null)) + } + takeIf { !newPurchases.isNullOrEmpty() }?.let { + apphudListener?.apphudNonRenewingPurchasesUpdated(customer?.purchases!!) + callback?.invoke(ApphudPurchaseResult(null, + newPurchases?.first(), + purchase, + null)) + } + } + else -> { + callback?.invoke(ApphudPurchaseResult(null, + null, + purchase, + errors) + ) + } } } } } - internal fun restorePurchases(callback: (subscriptions: List?, - purchases: List?, - error: ApphudError?) -> Unit) { + internal fun restorePurchases(callback: ApphudPurchasesRestoreCallback) { syncPurchases(allowsReceiptRefresh = true, callback = callback) } internal fun syncPurchases( allowsReceiptRefresh: Boolean = false, - callback: (( - subscriptions: List?, - purchases: List?, - error: ApphudError? - ) -> Unit)? = null + callback: ApphudPurchasesRestoreCallback? = null ) { storage.isNeedSync = true + skuDetailsForRestoreIsLoaded.set(0) billing.restoreCallback = { restoreStatus -> - when(restoreStatus){ + skuDetailsForRestoreIsLoaded.incrementAndGet() + when (restoreStatus) { is PurchaseRestoredCallbackStatus.Error -> { - when (restoreStatus.result == null) { - //Restore is success, but list of purchases is empty - true -> { - val error = ApphudError(message = restoreStatus.message ?: "") - callback?.invoke(null, null, error) - } - else -> { - val error = ApphudError(message = "Restore Purchases is failed for type: ${restoreStatus.message}", - secondErrorMessage = restoreStatus.result.debugMessage, - errorCode = restoreStatus.result.responseCode) - callback?.invoke(null, null, error) + ApphudLog.log("SyncPurchases: restore purchases is failed coz ${restoreStatus.message}") + if (skuDetailsForRestoreIsLoaded.isBothSkuLoaded() && tempPrevPurchases.isEmpty()) { + when (restoreStatus.result == null) { + //Restore is success, but list of purchases is empty + true -> { + val error = ApphudError(message = restoreStatus.message ?: "") + callback?.invoke(null, null, error) + } + else -> { + val error = + ApphudError(message = "Restore Purchases is failed for SkuType.SUBS and SkuType.INAPP", + secondErrorMessage = restoreStatus.message, + errorCode = restoreStatus.result.responseCode) + callback?.invoke(null, null, error) + } } } } @@ -383,15 +533,30 @@ internal object ApphudInternal { if (!allowsReceiptRefresh && prevPurchases.containsAll(restoreStatus.purchases)) { ApphudLog.log("SyncPurchases: Don't send equal purchases from prev state") } else { - client.purchased(makeRestorePurchasesBody(restoreStatus.purchases)) { customer -> + client.purchased(makeRestorePurchasesBody(restoreStatus.purchases)) { customer, errors -> handler.post { - prevPurchases.addAll(restoreStatus.purchases) - storage.isNeedSync = false - storage.customer = customer - ApphudLog.log("SyncPurchases: customer updated $customer") - apphudListener?.apphudSubscriptionsUpdated(customer.subscriptions) - apphudListener?.apphudNonRenewingPurchasesUpdated(customer.purchases) - callback?.invoke(customer.subscriptions, customer.purchases, null) + when (errors) { + null -> { + tempPrevPurchases.addAll(restoreStatus.purchases) + + if (skuDetailsForRestoreIsLoaded.isBothSkuLoaded()) { + prevPurchases.addAll(tempPrevPurchases) + .also { tempPrevPurchases.clear() } + storage.isNeedSync = false + storage.customer = customer + ApphudLog.log("SyncPurchases: customer updated $customer") + apphudListener?.apphudSubscriptionsUpdated(customer?.subscriptions!!) + apphudListener?.apphudNonRenewingPurchasesUpdated( + customer?.purchases!!) + callback?.invoke(customer?.subscriptions, + customer?.purchases, + null) + } + } + else -> { + callback?.invoke(null, null, errors) + } + } } ApphudLog.log("SyncPurchases: success send history purchases ${restoreStatus.purchases}") } @@ -401,7 +566,7 @@ internal object ApphudInternal { } billing.historyCallback = { purchases -> - if(!purchases.isNullOrEmpty()){ + if (!purchases.isNullOrEmpty()) { ApphudLog.log("historyCallback: $purchases") billing.restore(BillingClient.SkuType.SUBS, purchases) billing.restore(BillingClient.SkuType.INAPP, purchases) @@ -532,9 +697,9 @@ internal object ApphudInternal { } val body = UserPropertiesBody(this.deviceId, properties) - client.userProperties(body) { userproperties -> + client.userProperties(body) { userProperties -> handler.post { - if (userproperties.success) { + if (userProperties.success) { pendingUserProperties.clear() ApphudLog.log("User Properties successfully updated.") } else { @@ -560,6 +725,10 @@ internal object ApphudInternal { } private fun clear() { + skuDetailsIsLoaded.set(0) + skuDetailsForFetchIsLoaded.set(0) + skuDetailsForRestoreIsLoaded.set(0) + paywallsDelayedCallback = null isRegistered = false storage.customer = null storage.userId = null @@ -567,6 +736,7 @@ internal object ApphudInternal { userId = null generatedUUID = UUID.randomUUID().toString() prevPurchases.clear() + tempPrevPurchases.clear() skuDetails.clear() allowIdentifyUser = true customProductsFetchedBlock = null @@ -574,23 +744,6 @@ internal object ApphudInternal { setNeedsToUpdateUserProperties = false } - private fun fetchProducts() { - billing.skuCallback = { details -> - ApphudLog.log("fetchProducts: details from Google Billing: $details") - if (details.isNotEmpty()) { - skuDetails.addAll(details) - customProductsFetchedBlock?.invoke(skuDetails) - apphudListener?.apphudFetchSkuDetailsProducts(details) - } - } - client.allProducts { products -> - ApphudLog.log("fetchProducts: products from Apphud server: $products") - val ids = products.map { it.productId } - billing.details(BillingClient.SkuType.SUBS, ids) - billing.details(BillingClient.SkuType.INAPP, ids) - } - } - private fun updateUser(id: UserId?): UserId { val userId = when { id == null || id.isBlank() -> { @@ -627,7 +780,26 @@ internal object ApphudInternal { purchase_token = purchase.purchaseToken, price_currency_code = details?.priceCurrencyCode, price_amount_micros = details?.priceAmountMicros, - subscription_period = details?.subscriptionPeriod + subscription_period = details?.subscriptionPeriod, + paywallId = null, + product_bundle_id = null + ) + ) + ) + + private fun makePurchaseBodyV2(purchase: Purchase, product: ApphudProduct) = + PurchaseBody( + device_id = deviceId, + purchases = listOf( + PurchaseItemBody( + order_id = purchase.orderId, + product_id = purchase.sku, + purchase_token = purchase.purchaseToken, + price_currency_code = product.skuDetails?.priceCurrencyCode, + price_amount_micros = product.skuDetails?.priceAmountMicros, + subscription_period = product.skuDetails?.subscriptionPeriod, + paywallId = product.paywallId, + product_bundle_id = product.productId ) ) ) @@ -642,7 +814,9 @@ internal object ApphudInternal { purchase_token = purchase.record.purchaseToken, price_currency_code = purchase.details.priceCurrencyCode, price_amount_micros = purchase.details.priceAmountMicros, - subscription_period = purchase.details.subscriptionPeriod + subscription_period = purchase.details.subscriptionPeriod, + paywallId = null, + product_bundle_id = null ) } ) @@ -662,7 +836,7 @@ internal object ApphudInternal { user_id = userId, device_id = deviceId, time_zone = TimeZone.getDefault().id, - is_sandbox = isDebuggable(context), + is_sandbox = context.isDebuggable(), is_new = this.is_new ) @@ -674,7 +848,97 @@ internal object ApphudInternal { return getSkuDetailsList()?.let { skuList -> skuList.firstOrNull { it.sku == productIdentifier } } } - private fun isDebuggable(ctx: Context): Boolean { - return 0 != ctx.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE + private fun tryInvokePaywallsDelayedCallback(){ + if (!paywalls.isNullOrEmpty() && skuDetailsIsLoaded.isBothSkuLoaded()) { + setNeedsToUpdatePaywalls = false + paywallsDelayedCallback?.invoke(paywalls, null) + paywallsDelayedCallback = null + } + } + + internal fun getPaywalls(callback: PaywallCallback) { + ApphudLog.log("Invoke getPaywalls") + setNeedsToUpdatePaywalls = false + fetchPaywallsIfNeeded { paywalls, error, writeToCache -> + + paywalls?.let { + if (it.isNotEmpty() && writeToCache) { + cachePaywalls(paywalls = paywalls) + } + + updatePaywallsWithSkuDetails(paywalls) + + this.paywalls.apply { + clear() + addAll(paywalls) + } + if(skuDetailsIsLoaded.isBothSkuLoaded()) { + callback.invoke(paywalls, null) + } else { + paywallsDelayedCallback = callback + setNeedsToUpdatePaywalls = true + } + } ?: run { + callback.invoke(null, error) + } + } + } + + private fun fetchPaywallsIfNeeded( + forceRefresh: Boolean = false, + callback: (paywalls: List?, error: ApphudError?, writeToCache: Boolean) -> Unit + ) { + ApphudLog.log("try fetchPaywallsIfNeeded") + + if (!this.paywalls.isNullOrEmpty() || forceRefresh) { + ApphudLog.log("Using cached paywalls") + callback(mutableListOf(*this.paywalls.toTypedArray()), null, false) + return + } + + client.paywalls { paywalls, errors -> + callback.invoke(paywalls, errors, true) + } + } + + private fun updatePaywallsWithSkuDetails(paywalls: List) { + paywalls.forEach { paywall -> + paywall.products?.forEach { product -> + product.skuDetails = getSkuDetailsByProductId(product.productId) + } + } + } + + private fun updateGroupsWithSkuDetails(productGroups: List) { + productGroups.forEach { group -> + group.products?.forEach { product -> + product.skuDetails = getSkuDetailsByProductId(product.productId) + } + } + } + + private fun cachePaywalls(paywalls: List) { + storage.paywalls = paywalls } + + private fun cachedPaywalls(): MutableList { + val paywalls = storage.paywalls + paywalls?.let { + updatePaywallsWithSkuDetails(it) + } + return paywalls?.toMutableList() ?: mutableListOf() + } + + private fun cacheGroups(groups: List) { + storage.productGroups = groups + } + + private fun cachedGroups(): MutableList { + val productGroups = storage.productGroups + productGroups?.let { + updateGroupsWithSkuDetails(it) + } + return productGroups?.toMutableList() ?: mutableListOf() + } + } \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/aliases.kt b/sdk/src/main/java/com/apphud/sdk/aliases.kt index b5dd0029..4524b30e 100644 --- a/sdk/src/main/java/com/apphud/sdk/aliases.kt +++ b/sdk/src/main/java/com/apphud/sdk/aliases.kt @@ -1,18 +1,25 @@ package com.apphud.sdk -import com.apphud.sdk.domain.Attribution -import com.apphud.sdk.domain.Customer -import com.apphud.sdk.domain.Product +import com.apphud.sdk.domain.* typealias ApiKey = String typealias UserId = String typealias DeviceId = String +typealias GroupId = String typealias ProductId = String -typealias Callback = (T) -> Unit -typealias CustomerCallback = Callback -typealias ProductsCallback = Callback> -typealias AttributionCallback = Callback -typealias PurchasedCallback = Callback +typealias Callback1 = (T) -> Unit +typealias Callback2 = (T1, T2) -> Unit +typealias CustomerCallback = Callback1 +typealias ProductsCallback = Callback1> +typealias AttributionCallback = Callback1 +typealias PurchasedCallback = Callback2 +typealias PaywallCallback = Callback2?, ApphudError?> -typealias Milliseconds = Long \ No newline at end of file +typealias Milliseconds = Long + +typealias ApphudPurchasesRestoreCallback = ( + subscriptions: List?, + purchases: List?, + error: ApphudError? +) -> Unit \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/body/PurchaseItemBody.kt b/sdk/src/main/java/com/apphud/sdk/body/PurchaseItemBody.kt index 17256709..5e3de192 100644 --- a/sdk/src/main/java/com/apphud/sdk/body/PurchaseItemBody.kt +++ b/sdk/src/main/java/com/apphud/sdk/body/PurchaseItemBody.kt @@ -6,5 +6,7 @@ data class PurchaseItemBody( val purchase_token: String, val price_currency_code: String?, val price_amount_micros: Long?, - val subscription_period: String? + val subscription_period: String?, + val paywallId:String?, + val product_bundle_id:String? ) \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/client/ApphudClient.kt b/sdk/src/main/java/com/apphud/sdk/client/ApphudClient.kt index e39b9063..9bb02b24 100644 --- a/sdk/src/main/java/com/apphud/sdk/client/ApphudClient.kt +++ b/sdk/src/main/java/com/apphud/sdk/client/ApphudClient.kt @@ -2,26 +2,28 @@ package com.apphud.sdk.client import com.apphud.sdk.* import com.apphud.sdk.body.* -import com.apphud.sdk.mappers.AttributionMapper -import com.apphud.sdk.mappers.CustomerMapper -import com.apphud.sdk.mappers.ProductMapper -import com.apphud.sdk.mappers.SubscriptionMapper +import com.apphud.sdk.mappers.* import com.apphud.sdk.parser.Parser import com.apphud.sdk.tasks.* -internal class ApphudClient(apiKey: ApiKey, parser: Parser) { +internal class ApphudClient(apiKey: ApiKey, private val parser: Parser) { //TODO Про эти мапперы класс ApphudClient знать не должен private val customerMapper = CustomerMapper(SubscriptionMapper()) private val productMapper = ProductMapper() + private val paywallsMapper = PaywallsMapper() private val attributionMapper = AttributionMapper() private val thread = ThreadsUtils() - private val executor: NetworkExecutor = HttpUrlConnectionExecutor(ApiClient.host, ApphudVersion.V1, parser) - private val service = ApphudService(apiKey, executor) + private val executorV1: NetworkExecutor = HttpUrlConnectionExecutor(ApiClient.host, ApphudVersion.V1, parser) + private val serviceV1 = ApphudService(apiKey, executorV1) + + //Used in getProducts & getPaywalls + private val executorV2: NetworkExecutor = HttpUrlConnectionExecutor(ApiClient.host, ApphudVersion.V2, parser) + private val serviceV2 = ApphudService(apiKey, executorV2) fun registrationUser(body: RegistrationBody, callback: CustomerCallback) { - val callable = RegistrationCallable(body, service) + val callable = RegistrationCallable(body, serviceV1) thread.registration(callable) { response -> when (response.data.results) { null -> ApphudLog.log("Response success but result is null") @@ -31,17 +33,17 @@ internal class ApphudClient(apiKey: ApiKey, parser: Parser) { } fun allProducts(callback: ProductsCallback) { - val callable = ProductsCallable(service) + val callable = ProductsCallable(serviceV2) thread.allProducts(LoopRunnable(callable) { response -> when (response.data.results) { null -> ApphudLog.log("Response success but result is null") - else -> callback.invoke(response.data.results.map(productMapper::map)) + else -> callback.invoke(productMapper.map(response.data.results)) } }) } fun send(body: AttributionBody, callback: AttributionCallback) { - val callable = AttributionCallable(body, service) + val callable = AttributionCallable(body, serviceV1) thread.execute(LoopRunnable(callable) { response -> when (response.data.results) { null -> ApphudLog.log("Response success but result is null") @@ -51,7 +53,7 @@ internal class ApphudClient(apiKey: ApiKey, parser: Parser) { } fun send(body: PushBody, callback: AttributionCallback) { - val callable = PushCallable(body, service) + val callable = PushCallable(body, serviceV1) thread.execute(LoopRunnable(callable) { response -> when (response.data.results) { null -> ApphudLog.log("Response success but result is null") @@ -61,17 +63,23 @@ internal class ApphudClient(apiKey: ApiKey, parser: Parser) { } fun purchased(body: PurchaseBody, callback: PurchasedCallback) { - val callable = PurchaseCallable(body, service) + val callable = PurchaseCallable(body, serviceV1) thread.execute(LoopRunnable(callable) { response -> when (response.data.results) { - null -> ApphudLog.log("Response success but result is null") - else -> callback.invoke(customerMapper.map(response.data.results)) + null -> { + ApphudLog.log("Response success but result is null: + ${response.errors.toString()}") + val code = if(response.errors?.toString()?.contains("PUB key nor PRIV") == true) 422 else null + callback.invoke(null, ApphudError(message = response.errors.toString(), errorCode = code)) + } + else -> { + callback.invoke(customerMapper.map(response.data.results), null) + } } }) } fun userProperties(body: UserPropertiesBody, callback: AttributionCallback) { - val callable = UserPropertiesCallable(body, service) + val callable = UserPropertiesCallable(body, serviceV1) thread.execute(LoopRunnable(callable) { response -> when (response.data.results) { null -> ApphudLog.log("Response success but result is null") @@ -79,4 +87,19 @@ internal class ApphudClient(apiKey: ApiKey, parser: Parser) { } }) } + + fun paywalls(callback: PaywallCallback) { + val callable = PaywallsCallable(serviceV2) + thread.execute(LoopRunnable(callable) { response -> + when (response.data.results) { + null -> { + ApphudLog.log("Response success but result is null: + ${response.errors.toString()}") + callback.invoke(null, ApphudError(message = response.errors.toString())) + } + else -> { + callback.invoke(paywallsMapper.map(response.data.results, parser), null) + } + } + }) + } } \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/client/ApphudService.kt b/sdk/src/main/java/com/apphud/sdk/client/ApphudService.kt index 3d159df7..32156292 100644 --- a/sdk/src/main/java/com/apphud/sdk/client/ApphudService.kt +++ b/sdk/src/main/java/com/apphud/sdk/client/ApphudService.kt @@ -35,11 +35,11 @@ class ApphudService( /** * Получение идентификаторов продуктов */ - fun products(): ResponseDto> = + fun products(): ResponseDto> = executor.call( RequestConfig( path = "products", - type = object : TypeToken>>(){}.type, + type = object : TypeToken>>(){}.type, queries = mapOf(API_KEY to apiKey), requestType = RequestType.GET ) @@ -100,4 +100,17 @@ class ApphudService( ), body ) + + /** + * Receiving Paywalls + */ + fun getPaywalls(): ResponseDto> = + executor.call( + RequestConfig( + path = "paywall_configs", + type = object : TypeToken>>(){}.type, + queries = mapOf(API_KEY to apiKey), + requestType = RequestType.GET + ) + ) } \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/client/HttpUrlConnectionExecutor.kt b/sdk/src/main/java/com/apphud/sdk/client/HttpUrlConnectionExecutor.kt index 9c84fa59..91d4aeae 100644 --- a/sdk/src/main/java/com/apphud/sdk/client/HttpUrlConnectionExecutor.kt +++ b/sdk/src/main/java/com/apphud/sdk/client/HttpUrlConnectionExecutor.kt @@ -64,7 +64,14 @@ class HttpUrlConnectionExecutor( else -> { val response = buildStringBy(connection.errorStream) ApphudLog.logE("finish ${config.requestType} request ${apphudUrl.url} failed with code: ${connection.responseCode} response: ${buildPrettyPrintedBy(response)}") - null + when (connection.responseCode) { + 422 -> { + parser.fromJson(response, config.type) + } + else -> { + null + } + } } } diff --git a/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudGroupDto.kt b/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudGroupDto.kt new file mode 100644 index 00000000..ec86fdd8 --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudGroupDto.kt @@ -0,0 +1,8 @@ +package com.apphud.sdk.client.dto + +data class ApphudGroupDto( + val id: String, + val name: String, + val icon: Int?, + val bundles: List +) diff --git a/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudPaywallDto.kt b/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudPaywallDto.kt new file mode 100644 index 00000000..de5e1af4 --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudPaywallDto.kt @@ -0,0 +1,11 @@ +package com.apphud.sdk.client.dto + +data class ApphudPaywallDto( + val id: String,//paywall id + val name: String,//paywall name + val identifier: String, + val default: Boolean, + val json: String, + val items: List +) + diff --git a/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudProductDto.kt b/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudProductDto.kt new file mode 100644 index 00000000..905170fe --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudProductDto.kt @@ -0,0 +1,8 @@ +package com.apphud.sdk.client.dto + +data class ApphudProductDto( + val id: String, + val name: String, + val product_id: String, + val store: String +) \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/client/dto/ProductDto.kt b/sdk/src/main/java/com/apphud/sdk/client/dto/ProductDto.kt deleted file mode 100644 index da60a1e5..00000000 --- a/sdk/src/main/java/com/apphud/sdk/client/dto/ProductDto.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.apphud.sdk.client.dto - -data class ProductDto( - val id: String, - val product_id: String -) \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/domain/ApphudGroup.kt b/sdk/src/main/java/com/apphud/sdk/domain/ApphudGroup.kt new file mode 100644 index 00000000..93dbc47a --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/domain/ApphudGroup.kt @@ -0,0 +1,29 @@ +package com.apphud.sdk.domain + +import com.apphud.sdk.ApphudInternal +import com.apphud.sdk.GroupId + +data class ApphudGroup( + val id: GroupId, + /** + * Name of permission group configured in Apphud dashboard. + */ + val name: String, + /** + * Products that belong to this permission group. + */ + val products: List? +) { + /** + * Returns `true` if this permission group has active subscription. + * Keep in mind, that this method doesn't take into account non-renewing purchases. + */ + fun hasAccess(): Boolean { + ApphudInternal.currentUser?.subscriptions?.forEach { + if (it.isActive() && it.groupId == id) { + return true + } + } + return false + } +} \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/domain/ApphudPaywall.kt b/sdk/src/main/java/com/apphud/sdk/domain/ApphudPaywall.kt new file mode 100644 index 00000000..329fbf4a --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/domain/ApphudPaywall.kt @@ -0,0 +1,10 @@ +package com.apphud.sdk.domain + +data class ApphudPaywall( + val id: String, + val name: String, + val identifier: String, + val default: Boolean, + val json: Map?, + val products: List? +) \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/domain/ApphudProduct.kt b/sdk/src/main/java/com/apphud/sdk/domain/ApphudProduct.kt new file mode 100644 index 00000000..fd44775a --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/domain/ApphudProduct.kt @@ -0,0 +1,44 @@ +package com.apphud.sdk.domain + +import com.android.billingclient.api.SkuDetails +import com.apphud.sdk.ApphudInternal + +data class ApphudProduct( + /** + * Product id + * */ + var id: String?, + /** + Product Identifier from Google Play. + */ + var productId: String, + /** + Product name from Apphud Dashboard + */ + var name: String?, + /** + Always `play_store` in Android SDK. + */ + var store: String, + /** + When paywalls are successfully loaded, skuDetails model will always be present if Google Play returned model for this product id. + getPaywalls method will return callback only when Google Play products are fetched and mapped with Apphud products. + May be `null` if product identifier is invalid, or product is not available in Google Play. + */ + var skuDetails: SkuDetails?, + /** + Product Identifier from Paywalls. + */ + var paywallId: String? +) { + internal fun findPaywallId(): String? { + ApphudInternal.paywalls.forEach { paywall -> + paywall.products?.forEach { product -> + if (product.productId == productId) { + return paywall.id + } + } + } + return null + } +} diff --git a/sdk/src/main/java/com/apphud/sdk/domain/ApphudSubscription.kt b/sdk/src/main/java/com/apphud/sdk/domain/ApphudSubscription.kt index b2230b34..b3a02304 100644 --- a/sdk/src/main/java/com/apphud/sdk/domain/ApphudSubscription.kt +++ b/sdk/src/main/java/com/apphud/sdk/domain/ApphudSubscription.kt @@ -10,7 +10,8 @@ data class ApphudSubscription( val isInRetryBilling: Boolean, val isAutoRenewEnabled: Boolean, val isIntroductoryActivated: Boolean, - val kind: ApphudKind + val kind: ApphudKind, + val groupId: String ) { fun isActive() = when (status) { diff --git a/sdk/src/main/java/com/apphud/sdk/domain/Product.kt b/sdk/src/main/java/com/apphud/sdk/domain/Product.kt deleted file mode 100644 index d4b994f9..00000000 --- a/sdk/src/main/java/com/apphud/sdk/domain/Product.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.apphud.sdk.domain - -import com.apphud.sdk.ProductId - -data class Product( - val id: String, - val productId: ProductId -) \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/internal/BaseAsyncWrapper.kt b/sdk/src/main/java/com/apphud/sdk/internal/BaseAsyncWrapper.kt index f319ce01..83092120 100644 --- a/sdk/src/main/java/com/apphud/sdk/internal/BaseAsyncWrapper.kt +++ b/sdk/src/main/java/com/apphud/sdk/internal/BaseAsyncWrapper.kt @@ -5,5 +5,5 @@ import java.io.Closeable abstract class BaseAsyncWrapper : Closeable { val retryCapacity: Int = 10 var retryCount: Int = 0 - var retryDelay: Long = 200 + var retryDelay: Long = 350 } \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/internal/BillingWrapper.kt b/sdk/src/main/java/com/apphud/sdk/internal/BillingWrapper.kt index dc12d10c..9a771d77 100644 --- a/sdk/src/main/java/com/apphud/sdk/internal/BillingWrapper.kt +++ b/sdk/src/main/java/com/apphud/sdk/internal/BillingWrapper.kt @@ -5,6 +5,7 @@ import android.content.Context import android.util.SparseArray import com.android.billingclient.api.* import com.apphud.sdk.ApphudLog +import com.apphud.sdk.GroupId import com.apphud.sdk.ProductId import java.io.Closeable diff --git a/sdk/src/main/java/com/apphud/sdk/internal/SkuDetailsWrapper.kt b/sdk/src/main/java/com/apphud/sdk/internal/SkuDetailsWrapper.kt index 30364a29..47f6be37 100644 --- a/sdk/src/main/java/com/apphud/sdk/internal/SkuDetailsWrapper.kt +++ b/sdk/src/main/java/com/apphud/sdk/internal/SkuDetailsWrapper.kt @@ -4,11 +4,9 @@ import com.android.billingclient.api.BillingClient import com.android.billingclient.api.PurchaseHistoryRecord import com.android.billingclient.api.SkuDetails import com.android.billingclient.api.SkuDetailsParams +import com.apphud.sdk.* import com.apphud.sdk.ApphudLog -import com.apphud.sdk.ProductId import com.apphud.sdk.domain.PurchaseRecordDetails -import com.apphud.sdk.isSuccess -import com.apphud.sdk.logMessage import kotlin.concurrent.thread typealias SkuType = String diff --git a/sdk/src/main/java/com/apphud/sdk/mappers/PaywallsMapper.kt b/sdk/src/main/java/com/apphud/sdk/mappers/PaywallsMapper.kt new file mode 100644 index 00000000..b417b5b6 --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/mappers/PaywallsMapper.kt @@ -0,0 +1,30 @@ +package com.apphud.sdk.mappers + +import com.apphud.sdk.client.dto.ApphudPaywallDto +import com.apphud.sdk.domain.ApphudPaywall +import com.apphud.sdk.domain.ApphudProduct +import com.apphud.sdk.parser.Parser + +class PaywallsMapper { + + fun map(dto: List, parser: Parser): List = + dto.map { paywallDto -> + ApphudPaywall( + id = paywallDto.id, //paywall id + name = paywallDto.name, + identifier = paywallDto.identifier, + default = paywallDto.default, + json = parser.fromJson>(paywallDto.json, Map::class.java), + products = paywallDto.items.map { item -> + ApphudProduct( + id = item.id,//product id + productId = item.product_id, + name = item.name, + store = item.store, + skuDetails = null, + paywallId = paywallDto.id //paywall id + ) + } + ) + } +} diff --git a/sdk/src/main/java/com/apphud/sdk/mappers/ProductMapper.kt b/sdk/src/main/java/com/apphud/sdk/mappers/ProductMapper.kt index f8a82140..6def1abd 100644 --- a/sdk/src/main/java/com/apphud/sdk/mappers/ProductMapper.kt +++ b/sdk/src/main/java/com/apphud/sdk/mappers/ProductMapper.kt @@ -1,13 +1,26 @@ package com.apphud.sdk.mappers -import com.apphud.sdk.client.dto.ProductDto -import com.apphud.sdk.domain.Product +import com.apphud.sdk.client.dto.ApphudGroupDto +import com.apphud.sdk.domain.ApphudGroup +import com.apphud.sdk.domain.ApphudProduct class ProductMapper { - fun map(dto: ProductDto) : Product = - Product( - id = dto.id, - productId = dto.product_id - ) + fun map(dto: List): List = + dto.map { + ApphudGroup( + id = it.id, + name = it.name, + products = it.bundles.map { item -> + ApphudProduct( + id = item.id, + productId = item.product_id, + name = item.name, + store = item.store, + skuDetails = null, + paywallId = null + ) + } + ) + } } \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/mappers/SubscriptionMapper.kt b/sdk/src/main/java/com/apphud/sdk/mappers/SubscriptionMapper.kt index 0c48167d..9705a81b 100644 --- a/sdk/src/main/java/com/apphud/sdk/mappers/SubscriptionMapper.kt +++ b/sdk/src/main/java/com/apphud/sdk/mappers/SubscriptionMapper.kt @@ -29,7 +29,9 @@ class SubscriptionMapper { cancelledAt = buildDate(dto.cancelled_at), isInRetryBilling = dto.in_retry_billing, isIntroductoryActivated = dto.introductory_activated, - isAutoRenewEnabled = dto.autorenew_enabled + isAutoRenewEnabled = dto.autorenew_enabled, + //TODO + groupId = "" ) } diff --git a/sdk/src/main/java/com/apphud/sdk/storage/SharedPreferencesStorage.kt b/sdk/src/main/java/com/apphud/sdk/storage/SharedPreferencesStorage.kt index 300524f9..c7a1a740 100644 --- a/sdk/src/main/java/com/apphud/sdk/storage/SharedPreferencesStorage.kt +++ b/sdk/src/main/java/com/apphud/sdk/storage/SharedPreferencesStorage.kt @@ -1,9 +1,8 @@ package com.apphud.sdk.storage import android.content.Context -import com.apphud.sdk.domain.AppsflyerInfo -import com.apphud.sdk.domain.Customer -import com.apphud.sdk.domain.FacebookInfo +import com.apphud.sdk.domain.* +import com.apphud.sdk.isDebuggable import com.apphud.sdk.parser.Parser import com.google.gson.reflect.TypeToken @@ -24,6 +23,10 @@ class SharedPreferencesStorage( private const val FACEBOOK_KEY = "facebookKey" private const val APPSFLYER_KEY = "appsflyerKey" + private const val PAYWALLS_KEY = "payWallsKey" + private const val PAYWALLS_TIMESTAMP_KEY = "payWallsTimestampKey" + private const val GROUP_KEY = "apphudGroupKey" + private const val GROUP_TIMESTAMP_KEY = "apphudGroupTimestampKey" } private val preferences = context.getSharedPreferences( @@ -31,6 +34,8 @@ class SharedPreferencesStorage( MODE ) + val cacheTimeout = if (context.isDebuggable()) 60L else 3600L + override var userId: String? get() = preferences.getString(USER_ID_KEY, null) set(value) { @@ -101,4 +106,40 @@ class SharedPreferencesStorage( editor.putString(APPSFLYER_KEY, source) editor.apply() } + + override var paywalls: List? + get() { + val timestamp = preferences.getLong(PAYWALLS_TIMESTAMP_KEY, -1L) + (cacheTimeout * 1000) + val currentTime = System.currentTimeMillis() + return if (currentTime < timestamp) { + val source = preferences.getString(PAYWALLS_KEY, null) + val type = object : TypeToken>() {}.type + parser.fromJson>(source, type) + } else null + } + set(value) { + val source = parser.toJson(value) + val editor = preferences.edit() + editor.putLong(PAYWALLS_TIMESTAMP_KEY, System.currentTimeMillis()) + editor.putString(PAYWALLS_KEY, source) + editor.apply() + } + + override var productGroups: List? + get() { + val timestamp = preferences.getLong(GROUP_TIMESTAMP_KEY, -1L) + (cacheTimeout * 1000) + val currentTime = System.currentTimeMillis() + return if (currentTime < timestamp) { + val source = preferences.getString(GROUP_KEY, null) + val type = object : TypeToken>() {}.type + return parser.fromJson>(source, type) + } else null + } + set(value) { + val source = parser.toJson(value) + val editor = preferences.edit() + editor.putLong(GROUP_TIMESTAMP_KEY, System.currentTimeMillis()) + editor.putString(GROUP_KEY, source) + editor.apply() + } } \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/storage/Storage.kt b/sdk/src/main/java/com/apphud/sdk/storage/Storage.kt index 5ab4e62c..a2335ce7 100644 --- a/sdk/src/main/java/com/apphud/sdk/storage/Storage.kt +++ b/sdk/src/main/java/com/apphud/sdk/storage/Storage.kt @@ -1,8 +1,6 @@ package com.apphud.sdk.storage -import com.apphud.sdk.domain.AppsflyerInfo -import com.apphud.sdk.domain.Customer -import com.apphud.sdk.domain.FacebookInfo +import com.apphud.sdk.domain.* interface Storage { var userId: String? @@ -12,4 +10,6 @@ interface Storage { var isNeedSync: Boolean var facebook: FacebookInfo? var appsflyer: AppsflyerInfo? + var paywalls: List? + var productGroups: List? } \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/tasks/PaywallsCallable.kt b/sdk/src/main/java/com/apphud/sdk/tasks/PaywallsCallable.kt new file mode 100644 index 00000000..0a843ebe --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/tasks/PaywallsCallable.kt @@ -0,0 +1,19 @@ +package com.apphud.sdk.tasks + +import com.apphud.sdk.client.ApphudService +import com.apphud.sdk.client.dto.ApphudPaywallDto +import com.apphud.sdk.client.dto.ResponseDto + +internal class PaywallsCallable( + private val service: ApphudService +) : PriorityCallable>> { + override val priority: Int = Int.MAX_VALUE + override fun call(): ResponseDto> = service.getPaywalls() + + private var _counter: Int = 0 + override var counter: Int + get() = _counter + set(value) { + _counter = value + } +} \ No newline at end of file diff --git a/sdk/src/main/java/com/apphud/sdk/tasks/ProductsCallable.kt b/sdk/src/main/java/com/apphud/sdk/tasks/ProductsCallable.kt index c34dded1..2f15d2f1 100644 --- a/sdk/src/main/java/com/apphud/sdk/tasks/ProductsCallable.kt +++ b/sdk/src/main/java/com/apphud/sdk/tasks/ProductsCallable.kt @@ -1,14 +1,14 @@ package com.apphud.sdk.tasks import com.apphud.sdk.client.ApphudService -import com.apphud.sdk.client.dto.ProductDto +import com.apphud.sdk.client.dto.ApphudGroupDto import com.apphud.sdk.client.dto.ResponseDto internal class ProductsCallable( private val service: ApphudService -) : PriorityCallable>> { +) : PriorityCallable>> { override val priority: Int = Int.MAX_VALUE - override fun call(): ResponseDto> = service.products() + override fun call(): ResponseDto> = service.products() private var _counter: Int = 0 override var counter: Int