diff --git a/demo/src/main/java/com/apphud/demo/ApphudApplication.kt b/demo/src/main/java/com/apphud/demo/ApphudApplication.kt index 0cd0f201..e324a431 100644 --- a/demo/src/main/java/com/apphud/demo/ApphudApplication.kt +++ b/demo/src/main/java/com/apphud/demo/ApphudApplication.kt @@ -2,10 +2,16 @@ package com.apphud.demo import android.app.Application import android.content.Context +import android.util.Log +import androidx.lifecycle.lifecycleScope import com.apphud.sdk.Apphud +import com.apphud.sdk.ApphudUtils +import com.apphud.sdk.client.ApiClient +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch class ApphudApplication : Application() { - val API_KEY = "app_4sY9cLggXpMDDQMmvc5wXUPGReMp8G" + var API_KEY = "app_4sY9cLggXpMDDQMmvc5wXUPGReMp8G" companion object { private lateinit var instance: ApphudApplication @@ -28,6 +34,13 @@ class ApphudApplication : Application() { Apphud.enableDebugLogs() // Apphud.optOutOfTracking() + + if (BuildConfig.DEBUG) { + ApphudUtils.enableAllLogs() + } + + // check again restore cache from previous sdk version + Apphud.start(this, API_KEY) Apphud.collectDeviceIdentifiers() } diff --git a/demo/src/main/java/com/apphud/demo/MainActivity.kt b/demo/src/main/java/com/apphud/demo/MainActivity.kt index 0880cdd9..78ff0b75 100644 --- a/demo/src/main/java/com/apphud/demo/MainActivity.kt +++ b/demo/src/main/java/com/apphud/demo/MainActivity.kt @@ -63,19 +63,18 @@ class MainActivity : AppCompatActivity() { if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { binding.drawerLayout.closeDrawer(GravityCompat.START) } else { - if (!findNavController(R.id.nav_host_fragment_content_main).navigateUp()) - { - if (backPress + 2000 > System.currentTimeMillis()) { - super.onBackPressed() - } else { - Toast.makeText( - baseContext, - "Please press again to exit!", - Toast.LENGTH_SHORT, - ).show() - backPress = System.currentTimeMillis() - } + if (!findNavController(R.id.nav_host_fragment_content_main).navigateUp()) { + if (backPress + 2000 > System.currentTimeMillis()) { + super.onBackPressed() + } else { + Toast.makeText( + baseContext, + "Please press again to exit!", + Toast.LENGTH_SHORT, + ).show() + backPress = System.currentTimeMillis() } + } } } } diff --git a/demo/src/main/java/com/apphud/demo/ui/billing/BillingClientWrapper.kt b/demo/src/main/java/com/apphud/demo/ui/billing/BillingClientWrapper.kt index 1f6407e2..df53572c 100644 --- a/demo/src/main/java/com/apphud/demo/ui/billing/BillingClientWrapper.kt +++ b/demo/src/main/java/com/apphud/demo/ui/billing/BillingClientWrapper.kt @@ -21,7 +21,7 @@ class BillingClientWrapper( context: Context, ) : PurchasesUpdatedListener, ProductDetailsResponseListener { companion object { - private const val TAG = "BillingClient" + private const val TAG = "ApphudLogs" // List of subscription product offerings private const val PEACH = "com.apphud.demo.consumable.peach" diff --git a/demo/src/main/java/com/apphud/demo/ui/billing/BillingFragment.kt b/demo/src/main/java/com/apphud/demo/ui/billing/BillingFragment.kt index a9e7fcfc..54f12479 100644 --- a/demo/src/main/java/com/apphud/demo/ui/billing/BillingFragment.kt +++ b/demo/src/main/java/com/apphud/demo/ui/billing/BillingFragment.kt @@ -41,7 +41,7 @@ class BillingFragment : Fragment() { viewAdapter.selectProduct = { product -> activity?.let { activity -> val offers = product.subscriptionOfferDetails?.map { it.pricingPhases.pricingPhaseList[0].formattedPrice } - offers?.let { offers -> + offers?.let { _ -> product.subscriptionOfferDetails?.let { val fragment = OffersFragment() diff --git a/demo/src/main/java/com/apphud/demo/ui/billing/BillingViewModel.kt b/demo/src/main/java/com/apphud/demo/ui/billing/BillingViewModel.kt index bf0f3bef..aae8ab4a 100644 --- a/demo/src/main/java/com/apphud/demo/ui/billing/BillingViewModel.kt +++ b/demo/src/main/java/com/apphud/demo/ui/billing/BillingViewModel.kt @@ -14,7 +14,7 @@ import com.apphud.sdk.Apphud class BillingViewModel : ViewModel() { companion object { - private const val TAG: String = "BillingViewModel" + private const val TAG: String = "ApphudLogs" private const val MAX_CURRENT_PURCHASES_ALLOWED = 1 } @@ -29,7 +29,7 @@ class BillingViewModel : ViewModel() { var items = mutableListOf() - fun updateData() { + fun updateData() { items.clear() for (item in billingClient.productWithProductDetails) { items.add(item.value) @@ -171,23 +171,20 @@ class BillingViewModel : ViewModel() { val oldPurchaseToken: String billingClient.purchaseSuccessListener = { purchase, billingResult -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) - { - purchase?.let { p -> - if (p.purchaseState == Purchase.PurchaseState.PURCHASED) { - Log.d(TAG, "Purchase SUCCESS notify Apphud") - Apphud.trackPurchase(p, productDetails, offerIdToken) - } else - { - Log.e(TAG, "Purchase SUCCESS but purchase state is " + p.purchaseState) - } - } ?: run { - Log.e(TAG, "Purchase SUCCESS but purchase is null") + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + purchase?.let { p -> + if (p.purchaseState == Purchase.PurchaseState.PURCHASED) { + Log.d(TAG, "Purchase SUCCESS notify Apphud") + Apphud.trackPurchase(p, productDetails, offerIdToken) + } else { + Log.e(TAG, "Purchase SUCCESS but purchase state is " + p.purchaseState) } - } else - { - Log.e(TAG, "Purchase ERROR: code=" + billingResult.responseCode) + } ?: run { + Log.e(TAG, "Purchase SUCCESS but purchase is null") } + } else { + Log.e(TAG, "Purchase ERROR: code=" + billingResult.responseCode) + } } // Get current purchase. In this app, a user can only have one current purchase at diff --git a/demo/src/main/java/com/apphud/demo/ui/customer/CustomerFragment.kt b/demo/src/main/java/com/apphud/demo/ui/customer/CustomerFragment.kt index fd70f660..59092d14 100644 --- a/demo/src/main/java/com/apphud/demo/ui/customer/CustomerFragment.kt +++ b/demo/src/main/java/com/apphud/demo/ui/customer/CustomerFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import android.widget.TextView import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.RecyclerView @@ -18,8 +19,13 @@ import com.apphud.sdk.Apphud import com.apphud.sdk.ApphudListener import com.apphud.sdk.domain.ApphudNonRenewingPurchase import com.apphud.sdk.domain.ApphudPaywall +import com.apphud.sdk.domain.ApphudPlacement import com.apphud.sdk.domain.ApphudSubscription +import com.apphud.sdk.domain.ApphudUser import com.apphud.sdk.managers.HeadersInterceptor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class CustomerFragment : Fragment() { private var _binding: FragmentCustomerBinding? = null @@ -48,8 +54,8 @@ class CustomerFragment : Fragment() { paywallsViewModel = ViewModelProvider(this)[PaywallsViewModel::class.java] viewAdapter = PaywallsAdapter(paywallsViewModel, context) - viewAdapter.selectPaywall = { paywall -> - findNavController().navigate(CustomerFragmentDirections.actionNavCustomerToProductsFragment(paywall.identifier)) + viewAdapter.selectItem = { item -> + findNavController().navigate(CustomerFragmentDirections.actionNavCustomerToProductsFragment(item.paywall?.identifier, item.placement?.identifier)) } val recyclerView: RecyclerView = binding.paywallsList @@ -58,6 +64,11 @@ class CustomerFragment : Fragment() { addItemDecoration(DividerItemDecoration(this.context, DividerItemDecoration.VERTICAL)) } + binding.toggleButton.setOnCheckedChangeListener { buttonView, isChecked -> + paywallsViewModel.showPlacements = isChecked + updateData() + } + binding.swipeRefresh.setOnRefreshListener { updateData() binding.swipeRefresh.isRefreshing = false @@ -66,30 +77,36 @@ class CustomerFragment : Fragment() { val listener = object : ApphudListener { override fun apphudSubscriptionsUpdated(subscriptions: List) { - Log.d("Apphud", "apphudSubscriptionsUpdated") + Log.d("ApphudDemo", "apphudSubscriptionsUpdated") } override fun apphudNonRenewingPurchasesUpdated(purchases: List) { - Log.d("Apphud", "apphudNonRenewingPurchasesUpdated") + Log.d("ApphudDemo", "apphudNonRenewingPurchasesUpdated") } override fun apphudFetchProductDetails(details: List) { - Log.d("Apphud", "apphudFetchProductDetails()") + Log.d("ApphudDemo", "apphudFetchProductDetails()") // TODO handle loaded sku details } override fun apphudDidChangeUserID(userId: String) { - Log.d("Apphud", "apphudDidChangeUserID()") + Log.d("ApphudDemo", "apphudDidChangeUserID()") // TODO handle User ID changed event } - override fun userDidLoad() { - Log.d("Apphud", "userDidLoad()") + override fun userDidLoad(user: ApphudUser) { + Log.d("ApphudDemo", "userDidLoad()") // TODO handle user registered event + updateData() } - override fun paywallsDidFullyLoad(paywalls: List) { - Log.d("Apphud", "paywallsDidFullyLoad()") + override fun paywallsDidFullyLoad(paywalls: List) { + Log.d("ApphudDemo", "paywallsDidFullyLoad()") + updateData() + } + + override fun placementsDidFullyLoad(placements: List) { + Log.d("ApphudDemo", "placementsDidFullyLoad()") updateData() } } @@ -100,9 +117,13 @@ class CustomerFragment : Fragment() { return root } - private fun updateData() { - paywallsViewModel.updateData() - viewAdapter.notifyDataSetChanged() + private fun updateData() { + lifecycleScope.launch { + paywallsViewModel.updateData() + withContext(Dispatchers.Main) { + viewAdapter.notifyDataSetChanged() + } + } } override fun onDestroyView() { diff --git a/demo/src/main/java/com/apphud/demo/ui/customer/PaywallsAdapter.kt b/demo/src/main/java/com/apphud/demo/ui/customer/PaywallsAdapter.kt index 1fbbfeaa..5b44c604 100644 --- a/demo/src/main/java/com/apphud/demo/ui/customer/PaywallsAdapter.kt +++ b/demo/src/main/java/com/apphud/demo/ui/customer/PaywallsAdapter.kt @@ -9,10 +9,9 @@ import android.widget.LinearLayout import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.apphud.demo.R -import com.apphud.sdk.domain.ApphudPaywall class PaywallsAdapter(private val paywallsViewModel: PaywallsViewModel, private val context: Context?) : RecyclerView.Adapter>() { - var selectPaywall: ((account: ApphudPaywall) -> Unit)? = null + var selectItem: ((item: AdapterItem) -> Unit)? = null abstract class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { abstract fun bind( @@ -21,8 +20,9 @@ class PaywallsAdapter(private val paywallsViewModel: PaywallsViewModel, private ) } - inner class PaywallViewHolder(itemView: View) : BaseViewHolder(itemView) { + inner class PaywallViewHolder(itemView: View) : BaseViewHolder(itemView) { private val paywallName: TextView = itemView.findViewById(R.id.paywallName) + private val paywallIdentifier: TextView = itemView.findViewById(R.id.paywallIdentifier) private val paywallDefault: TextView = itemView.findViewById(R.id.paywallDefault) private val paywallExperiment: TextView = itemView.findViewById(R.id.paywallExperiment) private val paywallVariation: TextView = itemView.findViewById(R.id.paywallVariation) @@ -30,15 +30,25 @@ class PaywallsAdapter(private val paywallsViewModel: PaywallsViewModel, private private val layoutHolder: LinearLayout = itemView.findViewById(R.id.layoutHolder) override fun bind( - item: ApphudPaywall, + item: AdapterItem, position: Int, ) { - paywallName.text = item.name - paywallDefault.text = item.default.toString() - paywallExperiment.text = item.experimentName ?: "-" + val paywall = item.paywall ?: item.placement?.paywall + + val experimentName = item.placement?.experimentName ?: paywall?.experimentName + + paywallName.text = + if (item.placement != null) { + "${item.placement.identifier} -> ${paywall?.name}" + } else { + paywall?.name + } + paywallIdentifier.text = "Paywall ID: " + (paywall?.identifier ?: "N/A") + paywallDefault.text = paywall?.default.toString() + paywallExperiment.text = item.placement?.experimentName ?: paywall?.experimentName ?: "N/A" paywallVariation.text = "N/A" - paywallJson.text = if (item.json != null) "true" else "false" - item.experimentName?.let { + paywallJson.text = if (paywall?.json != null) "true" else "false" + experimentName?.let { layoutHolder.setBackgroundResource(R.color.teal_200) paywallDefault.setTextColor(Color.WHITE) paywallExperiment.setTextColor(Color.WHITE) @@ -51,7 +61,9 @@ class PaywallsAdapter(private val paywallsViewModel: PaywallsViewModel, private } itemView.setOnClickListener { - selectPaywall?.invoke(item) + paywall?.let { paywall -> + selectItem?.invoke(item) + } } } } @@ -81,14 +93,14 @@ class PaywallsAdapter(private val paywallsViewModel: PaywallsViewModel, private ) { val element = paywallsViewModel.items[position] when (holder) { - is PaywallViewHolder -> holder.bind(element as ApphudPaywall, position) + is PaywallViewHolder -> holder.bind(element as AdapterItem, position) else -> throw IllegalArgumentException() } } override fun getItemViewType(position: Int): Int { return when (paywallsViewModel.items[position]) { - is ApphudPaywall -> TYPE_PAYWALL + is AdapterItem -> TYPE_PAYWALL else -> throw IllegalArgumentException("Invalid type of data " + position) } } diff --git a/demo/src/main/java/com/apphud/demo/ui/customer/PaywallsViewModel.kt b/demo/src/main/java/com/apphud/demo/ui/customer/PaywallsViewModel.kt index e7d404ae..89269a28 100644 --- a/demo/src/main/java/com/apphud/demo/ui/customer/PaywallsViewModel.kt +++ b/demo/src/main/java/com/apphud/demo/ui/customer/PaywallsViewModel.kt @@ -2,15 +2,31 @@ package com.apphud.demo.ui.customer import androidx.lifecycle.ViewModel import com.apphud.sdk.Apphud +import com.apphud.sdk.domain.ApphudPaywall +import com.apphud.sdk.domain.ApphudPlacement + +class AdapterItem( + val paywall: ApphudPaywall?, + val placement: ApphudPlacement?, +) class PaywallsViewModel : ViewModel() { var items = mutableListOf() + var showPlacements: Boolean = false - fun updateData() { - val list = Apphud.paywalls() - items.clear() - list.forEach { - items.add(it) + suspend fun updateData() { + if (showPlacements) { + items.clear() + val placements = Apphud.placements() + placements.forEach { + items.add(AdapterItem(null, it)) + } + } else { + val list = Apphud.paywalls() + items.clear() + list.forEach { + items.add(AdapterItem(it, null)) + } } } } diff --git a/demo/src/main/java/com/apphud/demo/ui/groups/GroupsAdapter.kt b/demo/src/main/java/com/apphud/demo/ui/groups/GroupsAdapter.kt index 89a0cde6..67d4504c 100644 --- a/demo/src/main/java/com/apphud/demo/ui/groups/GroupsAdapter.kt +++ b/demo/src/main/java/com/apphud/demo/ui/groups/GroupsAdapter.kt @@ -46,16 +46,14 @@ class GroupsAdapter(private val groupsViewModel: GroupsViewModel, private val co position: Int, ) { productName.text = item.name - productId.text = item.product_id + productId.text = item.productId item.productDetails?.let { details -> - if (details.productType == BillingClient.ProductType.SUBS) - { - productPrice.text = details.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: "" - } else - { - productPrice.text = details.oneTimePurchaseOfferDetails?.formattedPrice ?: "" - } + if (details.productType == BillingClient.ProductType.SUBS) { + productPrice.text = details.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: "" + } else { + productPrice.text = details.oneTimePurchaseOfferDetails?.formattedPrice ?: "" + } } ?: run { productPrice.text = "" } diff --git a/demo/src/main/java/com/apphud/demo/ui/groups/GroupsFragment.kt b/demo/src/main/java/com/apphud/demo/ui/groups/GroupsFragment.kt index 88a519e2..79a7f455 100644 --- a/demo/src/main/java/com/apphud/demo/ui/groups/GroupsFragment.kt +++ b/demo/src/main/java/com/apphud/demo/ui/groups/GroupsFragment.kt @@ -50,7 +50,7 @@ class GroupsFragment : Fragment() { return root } - private fun updateData() { + private fun updateData() { groupsViewModel.updateData() } diff --git a/demo/src/main/java/com/apphud/demo/ui/groups/GroupsViewModel.kt b/demo/src/main/java/com/apphud/demo/ui/groups/GroupsViewModel.kt index 6d6941ed..04789fdb 100644 --- a/demo/src/main/java/com/apphud/demo/ui/groups/GroupsViewModel.kt +++ b/demo/src/main/java/com/apphud/demo/ui/groups/GroupsViewModel.kt @@ -6,7 +6,7 @@ import com.apphud.sdk.Apphud class GroupsViewModel : ViewModel() { var items = mutableListOf() - fun updateData() { + fun updateData() { val list = Apphud.permissionGroups() items.clear() diff --git a/demo/src/main/java/com/apphud/demo/ui/products/ProductsAdapter.kt b/demo/src/main/java/com/apphud/demo/ui/products/ProductsAdapter.kt index abd0e845..34c6c8b9 100644 --- a/demo/src/main/java/com/apphud/demo/ui/products/ProductsAdapter.kt +++ b/demo/src/main/java/com/apphud/demo/ui/products/ProductsAdapter.kt @@ -28,18 +28,16 @@ class ProductsAdapter(private val productsViewModel: ProductsViewModel, private item: ApphudProduct, position: Int, ) { - productName.text = "Name: " + item.name + "\nProduct ID: " + item.product_id + "\nBase Plan ID: " + item.basePlanId + productName.text = "Name: " + item.name + "\nProduct ID: " + item.productId + "\nBase Plan ID: " + item.basePlanId item.productDetails?.let { details -> - if (details.productType == BillingClient.ProductType.SUBS) - { - productPrice.text = item.subscriptionOffers()?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: "" - } else - { - productPrice.text = details.oneTimePurchaseOfferDetails?.formattedPrice ?: "" - } + if (details.productType == BillingClient.ProductType.SUBS) { + productPrice.text = item.subscriptionOffers()?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice ?: "" + } else { + productPrice.text = details.oneTimePurchaseOfferDetails?.formattedPrice ?: "" + } } ?: run { - productPrice.text = "" + productPrice.text = "ProductDetails N/A" } itemView.setOnClickListener { diff --git a/demo/src/main/java/com/apphud/demo/ui/products/ProductsFragment.kt b/demo/src/main/java/com/apphud/demo/ui/products/ProductsFragment.kt index 6401fdac..8f0b8935 100644 --- a/demo/src/main/java/com/apphud/demo/ui/products/ProductsFragment.kt +++ b/demo/src/main/java/com/apphud/demo/ui/products/ProductsFragment.kt @@ -1,5 +1,6 @@ package com.apphud.demo.ui.products +import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -7,6 +8,7 @@ import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -15,6 +17,10 @@ import com.apphud.demo.R import com.apphud.demo.databinding.FragmentProductsBinding import com.apphud.demo.ui.utils.OffersFragment import com.apphud.sdk.Apphud +import com.apphud.sdk.domain.ApphudPaywall +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class ProductsFragment : Fragment() { val args: ProductsFragmentArgs by navArgs() @@ -38,36 +44,34 @@ class ProductsFragment : Fragment() { activity?.let { activity -> product.productDetails?.let { details -> // Use Apphud purchases flow - if (details.productType == BillingClient.ProductType.SUBS) - { - product.productDetails?.subscriptionOfferDetails?.let { - val fragment = OffersFragment() - fragment.offers = it - fragment.offerSelected = { offer -> - Apphud.purchase(activity, product, offer.offerToken) { result -> - result.error?.let { err -> - Toast.makeText(activity, if (result.userCanceled()) "User Canceled" else err.message, Toast.LENGTH_SHORT).show() - } ?: run { - Toast.makeText(activity, R.string.success, Toast.LENGTH_SHORT).show() - } - } - } - fragment.apply { - show(activity.supportFragmentManager, tag) - } - } - } else { - if (product.product_id == "com.apphud.demo.nonconsumable.premium") - { - Apphud.purchase(activity = activity, apphudProduct = product, consumableInappProduct = false) { result -> + if (details.productType == BillingClient.ProductType.SUBS) { + product.productDetails?.subscriptionOfferDetails?.let { + val fragment = OffersFragment() + fragment.offers = it + fragment.offerSelected = { offer -> + Apphud.purchase(activity, product, offer.offerToken) { result -> result.error?.let { err -> Toast.makeText(activity, if (result.userCanceled()) "User Canceled" else err.message, Toast.LENGTH_SHORT).show() } ?: run { Toast.makeText(activity, R.string.success, Toast.LENGTH_SHORT).show() } } - } else { - Apphud.purchase(activity = activity, apphudProduct = product, consumableInappProduct = true) { result -> + } + fragment.apply { + show(activity.supportFragmentManager, tag) + } + } + } else { + if (product.productId == "com.apphud.demo.nonconsumable.premium") { + Apphud.purchase(activity = activity, apphudProduct = product, consumableInAppProduct = false) { result -> + result.error?.let { err -> + Toast.makeText(activity, if (result.userCanceled()) "User Canceled" else err.message, Toast.LENGTH_SHORT).show() + } ?: run { + Toast.makeText(activity, R.string.success, Toast.LENGTH_SHORT).show() + } + } + } else { + Apphud.purchase(activity = activity, apphudProduct = product, consumableInAppProduct = true) { result -> result.error?.let { err -> Toast.makeText(activity, if (result.userCanceled()) "User Canceled" else err.message, Toast.LENGTH_SHORT).show() } ?: run { @@ -85,14 +89,35 @@ class ProductsFragment : Fragment() { recyclerView.apply { adapter = viewAdapter } - updateData(args.paywallId) + + lifecycleScope.launch { + val p = findPaywall(args.paywallId, args.placementId) + p?.let { Apphud.paywallShown(it) } + updateData(p) + } return root } - private fun updateData(pywallId: String) { - productsViewModel.updateData(pywallId) - viewAdapter.notifyDataSetChanged() + suspend fun findPaywall( + paywallId: String?, + placementId: String?, + ): ApphudPaywall? { + val paywall = + if (placementId != null) { + Apphud.placements().firstOrNull { it.identifier == placementId }?.paywall + } else { + Apphud.paywalls().firstOrNull { it.identifier == paywallId } + } + return paywall + } + + @SuppressLint("NotifyDataSetChanged") + private suspend fun updateData(paywall: ApphudPaywall?) { + productsViewModel.updateData(paywall) + withContext(Dispatchers.Main) { + viewAdapter.notifyDataSetChanged() + } } override fun onDestroyView() { diff --git a/demo/src/main/java/com/apphud/demo/ui/products/ProductsViewModel.kt b/demo/src/main/java/com/apphud/demo/ui/products/ProductsViewModel.kt index a9a42ec9..42d66845 100644 --- a/demo/src/main/java/com/apphud/demo/ui/products/ProductsViewModel.kt +++ b/demo/src/main/java/com/apphud/demo/ui/products/ProductsViewModel.kt @@ -1,23 +1,15 @@ package com.apphud.demo.ui.products import androidx.lifecycle.ViewModel -import com.apphud.sdk.Apphud +import com.apphud.sdk.domain.ApphudPaywall class ProductsViewModel : ViewModel() { var items = mutableListOf() - fun updateData(paywallId: String) { - val list = Apphud.paywalls() + suspend fun updateData(paywall: ApphudPaywall?) { items.clear() - list.forEach { - if (it.identifier == paywallId) { - if (!it.products.isNullOrEmpty()) { - it.products?.let { productsList -> - items.addAll(productsList) - } - } - return - } + paywall?.products?.let { + items.addAll(it) } } } diff --git a/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesAdapter.kt b/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesAdapter.kt index 3874836e..bc159a72 100644 --- a/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesAdapter.kt +++ b/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesAdapter.kt @@ -63,13 +63,11 @@ class PurchasesAdapter(private val purchasesViewModel: PurchasesViewModel, priva purchasedAt.text = item.startedAt?.let { convertLongToTime(it) } ?: run { "" } expiresAt.text = convertLongToTime(item.expiresAt) status.text = item.status.name - if (item.status.name.equals("expired", true)) - { - status.setBackgroundResource(R.color.red) - } else - { - status.setBackgroundResource(R.color.green) - } + if (item.status.name.equals("expired", true)) { + status.setBackgroundResource(R.color.red) + } else { + status.setBackgroundResource(R.color.green) + } itemView.setOnClickListener { selectSubscription?.invoke(item) } diff --git a/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesFragment.kt b/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesFragment.kt index 7b300b87..d44f25aa 100644 --- a/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesFragment.kt +++ b/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesFragment.kt @@ -50,7 +50,7 @@ class PurchasesFragment : Fragment() { return root } - private fun updateData() { + private fun updateData() { purchasesViewModel.updateData() viewAdapter.notifyDataSetChanged() } diff --git a/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesViewModel.kt b/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesViewModel.kt index 0809e2aa..71757923 100644 --- a/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesViewModel.kt +++ b/demo/src/main/java/com/apphud/demo/ui/purchases/PurchasesViewModel.kt @@ -9,17 +9,15 @@ class PurchasesViewModel : ViewModel() { fun updateData() { val purchases = Apphud.nonRenewingPurchases() items.clear() - if (purchases.isNotEmpty()) - { - items.add("Non renewing purchases") - items.addAll(purchases) - } + if (purchases.isNotEmpty()) { + items.add("Non renewing purchases") + items.addAll(purchases) + } val subscriptions = Apphud.subscriptions() - if (subscriptions.isNotEmpty()) - { - items.add("Subscriptions") - items.addAll(subscriptions) - } + if (subscriptions.isNotEmpty()) { + items.add("Subscriptions") + items.addAll(subscriptions) + } } } diff --git a/demo/src/main/java/com/apphud/demo/ui/utils/BillingClientWrapper.kt b/demo/src/main/java/com/apphud/demo/ui/utils/BillingClientWrapper.kt index 8c9a2927..64f842be 100644 --- a/demo/src/main/java/com/apphud/demo/ui/utils/BillingClientWrapper.kt +++ b/demo/src/main/java/com/apphud/demo/ui/utils/BillingClientWrapper.kt @@ -20,7 +20,7 @@ class BillingClientWrapper( context: Context, ) : PurchasesUpdatedListener, ProductDetailsResponseListener { companion object { - private const val TAG = "BillingClient" + private const val TAG = "ApphudLogs" // List of subscription product offerings private const val BASIC_SUB = "com.apphud.demo.newsub" diff --git a/demo/src/main/java/com/apphud/demo/ui/utils/Utils.kt b/demo/src/main/java/com/apphud/demo/ui/utils/Utils.kt index 13274a6a..c1d9ac29 100644 --- a/demo/src/main/java/com/apphud/demo/ui/utils/Utils.kt +++ b/demo/src/main/java/com/apphud/demo/ui/utils/Utils.kt @@ -11,29 +11,27 @@ fun convertLongToTime(time: Long): String { return format.format(date) } -fun ProductDetails.getOfferDescription(offerToken: String): String { +fun ProductDetails.getOfferDescription(offerToken: String): String { var res = "" if (this.productType == BillingClient.ProductType.SUBS) { this.subscriptionOfferDetails?.let { details -> for (offer in details) { - if (offer.offerToken == offerToken) - { - offer.pricingPhases - for (phase in offer.pricingPhases.pricingPhaseList) { - if (res.isNotEmpty()) res += "->" - res += "[" + phase.billingPeriod + " " + phase.formattedPrice + getRecurrenceModeStr(phase.recurrenceMode) + "]" - } + if (offer.offerToken == offerToken) { + offer.pricingPhases + for (phase in offer.pricingPhases.pricingPhaseList) { + if (res.isNotEmpty()) res += "->" + res += "[" + phase.billingPeriod + " " + phase.formattedPrice + getRecurrenceModeStr(phase.recurrenceMode) + "]" } + } } } - } else - { - res = this.oneTimePurchaseOfferDetails?.formattedPrice ?: "" - } + } else { + res = this.oneTimePurchaseOfferDetails?.formattedPrice ?: "" + } return res } -fun getRecurrenceModeStr(mode: Int): String { +fun getRecurrenceModeStr(mode: Int): String { return when (mode) { 1 -> " {INFINITE}" 2 -> " {FINITE}" diff --git a/demo/src/main/res/layout/fragment_customer.xml b/demo/src/main/res/layout/fragment_customer.xml index 0efb2a7d..e1d2cfc1 100644 --- a/demo/src/main/res/layout/fragment_customer.xml +++ b/demo/src/main/res/layout/fragment_customer.xml @@ -132,16 +132,13 @@ - + android:text="ToggleButton" + android:textOff="Show Placements" + android:textOn="Show Paywalls" /> + app:layout_constraintTop_toTopOf="parent" + tools:text="Paywall 1" /> @@ -46,17 +46,38 @@ android:layout_height="wrap_content"> + + + + + + + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + android:layout_height="wrap_content" + android:layout_weight="23"> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toTopOf="parent" + tools:text="Var A" /> @@ -113,16 +135,16 @@ + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + tools:text="In Kotlin, internal is a visibility modifier that restricts the visibility of a class, function, property, or constructor to " + tools:ignore="RtlCompat" /> + app:argType="string" + app:nullable="true" /> + Unit)? = null, + ) = start(context, apiKey, null, null, callback) /** * Initializes Apphud SDK. You should call it during app launch. * - * @parameter apiKey: Required. Your api key. - * @parameter userId: Optional. You can provide your own unique user identifier. If null passed then UUID will be generated instead. + * @param context The application context. + * @param apiKey Your API key. This is a required parameter. + * @param userId (Optional) A unique user identifier. If null is passed, a UUID will be + * generated and used as the user identifier. + * @param callback (Optional) A callback function that is invoked with the `ApphudUser` + * object after the SDK initialization is complete. __Note__: Do not store + * `ApphudUser` + * instance in your own code, since it may change at runtime. */ - @kotlin.jvm.JvmStatic fun start( context: Context, apiKey: ApiKey, userId: UserId? = null, - ) = start(context, apiKey, userId, null) + callback: ((ApphudUser) -> Unit)? = null, + ) = start(context, apiKey, userId, null, callback) /** - * Initializes Apphud SDK. You should call it during app launch. + * Initializes the Apphud SDK. This method should be called during the app launch. * - * @parameter apiKey: Required. Your api key. - * @parameter userId: Optional. You can provide your own unique user identifier. If null passed then UUID will be generated instead. - * @parameter deviceID: Optional. You can provide your own unique device identifier. If null passed then UUID will be generated instead. + * @param context The application context. + * @param apiKey Your API key. This is a required parameter. + * @param userId (Optional) A unique user identifier. If null is passed, a UUID will be + * generated and used as the user identifier. + * @param deviceId (Optional) A unique device identifier. If null is passed, a UUID will be + * generated and used as the device identifier. __Important__: Use this + * parameter with caution. Passing different device IDs + * can result in the creation of multiple user records in Apphud for the same + * actual user. Best practice is to always pass null. + * @param callback (Optional) A callback function that is invoked with the `ApphudUser` + * object after the SDK initialization is complete. __Note__: Do not store + * `ApphudUser` + * instance in your own code, since it may change at runtime. */ - @kotlin.jvm.JvmStatic fun start( context: Context, apiKey: ApiKey, userId: UserId? = null, deviceId: DeviceId? = null, + callback: ((ApphudUser) -> Unit)? = null, ) { ApphudUtils.setPackageName(context.packageName) - ApphudInternal.initialize(context, apiKey, userId, deviceId) + ApphudInternal.initialize(context, apiKey, userId, deviceId, callback) } /** - * Set a listener - * @param apphudListener Any ApphudDelegate conformable object. + * Sets a listener for Apphud events. + * + * @param apphudListener The listener object that conforms to the ApphudListener interface. */ - @kotlin.jvm.JvmStatic fun setListener(apphudListener: ApphudListener) { ApphudInternal.apphudListener = apphudListener } /** - * Updates user ID value. Note that it should be called only after user is registered, i.e. - * inside ApphudListener's userDidRegister method. - * - parameter userId: Required. New user ID value. + * Updates the user ID. This method should only be called after the user is registered, + * for example, inside the ApphudListener's userDidLoad method. + * + * @param userId The new user ID value to be set. */ - @kotlin.jvm.JvmStatic fun updateUserId(userId: UserId) = ApphudInternal.updateUserId(userId) /** - * Returns current userID that identifies user across his multiple devices. + * Retrieves the current user ID that identifies the user across multiple devices. + * + * @return The user ID. */ - @kotlin.jvm.JvmStatic fun userId(): UserId = ApphudInternal.userId /** - * Returns current device ID. You should use it only if you want to implement custom logout/login flow by saving User ID & Device ID pair for each app user. + * Retrieves the current device ID. This method is useful if you want to implement + * a custom logout/login flow by saving the User ID and Device ID pair for each app user. + * + * @return The device ID. */ fun deviceId(): String { return ApphudInternal.deviceId } //endregion - //region === Paywalls === + //region === Placements, Paywalls and Products === + + /** + * Suspends the current coroutine until the placements from + * Product Hub > Placements are available, potentially altered based on the + * user's involvement in A/B testing, if applicable. + * Method suspends until the inner `ProductDetails` are loaded from Google Play. + * + * A placement is a specific location within a user's journey + * (such as onboarding, settings, etc.) where its internal paywall + * is intended to be displayed. + * + * If you want to obtain placements without waiting for `ProductDetails` + * from Google Play, you can use `rawPlacements()` method. + * + * @return The list of `ApphudPlacement` objects. + */ + suspend fun placements(): List = + suspendCancellableCoroutine { continuation -> + ApphudInternal.performWhenOfferingsPrepared { + continuation.resume(ApphudInternal.placements) + } + } /** - * Returns paywalls configured in Apphud Dashboard > Product Hub > Paywalls. - * Each paywall contains an array of `ApphudProduct` objects that you use for purchase. - * This callback is called when paywalls are populated with their `ProductDetails` objects. - * Callback is called immediately if paywalls are already loaded. + * Suspends the current coroutine until the specific placement by identifier + * is available, potentially altered based on the + * user's involvement in A/B testing, if applicable. + * Method suspends until the inner `ProductDetails` are loaded from Google Play. + * + * A placement is a specific location within a user's journey + * (such as onboarding, settings, etc.) where its internal paywall + * is intended to be displayed. + * + * If you want to obtain placements without waiting for `ProductDetails` + * from Google Play, you can use `rawPlacements()` method. + * + * @return The list of `ApphudPlacement` objects. */ - @kotlin.jvm.JvmStatic + suspend fun placement(identifier: String): ApphudPlacement? = + placements().firstOrNull { it.identifier == identifier } + + /** + * Returns the placements from Product Hub > Placements, potentially altered + * based on the user's involvement in A/B testing, if applicable. + * + * A placement is a specific location within a user's journey + * (such as onboarding, settings, etc.) where its internal paywall + * is intended to be displayed. + * + * If you want to obtain placements without waiting for `ProductDetails` + * from Google Play, you can use `rawPlacements()` method. + * + * @param callback The callback function that is invoked with the list of `ApphudPlacement` objects. + */ + fun placementsDidLoadCallback(callback: (List) -> Unit) { + ApphudInternal.performWhenOfferingsPrepared { callback(ApphudInternal.placements) } + } + + /** Returns: + * List: A list of placements, potentially altered based + * on the user's involvement in A/B testing, if any. + * + * __Note__: This function doesn't suspend until inner `ProductDetails` + * are loaded from Google Play. That means placements may or may not have + * inner Google Play products at the time you call this function. + * + * To get placements with awaiting for inner Google Play products, use + * `placements()` or `placementsDidLoadCallback(...)` functions. + */ + fun rawPlacements(): List = ApphudInternal.placements + + /** + * Suspends the current coroutine until the paywalls from + * Product Hub > Paywalls are available, potentially altered based on the + * user's involvement in A/B testing, if applicable. + * + * Each paywall contains an array of `ApphudProduct` objects that + * can be used for purchases. + * `ApphudProduct` is Apphud's wrapper around `ProductDetails`. + * + * Method suspends until the inner `ProductDetails` are loaded from Google Play. + * + * If you want to obtain paywalls without waiting for `ProductDetails` from + * Google Play, you can use `rawPaywalls()` method. + * + * @return The list of `ApphudPaywall` objects. + */ + @Deprecated( + "Deprecated in favor of Placements", + ReplaceWith("this.placements()"), + ) + suspend fun paywalls(): List = + suspendCancellableCoroutine { continuation -> + ApphudInternal.performWhenOfferingsPrepared { + continuation.resume(ApphudInternal.paywalls) + } + } + + /** + * Suspends the current coroutine until the specific paywall by identifier + * is available, potentially altered based on the + * user's involvement in A/B testing, if applicable. + * + * Each paywall contains an array of `ApphudProduct` objects that + * can be used for purchases. + * `ApphudProduct` is Apphud's wrapper around `ProductDetails`. + * + * Method suspends until the inner `ProductDetails` are loaded from Google Play. + * + * If you want to obtain paywalls without waiting for `ProductDetails` from + * Google Play, you can use `rawPaywalls()` method. + * + * @return The list of `ApphudPaywall` objects. + */ + @Deprecated( + "Deprecated in favor of Placements", + ReplaceWith("this.placement(identifier: String)"), + ) + suspend fun paywall(identifier: String): ApphudPaywall? = + paywalls().firstOrNull { it.identifier == identifier } + + /** + * Returns the paywalls from Product Hub > Paywalls, potentially altered + * based on the user's involvement in A/B testing, if applicable. + * + * Each paywall contains an array of `ApphudProduct` objects that + * can be used for purchases. + * `ApphudProduct` is Apphud's wrapper around `ProductDetails`. + * + * Method suspends until the inner `ProductDetails` are loaded from Google Play. + * + * If you want to obtain paywalls without waiting for `ProductDetails` from + * Google Play, you can use `rawPaywalls()` method. + * + * @param callback The callback function that is invoked with the list of `ApphudPaywall` objects. + */ + @Deprecated( + "Deprecated in favor of Placements", + ReplaceWith("this.placementsDidLoadCallback(callback)"), + ) fun paywallsDidLoadCallback(callback: (List) -> Unit) { - ApphudInternal.paywallsFetchCallback(callback) + ApphudInternal.performWhenOfferingsPrepared { callback(ApphudInternal.paywalls) } } + /** Returns: + * List: A list of paywalls, potentially altered based + * on the user's involvement in A/B testing, if any. + * + * __Note__: This function doesn't suspend until inner `ProductDetails` + * are loaded from Google Play. That means paywalls may or may not have + * inner Google Play products at the time you call this function. + * + * To get paywalls with awaiting for inner Google Play products, use + * Apphud.paywalls() or Apphud.paywallsDidLoadCallback(...) functions. + */ + fun rawPaywalls(): List = ApphudInternal.paywalls + /** - * Optional. Use this method when your paywall screen is displayed to the user. - * Used for paywalls A/B testing analysis. + * Call this method when your paywall screen is displayed to the user. + * This is required for A/B testing analysis. + * + * @param paywall The `ApphudPaywall` object representing the paywall shown to the user. */ - @kotlin.jvm.JvmStatic fun paywallShown(paywall: ApphudPaywall) { ApphudInternal.paywallShown(paywall) } /** - * Returns paywalls configured in Apphud Dashboard > Product Hub > Paywalls. - * Each paywall contains an array of `ApphudProduct` objects that you use for purchase. - * `ApphudProduct` is Apphud's wrapper around `ProductsDetails`. - * Returns empty array if paywalls are not yet fetched. - * To get notified when paywalls are ready to use, use ApphudListener's `userDidLoad` or `paywallsDidFullyLoad` methods, - * depending on whether or not you need `ProductsDetails` to be already filled in paywalls. - * Best practice is to use this method together with `paywallsDidFullyLoad` listener. + * Call this method when your paywall screen is dismissed without a purchase. + * This is required for A/B testing analysis. + * + * @param paywall The `ApphudPaywall` object representing the paywall that was closed. */ - fun paywalls(): List { - return ApphudInternal.getPaywalls() + fun paywallClosed(paywall: ApphudPaywall) { + ApphudInternal.paywallClosed(paywall) } /** - * Returns permission groups configured in Apphud dashboard > Product Hub > Products. Groups are cached on device. - * Note that this method returns empty array if `ProductsDetails` are not yet fetched from Google Play. - * To get notified when `permissionGroups` are ready to use, use ApphudListener's - * `apphudFetchProductsDetailsProducts` or `paywallsDidFullyLoad` methods or `productsFetchCallback`. - * When any of these methods is called, `ProductsDetails` are loaded, which means that current - * `permissionGroups` method is ready to use. - * Best practice is not to use this method at all, but use `paywalls()` instead. + * Returns permission groups configured in the Apphud dashboard under Product Hub > Products. + * These groups are cached on the device. + * Note that this method returns an empty array if `ProductDetails` are not yet fetched from Google Play. + * + * To get notified when `permissionGroups` are ready to use, you can use ApphudListener's + * `apphudFetchProductsDetailsProducts` or `paywallsDidFullyLoad` methods, or `productsFetchCallback`. + * When any of these methods is called, it indicates that `ProductDetails` are loaded and + * the `permissionGroups` method is ready to use. + * + * Best practice is not to use this method directly but to use `paywalls()` instead. + * + * @return A list of `ApphudGroup` objects representing permission groups. */ fun permissionGroups(): List { - return ApphudInternal.permissionGroups() + return ApphudInternal.getPermissionGroups() } /** - * Returns array of `ProductsDetails` objects, identifiers of which you added in Apphud > Product Hub > Products. - * Note that this method will return **null** if products are not yet fetched. - * To get notified when `products` are ready to use, use ApphudListener's - * `apphudFetchProductsDetails` or `paywallsDidFullyLoad` methods or `productsFetchCallback`. - * When any of these methods is called, `ProductsDetails` are loaded, which means that current - * `products` method is ready to use. - * Best practice is not to use this method at all, but use `paywalls()` instead. + * Returns an array of `ProductDetails` objects, whose identifiers you added in Apphud > Product Hub > Products. + * Note that this method will return empty array if products are not yet fetched. + * To get notified when `products` are ready to use, implement `ApphudListener`'s + * `apphudFetchProductsDetails` or `paywallsDidFullyLoad` methods, or use `productsFetchCallback`. + * When any of these methods is called, it indicates that `ProductDetails` are loaded and + * the `products` method is ready to use. + * It is recommended not to use this method directly, but to use `paywalls()` instead. + * + * @return A list of `ProductDetails` objects, or null if not yet available. */ @Deprecated( - "Use \"getPaywalls\" method instead.", - ReplaceWith("getPaywalls(callback: (paywalls: List?, error: ApphudError?) -> Unit)"), + "Use \"paywalls()\" method instead.", + ReplaceWith("this.paywalls()"), ) - @kotlin.jvm.JvmStatic - fun products(): List? { - return ApphudInternal.getProductDetailsList() + fun products(): List { + return ApphudInternal.getProductDetails() } /** - * This callback is called when `ProductsDetails` are fetched from Google Play Billing. - * Note that you have to add all product identifiers in Apphud > Product Hub > Products. - * You can use `productsDidFetchCallback` callback - * or implement `apphudFetchProductsDetails` listener method. Use whatever you like most. + * This callback is triggered when `ProductDetails` are fetched from Google Play Billing. + * Ensure that all product identifiers are added in Apphud > Product Hub > Products. + * You can use this callback or implement `ApphudListener`'s `apphudFetchProductsDetails` + * method, based on your preference. + * + * @param callback The callback function to be invoked with the list of `ProductDetails`. */ @Deprecated( - "Use \"getPaywalls\" method instead.", - ReplaceWith("getPaywalls(callback: (paywalls: List?, error: ApphudError?) -> Unit)"), + "Use \"paywalls()\" method instead.", + ReplaceWith("this.paywalls()"), ) - @kotlin.jvm.JvmStatic fun productsFetchCallback(callback: (List) -> Unit) { ApphudInternal.productsFetchCallback(callback) } /** - * Returns `ProductsDetails` object by product identifier. - * Note that you have to add this product identifier in Apphud > Product Hub > Products. - * Will return `null` if product is not yet fetched from Google Play. + * Returns the `ProductDetails` object for a specific product identifier. + * Ensure the product identifier is added in Apphud > Product Hub > Products. + * The method will return `null` if the product is not yet fetched from Google Play. + * + * @param productIdentifier The identifier of the product. + * @return The `ProductDetails` object for the specified product, or null if not available. */ @Deprecated( - "Use \"getPaywalls\" method instead.", - ReplaceWith("getPaywalls(callback: (paywalls: List?, error: ApphudError?) -> Unit)"), + "Use \"paywalls()\" method instead.", + ReplaceWith("this.paywalls()"), ) - @kotlin.jvm.JvmStatic fun product(productIdentifier: String): ProductDetails? { return ApphudInternal.getProductDetailsByProductId(productIdentifier) } @@ -181,77 +360,86 @@ object Apphud { //region === Purchases === /** - Returns `true` if user has active subscription or non renewing purchase (lifetime). - Note: You should not use this method if you have consumable in-app purchases, like coin packs. - Use this method to determine whether or not user has active premium access. - If you have consumable purchases, this method won't operate correctly, - because Apphud SDK doesn't differ consumables from non-consumables. + * Determines if the user has active premium access, which includes any active subscription + * or non-renewing purchase (lifetime). + * Note: This method is not suitable for consumable in-app purchases, like coin packs. + * Use this method to check if the user has active premium access. If you have consumable + * purchases, consider using alternative methods, as this won't distinguish consumables + * from non-consumables. + * + * @return `true` if the user has an active subscription or non-renewing purchase, `false` otherwise. */ - @kotlin.jvm.JvmStatic fun hasPremiumAccess(): Boolean { return hasActiveSubscription() || nonRenewingPurchases().firstOrNull { it.isActive() } != null } /** - * Returns `true` if user has active subscription. Value is cached on device. - * Use this method to determine whether or not user has active premium subscription. - * Note that if you have lifetime purchases, you must use another `isNonRenewingPurchaseActive` method. + * Checks if the user has an active subscription. The information is cached on the device. + * Use this method to determine whether the user has an active premium subscription. + * Note: If you offer lifetime purchases, you must use the `isNonRenewingPurchaseActive` method. + * + * @return `true` if the user has an active subscription, `false` otherwise. */ - @kotlin.jvm.JvmStatic fun hasActiveSubscription(): Boolean = subscriptions().firstOrNull { it.isActive() } != null /** - * Returns subscription object that current user has ever purchased. Subscriptions are cached on device. - * Note: If returned object is not null, it doesn't mean that subscription is active. - * You should check `ApphudSdk.hasActiveSubscription()` method or `subscription.isActive()` - * value to determine whether or not to unlock premium functionality to the user. + * Retrieves the subscription object that the current user has ever purchased. + * Subscriptions are cached on the device. + * Note: A non-null return value does not imply that the subscription is active. + * Check `ApphudSdk.hasActiveSubscription()` or `subscription.isActive()` to determine + * if the subscription should unlock premium functionality for the user. + * + * @return The `ApphudSubscription` object if available, `null` otherwise. */ - @kotlin.jvm.JvmStatic - fun subscription(): ApphudSubscription? = ApphudInternal.subscriptions().firstOrNull() + fun subscription(): ApphudSubscription? = subscriptions().firstOrNull() /** - * Returns an array of all subscriptions that this user has ever purchased. Subscriptions are cached on device. + * Retrieves all the subscription objects that the user has ever purchased. + * The information is cached on the device. + * + * @return A list of `ApphudSubscription` objects. */ - @kotlin.jvm.JvmStatic - fun subscriptions(): List = ApphudInternal.subscriptions() + fun subscriptions(): List = ApphudInternal.currentUser?.subscriptions ?: listOf() /** - * Returns an array of all in-app product purchases that this user has ever purchased. - * Purchases are cached on device. This array is sorted by purchase date. + * Retrieves all non-renewing product purchases that the user has ever made. + * The information is cached on the device and sorted by purchase date. + * + * @return A list of `ApphudNonRenewingPurchase` objects. */ - @kotlin.jvm.JvmStatic - fun nonRenewingPurchases(): List = ApphudInternal.purchases() + fun nonRenewingPurchases(): List = ApphudInternal.currentUser?.purchases ?: listOf() /** - * Returns `true` if current user has purchased in-app product with given product identifier. - * Returns `false` if this product is refunded or never purchased. - * Note: Purchases are sorted by purchase date, so it returns Bool value for the most recent purchase by given product identifier. + * Checks if the current user has purchased a specific in-app product. + * Returns `false` if the product is refunded or never purchased. + * Note: This method considers the most recent purchase of the given product identifier. + * + * @param productId The identifier of the product to check. + * @return `true` if the product is active, `false` otherwise. */ - @kotlin.jvm.JvmStatic fun isNonRenewingPurchaseActive(productId: ProductId): Boolean = - ApphudInternal.currentUser?.purchases - ?.firstOrNull { it.productId == productId }?.isActive() ?: false + nonRenewingPurchases().firstOrNull { it.productId == productId }?.isActive() ?: false /** - * Purchase product and automatically submit Google Play purchase token to Apphud + * Initiates the purchase process for a specified product and automatically submits the + * Google Play purchase token to Apphud. * - * @param activity Required. Current Activity for use - * @param product Required. ApphudProduct to purchase - * @param offerIdToken Optional. Specifies the identifier of the offer to initiate purchase with. You must manually select base plan - * and offer from ProductDetails and pass offer id token. - * @param oldToken Optional.Specifies the Google Play Billing purchase token that the user is upgrading or downgrading from. - * @param replacementMode Optional.Replacement mode (https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.ReplacementMode?hl=en) - * @param consumableInappProduct Optional. Default false. Pass true for consumables products. - * @param block Optional. Returns `ApphudPurchaseResult` object. + * @param activity The current Activity context. + * @param apphudProduct The `ApphudProduct` object representing the product to be purchased. + * @param offerIdToken (Required for Subscriptions) The identifier of the offer for initiating the purchase. Developer should retrieve it from SubscriptionOfferDetails object. + * @param oldToken (Optional) The Google Play Billing purchase token that the user is + * upgrading or downgrading from. + * @param replacementMode (Optional) The replacement mode for the subscription update. + * @param consumableInAppProduct (Optional) Set to true for consumable products. Otherwise purchase will be treated as non-consumable and acknowledged. + * @param block (Optional) A callback that returns an `ApphudPurchaseResult` object. */ - @kotlin.jvm.JvmStatic fun purchase( activity: Activity, apphudProduct: ApphudProduct, offerIdToken: String? = null, oldToken: String? = null, replacementMode: Int? = null, - consumableInappProduct: Boolean = false, + consumableInAppProduct: Boolean = false, block: ((ApphudPurchaseResult) -> Unit)?, ) = ApphudInternal.purchase( activity = activity, @@ -260,30 +448,30 @@ object Apphud { offerIdToken = offerIdToken, oldToken = oldToken, replacementMode = replacementMode, - consumableInappProduct = consumableInappProduct, + consumableInappProduct = consumableInAppProduct, callback = block, ) /** - * Purchase product and automatically submit Google Play purchase token to Apphud + * Initiates the purchase process for a product by its Google Play product ID and automatically + * submits the purchase token to Apphud. * - * @param activity Required. Current Activity for use - * @param productId Required. Google Play product id - * @param offerIdToken Optional. Specifies the identifier of the offer to initiate purchase with. You must manually select base plan - * and offer from ProductDetails and pass offer id token. - * @param oldToken Optional.Specifies the Google Play Billing purchase token that the user is upgrading or downgrading from. - * @param replacementMode Optional.Replacement mode (https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.ReplacementMode?hl=en) - * @param consumableInappProduct Optional. Default false. Pass true for consumables products. - * @param block Optional. Returns `ApphudPurchaseResult` object. + * @param activity The current Activity context. + * @param productId The Google Play product ID of the item to purchase. + * @param offerIdToken (Required for Subscriptions) The identifier of the offer for initiating the purchase. Developer should retrieve it from SubscriptionOfferDetails object. + * @param oldToken (Optional) The Google Play Billing purchase token that the user is + * upgrading or downgrading from. + * @param replacementMode (Optional) The replacement mode for the subscription update. + * @param consumableInAppProduct (Optional) Set to true for consumable products. Otherwise purchase will be treated as non-consumable and acknowledged. + * @param block (Optional) A callback that returns an `ApphudPurchaseResult` object. */ - @kotlin.jvm.JvmStatic fun purchase( activity: Activity, productId: String, offerIdToken: String? = null, oldToken: String? = null, replacementMode: Int? = null, - consumableInappProduct: Boolean = false, + consumableInAppProduct: Boolean = false, block: ((ApphudPurchaseResult) -> Unit)?, ) = ApphudInternal.purchase( activity = activity, @@ -292,44 +480,51 @@ object Apphud { offerIdToken = offerIdToken, oldToken = oldToken, replacementMode = replacementMode, - consumableInappProduct = consumableInappProduct, + consumableInappProduct = consumableInAppProduct, callback = block, ) /** - * Only in Observer Mode: call this method after every successful purchase. - * __Passing offerIdToken is mandatory for subscriptions!__ - * This method submits successful purchase to Apphud. - * Pass `Paywall Identifier` to be able to use A/B tests in Observer Mode. See https://docs.apphud.com/docs/observer-mode#android for details. + * Only for use in Observer Mode: call this method after every successful purchase. + * Note: Passing the offerIdToken is mandatory for subscriptions! + * This method submits the successful purchase information to Apphud. + * Pass `paywallIdentifier` and `placementIdentifier` for A/B test analysis in Observer Mode. + * + * @param purchase The `Purchase` object representing the successful purchase. + * @param productDetails The `ProductDetails` object associated with the purchase. + * @param offerIdToken The identifier of the subscription's offer token. + * @param paywallIdentifier (Optional) The identifier of the paywall. + * @param placementIdentifier (Optional) The identifier of the placement. */ - @kotlin.jvm.JvmStatic fun trackPurchase( purchase: Purchase, productDetails: ProductDetails, offerIdToken: String?, paywallIdentifier: String? = null, - ) = ApphudInternal.trackPurchase(purchase, productDetails, offerIdToken, paywallIdentifier) + placementIdentifier: String? = null, + ) = ApphudInternal.trackPurchase(purchase, productDetails, offerIdToken, paywallIdentifier, placementIdentifier) /** - * Implements `Restore Purchases` mechanism. Basically it just sends current Play Market Purchase Tokens to Apphud and returns subscriptions info. - * Even if callback returns some subscription, it doesn't mean that subscription is active. You should check `subscription.isActive()` value. - * @param callback: Required. Returns array of subscriptions, in-app products or optional, error. + * Implements the 'Restore Purchases' mechanism. This method sends the current Play Market + * Purchase Tokens to Apphud and returns subscription information. + * Note: Even if the callback returns some subscription, it doesn't necessarily mean that + * the subscription is active. Check `subscription.isActive()` for subscription status. + * + * @param callback Required. A callback that returns an array of subscriptions, in-app products, + * or an optional error. */ - @kotlin.jvm.JvmStatic fun restorePurchases(callback: ApphudPurchasesRestoreCallback) { ApphudInternal.restorePurchases(callback) } /** - * Refreshes current purchases: subscriptions, promotionals or non-renewing purchases. - * To get notified about updates, you should listen for ApphudListener's - * apphudSubscriptionsUpdated(subscriptions: List) and - * apphudNonRenewingPurchasesUpdated(purchases: List) methods. - * You should not call this method on app launch, because Apphud SDK does it automatically. - * Best practice is to refresh the user when a promotional has been granted on the web - * or when your app reactivates from a background, if needed. + * Refreshes the current entitlements, which includes subscriptions, promotional or non-renewing purchases. + * To be notified about updates, listen for `ApphudListener`'s `apphudSubscriptionsUpdated` and + * `apphudNonRenewingPurchasesUpdated` methods. + * Note: Do not call this method on app launch, as Apphud SDK does it automatically. + * It is best used when a promotional has been granted on the web or when the app reactivates + * from the background, if needed. */ - @kotlin.jvm.JvmStatic fun refreshEntitlements() { ApphudInternal.refreshEntitlements() } @@ -338,23 +533,22 @@ object Apphud { //region === Attribution === /** - * Collects device identifiers that are required for some third-party integrations, like AppsFlyer, Adjust, Singular, etc. + * Collects device identifiers required for some third-party integrations (e.g., AppsFlyer, Adjust, Singular). * Identifiers include Advertising ID, Android ID, App Set ID. - * @warning When targeting Android 13 and above, you must declare AD_ID permission in the manifest file: https://support.google.com/googleplay/android-developer/answer/6048248?hl=en - * @warning Be sure optOutOfTracking() not called before. Otherwise device identifiers will not be collected. + * Warning: When targeting Android 13 and above, declare the AD_ID permission in the manifest. + * Be sure `optOutOfTracking()` is not called before this, otherwise identifiers will not be collected. */ - @kotlin.jvm.JvmStatic fun collectDeviceIdentifiers() { ApphudInternal.collectDeviceIdentifiers() } /** - * Submit attribution data to Apphud from your attribution network provider. - * @data: Required. Attribution dictionary. - * @provider: Required. Attribution provider name. - * @identifier: Optional. Identifier that matches Apphud and Attribution provider. + * Submits attribution data to Apphud from your attribution network provider. + * + * @param data Required. Attribution dictionary. + * @param provider Required. Attribution provider name. + * @param identifier Optional. Identifier that matches Apphud and the Attribution provider. */ - @kotlin.jvm.JvmStatic fun addAttribution( provider: ApphudAttributionProvider, data: Map? = null, @@ -365,16 +559,14 @@ object Apphud { //region === User Properties === /** - * Set custom user property. - * Value must be one of: "Int", "Float", "Double", "Boolean", "String" or "null". + * Sets a custom user property. The value must be one of the following types: + * "Int", "Float", "Double", "Boolean", "String", or "null". * * Example: - * // use built-in property key - * Apphud.setUserProperty(key: ApphudUserPropertyKey.Email, value: "user4@example.com", setOnce: true) - * // use custom property key - * Apphud.setUserProperty(key: ApphudUserPropertyKey.CustomProperty("custom_test_property_1"), value: 0.5) + * Apphud.setUserProperty(ApphudUserPropertyKey.Email, "user@example.com") + * Apphud.setUserProperty(ApphudUserPropertyKey.CustomProperty("custom_key"), 123) * - * __Note__: You can use several built-in keys with their value types: + * Note: Built-in keys have predefined value types: * "ApphudUserPropertyKey.Email": User email. Value must be String. * "ApphudUserPropertyKey.Name": User name. Value must be String. * "ApphudUserPropertyKey.Phone": User phone number. Value must be String. @@ -382,11 +574,10 @@ object Apphud { * "ApphudUserPropertyKey.Gender": User gender. Value must be one of: "male", "female", "other". * "ApphudUserPropertyKey.Cohort": User install cohort. Value must be String. * - * @param key Required. Initialize class with custom string or using built-in keys. See example above. - * @param value Required/Optional. Pass "null" to remove given property from Apphud. - * @param setOnce Optional. Pass "true" to make this property non-updatable. + * @param key The property key, either custom or built-in. + * @param value The property value, or "null" to remove the property. + * @param setOnce If set to "true", the property cannot be updated later. */ - @kotlin.jvm.JvmStatic fun setUserProperty( key: ApphudUserPropertyKey, value: Any?, @@ -396,16 +587,15 @@ object Apphud { } /** - * Increment custom user property. - * Value must be one of: "Int", "Float", "Double". + * Increments a custom user property. The value to increment must be one of the types: + * "Int", "Float", or "Double". * * Example: - * Apphud.incrementUserProperty(key: ApphudUserPropertyKey.CustomProperty("progress"), by: 0.5) + * Apphud.incrementUserProperty(ApphudUserPropertyKey.CustomProperty("progress"), 10) * - * @param key Required. Use your custom string key or some of built-in keys. - * @param by Required/Optional. You can pass negative value to decrement. + * @param key The property key, which should be a custom key. + * @param by The value to increment the property by. Negative values will decrement. */ - @kotlin.jvm.JvmStatic fun incrementUserProperty( key: ApphudUserPropertyKey, by: Any, @@ -417,16 +607,17 @@ object Apphud { //region === Other === /** - You can grant free promotional subscription to user. Returns `true` in a callback if promotional was granted. - - __Note__: You should pass either `productId` (recommended) or `permissionGroup` OR both parameters `nil`. Sending both `productId` and `permissionGroup` parameters will result in `productId` being used. - - - parameter daysCount: Required. Number of days of free premium usage. For lifetime promotionals just pass extremely high value, like 10000. - - parameter productId: Optional*. Recommended. Product Id of promotional subscription. See __Note__ message above for details. - - parameter permissionGroup: Optional*. Permission Group of promotional subscription. Use this parameter in case you have multiple permission groups. See __Note__ message above for details. - - parameter callback: Optional. Returns `true` if promotional subscription was granted. + * Grants a free promotional subscription to the user. + * Returns `true` in the callback if the promotional subscription was successfully granted. + * + * Note: Either pass `productId` or `permissionGroup`, or pass both as null. + * If both are provided, `productId` will be used. + * + * @param daysCount The number of days for the free premium access. For a lifetime promotion, pass a large number. + * @param productId (Optional) The product ID of the subscription for the promotion. + * @param permissionGroup (Optional) The permission group for the subscription. Use when you have multiple groups. + * @param callback (Optional) Returns `true` if the promotional subscription was granted. */ - @kotlin.jvm.JvmStatic fun grantPromotional( daysCount: Int, productId: String?, @@ -437,29 +628,20 @@ object Apphud { } /** - * Optional. Use this method when your paywall screen is dismissed without purchase. - */ - @kotlin.jvm.JvmStatic - fun paywallClosed(paywall: ApphudPaywall) { - ApphudInternal.paywallClosed(paywall) - } - - /** - * Enable debug logs. Better to call this method before SDK initialization. + * Enables debug logs. It is recommended to call this method before SDK initialization. */ - @kotlin.jvm.JvmStatic fun enableDebugLogs() = ApphudUtils.enableDebugLogs() /** - * Use this method if you have your custom login system with own backend logic. + * Use this method if you have a custom login system with your own backend logic. + * It effectively logs out the current user in the context of the Apphud SDK. */ - @kotlin.jvm.JvmStatic fun logout() = ApphudInternal.logout() /** - * Must be called before SDK initialization. If called, some user parameters like Advertising ID, Android ID, App Set ID, Device Type, IP address will not be tracked by Apphud. + * Must be called before SDK initialization. If called, certain user parameters + * like Advertising ID, Android ID, App Set ID, Device Type, IP address will not be tracked by Apphud. */ - @kotlin.jvm.JvmStatic fun optOutOfTracking() { ApphudUtils.optOutOfTracking = true } diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudExtensions.kt b/sdk/src/main/java/com/apphud/sdk/ApphudExtensions.kt index 2f6b5a91..266ca8c6 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudExtensions.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudExtensions.kt @@ -11,3 +11,26 @@ internal fun Context.isDebuggable(): Boolean { internal fun AtomicInteger.isBothLoaded(): Boolean { return this.get() == 2 } + +enum class ApphudBillingResponseCodes(val code: Int) { + SERVICE_TIMEOUT(-3), + FEATURE_NOT_SUPPORTED(-2), + SERVICE_DISCONNECTED(-1), + OK(0), + USER_CANCELED(1), + SERVICE_UNAVAILABLE(2), + BILLING_UNAVAILABLE(3), + ITEM_UNAVAILABLE(4), + DEVELOPER_ERROR(5), + ERROR(6), + ITEM_ALREADY_OWNED(7), + ITEM_NOT_OWNED(8), + NETWORK_ERROR(12), + ; + + companion object { + fun getName(code: Int): String { + return values().firstOrNull { it.code == code }?.name ?: "UNKNOWN" + } + } +} diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Attribution.kt b/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Attribution.kt index c7301570..c3a0156a 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Attribution.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Attribution.kt @@ -90,7 +90,7 @@ internal fun ApphudInternal.addAttribution( } } - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { ApphudLog.logE(it.message) } ?: run { diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Fallback.kt b/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Fallback.kt index fbc0a713..173ddb06 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Fallback.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Fallback.kt @@ -2,7 +2,6 @@ package com.apphud.sdk import android.content.Context import com.apphud.sdk.domain.ApphudUser -import com.apphud.sdk.domain.Customer import com.apphud.sdk.domain.FallbackJsonObject import com.apphud.sdk.mappers.PaywallsMapper import com.apphud.sdk.parser.GsonParser @@ -19,13 +18,9 @@ private val parser: Parser = GsonParser(gson) private val paywallsMapper = PaywallsMapper(parser) internal fun ApphudInternal.processFallbackError(request: Request) { - if (request.url.encodedPath.endsWith("/customers") && storage.needProcessFallback() && !fallbackMode) - { - fallbackMode = true - didRegisterCustomerAtThisLaunch = false - processFallbackData() - ApphudLog.log("Fallback: ENABLED") - } + if (request.url.encodedPath.endsWith("/customers") && !fallbackMode) { + processFallbackData() + } } private fun ApphudInternal.processFallbackData() { @@ -36,29 +31,33 @@ private fun ApphudInternal.processFallbackData() { val contentType = object : TypeToken() {}.type val fallbackJson: FallbackJsonObject = gson.fromJson(jsonFileString, contentType) - if (paywalls.isEmpty() && fallbackJson.data.results.isNotEmpty()) - { - val paywallToParse = paywallsMapper.map(fallbackJson.data.results) - val ids = paywallToParse.map { it.products?.map { it.product_id } ?: listOf() }.flatten() - if (ids.isNotEmpty()) - { - fetchDetails(ids) - cachePaywalls(paywallToParse) + if (paywalls.isEmpty() && fallbackJson.data.results.isNotEmpty()) { + val paywallToParse = paywallsMapper.map(fallbackJson.data.results) + val ids = paywallToParse.map { it.products?.map { it.productId } ?: listOf() }.flatten() + if (ids.isNotEmpty() && !fallbackMode) { + fallbackMode = true + didRegisterCustomerAtThisLaunch = false + ApphudLog.log("Fallback: ENABLED") + fetchDetails(ids) + cachePaywalls(paywallToParse) - if (currentUser == null) - { - currentUser = Customer(ApphudUser(userId, "", ""), mutableListOf(), mutableListOf(), listOf(), true) - ApphudLog.log("Fallback: user created: $userId") - } - mainScope.launch { - notifyLoadingCompleted( - customerLoaded = currentUser, - productDetailsLoaded = productDetails, - fromFallback = true, - ) - } - } + if (currentUser == null) { + currentUser = + ApphudUser( + userId, "", "", listOf(), listOf(), listOf(), + listOf(), true, + ) + ApphudLog.log("Fallback: user created: $userId") + } + mainScope.launch { + notifyLoadingCompleted( + customerLoaded = currentUser, + productDetailsLoaded = productDetails, + fromFallback = true, + ) + } } + } } catch (ex: Exception) { ApphudLog.logE("Fallback: ${ex.message}") } @@ -85,12 +84,12 @@ internal fun ApphudInternal.disableFallback() { storage.isNeedSync = true coroutineScope.launch(errorHandler) { - if (productGroups.isEmpty()) - { // if fallback raised on start, there no product groups, so reload products and details - ApphudLog.log("Fallback: reload products") - loadProducts() - } + if (productGroups.isEmpty()) { // if fallback raised on start, there no product groups, so reload products and details + ApphudLog.log("Fallback: reload products") + loadProducts() + } ApphudLog.log("Fallback: syncPurchases()") - syncPurchases() + // no need +// syncPurchases() } } diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Products.kt b/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Products.kt index 185085c2..e912ee13 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Products.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Products.kt @@ -1,32 +1,31 @@ package com.apphud.sdk import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.ProductDetails import com.apphud.sdk.managers.RequestManager import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger -internal var productsLoaded = AtomicInteger(0) // to know that products already loaded by another thread +internal var productsLoaded = AtomicBoolean(false) private val mutexProducts = Mutex() -internal fun ApphudInternal.loadProducts() { +internal fun ApphudInternal.loadProducts() { + if (productsLoaded.get()) { return } + coroutineScope.launch(errorHandler) { mutexProducts.withLock { async { - if (productsLoaded.get() == 0) { - if (fetchProducts()) { - // Let to know to another threads that details are loaded successfully - productsLoaded.incrementAndGet() + if (fetchProducts()) { + // Let to know to another threads that details are loaded successfully + productsLoaded.set(true) - mainScope.launch { - notifyLoadingCompleted( - null, - productDetails, - ) - } + mainScope.launch { + notifyLoadingCompleted(null, productDetails) } } } @@ -35,58 +34,51 @@ internal fun ApphudInternal.loadProducts() { } private suspend fun ApphudInternal.fetchProducts(): Boolean { - val cachedGroups = storage.productGroups - if (cachedGroups == null || storage.needUpdateProductGroups()) - { - val groupsList = RequestManager.allProducts() - groupsList?.let { groups -> - cacheGroups(groups) - val ids = groups.map { it -> it.products?.map { it.product_id }!! }.flatten() - return fetchDetails(ids) - } - } else - { - val ids = cachedGroups.map { it -> it.products?.map { it.product_id }!! }.flatten() + val permissionGroupsCopy = getPermissionGroups() + if (permissionGroupsCopy.isEmpty() || storage.needUpdateProductGroups()) { + val groupsList = RequestManager.allProducts() + groupsList?.let { groups -> + cacheGroups(groups) + val ids = groups.map { it -> it.products?.map { it.productId }!! }.flatten() return fetchDetails(ids) } + } else { + val ids = permissionGroupsCopy.map { it -> it.products?.map { it.productId }!! }.flatten() + return fetchDetails(ids) + } return false } internal suspend fun ApphudInternal.fetchDetails(ids: List): Boolean { - var isInapLoaded = false - var isSubsLoaded = false - synchronized(productDetails) { - productDetails.clear() - } + var subsDetails: List? = null + var inAppDetails: List? = null coroutineScope { val subs = async { billing.detailsEx(BillingClient.ProductType.SUBS, ids) } - val inap = async { billing.detailsEx(BillingClient.ProductType.INAPP, ids) } + val inApp = async { billing.detailsEx(BillingClient.ProductType.INAPP, ids) } subs.await()?.let { - synchronized(productDetails) { - productDetails.addAll(it) - - for (item in productDetails) { - ApphudLog.log(item.zza()) - } - } - isSubsLoaded = true + subsDetails = it } ?: run { ApphudLog.logE("Unable to load SUBS details", false) } - inap.await()?.let { - synchronized(productDetails) { - productDetails.addAll(it) - for (item in productDetails) { - ApphudLog.log(item.name + ": " + item.toString()) - } - } - isInapLoaded = true + inApp.await()?.let { + inAppDetails = it } ?: run { ApphudLog.logE("Unable to load INAP details", false) } } - return isSubsLoaded && isInapLoaded + + synchronized(productDetails) { + productDetails.clear() + subsDetails?.let { + productDetails.addAll(it) + } + inAppDetails?.let { + productDetails.addAll(it) + } + } + + return subsDetails != null && inAppDetails != null } diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Purchases.kt b/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Purchases.kt index f76f26de..be133fc1 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Purchases.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudInternal+Purchases.kt @@ -7,7 +7,7 @@ import com.android.billingclient.api.Purchase import com.apphud.sdk.domain.ApphudNonRenewingPurchase import com.apphud.sdk.domain.ApphudProduct import com.apphud.sdk.domain.ApphudSubscription -import com.apphud.sdk.domain.Customer +import com.apphud.sdk.domain.ApphudUser import com.apphud.sdk.internal.callback_status.PurchaseCallbackStatus import com.apphud.sdk.internal.callback_status.PurchaseUpdatedCallbackStatus import com.apphud.sdk.managers.RequestManager @@ -26,47 +26,42 @@ internal fun ApphudInternal.purchase( consumableInappProduct: Boolean, callback: ((ApphudPurchaseResult) -> Unit)?, ) { - if (apphudProduct == null && productId.isNullOrEmpty()) - { - callback?.invoke(ApphudPurchaseResult(null, null, null, ApphudError("Invalid parameters"))) - return - } + if (apphudProduct == null && productId.isNullOrEmpty()) { + callback?.invoke(ApphudPurchaseResult(null, null, null, ApphudError("Invalid parameters"))) + return + } val productToPurchase = apphudProduct - ?: - productId?.let { pId -> + ?: productId?.let { pId -> val products = paywalls.map { it.products ?: listOf() }.flatten().distinctBy { it.id } - products.firstOrNull { it.product_id == pId } + products.firstOrNull { it.productId == pId } } productToPurchase?.let { product -> var details = product.productDetails - if (details == null) - { - details = getProductDetailsByProductId(product.product_id) - } + if (details == null) { + details = getProductDetailsByProductId(product.productId) + } details?.let { - if (details.productType == BillingClient.ProductType.SUBS) - { - offerIdToken?.let { - purchaseInternal(activity, product, offerIdToken, oldToken, replacementMode, consumableInappProduct, callback) - } ?: run { - callback?.invoke(ApphudPurchaseResult(null, null, null, ApphudError("OfferToken required"))) - } - } else - { + if (details.productType == BillingClient.ProductType.SUBS) { + offerIdToken?.let { purchaseInternal(activity, product, offerIdToken, oldToken, replacementMode, consumableInappProduct, callback) + } ?: run { + callback?.invoke(ApphudPurchaseResult(null, null, null, ApphudError("OfferToken required"))) } + } else { + purchaseInternal(activity, product, offerIdToken, oldToken, replacementMode, consumableInappProduct, callback) + } } ?: run { coroutineScope.launch(errorHandler) { fetchDetails(activity, product, offerIdToken, oldToken, replacementMode, consumableInappProduct, callback) } } } ?: run { - val id = productId ?: apphudProduct?.product_id ?: "" - callback?.invoke(ApphudPurchaseResult(null, null, null, ApphudError("Appphud product not found: $id"))) + val id = productId ?: apphudProduct?.productId ?: "" + callback?.invoke(ApphudPurchaseResult(null, null, null, ApphudError("Apphud product not found: $id"))) } } @@ -79,30 +74,28 @@ private suspend fun ApphudInternal.fetchDetails( consumableInappProduct: Boolean, callback: ((ApphudPurchaseResult) -> Unit)?, ) { - val productName: String = apphudProduct.product_id - if (loadDetails(productName, apphudProduct)) - { - getProductDetailsByProductId(productName)?.let { details -> - mainScope.launch { - apphudProduct.productDetails = details - purchaseInternal(activity, apphudProduct, offerIdToken, oldToken, prorationMode, consumableInappProduct, callback) - } - } - } else - { - val message = "Unable to fetch product with given product id: $productName" + apphudProduct.let { " [Apphud product ID: " + it.id + "]" } - ApphudLog.log(message = message, sendLogToServer = true) + val productName: String = apphudProduct.productId + if (loadDetails(productName, apphudProduct)) { + getProductDetailsByProductId(productName)?.let { details -> mainScope.launch { - callback?.invoke(ApphudPurchaseResult(null, null, null, ApphudError(message))) + apphudProduct.productDetails = details + purchaseInternal(activity, apphudProduct, offerIdToken, oldToken, prorationMode, consumableInappProduct, callback) } } + } else { + val message = "Unable to fetch product with given product id: $productName" + apphudProduct.let { " [Apphud product ID: " + it.id + "]" } + ApphudLog.log(message = message, sendLogToServer = true) + mainScope.launch { + callback?.invoke(ApphudPurchaseResult(null, null, null, ApphudError(message))) + } + } } private suspend fun ApphudInternal.loadDetails( productId: String?, apphudProduct: ApphudProduct?, ): Boolean { - val productName: String = productId ?: apphudProduct?.product_id!! + val productName: String = productId ?: apphudProduct?.productId ?: "none" ApphudLog.log("Could not find Product for product id: $productName in memory") ApphudLog.log("Now try fetch it from Google Billing") @@ -184,8 +177,7 @@ private fun ApphudInternal.purchaseInternal( apphudProduct.productDetails?.let { "Unable to buy product with given product id: ${it.productId} " } ?: run { - paywallPaymentCancelled(apphudProduct.paywall_id, apphudProduct.product_id, purchasesResult.result.responseCode) - "Unable to buy product with given product id: ${apphudProduct.productDetails?.productId} " + "Unable to buy product with given product id: ${apphudProduct.productId} " } message += " [Apphud product ID: " + apphudProduct.id + "]" @@ -196,6 +188,14 @@ private fun ApphudInternal.purchaseInternal( secondErrorMessage = purchasesResult.result.debugMessage, errorCode = purchasesResult.result.responseCode, ) + + paywallPaymentCancelled( + apphudProduct.paywallId, + apphudProduct.placementId, + apphudProduct.productId, + purchasesResult.result.responseCode, + ) + ApphudLog.log(message = error.toString()) callback?.invoke(ApphudPurchaseResult(null, null, null, error)) processPurchaseError(purchasesResult) @@ -220,11 +220,10 @@ private fun ApphudInternal.purchaseInternal( } } BillingClient.ProductType.INAPP -> { - if (consumableInappProduct) - { - ApphudLog.log("Start inapp consume purchase") - billing.consume(it) - } else { + if (consumableInappProduct) { + ApphudLog.log("Start inapp consume purchase") + billing.consume(it) + } else { ApphudLog.log("Start inapp purchase acknowledge") billing.acknowledge(it) } @@ -248,7 +247,7 @@ private fun ApphudInternal.purchaseInternal( } apphudProduct.productDetails?.let { - paywallCheckoutInitiated(apphudProduct.paywall_id, apphudProduct.product_id) + paywallCheckoutInitiated(apphudProduct.paywallId, apphudProduct.placementId, apphudProduct.productId) billing.purchase( activity, it, offerIdToken, oldToken, replacementMode, deviceId, @@ -262,7 +261,7 @@ private fun ApphudInternal.purchaseInternal( } } -private fun ApphudInternal.processPurchaseError(status: PurchaseUpdatedCallbackStatus.Error) { +private fun ApphudInternal.processPurchaseError(status: PurchaseUpdatedCallbackStatus.Error) { if (status.result.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) { SharedPreferencesStorage.isNeedSync = true coroutineScope.launch(errorHandler) { @@ -279,15 +278,22 @@ private fun ApphudInternal.sendCheckToApphud( oldToken: String?, callback: ((ApphudPurchaseResult) -> Unit)?, ) { - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { ApphudLog.logE(it.message) - if (fallbackMode) - { - currentUser?.let { - addTempPurchase(it, purchase, apphudProduct.productDetails?.productType ?: "", apphudProduct.product_id, callback) + if (fallbackMode) { + currentUser?.let { + mainScope.launch { + addTempPurchase( + it, + purchase, + apphudProduct.productDetails?.productType ?: "", + apphudProduct.productId, + callback, + ) } } + } } ?: run { coroutineScope.launch(errorHandler) { RequestManager.purchased(purchase, apphudProduct, offerIdToken, oldToken) { customer, error -> @@ -299,18 +305,16 @@ private fun ApphudInternal.sendCheckToApphud( notifyAboutSuccess(it, purchase, newSubscriptions, newPurchases, false, callback) } error?.let { - if (fallbackMode) - { - it.errorCode?.let { code -> - if (code in FALLBACK_ERRORS) - { - currentUser?.let { - addTempPurchase(it, purchase, apphudProduct.productDetails?.productType ?: "", apphudProduct.product_id, callback) - return@launch - } - } + if (fallbackMode) { + it.errorCode?.let { code -> + if (code in FALLBACK_ERRORS) { + currentUser?.let { + addTempPurchase(it, purchase, apphudProduct.productDetails?.productType ?: "", apphudProduct.productId, callback) + return@launch + } } } + } val message = "Unable to validate purchase with error = ${it.message}" + apphudProduct?.let { " [Apphud product ID: " + it.id + "]" } ApphudLog.logI(message = message) @@ -324,43 +328,51 @@ private fun ApphudInternal.sendCheckToApphud( } internal fun ApphudInternal.addTempPurchase( - customer: Customer, + apphudUser: ApphudUser, purchase: Purchase, type: String, productId: String, callback: ( (ApphudPurchaseResult) -> Unit )?, -) { - var newSubscriptions: ApphudSubscription? = null - var newPurchases: ApphudNonRenewingPurchase? = null +) { + var newSubscription: ApphudSubscription? = null + var newPurchase: ApphudNonRenewingPurchase? = null when (type) { BillingClient.ProductType.SUBS -> { - newSubscriptions = ApphudSubscription.createTemporary(productId) - currentUser?.subscriptions?.add(newSubscriptions) - ApphudLog.log("Fallback: created temp SUBS purchase: $productId") + newSubscription = ApphudSubscription.createTemporary(productId) + val mutableSubs = currentUser?.subscriptions?.toMutableList() ?: mutableListOf() + newSubscription.let { + mutableSubs.add(it) + currentUser?.subscriptions = mutableSubs + ApphudLog.log("Fallback: created temp SUBS purchase: $productId") + } } BillingClient.ProductType.INAPP -> { - newPurchases = ApphudNonRenewingPurchase.createTemporary(productId) - currentUser?.purchases?.add(newPurchases) - ApphudLog.log("Fallback: created temp INAPP purchase: $productId") + newPurchase = ApphudNonRenewingPurchase.createTemporary(productId) + val mutablePurchs = currentUser?.purchases?.toMutableList() ?: mutableListOf() + newPurchase.let { + mutablePurchs.add(it) + currentUser?.purchases = mutablePurchs + ApphudLog.log("Fallback: created temp INAPP purchase: $productId") + } } else -> { // nothing } } - notifyAboutSuccess(customer, purchase, newSubscriptions, newPurchases, true, callback) + notifyAboutSuccess(apphudUser, purchase, newSubscription, newPurchase, true, callback) } private fun notifyAboutSuccess( - customer: Customer, + apphudUser: ApphudUser, purchase: Purchase, newSubscription: ApphudSubscription?, newPurchase: ApphudNonRenewingPurchase?, fromFallback: Boolean, callback: ((ApphudPurchaseResult) -> Unit)?, -) { - ApphudInternal.notifyLoadingCompleted(customerLoaded = customer, fromFallback = fromFallback) +) { + ApphudInternal.notifyLoadingCompleted(customerLoaded = apphudUser, fromFallback = fromFallback) if (newSubscription == null && newPurchase == null) { val productId = purchase.products.first() ?: "unknown" @@ -381,8 +393,9 @@ internal fun ApphudInternal.trackPurchase( productDetails: ProductDetails, offerIdToken: String?, paywallIdentifier: String? = null, + placementIdentifier: String? = null, ) { - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { ApphudLog.logE(it.message) } ?: run { @@ -390,6 +403,7 @@ internal fun ApphudInternal.trackPurchase( coroutineScope.launch(errorHandler) { sendPurchasesToApphud( paywallIdentifier, + placementIdentifier, null, purchase, productDetails, diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudInternal+RestorePurchases.kt b/sdk/src/main/java/com/apphud/sdk/ApphudInternal+RestorePurchases.kt index bdb3b3db..26c72a6c 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudInternal+RestorePurchases.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudInternal+RestorePurchases.kt @@ -22,13 +22,13 @@ private val mutexSync = Mutex() internal fun ApphudInternal.syncPurchases( paywallIdentifier: String? = null, + placementIdentifier: String? = null, observerMode: Boolean = true, callback: ApphudPurchasesRestoreCallback? = null, ) { - ApphudLog.log("SyncPurchases()") - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { - ApphudLog.log("SyncPurchases: checkRegistration fail") + ApphudLog.log("SyncPurchases: performWhenUserRegistered fail") callback?.invoke(null, null, error) } ?: run { coroutineScope.launch(errorHandler) { @@ -50,7 +50,7 @@ internal fun ApphudInternal.syncPurchases( } } } else { - ApphudLog.log("SyncPurchases: Products to restore: $purchases") + ApphudLog.log("SyncPurchases: Products to restore: ${purchases.map { it.products.firstOrNull() ?: ""}} ") val restoredPurchases = mutableListOf() val purchasesToLoadDetails = mutableListOf() @@ -68,7 +68,7 @@ internal fun ApphudInternal.syncPurchases( } if (purchasesToLoadDetails.isNotEmpty()) { - ApphudLog.log("SyncPurchases: Load product details for: $purchasesToLoadDetails") + ApphudLog.log("SyncPurchases: Load product details for: ${purchasesToLoadDetails.map { it.products.firstOrNull() ?: "" } }") val subsRestored = billing.restoreSync(BillingClient.ProductType.SUBS, purchasesToLoadDetails) val inapsRestored = billing.restoreSync(BillingClient.ProductType.INAPP, purchasesToLoadDetails) @@ -78,9 +78,7 @@ internal fun ApphudInternal.syncPurchases( ApphudLog.log("SyncPurchases: All products details already loaded.") } - ApphudLog.log("SyncPurchases: Products restored: $restoredPurchases") - - ApphudLog.log("SyncPurchases: observerMode = $observerMode") + ApphudLog.log("SyncPurchases: Products restored: ${restoredPurchases.map { it.details.productId } }") if (observerMode && prevPurchases.containsAll(restoredPurchases)) { ApphudLog.log("SyncPurchases: Don't send equal purchases from prev state") @@ -89,9 +87,9 @@ internal fun ApphudInternal.syncPurchases( refreshEntitlements(true) } } else { - ApphudLog.log("SyncPurchases: call syncPurchasesWithApphud()") sendPurchasesToApphud( paywallIdentifier, + placementIdentifier, restoredPurchases, null, null, @@ -101,7 +99,6 @@ internal fun ApphudInternal.syncPurchases( ) } } - ApphudLog.log("SyncPurchases: finish") } } } @@ -110,19 +107,17 @@ internal fun ApphudInternal.syncPurchases( internal suspend fun ApphudInternal.sendPurchasesToApphud( paywallIdentifier: String? = null, + placementIdentifier: String? = null, tempPurchaseRecordDetails: List?, purchase: Purchase?, productDetails: ProductDetails?, offerIdToken: String?, callback: ApphudPurchasesRestoreCallback? = null, observerMode: Boolean, -) { +) { val apphudProduct: ApphudProduct? = - tempPurchaseRecordDetails?.let { - findJustPurchasedProduct(paywallIdentifier, it) - } ?: run { - findJustPurchasedProduct(paywallIdentifier, productDetails) - } + findJustPurchasedProduct(paywallIdentifier, placementIdentifier, productDetails, tempPurchaseRecordDetails) + val customer = RequestManager.restorePurchasesSync( apphudProduct, @@ -140,16 +135,15 @@ internal suspend fun ApphudInternal.sendPurchasesToApphud( "Ensure Google Service Credentials are correct and have necessary permissions. " + "Check https://docs.apphud.com/getting-started/creating-app#google-play-service-credentials or contact support." ApphudLog.logE(message = message) - } else - { - ApphudLog.log("SyncPurchases: customer was successfully updated $customer") - } + } else { + ApphudLog.log("SyncPurchases: customer was successfully updated $customer") + } storage.isNeedSync = false prevPurchases.addAll(records) } - userId = customer.user.userId + userId = customer.userId mainScope.launch { ApphudLog.log("SyncPurchases: success $customer") @@ -165,7 +159,7 @@ internal suspend fun ApphudInternal.sendPurchasesToApphud( } } -private fun processHistoryCallbackStatus(result: PurchaseHistoryCallbackStatus): List { +private fun processHistoryCallbackStatus(result: PurchaseHistoryCallbackStatus): List { when (result) { is PurchaseHistoryCallbackStatus.Error -> { val type = if (result.type() == BillingClient.ProductType.SUBS) "subscriptions" else "in-app products" @@ -182,7 +176,7 @@ private fun processHistoryCallbackStatus(result: PurchaseHistoryCallbackStatus): return emptyList() } -private fun processRestoreCallbackStatus(result: PurchaseRestoredCallbackStatus): List { +private fun processRestoreCallbackStatus(result: PurchaseRestoredCallbackStatus): List { when (result) { is PurchaseRestoredCallbackStatus.Error -> { val type = if (result.type() == BillingClient.ProductType.SUBS) "subscriptions" else "in-app products" @@ -203,41 +197,28 @@ private fun processRestoreCallbackStatus(result: PurchaseRestoredCallbackStatus) private fun ApphudInternal.findJustPurchasedProduct( paywallIdentifier: String?, - tempPurchaseRecordDetails: List, -): ApphudProduct? { + placementIdentifier: String?, + productDetails: ProductDetails?, + tempPurchaseRecordDetails: List?, +): ApphudProduct? { try { - paywallIdentifier?.let { - getPaywalls().firstOrNull { it.identifier == paywallIdentifier } - ?.let { currentPaywall -> - val record = tempPurchaseRecordDetails.maxByOrNull { it.record.purchaseTime } - record?.let { rec -> - val offset = System.currentTimeMillis() - rec.record.purchaseTime - if (offset < 300000L) { // 5 min - return currentPaywall.products?.find { it.productDetails?.productId == rec.details.productId } - } - } - } - } - } catch (ex: Exception) { - ex.message?.let { - ApphudLog.logE(message = it) + val targetPaywall = + if (placementIdentifier != null) { + placements.firstOrNull { it.identifier == placementIdentifier }?.paywall + } else { + paywalls.firstOrNull { it.identifier == paywallIdentifier } + } + + productDetails?.let { details -> + return targetPaywall?.products?.find { it.productDetails?.productId == details.productId } } - } - return null -} -internal fun ApphudInternal.findJustPurchasedProduct( - paywallIdentifier: String?, - productDetails: ProductDetails?, -): ApphudProduct? { - try { - paywallIdentifier?.let { - getPaywalls().firstOrNull { it.identifier == paywallIdentifier } - ?.let { currentPaywall -> - productDetails?.let { details -> - return currentPaywall.products?.find { it.productDetails?.productId == details.productId } - } - } + val record = tempPurchaseRecordDetails?.maxByOrNull { it.record.purchaseTime } + record?.let { rec -> + val offset = System.currentTimeMillis() - rec.record.purchaseTime + if (offset < 300000L) { // 5 min + return targetPaywall?.products?.find { it.productId == rec.details.productId } + } } } catch (ex: Exception) { ex.message?.let { diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudInternal.kt b/sdk/src/main/java/com/apphud/sdk/ApphudInternal.kt index 8cf656dc..6325ae47 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudInternal.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudInternal.kt @@ -31,7 +31,7 @@ internal object ApphudInternal { internal val mainScope = CoroutineScope(Dispatchers.Main) internal val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) internal val errorHandler = - CoroutineExceptionHandler { context, error -> + CoroutineExceptionHandler { _, error -> error.message?.let { ApphudLog.logE(it) } } @@ -42,7 +42,10 @@ internal object ApphudInternal { internal val storage by lazy { SharedPreferencesStorage.getInstance(context) } internal var prevPurchases = mutableSetOf() internal var productDetails = mutableListOf() - internal var paywalls = mutableListOf() + internal var paywalls = listOf() + internal var placements = listOf() + + internal var didLoadOfferings = false private val handler: Handler = Handler(Looper.getMainLooper()) private val pendingUserProperties = mutableMapOf() @@ -67,30 +70,29 @@ internal object ApphudInternal { } private const val MUST_REGISTER_ERROR = " :You must call `Apphud.start` method before calling any other methods." - private var generatedUUID = UUID.randomUUID().toString() internal var productGroups: MutableList = mutableListOf() private var allowIdentifyUser = true internal var didRegisterCustomerAtThisLaunch = false private var is_new = true private lateinit var apiKey: ApiKey lateinit var deviceId: DeviceId - + private var notifyFullyLoaded = false internal var fallbackMode = false internal lateinit var userId: UserId internal lateinit var context: Context - internal var currentUser: Customer? = null + internal var currentUser: ApphudUser? = null internal var apphudListener: ApphudListener? = null private var customProductsFetchedBlock: ((List) -> Unit)? = null - private var paywallsFetchedBlock: ((List) -> Unit)? = null + private var offeringsPreparedCallbacks = mutableListOf<(() -> Unit)>() + private var userRegisteredBlock: ((ApphudUser) -> Unit)? = null private var lifecycleEventObserver = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_STOP -> { - if (fallbackMode) - { - storage.isNeedSync = true - } + if (fallbackMode) { + storage.isNeedSync = true + } ApphudLog.log("Application stopped [need sync ${storage.isNeedSync}]") } Lifecycle.Event.ON_START -> { @@ -108,8 +110,9 @@ internal object ApphudInternal { internal fun initialize( context: Context, apiKey: ApiKey, - userId: UserId?, - deviceId: DeviceId?, + inputUserId: UserId?, + inputDeviceId: DeviceId?, + callback: ((ApphudUser) -> Unit)?, ) { if (!allowIdentifyUser) { ApphudLog.logE( @@ -127,139 +130,181 @@ internal object ApphudInternal { ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleEventObserver) } - ApphudLog.log("Start initialization with userId=$userId, deviceId=$deviceId") + ApphudLog.log("Start initialization with userId=$inputUserId, deviceId=$inputDeviceId") if (apiKey.isEmpty()) throw Exception("ApiKey can't be empty") this.context = context this.apiKey = apiKey - billing = BillingWrapper(context) - val needRegistration = needRegistration(userId) + val cachedUser = storage.apphudUser + val cachedPaywalls = readPaywallsFromCache() + val cachedPlacements = readPlacementsFromCache() + val cachedGroups = readGroupsFromCache() + val cachedDeviceId = storage.deviceId + val cachedUserId = storage.userId - this.userId = updateUser(id = userId) - this.deviceId = updateDevice(id = deviceId) - RequestManager.setParams(this.context, this.userId, this.deviceId, this.apiKey) + val generatedUUID = UUID.randomUUID().toString() - allowIdentifyUser = false - ApphudLog.log("Start initialize with saved userId=${this.userId}, saved deviceId=${this.deviceId}") + val newUserId = + if (inputUserId.isNullOrBlank()) { + cachedUserId ?: generatedUUID + } else { + inputUserId + } + val newDeviceId = + if (inputDeviceId.isNullOrBlank()) { + cachedDeviceId ?: generatedUUID + } else { + inputDeviceId + } + + val credentialsChanged = cachedUserId != newUserId || cachedDeviceId != newDeviceId + + if (credentialsChanged) { + storage.userId = newUserId + storage.deviceId = newDeviceId + } - // Restore from cache - this.currentUser = storage.customer - RequestManager.currentUser = this.currentUser - this.productGroups = readGroupsFromCache() - this.paywalls = readPaywallsFromCache() + /** + * We cannot get paywalls and placements from paying current user, + * because paying currentUser doesn't have cache timeout because it has + * purchases history + * + * But paywalls and placements must have cache timeout + */ + this.userId = newUserId + this.deviceId = newDeviceId + this.currentUser = cachedUser + this.productGroups = cachedGroups + this.paywalls = cachedPaywalls + this.placements = cachedPlacements + + this.userRegisteredBlock = callback + billing = BillingWrapper(context) + RequestManager.setParams(this.context, this.userId, this.deviceId, this.apiKey) + allowIdentifyUser = false loadProducts() + val needRegistration = needRegistration(credentialsChanged, cachedPaywalls, cachedUser) + if (needRegistration) { registration(this.userId, this.deviceId, true, null) - } else - { - notifyLoadingCompleted(storage.customer, null, true) + } else { + mainScope.launch { + notifyLoadingCompleted(cachedUser, null, true) } + } } - internal fun refreshEntitlements(forceRefresh: Boolean = false) { - if (didRegisterCustomerAtThisLaunch || forceRefresh) - { - ApphudLog.log("RefreshEntitlements: didRegister:$didRegisterCustomerAtThisLaunch force:$forceRefresh") - registration(this.userId, this.deviceId, true, null) - } - } //endregion //region === Registration === - private fun needRegistration(passedUserId: String?): Boolean { - passedUserId?.let { - if (!storage.userId.isNullOrEmpty()) - { - if (it != storage.userId) { - return true - } - } - } - if (storage.userId.isNullOrEmpty() || - storage.deviceId.isNullOrEmpty() || - storage.customer == null || - storage.paywalls == null || - storage.needRegistration() - ) { - return true + private fun needRegistration( + credentialsChanged: Boolean, + cachedPaywalls: List?, + cachedUser: ApphudUser?, + ): Boolean { + return credentialsChanged || + cachedPaywalls == null || + cachedUser == null || + cachedUser.hasPurchases() || + storage.cacheExpired(cachedUser) + } + + internal fun refreshEntitlements(forceRefresh: Boolean = false) { + if (didRegisterCustomerAtThisLaunch || forceRefresh) { + ApphudLog.log("RefreshEntitlements: didRegister:$didRegisterCustomerAtThisLaunch force:$forceRefresh") + registration(this.userId, this.deviceId, true, null) } - return false } - private var notifyFullyLoaded = false - @Synchronized internal fun notifyLoadingCompleted( - customerLoaded: Customer? = null, + customerLoaded: ApphudUser? = null, productDetailsLoaded: List? = null, fromCache: Boolean = false, fromFallback: Boolean = false, - ) { - var restorePaywalls = true + ) { + var paywallsPrepared = true productDetailsLoaded?.let { productGroups = readGroupsFromCache() updateGroupsWithProductDetails(productGroups) - // notify that productDetails are loaded - apphudListener?.apphudFetchProductDetails(getProductDetailsList()) - customProductsFetchedBlock?.invoke(getProductDetailsList()) + synchronized(productDetails) { + // notify that productDetails are loaded + apphudListener?.apphudFetchProductDetails(productDetails) + customProductsFetchedBlock?.invoke(productDetails) + } } customerLoaded?.let { + var updateOfferingsFromCustomer = false + if (fromCache || fromFallback) { - RequestManager.currentUser = it notifyFullyLoaded = true } else { if (it.paywalls.isNotEmpty()) { notifyFullyLoaded = true + updateOfferingsFromCustomer = true cachePaywalls(it.paywalls) + cachePlacements(it.placements) } else { /* Attention: * If customer loaded without paywalls, do not reload paywalls from cache! * If cache time is over, paywall from cache will be NULL */ - restorePaywalls = false + paywallsPrepared = false } storage.updateCustomer(it, apphudListener) } - if (restorePaywalls || fromFallback) { + if (updateOfferingsFromCustomer) { + paywalls = it.paywalls + placements = it.placements + } else if (paywallsPrepared || fromFallback) { paywalls = readPaywallsFromCache() + placements = readPlacementsFromCache() } currentUser = it - RequestManager.currentUser = currentUser - userId = it.user.userId + userId = it.userId + // TODO: should be called only if something changed apphudListener?.apphudNonRenewingPurchasesUpdated(currentUser!!.purchases) apphudListener?.apphudSubscriptionsUpdated(currentUser!!.subscriptions) if (!didRegisterCustomerAtThisLaunch) { - apphudListener?.userDidLoad() - } - if (it.isTemporary == false && !fallbackMode) - { + apphudListener?.userDidLoad(it) + this.userRegisteredBlock?.invoke(it) + this.userRegisteredBlock = null + + if (it.isTemporary == false && !fallbackMode) { didRegisterCustomerAtThisLaunch = true } + } - if (!fromFallback && fallbackMode) - { - disableFallback() - } + if (!fromFallback && fallbackMode) { + disableFallback() + } } - updatePaywallsWithProductDetails(paywalls) + updatePaywallsAndPlacements() - if (restorePaywalls && currentUser != null && paywalls.isNotEmpty() && productDetails.isNotEmpty() && notifyFullyLoaded) - { - notifyFullyLoaded = false + if (paywallsPrepared && currentUser != null && paywalls.isNotEmpty() && productDetails.isNotEmpty() && notifyFullyLoaded) { + notifyFullyLoaded = false + if (!didLoadOfferings) { + didLoadOfferings = true apphudListener?.paywallsDidFullyLoad(paywalls) - paywallsFetchedBlock?.invoke(paywalls) + apphudListener?.placementsDidFullyLoad(placements) + offeringsPreparedCallbacks.forEach { it.invoke() } + offeringsPreparedCallbacks.clear() + ApphudLog.log("Did Fully Load") } + } else { + ApphudLog.log("Not yet fully loaded") + } } private val mutex = Mutex() @@ -268,7 +313,7 @@ internal object ApphudInternal { userId: UserId, deviceId: DeviceId, forceRegistration: Boolean = false, - completionHandler: ((Customer?, ApphudError?) -> Unit)?, + completionHandler: ((ApphudUser?, ApphudError?) -> Unit)?, ) { coroutineScope.launch(errorHandler) { mutex.withLock { @@ -278,7 +323,7 @@ internal object ApphudInternal { "Registration conditions: user_is_null=${currentUser == null}, forceRegistration=$forceRegistration isTemporary=${currentUser?.isTemporary}", ) - RequestManager.registration(!didRegisterCustomerAtThisLaunch, is_new, forceRegistration) { customer, error -> + RequestManager.registration(!didRegisterCustomerAtThisLaunch, is_new, forceRegistration) { customer, _ -> customer?.let { currentUser = it storage.lastRegistration = System.currentTimeMillis() @@ -294,7 +339,7 @@ internal object ApphudInternal { if (storage.isNeedSync) { coroutineScope.launch(errorHandler) { - ApphudLog.log("Registration: syncPurchases()") + ApphudLog.log("Registration: isNeedSync true, start syncing") syncPurchases() } } @@ -308,33 +353,40 @@ internal object ApphudInternal { } } } - } else - { - mainScope.launch { - completionHandler?.invoke(currentUser, null) - } + } else { + mainScope.launch { + completionHandler?.invoke(currentUser, null) } + } } } } - private suspend fun repeatRegistrationSilent() { - RequestManager.registrationSync(!didRegisterCustomerAtThisLaunch, is_new, true) + private suspend fun repeatRegistrationSilent() { + val newUser = RequestManager.registrationSync(!didRegisterCustomerAtThisLaunch, is_new, true) + + newUser?.let { + storage.lastRegistration = System.currentTimeMillis() + mainScope.launch { notifyLoadingCompleted(it) } + } } internal fun productsFetchCallback(callback: (List) -> Unit) { - customProductsFetchedBlock = callback if (productDetails.isNotEmpty()) { - customProductsFetchedBlock?.invoke(productDetails) + callback.invoke(productDetails) + } else { + customProductsFetchedBlock = callback } } - internal fun paywallsFetchCallback(callback: (List) -> Unit) { - paywallsFetchedBlock = callback - if (paywalls.isNotEmpty() && productDetails.isNotEmpty()) { - paywallsFetchedBlock?.invoke(paywalls) + internal fun performWhenOfferingsPrepared(callback: () -> Unit) { + if (didLoadOfferings) { + callback.invoke() + } else { + offeringsPreparedCallbacks.add(callback) } } + //endregion //region === User Properties === @@ -367,10 +419,9 @@ internal object ApphudInternal { type = typeString, ) - if (!storage.needSendProperty(property)) - { - return - } + if (!storage.needSendProperty(property)) { + return + } synchronized(pendingUserProperties) { pendingUserProperties.run { @@ -392,7 +443,7 @@ internal object ApphudInternal { setNeedsToUpdateUserProperties = false if (pendingUserProperties.isEmpty()) return - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { ApphudLog.logE(it.message) } ?: run { @@ -441,14 +492,18 @@ internal object ApphudInternal { } internal fun updateUserId(userId: UserId) { + if (userId.isBlank()) { + ApphudLog.log("Invalid UserId=$userId") + return + } ApphudLog.log("Start updateUserId userId=$userId") - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { ApphudLog.logE(it.message) } ?: run { - val id = updateUser(id = userId) - this.userId = id + this.userId = userId + storage.userId = userId RequestManager.setParams(this.context, userId, this.deviceId, this.apiKey) coroutineScope.launch(errorHandler) { @@ -468,29 +523,13 @@ internal object ApphudInternal { //endregion //region === Primary methods === - fun getPaywalls(): List { - var out: MutableList - synchronized(this.paywalls) { - out = this.paywalls.toCollection(mutableListOf()) - } - return out - } - - fun permissionGroups(): List { - var out: MutableList - synchronized(this.productGroups) { - out = this.productGroups.toCollection(mutableListOf()) - } - return out - } - fun grantPromotional( daysCount: Int, productId: String?, permissionGroup: ApphudGroup?, callback: ((Boolean) -> Unit)?, ) { - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { callback?.invoke(false) } ?: run { @@ -516,28 +555,8 @@ internal object ApphudInternal { } } - fun subscriptions(): List { - var subscriptions: MutableList = mutableListOf() - this.currentUser?.let { user -> - synchronized(user) { - subscriptions = user.subscriptions.toCollection(mutableListOf()) - } - } - return subscriptions.filter { !it.isTemporary || it.isActive() } - } - - fun purchases(): List { - var purchases: MutableList = mutableListOf() - this.currentUser?.let { user -> - synchronized(user) { - purchases = user.purchases.toCollection(mutableListOf()) - } - } - return purchases.filter { !it.isTemporary || it.isActive() } - } - fun paywallShown(paywall: ApphudPaywall) { - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { ApphudLog.logI(error.message) } ?: run { @@ -549,7 +568,7 @@ internal object ApphudInternal { } fun paywallClosed(paywall: ApphudPaywall) { - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { ApphudLog.logI(error.message) } ?: run { @@ -561,52 +580,53 @@ internal object ApphudInternal { } internal fun paywallCheckoutInitiated( - paywall_id: String?, - product_id: String?, + paywallId: String?, + placementId: String?, + productId: String?, ) { - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { ApphudLog.logI(error.message) } ?: run { coroutineScope.launch(errorHandler) { - RequestManager.paywallCheckoutInitiated(paywall_id, product_id) + RequestManager.paywallCheckoutInitiated(paywallId, placementId, productId) } } } } internal fun paywallPaymentCancelled( - paywall_id: String?, - product_id: String?, - error_Code: Int, + paywallId: String?, + placementId: String?, + productId: String?, + errorCode: Int, ) { - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { ApphudLog.logI(error.message) } ?: run { coroutineScope.launch(errorHandler) { - if (error_Code == BillingClient.BillingResponseCode.USER_CANCELED) { - RequestManager.paywallPaymentCancelled(paywall_id, product_id) - } else - { - RequestManager.paywallPaymentError(paywall_id, product_id, error_Code.toString()) - } + if (errorCode == BillingClient.BillingResponseCode.USER_CANCELED) { + RequestManager.paywallPaymentCancelled(paywallId, placementId, productId) + } else { + val errorMessage = ApphudBillingResponseCodes.getName(errorCode) + RequestManager.paywallPaymentError(paywallId, placementId, productId, errorMessage) + } } } } } - internal fun checkRegistration(callback: (ApphudError?) -> Unit) { + internal fun performWhenUserRegistered(callback: (ApphudError?) -> Unit) { if (!isInitialized()) { callback.invoke(ApphudError(MUST_REGISTER_ERROR)) return } currentUser?.let { - if (it.isTemporary == false) - { - callback.invoke(null) - } else { + if (it.isTemporary == false) { + callback.invoke(null) + } else { registration(this.userId, this.deviceId) { _, error -> callback.invoke(error) } @@ -618,16 +638,8 @@ internal object ApphudInternal { } } - internal fun getProductDetailsList(): List { - var out: MutableList - synchronized(this.productDetails) { - out = this.productDetails.toCollection(mutableListOf()) - } - return out - } - fun sendErrorLogs(message: String) { - checkRegistration { error -> + performWhenUserRegistered { error -> error?.let { ApphudLog.logI(error.message) } ?: run { @@ -638,7 +650,7 @@ internal object ApphudInternal { } } - private suspend fun fetchAdvertisingId(): String? { + private suspend fun fetchAdvertisingId(): String? { return RequestManager.fetchAdvertisingId() } @@ -647,9 +659,6 @@ internal object ApphudInternal { val client = AppSet.getClient(applicationContext) val task: Task = client.appSetIdInfo task.addOnSuccessListener { - // Determine current scope of app set ID. - val scope: Int = it.scope - // Read app set ID value, which uses version 4 of the // universally unique identifier (UUID) format. val id: String = it.id @@ -678,6 +687,19 @@ internal object ApphudInternal { } } + fun getProductDetails(): List { + synchronized(productDetails) { + return productDetails.toCollection(mutableListOf()) + } + } + fun getPermissionGroups(): List { + var out: MutableList + synchronized(this.productGroups) { + out = this.productGroups.toCollection(mutableListOf()) + } + return out + } + @Synchronized fun collectDeviceIdentifiers() { if (!isInitialized()) { @@ -691,58 +713,50 @@ internal object ApphudInternal { } coroutineScope.launch(errorHandler) { - var repeatRegistration = false + val cachedIdentifiers = storage.deviceIdentifiers + val newIdentifiers = arrayOf("", "", "") val threads = listOf( async { - val advertisingId = fetchAdvertisingId() - advertisingId?.let { - if (it == "00000000-0000-0000-0000-000000000000") - { - ApphudLog.log("Unable to fetch Advertising ID, please check AD_ID permission in the manifest file.") - } else if (RequestManager.advertisingId.isNullOrEmpty() || RequestManager.advertisingId != it) { - repeatRegistration = true - RequestManager.advertisingId = it - ApphudLog.log(message = "advertisingID: $it") - } - } ?: run { + val adId = fetchAdvertisingId() + if (adId == null || adId == "00000000-0000-0000-0000-000000000000") { ApphudLog.log("Unable to fetch Advertising ID, please check AD_ID permission in the manifest file.") + } else { + newIdentifiers[0] = adId } }, async { val appSetID = fetchAppSetId() appSetID?.let { - repeatRegistration = true - RequestManager.appSetId = it - ApphudLog.log(message = "appSetID: $it") + newIdentifiers[1] = it } }, async { val androidID = fetchAndroidId() androidID?.let { - repeatRegistration = true - RequestManager.androidId = it - ApphudLog.log(message = "androidID: $it") + newIdentifiers[2] = it } }, ) threads.awaitAll().let { - if (repeatRegistration) { + if (!newIdentifiers.contentEquals(cachedIdentifiers)) { + storage.deviceIdentifiers = newIdentifiers mutex.withLock { repeatRegistrationSilent() } + } else { + ApphudLog.log("Device Identifiers not changed") } } } } - //endregion - //region === Secondary methods === - internal fun getPackageName(): String { + //endregion//region === Secondary methods === + internal fun getPackageName(): String { return context.packageName } - private fun isInitialized(): Boolean { + private fun isInitialized(): Boolean { return ::context.isInitialized && ::userId.isInitialized && ::deviceId.isInitialized && @@ -767,9 +781,9 @@ internal object ApphudInternal { private fun clear() { RequestManager.cleanRegistration() currentUser = null - generatedUUID = UUID.randomUUID().toString() - productsLoaded.set(0) + productsLoaded.set(false) customProductsFetchedBlock = null + offeringsPreparedCallbacks.clear() storage.clean() prevPurchases.clear() productDetails.clear() @@ -779,36 +793,6 @@ internal object ApphudInternal { setNeedsToUpdateUserProperties = false } - private fun updateUser(id: UserId?): UserId { - val userId = - when { - id == null || id.isBlank() -> { - storage.userId ?: generatedUUID - } - else -> { - id - } - } - storage.userId = userId - return userId - } - - private fun updateDevice(id: DeviceId?): DeviceId { - val deviceId = - when { - id == null || id.isBlank() -> { - storage.deviceId?.let { - is_new = false - it - } ?: generatedUUID - } - else -> { - id - } - } - storage.deviceId = deviceId - return deviceId - } //endregion //region === Cache === @@ -817,14 +801,14 @@ internal object ApphudInternal { storage.productGroups = groups } - private fun readGroupsFromCache(): MutableList { + private fun readGroupsFromCache(): MutableList { return storage.productGroups?.toMutableList() ?: mutableListOf() } private fun updateGroupsWithProductDetails(productGroups: List) { productGroups.forEach { group -> group.products?.forEach { product -> - product.productDetails = getProductDetailsByProductId(product.product_id) + product.productDetails = getProductDetailsByProductId(product.productId) } } } @@ -834,15 +818,39 @@ internal object ApphudInternal { storage.paywalls = paywalls } - internal fun readPaywallsFromCache(): MutableList { - return storage.paywalls?.toMutableList() ?: mutableListOf() + private fun readPaywallsFromCache(): List { + return storage.paywalls ?: listOf() + } + + private fun cachePlacements(placements: List) { + storage.placements = placements + } + + private fun readPlacementsFromCache(): List { + return storage.placements ?: listOf() } - private fun updatePaywallsWithProductDetails(paywalls: List) { + private fun updatePaywallsAndPlacements() { synchronized(paywalls) { paywalls.forEach { paywall -> paywall.products?.forEach { product -> - product.productDetails = getProductDetailsByProductId(product.product_id) + product.paywallId = paywall.id + product.paywallIdentifier = paywall.identifier + product.productDetails = getProductDetailsByProductId(product.productId) + } + } + } + + synchronized(placements) { + placements.forEach { placement -> + val paywall = placement.paywall + paywall?.placementId = placement.id + paywall?.products?.forEach { product -> + product.paywallId = placement.paywall.id + product.paywallIdentifier = placement.paywall.identifier + product.placementId = placement.id + product.placementIdentifier = placement.identifier + product.productDetails = getProductDetailsByProductId(product.productId) } } } @@ -850,10 +858,7 @@ internal object ApphudInternal { // Find ProductDetails ====================================== internal fun getProductDetailsByProductId(productIdentifier: String): ProductDetails? { - var productDetail: ProductDetails? - synchronized(productDetails) { - productDetail = productDetails.let { productsList -> productsList.firstOrNull { it.productId == productIdentifier } } - } + val productDetail = productDetails.firstOrNull { it.productId == productIdentifier } return productDetail } //endregion diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudListener.kt b/sdk/src/main/java/com/apphud/sdk/ApphudListener.kt index 32ddff09..e62dc671 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudListener.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudListener.kt @@ -3,7 +3,9 @@ package com.apphud.sdk import com.android.billingclient.api.ProductDetails import com.apphud.sdk.domain.ApphudNonRenewingPurchase import com.apphud.sdk.domain.ApphudPaywall +import com.apphud.sdk.domain.ApphudPlacement import com.apphud.sdk.domain.ApphudSubscription +import com.apphud.sdk.domain.ApphudUser interface ApphudListener { /** @@ -32,18 +34,28 @@ interface ApphudListener { fun apphudDidChangeUserID(userId: String) /** - Called when user is registered in Apphud [or used from cache]. - After this method is called, Apphud.paywalls() will begin to return values, - however their ProductDetails may still be nil at the moment. - - You should only use this method in two cases: - 1) If using A/B testing, to fetch `experimentName` from your paywalls. - 2) To update User ID via Apphud.updateUserId method which should be placed inside. + * This method is invoked when a user is registered in Apphud + * or retrieved from the cache. It is called once per app lifecycle. + * + * The `ApphudUser` object passed as a parameter contains a record of + * all purchases tracked by Apphud and associated raw placements and + * paywalls for that user. + * These lists may or may not have their inner Google Play products fully + * loaded at the time of this method's call. + * + * __Note__: Do not store `ApphudUser` instance in your own code, + * since it may change at runtime. */ - fun userDidLoad() + fun userDidLoad(user: ApphudUser) /** - Called when paywalls are fully loaded with their ProductDetails. + Called when paywalls are fully loaded with their inner ProductDetails. */ fun paywallsDidFullyLoad(paywalls: List) + + /** + * Called when placements are fully loaded with their ApphudPaywalls and + * inner ProductDetails. + */ + fun placementsDidFullyLoad(placements: List) } diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudLog.kt b/sdk/src/main/java/com/apphud/sdk/ApphudLog.kt index 372387b8..228358c0 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudLog.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudLog.kt @@ -9,7 +9,7 @@ import kotlin.math.pow import kotlin.math.roundToInt internal object ApphudLog { - private const val TAG = "Apphud" + private const val TAG = "ApphudLogs" val data = mutableListOf>() /** @@ -22,10 +22,9 @@ internal object ApphudLog { if (ApphudUtils.logging) { Log.d(TAG, message) } - if (sendLogToServer) - { - sendErrorLogs(message) - } + if (sendLogToServer) { + sendErrorLogs(message) + } } /** @@ -38,10 +37,9 @@ internal object ApphudLog { if (ApphudUtils.logging) { Log.i(TAG, message) } - if (sendLogToServer) - { - sendErrorLogs(message) - } + if (sendLogToServer) { + sendErrorLogs(message) + } } /** @@ -53,10 +51,9 @@ internal object ApphudLog { ) { Log.e(TAG, message) - if (sendLogToServer) - { - sendErrorLogs(message) - } + if (sendLogToServer) { + sendErrorLogs(message) + } } /** @@ -77,9 +74,8 @@ internal object ApphudLog { path == "/v2/products" || path == "/v2/paywall_configs" || path == "/v1/subscriptions" - ) - { - logI("Benchmark: " + path + ": " + time + "ms") + ) { + logI("Benchmark: " + path + ": " + time + "ms") /*val seconds: Double = time / 1000.0 synchronized(data){ val logItem: MutableMap = mutableMapOf( @@ -89,7 +85,7 @@ internal object ApphudLog { data.add(logItem) } startTimer()*/ - } + } } fun Double.roundTo(numFractionDigits: Int): Double { @@ -99,35 +95,32 @@ internal object ApphudLog { var timer: Timer? = null - fun startTimer() { - if (timer == null) - { - timer = - fixedRateTimer(name = "benchmark_timer", initialDelay = 5000, period = 5000) { - if (data.isNotEmpty()) - { - var body: BenchmarkBody? - synchronized(data) { - val listToSend = mutableListOf>() - listToSend.addAll(data) - body = - BenchmarkBody( - device_id = ApphudInternal.deviceId, - user_id = ApphudInternal.userId, - bundle_id = ApphudInternal.getPackageName(), - data = listToSend, - ) - data.clear() - } - body?.let { - RequestManager.sendBenchmarkLogs(it) - } - } else - { - timer?.cancel() - timer = null - } + fun startTimer() { + if (timer == null) { + timer = + fixedRateTimer(name = "benchmark_timer", initialDelay = 5000, period = 5000) { + if (data.isNotEmpty()) { + var body: BenchmarkBody? + synchronized(data) { + val listToSend = mutableListOf>() + listToSend.addAll(data) + body = + BenchmarkBody( + device_id = ApphudInternal.deviceId, + user_id = ApphudInternal.userId, + bundle_id = ApphudInternal.getPackageName(), + data = listToSend, + ) + data.clear() + } + body?.let { + RequestManager.sendBenchmarkLogs(it) + } + } else { + timer?.cancel() + timer = null } - } + } + } } } diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudUserProperty.kt b/sdk/src/main/java/com/apphud/sdk/ApphudUserProperty.kt index f2842091..859a1615 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudUserProperty.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudUserProperty.kt @@ -33,7 +33,7 @@ data class ApphudUserProperty( return jsonParamsString } - internal fun getValue(): Any { + internal fun getValue(): Any { try { when (type) { "string" -> { diff --git a/sdk/src/main/java/com/apphud/sdk/ApphudUtils.kt b/sdk/src/main/java/com/apphud/sdk/ApphudUtils.kt index 1e854367..4b72bd64 100644 --- a/sdk/src/main/java/com/apphud/sdk/ApphudUtils.kt +++ b/sdk/src/main/java/com/apphud/sdk/ApphudUtils.kt @@ -3,13 +3,16 @@ package com.apphud.sdk /** * This class will contain some utils, more will be added in the future. */ -internal object ApphudUtils { +object ApphudUtils { var packageName: String = "" private set var logging: Boolean = false private set + var httpLogging: Boolean = false + private set + var optOutOfTracking: Boolean = false /** @@ -19,7 +22,12 @@ internal object ApphudUtils { logging = true } - fun setPackageName(packageName: String) { + fun enableAllLogs() { + logging = true + httpLogging = true + } + + internal fun setPackageName(packageName: String) { this.packageName = packageName } } diff --git a/sdk/src/main/java/com/apphud/sdk/aliases.kt b/sdk/src/main/java/com/apphud/sdk/aliases.kt index 363e750d..d1ae71cb 100644 --- a/sdk/src/main/java/com/apphud/sdk/aliases.kt +++ b/sdk/src/main/java/com/apphud/sdk/aliases.kt @@ -10,10 +10,10 @@ typealias ProductId = String typealias Callback1 = (T) -> Unit typealias Callback2 = (T1, T2) -> Unit -typealias CustomerCallback = Callback1 +typealias CustomerCallback = Callback1 typealias ProductsCallback = Callback1> typealias AttributionCallback = Callback1 -typealias PurchasedCallback = Callback2 +typealias PurchasedCallback = Callback2 typealias PaywallCallback = Callback2?, ApphudError?> typealias Milliseconds = Long 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 83cb1b03..ff976efc 100644 --- a/sdk/src/main/java/com/apphud/sdk/body/PurchaseItemBody.kt +++ b/sdk/src/main/java/com/apphud/sdk/body/PurchaseItemBody.kt @@ -10,6 +10,7 @@ data class PurchaseItemBody( val price_amount_micros: Long?, val subscription_period: String?, val paywall_id: String?, + val placement_id: String?, val product_bundle_id: String?, val observer_mode: Boolean = false, val billing_version: Int, diff --git a/sdk/src/main/java/com/apphud/sdk/body/RegistrationBody.kt b/sdk/src/main/java/com/apphud/sdk/body/RegistrationBody.kt index 361d985f..01851b0f 100644 --- a/sdk/src/main/java/com/apphud/sdk/body/RegistrationBody.kt +++ b/sdk/src/main/java/com/apphud/sdk/body/RegistrationBody.kt @@ -18,5 +18,6 @@ data class RegistrationBody( val is_sandbox: Boolean, val is_new: Boolean, val need_paywalls: Boolean, + val need_placements: Boolean, val first_seen: Long?, ) diff --git a/sdk/src/main/java/com/apphud/sdk/client/ApiClient.kt b/sdk/src/main/java/com/apphud/sdk/client/ApiClient.kt index 2f62e0eb..f048d53b 100644 --- a/sdk/src/main/java/com/apphud/sdk/client/ApiClient.kt +++ b/sdk/src/main/java/com/apphud/sdk/client/ApiClient.kt @@ -1,5 +1,6 @@ package com.apphud.sdk.client -internal object ApiClient { - const val host = "https://api.apphud.com" +object ApiClient { + var host = "https://api.apphud.com" + var readTimeout: Long = 10L } diff --git a/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudPlacementDto.kt b/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudPlacementDto.kt new file mode 100644 index 00000000..0e83bcef --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/client/dto/ApphudPlacementDto.kt @@ -0,0 +1,7 @@ +package com.apphud.sdk.client.dto + +class ApphudPlacementDto( + val id: String, + val identifier: String, + val paywalls: List +) diff --git a/sdk/src/main/java/com/apphud/sdk/client/dto/CurrencyDto.kt b/sdk/src/main/java/com/apphud/sdk/client/dto/CurrencyDto.kt index ce91b1a4..833079a5 100644 --- a/sdk/src/main/java/com/apphud/sdk/client/dto/CurrencyDto.kt +++ b/sdk/src/main/java/com/apphud/sdk/client/dto/CurrencyDto.kt @@ -1,7 +1,6 @@ package com.apphud.sdk.client.dto data class CurrencyDto( - val id: String, val code: String?, val country_code: String, ) diff --git a/sdk/src/main/java/com/apphud/sdk/client/dto/CustomerDto.kt b/sdk/src/main/java/com/apphud/sdk/client/dto/CustomerDto.kt index f11be85e..33e29517 100644 --- a/sdk/src/main/java/com/apphud/sdk/client/dto/CustomerDto.kt +++ b/sdk/src/main/java/com/apphud/sdk/client/dto/CustomerDto.kt @@ -1,10 +1,9 @@ package com.apphud.sdk.client.dto data class CustomerDto( - val id: String, val user_id: String, - val locale: String, val subscriptions: List, val currency: CurrencyDto?, val paywalls: List?, + val placements: List?, ) diff --git a/sdk/src/main/java/com/apphud/sdk/domain/ApphudNonRenewingPurchase.kt b/sdk/src/main/java/com/apphud/sdk/domain/ApphudNonRenewingPurchase.kt index 4d8b6084..0e17d5f1 100644 --- a/sdk/src/main/java/com/apphud/sdk/domain/ApphudNonRenewingPurchase.kt +++ b/sdk/src/main/java/com/apphud/sdk/domain/ApphudNonRenewingPurchase.kt @@ -36,14 +36,13 @@ data class ApphudNonRenewingPurchase( * Returns `true` if purchase is not refunded. */ fun isActive() = - if (isTemporary) - { - !isTemporaryExpired() - } else { + if (isTemporary) { + !isTemporaryExpired() + } else { canceledAt == null } - private fun isTemporaryExpired(): Boolean { + private fun isTemporaryExpired(): Boolean { return System.currentTimeMillis() > (canceledAt ?: 0L) } } diff --git a/sdk/src/main/java/com/apphud/sdk/domain/ApphudPaywall.kt b/sdk/src/main/java/com/apphud/sdk/domain/ApphudPaywall.kt index 9c416b82..65434811 100644 --- a/sdk/src/main/java/com/apphud/sdk/domain/ApphudPaywall.kt +++ b/sdk/src/main/java/com/apphud/sdk/domain/ApphudPaywall.kt @@ -29,4 +29,8 @@ data class ApphudPaywall( You can use it for additional analytics. */ val experimentName: String?, + /** + * For internal usage + */ + internal var placementId: String?, ) diff --git a/sdk/src/main/java/com/apphud/sdk/domain/ApphudPlacement.kt b/sdk/src/main/java/com/apphud/sdk/domain/ApphudPlacement.kt new file mode 100644 index 00000000..153904cb --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/domain/ApphudPlacement.kt @@ -0,0 +1,12 @@ +package com.apphud.sdk.domain + +data class ApphudPlacement( + val identifier: String, + val paywall: ApphudPaywall?, + internal val id: String, +) { + /** + * @return A/B experiment name of it's paywall, if any. + */ + var experimentName: String? = paywall?.experimentName +} diff --git a/sdk/src/main/java/com/apphud/sdk/domain/ApphudProduct.kt b/sdk/src/main/java/com/apphud/sdk/domain/ApphudProduct.kt index b47d35b6..85ccdeff 100644 --- a/sdk/src/main/java/com/apphud/sdk/domain/ApphudProduct.kt +++ b/sdk/src/main/java/com/apphud/sdk/domain/ApphudProduct.kt @@ -11,7 +11,7 @@ data class ApphudProduct( /** Product Identifier from Google Play. */ - var product_id: String, + var productId: String, /** Product name from Apphud Dashboard */ @@ -31,13 +31,21 @@ data class ApphudProduct( */ var productDetails: ProductDetails?, /** - * Product Identifier from Paywalls. + * Placement Identifier, if any. */ - var paywall_id: String?, + var placementIdentifier: String?, /** User Generated Paywall Identifier */ - var paywall_identifier: String?, + var paywallIdentifier: String?, + /** + * For internal usage + * */ + internal var placementId: String?, + /** + * For internal usage + */ + internal var paywallId: String?, ) { /** * @returns – Array of subscription offers with given Base Plan Id, or all offers. @@ -49,4 +57,8 @@ data class ApphudProduct( return productDetails?.subscriptionOfferDetails } } + + override fun toString(): String { + return "ApphudProduct(id: ${id}, productId: ${productId}, name: ${name}, basePlanId: ${basePlanId}, productDetails: ${productDetails?.productId ?: "N/A"}, placementIdentifier: ${placementIdentifier}, paywallIdenfitier: ${paywallIdentifier}, placementId: ${placementId}, paywallId: ${paywallId})" + } } 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 be953bf4..8e8b28cf 100644 --- a/sdk/src/main/java/com/apphud/sdk/domain/ApphudSubscription.kt +++ b/sdk/src/main/java/com/apphud/sdk/domain/ApphudSubscription.kt @@ -80,16 +80,15 @@ data class ApphudSubscription( ApphudSubscriptionStatus.REGULAR, ApphudSubscriptionStatus.GRACE, -> - if (isTemporary) - { - !isTemporaryExpired() - } else { + if (isTemporary) { + !isTemporaryExpired() + } else { true } else -> false } - private fun isTemporaryExpired(): Boolean { + private fun isTemporaryExpired(): Boolean { return System.currentTimeMillis() > expiresAt } } diff --git a/sdk/src/main/java/com/apphud/sdk/domain/ApphudUser.kt b/sdk/src/main/java/com/apphud/sdk/domain/ApphudUser.kt index a6745580..5464c43b 100644 --- a/sdk/src/main/java/com/apphud/sdk/domain/ApphudUser.kt +++ b/sdk/src/main/java/com/apphud/sdk/domain/ApphudUser.kt @@ -1,12 +1,74 @@ package com.apphud.sdk.domain +import com.apphud.sdk.ApphudInternal import com.apphud.sdk.UserId data class ApphudUser( + /** - * Unique user identifier. This can be updated later. + * User Identifier. */ val userId: UserId, + + /** + * Currency Code based on user's locale or purchases. + */ val currencyCode: String?, - val currencyCountryCode: String?, -) + + /** + * Country Code based on user's locale or purchases. + */ + val countryCode: String?, + + /** Returns: + * List: A list of user's subscriptions of any statuses. + */ + var subscriptions: List, + + /** Returns: + * List: A list of user's non-consumable or + * consumable purchases, if any. + */ + var purchases: List, + + /** + * There properties are for internal usage, to get paywalls and placements + * use paywalls() and placements() functions below + */ + internal val paywalls: List, + internal val placements: List, + internal val isTemporary: Boolean?, +) { + /** Returns: + * List: A list of placements, potentially altered based + * on the user's involvement in A/B testing, if any. + * + * __Note__: This function doesn't suspend until inner `ProductDetails` + * are loaded from Google Play. That means placements may or may not have + * inner Google Play products at the time you call this function. + * + * To get placements with awaiting for inner Google Play products, use + * Apphud.placements() or Apphud.placementsDidLoadCallback(...) functions. + */ + fun rawPlacements(): List = ApphudInternal.placements + + /** Returns: + * List: A list of paywalls, potentially altered based + * on the user's involvement in A/B testing, if any. + * + * __Note__: This function doesn't suspend until inner `ProductDetails` + * are loaded from Google Play. That means paywalls may or may not have + * inner Google Play products at the time you call this function. + * + * To get paywalls with awaiting for inner Google Play products, use + * Apphud.paywalls() or Apphud.paywallsDidLoadCallback(...) functions. + */ + fun rawPaywalls(): List = ApphudInternal.paywalls + + /** + * Returns true if user has any subscriptions or non-renewing purchases. + */ + fun hasPurchases(): Boolean { + return subscriptions.isNotEmpty() || purchases.isNotEmpty() + } +} diff --git a/sdk/src/main/java/com/apphud/sdk/domain/Customer.kt b/sdk/src/main/java/com/apphud/sdk/domain/Customer.kt deleted file mode 100644 index 2a4e4606..00000000 --- a/sdk/src/main/java/com/apphud/sdk/domain/Customer.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.apphud.sdk.domain - -data class Customer( - val user: ApphudUser, - val subscriptions: MutableList, - val purchases: MutableList, - val paywalls: List, - val isTemporary: Boolean?, -) diff --git a/sdk/src/main/java/com/apphud/sdk/flutter/ApphudFlutter.kt b/sdk/src/main/java/com/apphud/sdk/flutter/ApphudFlutter.kt index 72db95e3..209b8c3f 100644 --- a/sdk/src/main/java/com/apphud/sdk/flutter/ApphudFlutter.kt +++ b/sdk/src/main/java/com/apphud/sdk/flutter/ApphudFlutter.kt @@ -14,7 +14,10 @@ object ApphudFlutter { * Pass `Paywall Identifier` to be able to use A/B tests in Observer Mode. See docs.apphud.com for details. */ @kotlin.jvm.JvmStatic - fun syncPurchases(paywallIdentifier: String? = null) = ApphudInternal.syncPurchases(paywallIdentifier) + fun syncPurchases( + paywallIdentifier: String? = null, + placementIdentifier: String? = null, + ) = ApphudInternal.syncPurchases(paywallIdentifier, placementIdentifier) /** * Purchase product by id and automatically submit Google Play purchase token to Apphud 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 86220336..7aee3e26 100644 --- a/sdk/src/main/java/com/apphud/sdk/internal/BillingWrapper.kt +++ b/sdk/src/main/java/com/apphud/sdk/internal/BillingWrapper.kt @@ -37,18 +37,17 @@ internal class BillingWrapper(context: Context) : Closeable { mutex.withLock { if (billing.isReady) { result = true - } else - { - try { - while (!billing.connect()) { - Thread.sleep(300) - } - result = true - } catch (ex: java.lang.Exception) { - ApphudLog.log("Connect to Billing failed: ${ex.message ?: "error"}") - result = false + } else { + try { + while (!billing.connect()) { + Thread.sleep(300) } + result = true + } catch (ex: java.lang.Exception) { + ApphudLog.log("Connect to Billing failed: ${ex.message ?: "error"}") + result = false } + } } return result } diff --git a/sdk/src/main/java/com/apphud/sdk/internal/FlowWrapper.kt b/sdk/src/main/java/com/apphud/sdk/internal/FlowWrapper.kt index c413b392..af6e15be 100644 --- a/sdk/src/main/java/com/apphud/sdk/internal/FlowWrapper.kt +++ b/sdk/src/main/java/com/apphud/sdk/internal/FlowWrapper.kt @@ -22,29 +22,24 @@ internal class FlowWrapper(private val billing: BillingClient) { obfuscatedAccountId = deviceId?.let { val regex = Regex("[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}") - if (regex.matches(input = it)) - { - it - } else { + if (regex.matches(input = it)) { + it + } else { null } } try { val params: BillingFlowParams = - if (offerToken != null) - { - if (oldToken != null) - { - upDowngradeBillingFlowParamsBuilder(details, offerToken, oldToken, replacementMode) - } else - { - billingFlowParamsBuilder(details, offerToken) - } - } else - { - billingFlowParamsBuilder(details) + if (offerToken != null) { + if (oldToken != null) { + upDowngradeBillingFlowParamsBuilder(details, offerToken, oldToken, replacementMode) + } else { + billingFlowParamsBuilder(details, offerToken) } + } else { + billingFlowParamsBuilder(details) + } billing.launchBillingFlow(activity, params) .also { @@ -129,7 +124,7 @@ internal class FlowWrapper(private val billing: BillingClient) { * * @return [BillingFlowParams]. */ - private fun billingFlowParamsBuilder(productDetails: ProductDetails): BillingFlowParams { + private fun billingFlowParamsBuilder(productDetails: ProductDetails): BillingFlowParams { return BillingFlowParams.newBuilder().setProductDetailsParamsList( listOf( BillingFlowParams.ProductDetailsParams.newBuilder() diff --git a/sdk/src/main/java/com/apphud/sdk/managers/HttpRetryInterceptor.kt b/sdk/src/main/java/com/apphud/sdk/managers/HttpRetryInterceptor.kt index a25dd429..0d5438a2 100644 --- a/sdk/src/main/java/com/apphud/sdk/managers/HttpRetryInterceptor.kt +++ b/sdk/src/main/java/com/apphud/sdk/managers/HttpRetryInterceptor.kt @@ -14,7 +14,7 @@ import java.net.SocketTimeoutException class HttpRetryInterceptor : Interceptor { companion object { private const val STEP = 3_000L - private const val MAX_COUNT = 30 + private const val MAX_COUNT = 7 } @Throws(IOException::class) @@ -28,18 +28,16 @@ class HttpRetryInterceptor : Interceptor { response = chain.proceed(request) isSuccess = response.isSuccessful - if (!isSuccess) - { - ApphudLog.logE( - "Request (${request.url.encodedPath}) failed with code (${response.code}). Will retry in ${STEP / 1000} seconds ($tryCount).", - ) + if (!isSuccess) { + ApphudLog.logE( + "Request (${request.url.encodedPath}) failed with code (${response.code}). Will retry in ${STEP / 1000} seconds ($tryCount).", + ) - if (response.code in FALLBACK_ERRORS) - { - ApphudInternal.processFallbackError(request) - } - Thread.sleep(STEP) + if (response.code in FALLBACK_ERRORS) { + ApphudInternal.processFallbackError(request) } + Thread.sleep(STEP) + } } catch (e: SocketTimeoutException) { ApphudInternal.processFallbackError(request) ApphudLog.logE( @@ -58,10 +56,9 @@ class HttpRetryInterceptor : Interceptor { tryCount++ } } - if (!isSuccess) - { - ApphudLog.logE("Reached max number (${MAX_COUNT}) of (${request.url.encodedPath}) request retries. Exiting..") - } + if (!isSuccess) { + ApphudLog.logE("Reached max number (${MAX_COUNT}) of (${request.url.encodedPath}) request retries. Exiting..") + } return response ?: chain.proceed(request) } } diff --git a/sdk/src/main/java/com/apphud/sdk/managers/RequestManager.kt b/sdk/src/main/java/com/apphud/sdk/managers/RequestManager.kt index a79bac4e..b5c9d290 100644 --- a/sdk/src/main/java/com/apphud/sdk/managers/RequestManager.kt +++ b/sdk/src/main/java/com/apphud/sdk/managers/RequestManager.kt @@ -44,7 +44,8 @@ object RequestManager { " :You must call the Apphud.start method once when your application starts before calling any other methods." val BILLING_VERSION: Int = 5 - var currentUser: Customer? = null + val currentUser: ApphudUser? + get() = ApphudInternal.currentUser val gson = GsonBuilder().serializeNulls().create() val parser: Parser = GsonParser(gson) @@ -52,7 +53,8 @@ object RequestManager { private val productMapper = ProductMapper() private val paywallsMapper = PaywallsMapper(parser) private val attributionMapper = AttributionMapper() - private val customerMapper = CustomerMapper(SubscriptionMapper(), paywallsMapper) + private val placementsMapper = PlacementsMapper(parser) + private val customerMapper = CustomerMapper(SubscriptionMapper(), paywallsMapper, placementsMapper) // TODO to be settled private var apiKey: String? = null @@ -60,17 +62,6 @@ object RequestManager { lateinit var deviceId: DeviceId lateinit var applicationContext: Context lateinit var storage: SharedPreferencesStorage - var appSetId: String? = null - var androidId: String? = null - - var advertisingId: String? = null - get() = storage.advertisingId - set(value) { - field = value - if (storage.advertisingId != value) { - storage.advertisingId = value - } - } fun setParams( applicationContext: Context, @@ -85,12 +76,9 @@ object RequestManager { this.apiKey = it } this.storage = SharedPreferencesStorage - currentUser = null } fun cleanRegistration() { - currentUser = null - advertisingId = null apiKey = null } @@ -125,12 +113,12 @@ object RequestManager { logging.level = HttpLoggingInterceptor.Level.NONE }*/ - var readTimeout: Long = 10L + var readTimeout: Long = ApiClient.readTimeout if (request.method == "POST" && request.url.toString().contains("subscriptions")) { readTimeout = 30L } - var builder = + val builder = OkHttpClient.Builder() .readTimeout(readTimeout, TimeUnit.SECONDS) .writeTimeout(10, TimeUnit.SECONDS) @@ -142,26 +130,28 @@ object RequestManager { return builder.build() } - private fun logRequestStart(request: Request) { + private fun logRequestStart(request: Request) { try { var body: String? = "" - request.body?.let { - val buffer = Buffer() - it.writeTo(buffer) - - body = buffer.readString(Charset.forName("UTF-8")) - body?.let { - if (parser.isJson(it)) { - body = buildPrettyPrintedBy(it) + if (ApphudUtils.httpLogging) { + request.body?.let { + val buffer = Buffer() + it.writeTo(buffer) + + body = buffer.readString(Charset.forName("UTF-8")) + body?.let { + if (parser.isJson(it)) { + body = buildPrettyPrintedBy(it) + } } - } - body?.let { - if (it.isNotEmpty()) { - body = "\n" + it + body?.let { + if (it.isNotEmpty()) { + body = "\n" + it + } + } ?: { + body = "" } - } ?: { - body = "" } } ApphudLog.logI("Start " + request.method + " request " + request.url + " with params:" + body) @@ -173,17 +163,21 @@ object RequestManager { private fun logRequestFinish( request: Request, response: Response, - ) { + ) { try { val responseBody = response.body val source = responseBody?.source() source?.request(Long.MAX_VALUE) - val buffer = source?.buffer?.clone()?.readString(Charset.forName("UTF-8")) var outputBody = "" - buffer?.let { - if (parser.isJson(buffer)) { - outputBody = buildPrettyPrintedBy(it) ?: "" + if (ApphudUtils.httpLogging) { + val buffer = + source?.buffer?.clone() + ?.readString(Charset.forName("UTF-8")) + buffer?.let { + if (parser.isJson(buffer)) { + outputBody = buildPrettyPrintedBy(it) ?: "" + } } } @@ -295,7 +289,7 @@ object RequestManager { private fun checkLock403( request: Request, response: Response, - ) { + ) { if (response.code == 403 && request.method == "POST" && request.url.encodedPath.endsWith("/customers")) { HeadersInterceptor.isBlocked = true } @@ -354,7 +348,7 @@ object RequestManager { needPaywalls: Boolean, isNew: Boolean, forceRegistration: Boolean = false, - ): Customer? = + ): ApphudUser? = suspendCancellableCoroutine { continuation -> if (!canPerformRequest()) { ApphudLog.logE("registrationSync $MUST_REGISTER_ERROR") @@ -369,12 +363,11 @@ object RequestManager { continuation.resume(customer) } } - } else - { - if (continuation.isActive) { - continuation.resume(currentUser) - } + } else { + if (continuation.isActive) { + continuation.resume(currentUser) } + } } @Synchronized @@ -382,7 +375,7 @@ object RequestManager { needPaywalls: Boolean, isNew: Boolean, forceRegistration: Boolean = false, - completionHandler: (Customer?, ApphudError?) -> Unit, + completionHandler: (ApphudUser?, ApphudError?) -> Unit, ) { if (!canPerformRequest()) { ApphudLog.logE(::registration.name + MUST_REGISTER_ERROR) @@ -408,7 +401,7 @@ object RequestManager { ) responseDto?.let { cDto -> - currentUser = + val currentUser = cDto.data.results?.let { customerObj -> customerMapper.map(customerObj) } @@ -424,10 +417,9 @@ object RequestManager { val message = ex.message ?: "Undefined error" completionHandler(null, ApphudError(message)) } - } else - { - completionHandler(currentUser, null) - } + } else { + completionHandler(currentUser, null) + } } suspend fun allProducts(): List? = @@ -472,7 +464,7 @@ object RequestManager { apphudProduct: ApphudProduct?, offerToken: String?, oldToken: String?, - completionHandler: (Customer?, ApphudError?) -> Unit, + completionHandler: (ApphudUser?, ApphudError?) -> Unit, ) { if (!canPerformRequest()) { ApphudLog.logE(::purchased.name + MUST_REGISTER_ERROR) @@ -488,14 +480,11 @@ object RequestManager { val purchaseBody = apphudProduct?.let { - makePurchaseBody(purchase, it.productDetails, it.paywall_id, it.id, offerToken, oldToken) + makePurchaseBody(purchase, it.productDetails, it.paywallId, it.placementId, it.id, offerToken, oldToken) } if (purchaseBody == null) { val message = - "ProductsDetails and ApphudProduct can not be null at the same time" + - apphudProduct?.let { - " [Apphud product ID: " + it.id + "]" - } + "ProductsDetails and ApphudProduct can not be null at the same time" ApphudLog.logE(message = message) completionHandler.invoke(null, ApphudError(message)) return @@ -511,7 +500,7 @@ object RequestManager { object : TypeToken>() {}.type, ) responseDto?.let { cDto -> - currentUser = + val currentUser = cDto.data.results?.let { customerObj -> customerMapper.map(customerObj) } @@ -532,7 +521,7 @@ object RequestManager { productDetails: ProductDetails?, offerIdToken: String?, observerMode: Boolean, - ): Customer? = + ): ApphudUser? = suspendCancellableCoroutine { continuation -> if (!canPerformRequest()) { ApphudLog.logE("restorePurchasesSync $MUST_REGISTER_ERROR") @@ -549,29 +538,27 @@ object RequestManager { .build() val purchaseBody = - if (purchaseRecordDetailsSet != null) - { - makeRestorePurchasesBody( - apphudProduct, - purchaseRecordDetailsSet, - observerMode, - ) - } else if (purchase != null && productDetails != null) - { - makeTrackPurchasesBody( - apphudProduct, - purchase, - productDetails, - offerIdToken, - observerMode, - ) - } else { + if (purchaseRecordDetailsSet != null) { + makeRestorePurchasesBody( + apphudProduct, + purchaseRecordDetailsSet, + observerMode, + ) + } else if (purchase != null && productDetails != null) { + makeTrackPurchasesBody( + apphudProduct, + purchase, + productDetails, + offerIdToken, + observerMode, + ) + } else { null } purchaseBody?.let { val request = buildPostRequest(URL(apphudUrl.url), it) - makeUserRegisteredRequest(request, !fallbackMode) { serverResponse, error -> + makeUserRegisteredRequest(request, !fallbackMode) { serverResponse, _ -> serverResponse?.let { val responseDto: ResponseDto? = parser.fromJson>( @@ -579,7 +566,7 @@ object RequestManager { object : TypeToken>() {}.type, ) responseDto?.let { cDto -> - currentUser = + val currentUser = cDto.data.results?.let { customerObj -> customerMapper.map(customerObj) } @@ -607,7 +594,7 @@ object RequestManager { fun send( attributionBody: AttributionBody, completionHandler: (Attribution?, ApphudError?) -> Unit, - ) { + ) { if (!canPerformRequest()) { ApphudLog.logE(::send.name + MUST_REGISTER_ERROR) return @@ -644,7 +631,7 @@ object RequestManager { fun userProperties( userPropertiesBody: UserPropertiesBody, completionHandler: (Attribution?, ApphudError?) -> Unit, - ) { + ) { if (!canPerformRequest()) { ApphudLog.logE(::userProperties.name + MUST_REGISTER_ERROR) return @@ -682,7 +669,7 @@ object RequestManager { daysCount: Int, productId: String?, permissionGroup: ApphudGroup?, - completionHandler: (Customer?, ApphudError?) -> Unit, + completionHandler: (ApphudUser?, ApphudError?) -> Unit, ) { if (!canPerformRequest()) { ApphudLog.logE(::grantPromotional.name + MUST_REGISTER_ERROR) @@ -707,7 +694,7 @@ object RequestManager { ) responseDto?.let { cDto -> - currentUser = + val currentUser = cDto.data.results?.let { customerObj -> customerMapper.map(customerObj) } @@ -725,7 +712,8 @@ object RequestManager { trackPaywallEvent( makePaywallEventBody( name = "paywall_shown", - paywall_id = paywall.id, + paywallId = paywall.id, + placementId = paywall.placementId, ), ) } @@ -734,48 +722,55 @@ object RequestManager { trackPaywallEvent( makePaywallEventBody( name = "paywall_closed", - paywall_id = paywall.id, + paywallId = paywall.id, + placementId = paywall.placementId, ), ) } fun paywallCheckoutInitiated( - paywall_id: String?, - product_id: String?, + paywallId: String?, + placementId: String?, + productId: String?, ) { trackPaywallEvent( makePaywallEventBody( name = "paywall_checkout_initiated", - paywall_id = paywall_id, - product_id = product_id, + paywallId = paywallId, + placementId = placementId, + productId = productId, ), ) } fun paywallPaymentCancelled( - paywall_id: String?, - product_id: String?, + paywallId: String?, + placementId: String?, + productId: String?, ) { trackPaywallEvent( makePaywallEventBody( name = "paywall_payment_cancelled", - paywall_id = paywall_id, - product_id = product_id, + paywallId = paywallId, + placementId = placementId, + productId = productId, ), ) } fun paywallPaymentError( - paywall_id: String?, - product_id: String?, - error_code: String?, + paywallId: String?, + placementId: String?, + productId: String?, + errorMessage: String?, ) { trackPaywallEvent( makePaywallEventBody( name = "paywall_payment_error", - paywall_id = paywall_id, - product_id = product_id, - error_code = error_code, + paywallId = paywallId, + placementId = placementId, + productId = productId, + errorMessage = errorMessage, ), ) } @@ -807,7 +802,7 @@ object RequestManager { } } - fun sendErrorLogs(message: String) { + fun sendErrorLogs(message: String) { if (!canPerformRequest()) { ApphudLog.logE(::sendErrorLogs.name + MUST_REGISTER_ERROR) return @@ -833,7 +828,7 @@ object RequestManager { } } - fun sendBenchmarkLogs(body: BenchmarkBody) { + fun sendBenchmarkLogs(body: BenchmarkBody) { if (!canPerformRequest()) { ApphudLog.logE(::sendErrorLogs.name + MUST_REGISTER_ERROR) return @@ -883,49 +878,60 @@ object RequestManager { private fun makePaywallEventBody( name: String, - paywall_id: String? = null, - product_id: String? = null, - error_code: String? = null, + paywallId: String?, + placementId: String?, + productId: String? = null, + errorMessage: String? = null, ): PaywallEventBody { val properties = mutableMapOf() - paywall_id?.let { properties.put("paywall_id", it) } - product_id?.let { properties.put("product_id", it) } - error_code?.let { properties.put("error_code", it) } + paywallId?.let { properties.put("paywall_id", it) } + productId?.let { properties.put("product_id", it) } + placementId?.let { properties.put("placement_id", it) } + errorMessage?.let { properties.put("error_message", it) } + return PaywallEventBody( name = name, user_id = userId, device_id = deviceId, environment = if (applicationContext.isDebuggable()) "sandbox" else "production", timestamp = System.currentTimeMillis(), - properties = if (properties.isNotEmpty()) properties else null, + properties = properties.ifEmpty { null }, ) } private fun mkRegistrationBody( needPaywalls: Boolean, isNew: Boolean, - ) = RegistrationBody( - locale = Locale.getDefault().toString(), - sdk_version = BuildConfig.VERSION_NAME, - app_version = this.applicationContext.buildAppVersion(), - device_family = Build.MANUFACTURER, - platform = "Android", - device_type = if (ApphudUtils.optOutOfTracking) "Restricted" else Build.MODEL, - os_version = Build.VERSION.RELEASE, - start_app_version = this.applicationContext.buildAppVersion(), - idfv = if (ApphudUtils.optOutOfTracking) null else appSetId, - idfa = if (!ApphudUtils.optOutOfTracking && !advertisingId.isNullOrEmpty()) advertisingId else null, - android_id = if (ApphudUtils.optOutOfTracking) null else androidId, - user_id = userId, - device_id = deviceId, - time_zone = TimeZone.getDefault().id, - is_sandbox = this.applicationContext.isDebuggable(), - is_new = isNew, - need_paywalls = needPaywalls, - first_seen = getInstallationDate(), - ) + ): RegistrationBody { + val deviceIds = storage.deviceIdentifiers + val idfa = deviceIds[0] + val appSetId = deviceIds[1] + val androidId = deviceIds[2] + + return RegistrationBody( + locale = Locale.getDefault().toString(), + sdk_version = BuildConfig.VERSION_NAME, + app_version = this.applicationContext.buildAppVersion(), + device_family = Build.MANUFACTURER, + platform = "Android", + device_type = if (ApphudUtils.optOutOfTracking) "Restricted" else Build.MODEL, + os_version = Build.VERSION.RELEASE, + start_app_version = this.applicationContext.buildAppVersion(), + idfv = if (ApphudUtils.optOutOfTracking || appSetId.isEmpty()) null else appSetId, + idfa = if (ApphudUtils.optOutOfTracking || idfa.isEmpty()) null else idfa, + android_id = if (ApphudUtils.optOutOfTracking || androidId.isEmpty()) null else androidId, + user_id = userId, + device_id = deviceId, + time_zone = TimeZone.getDefault().id, + is_sandbox = this.applicationContext.isDebuggable(), + is_new = isNew, + need_paywalls = needPaywalls, + need_placements = needPaywalls, + first_seen = getInstallationDate(), + ) + } - private fun getInstallationDate(): Long? { + private fun getInstallationDate(): Long? { var dateInSecond: Long? = null try { this.applicationContext.packageManager?.let { manager -> @@ -943,6 +949,7 @@ object RequestManager { purchase: Purchase, productDetails: ProductDetails?, paywall_id: String?, + placement_id: String?, apphud_product_id: String?, offerIdToken: String?, oldToken: String?, @@ -959,6 +966,7 @@ object RequestManager { price_amount_micros = productDetails?.priceAmountMicros(), subscription_period = productDetails?.subscriptionPeriod(), paywall_id = paywall_id, + placement_id = placement_id, product_bundle_id = apphud_product_id, observer_mode = false, billing_version = BILLING_VERSION, @@ -992,7 +1000,8 @@ object RequestManager { null }, subscription_period = purchase.details.subscriptionPeriod(), - paywall_id = if (apphudProduct?.productDetails?.productId == purchase.details.productId) apphudProduct.paywall_id else null, + paywall_id = if (apphudProduct?.productDetails?.productId == purchase.details.productId) apphudProduct.paywallId else null, + placement_id = if (apphudProduct?.productDetails?.productId == purchase.details.productId) apphudProduct.placementId else null, product_bundle_id = if (apphudProduct?.productDetails?.productId == purchase.details.productId) apphudProduct.id else null, observer_mode = observerMode, billing_version = BILLING_VERSION, @@ -1020,7 +1029,8 @@ object RequestManager { price_currency_code = productDetails.priceCurrencyCode(), price_amount_micros = productDetails.priceAmountMicros(), subscription_period = productDetails.subscriptionPeriod(), - paywall_id = if (apphudProduct?.productDetails?.productId == purchase.products.first()) apphudProduct?.paywall_id else null, + paywall_id = if (apphudProduct?.productDetails?.productId == purchase.products.first()) apphudProduct?.paywallId else null, + placement_id = if (apphudProduct?.productDetails?.productId == purchase.products.first()) apphudProduct?.placementId else null, product_bundle_id = if (apphudProduct?.productDetails?.productId == purchase.products.first()) apphudProduct?.id else null, observer_mode = observerMode, billing_version = BILLING_VERSION, @@ -1074,20 +1084,19 @@ object RequestManager { suspend fun fetchAdvertisingId(): String? = suspendCancellableCoroutine { continuation -> - if (hasPermission("com.google.android.gms.permission.AD_ID")) - { - var advId: String? = null - try { - val adInfo: AdInfo = AdvertisingIdManager.getAdvertisingIdInfo(applicationContext) - advId = adInfo.id - } catch (e: java.lang.Exception) { - ApphudLog.logE("Finish load advertisingId: $e") - } + if (hasPermission("com.google.android.gms.permission.AD_ID")) { + var advId: String? = null + try { + val adInfo: AdInfo = AdvertisingIdManager.getAdvertisingIdInfo(applicationContext) + advId = adInfo.id + } catch (e: java.lang.Exception) { + ApphudLog.logE("Finish load advertisingId: $e") + } - if (continuation.isActive) { - continuation.resume(advId) - } - } else { + if (continuation.isActive) { + continuation.resume(advId) + } + } else { if (continuation.isActive) { continuation.resume(null) } @@ -1120,7 +1129,7 @@ object RequestManager { } } -fun ProductDetails.priceCurrencyCode(): String? { +fun ProductDetails.priceCurrencyCode(): String? { val res: String? = if (this.productType == BillingClient.ProductType.SUBS) { this.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.priceCurrencyCode @@ -1138,7 +1147,7 @@ fun ProductDetails.priceAmountMicros(): Long? { } } -fun ProductDetails.subscriptionPeriod(): String? { +fun ProductDetails.subscriptionPeriod(): String? { val res: String? = if (this.productType == BillingClient.ProductType.SUBS) { if (this.subscriptionOfferDetails?.size == 1 && this.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.size == 1) { diff --git a/sdk/src/main/java/com/apphud/sdk/mappers/CustomerMapper.kt b/sdk/src/main/java/com/apphud/sdk/mappers/CustomerMapper.kt index 38132287..ac81fac8 100644 --- a/sdk/src/main/java/com/apphud/sdk/mappers/CustomerMapper.kt +++ b/sdk/src/main/java/com/apphud/sdk/mappers/CustomerMapper.kt @@ -2,37 +2,39 @@ package com.apphud.sdk.mappers import com.apphud.sdk.client.dto.CustomerDto import com.apphud.sdk.domain.ApphudKind -import com.apphud.sdk.domain.ApphudPaywall import com.apphud.sdk.domain.ApphudUser -import com.apphud.sdk.domain.Customer class CustomerMapper( private val mapper: SubscriptionMapper, private val paywallsMapper: PaywallsMapper, + private var placementsMapper: PlacementsMapper, ) { fun map(customer: CustomerDto) = - Customer( - user = - ApphudUser( - userId = customer.user_id, - currencyCode = customer.currency?.code, - currencyCountryCode = customer.currency?.country_code, - ), + ApphudUser( + userId = customer.user_id, + currencyCode = customer.currency?.code, + countryCode = customer.currency?.country_code, subscriptions = customer.subscriptions .filter { it.kind == ApphudKind.AUTORENEWABLE.source } .mapNotNull { mapper.mapRenewable(it) } - .sortedByDescending { it.expiresAt }.toMutableList(), + .sortedByDescending { it.expiresAt }, purchases = customer.subscriptions .filter { it.kind == ApphudKind.NONRENEWABLE.source } .mapNotNull { mapper.mapNonRenewable(it) } - .sortedByDescending { it.purchasedAt }.toMutableList(), + .sortedByDescending { it.purchasedAt }, paywalls = customer.paywalls?.let { paywallsList -> paywallsList.map { paywallsMapper.map(it) } } ?: run { - mutableListOf() + listOf() + }, + placements = + customer.placements?.let { placementsList -> + placementsList.map { placementsMapper.map(it) } + } ?: run { + listOf() }, isTemporary = false, ) diff --git a/sdk/src/main/java/com/apphud/sdk/mappers/PaywallsMapper.kt b/sdk/src/main/java/com/apphud/sdk/mappers/PaywallsMapper.kt index 67507a2c..57d96d8f 100644 --- a/sdk/src/main/java/com/apphud/sdk/mappers/PaywallsMapper.kt +++ b/sdk/src/main/java/com/apphud/sdk/mappers/PaywallsMapper.kt @@ -8,7 +8,6 @@ import com.apphud.sdk.parser.Parser class PaywallsMapper( private val parser: Parser, ) { - fun map(dto: List): List = dto.map { paywallDto -> map(paywallDto) } fun map(paywallDto: ApphudPaywallDto) = @@ -22,15 +21,18 @@ class PaywallsMapper( paywallDto.items.map { item -> ApphudProduct( id = item.id, // product bundle id - product_id = item.product_id, + productId = item.product_id, name = item.name, store = item.store, basePlanId = item.base_plan_id, productDetails = null, - paywall_id = paywallDto.id, - paywall_identifier = paywallDto.identifier, + paywallId = paywallDto.id, + paywallIdentifier = paywallDto.identifier, + placementId = null, + placementIdentifier = null, ) }, experimentName = paywallDto.experiment_name, + placementId = null, ) } diff --git a/sdk/src/main/java/com/apphud/sdk/mappers/PlacementsMapper.kt b/sdk/src/main/java/com/apphud/sdk/mappers/PlacementsMapper.kt new file mode 100644 index 00000000..7d9ef2a9 --- /dev/null +++ b/sdk/src/main/java/com/apphud/sdk/mappers/PlacementsMapper.kt @@ -0,0 +1,29 @@ +package com.apphud.sdk.mappers + +import com.apphud.sdk.client.dto.ApphudPlacementDto +import com.apphud.sdk.domain.ApphudPlacement +import com.apphud.sdk.parser.Parser + +class PlacementsMapper( + private val parser: Parser, +) { + private val paywallsMapper = PaywallsMapper(parser) + + fun map(dto: List): List = dto.map { placementDto -> map(placementDto) } + + fun map(placementDto: ApphudPlacementDto): ApphudPlacement { + val paywallDto = placementDto.paywalls.firstOrNull() + val paywallObject = + if (paywallDto != null) { + paywallsMapper.map(paywallDto) + } else { + null + } + + return ApphudPlacement( + id = placementDto.id, + identifier = placementDto.identifier, + paywall = paywallObject + ) + } +} 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 d484c5c0..5766d7b8 100644 --- a/sdk/src/main/java/com/apphud/sdk/mappers/ProductMapper.kt +++ b/sdk/src/main/java/com/apphud/sdk/mappers/ProductMapper.kt @@ -14,13 +14,15 @@ class ProductMapper { it.bundles.map { item -> ApphudProduct( id = item.id, - product_id = item.product_id, + productId = item.product_id, name = item.name, store = item.store, basePlanId = item.base_plan_id, productDetails = null, - paywall_id = null, - paywall_identifier = null, + paywallId = null, + paywallIdentifier = null, + placementId = null, + placementIdentifier = null, ) }, ) 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 9a706902..ee186ca1 100644 --- a/sdk/src/main/java/com/apphud/sdk/storage/SharedPreferencesStorage.kt +++ b/sdk/src/main/java/com/apphud/sdk/storage/SharedPreferencesStorage.kt @@ -14,12 +14,12 @@ import com.google.gson.GsonBuilder import com.google.gson.reflect.TypeToken object SharedPreferencesStorage : Storage { - var cacheTimeout: Long = 90000L + private var cacheTimeout: Long = 90000L fun getInstance(applicationContext: Context): SharedPreferencesStorage { this.applicationContext = applicationContext preferences = SharedPreferencesStorage.applicationContext.getSharedPreferences(NAME, Context.MODE_PRIVATE) - cacheTimeout = if (SharedPreferencesStorage.applicationContext.isDebuggable()) 30L else 90000L // 25 hours + this.cacheTimeout = if (SharedPreferencesStorage.applicationContext.isDebuggable()) 120L else 90000L // 25 hours return this } @@ -29,24 +29,24 @@ object SharedPreferencesStorage : Storage { private const val NAME = "apphud_storage" private const val USER_ID_KEY = "userIdKey" - private const val CUSTOMER_KEY = "customerKey" + private const val APPHUD_USER_KEY = "APPHUD_USER_KEY" private const val DEVICE_ID_KEY = "deviceIdKey" - private const val ADVERTISING_DI_KEY = "advertisingIdKey" + private const val DEVICE_IDENTIFIERS_KEY = "DEVICE_IDENTIFIERS_KEY" private const val NEED_RESTART_KEY = "needRestartKey" private const val PROPERTIES_KEY = "propertiesKey" private const val FACEBOOK_KEY = "facebookKey" private const val FIREBASE_KEY = "firebaseKey" private const val APPSFLYER_KEY = "appsflyerKey" private const val ADJUST_KEY = "adjustKey" - private const val PAYWALLS_KEY = "payWallsKey" - private const val PAYWALLS_TIMESTAMP_KEY = "payWallsTimestampKey" + private const val PAYWALLS_KEY = "PAYWALLS_KEY" + private const val PAYWALLS_TIMESTAMP_KEY = "PAYWALLS_TIMESTAMP_KEY" + private const val PLACEMENTS_KEY = "PLACEMENTS_KEY" + private const val PLACEMENTS_TIMESTAMP_KEY = "PLACEMENTS_TIMESTAMP_KEY" private const val GROUP_KEY = "apphudGroupKey" private const val GROUP_TIMESTAMP_KEY = "apphudGroupTimestampKey" private const val SKU_KEY = "skuKey" private const val SKU_TIMESTAMP_KEY = "skuTimestampKey" private const val LAST_REGISTRATION_KEY = "lastRegistrationKey" - private const val TEMP_SUBSCRIPTIONS = "temp_subscriptions" - private const val TEMP_PURCHASES = "temp_purchases" private val gson = GsonBuilder() @@ -62,16 +62,16 @@ object SharedPreferencesStorage : Storage { editor.apply() } - override var customer: Customer? + override var apphudUser: ApphudUser? get() { - val source = preferences.getString(CUSTOMER_KEY, null) - val type = object : TypeToken() {}.type - return parser.fromJson(source, type) + val source = preferences.getString(APPHUD_USER_KEY, null) + val type = object : TypeToken() {}.type + return parser.fromJson(source, type) } set(value) { val source = parser.toJson(value) val editor = preferences.edit() - editor.putString(CUSTOMER_KEY, source) + editor.putString(APPHUD_USER_KEY, source) editor.apply() } @@ -83,11 +83,18 @@ object SharedPreferencesStorage : Storage { editor.apply() } - override var advertisingId: String? - get() = preferences.getString(ADVERTISING_DI_KEY, null) + override var deviceIdentifiers: Array + get() { + val string = preferences.getString(DEVICE_IDENTIFIERS_KEY, null) + val ids = string?.split("|") + return if (ids?.count() == 3) ids.toTypedArray() else arrayOf("", "", "") + } set(value) { val editor = preferences.edit() - editor.putString(ADVERTISING_DI_KEY, value) + + val idsString = value?.joinToString("|") ?: "" + + editor.putString(DEVICE_IDENTIFIERS_KEY, idsString) editor.apply() } @@ -175,7 +182,8 @@ object SharedPreferencesStorage : Storage { val type = object : TypeToken>() {}.type parser.fromJson>(source, type) } else { - null + ApphudLog.log("Paywalls Cache Expired") + return null } } set(value) { @@ -186,6 +194,27 @@ object SharedPreferencesStorage : Storage { editor.apply() } + override var placements: List? + get() { + val timestamp = preferences.getLong(PLACEMENTS_TIMESTAMP_KEY, -1L) + (cacheTimeout * 1000) + val currentTime = System.currentTimeMillis() + return if ((currentTime < timestamp) || ApphudInternal.fallbackMode) { + val source = preferences.getString(PLACEMENTS_KEY, null) + val type = object : TypeToken>() {}.type + parser.fromJson>(source, type) + } else { + ApphudLog.log("Placements Cache Expired") + null + } + } + set(value) { + val source = parser.toJson(value) + val editor = preferences.edit() + editor.putLong(PLACEMENTS_TIMESTAMP_KEY, System.currentTimeMillis()) + editor.putString(PLACEMENTS_KEY, source) + editor.apply() + } + override var productDetails: List? get() { val timestamp = preferences.getLong(SKU_TIMESTAMP_KEY, -1L) + (cacheTimeout * 1000) @@ -215,32 +244,31 @@ object SharedPreferencesStorage : Storage { } fun updateCustomer( - customer: Customer, + apphudUser: ApphudUser, apphudListener: ApphudListener?, - ) { + ) { var userIdChanged = false - this.customer?.let { - if (it.user.userId != customer.user.userId) - { - userIdChanged = true - } + this.apphudUser?.let { + if (it.userId != apphudUser.userId) { + userIdChanged = true + } } - this.customer = customer - this.userId = customer.user.userId + this.apphudUser = apphudUser + this.userId = apphudUser.userId if (userIdChanged) { apphudListener?.let { - apphudListener.apphudDidChangeUserID(customer.user.userId) + apphudListener.apphudDidChangeUserID(apphudUser.userId) } } } fun clean() { lastRegistration = 0L - customer = null + apphudUser = null userId = null deviceId = null - advertisingId = null + deviceIdentifiers = arrayOf("", "", "") isNeedSync = false facebook = null firebase = null @@ -252,39 +280,19 @@ object SharedPreferencesStorage : Storage { adjust = null } - fun needRegistration(): Boolean { + fun cacheExpired(user: ApphudUser): Boolean { val timestamp = lastRegistration + (cacheTimeout * 1000) val currentTime = System.currentTimeMillis() - return if (customerWithPurchases()) - { - ApphudLog.logI("User with purchases: perform registration") - true - } else { - val result = currentTime > timestamp - if (result) - { - ApphudLog.logI("User without purchases: perform registration") - } else - { - val minutes = (timestamp - currentTime) / 60_000L - val seconds = (timestamp - currentTime - minutes * 60_000L) / 1_000L - ApphudLog.logI("User without purchases: registration will available after ${minutes}min. ${seconds}sec.") - } - return result + val result = currentTime > timestamp + if (result) { + ApphudLog.logI("Cached ApphudUser found, but cache expired") + } else { + val minutes = (timestamp - currentTime) / 60_000L + val seconds = (timestamp - currentTime - minutes * 60_000L) / 1_000L + ApphudLog.logI("Using cached ApphudUser") } - } - - private fun customerWithPurchases(): Boolean { - return customer?.let { - !(it.purchases.isEmpty() && it.subscriptions.isEmpty()) - } ?: false - } - - fun needProcessFallback(): Boolean { - return customer?.let { - it.purchases.isEmpty() && it.subscriptions.isEmpty() - } ?: true + return result } override var properties: HashMap? @@ -300,44 +308,39 @@ object SharedPreferencesStorage : Storage { editor.apply() } - fun needSendProperty(property: ApphudUserProperty): Boolean { - if (properties == null) - { - properties = hashMapOf() - } + fun needSendProperty(property: ApphudUserProperty): Boolean { + if (properties == null) { + properties = hashMapOf() + } properties?.let { - if (property.value == null) - { - // clean property + if (property.value == null) { + // clean property + if (it.containsKey(property.key)) { + it.remove(property.key) + properties = it + } + return true + } + + if (it.containsKey(property.key)) { + if (it[property.key]?.setOnce == true) { + val message = "Sending a property with key '${property.key}' is skipped. The property was previously specified as not updatable" + ApphudLog.logI(message) + return false + } + if (property.increment) { + // clean property to allow to set any value after increment if (it.containsKey(property.key)) { it.remove(property.key) properties = it } return true } - - if (it.containsKey(property.key)) { - if (it[property.key]?.setOnce == true) - { - val message = "Sending a property with key '${property.key}' is skipped. The property was previously specified as not updatable" - ApphudLog.logI(message) - return false - } - if (property.increment) - { - // clean property to allow to set any value after increment - if (it.containsKey(property.key)) { - it.remove(property.key) - properties = it - } - return true - } - if (it[property.key]?.getValue() == property.getValue() && !property.setOnce) - { - val message = "Sending a property with key '${property.key}' is skipped. Property value was not changed" - ApphudLog.logI(message) - return false - } + if (it[property.key]?.getValue() == property.getValue() && !property.setOnce) { + val message = "Sending a property with key '${property.key}' is skipped. Property value was not changed" + ApphudLog.logI(message) + return false + } } } 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 30195841..b6c4ea8a 100644 --- a/sdk/src/main/java/com/apphud/sdk/storage/Storage.kt +++ b/sdk/src/main/java/com/apphud/sdk/storage/Storage.kt @@ -7,8 +7,8 @@ interface Storage { var lastRegistration: Long var userId: String? var deviceId: String? - var customer: Customer? - var advertisingId: String? + var apphudUser: ApphudUser? + var deviceIdentifiers: Array var isNeedSync: Boolean var facebook: FacebookInfo? var firebase: String? @@ -16,6 +16,7 @@ interface Storage { var adjust: AdjustInfo? var productGroups: List? var paywalls: List? + var placements: List? var productDetails: List? var properties: HashMap? }