diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/exception/ErrorMessage.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/exception/ErrorMessage.kt index 4cb0327834..8d9e3c6017 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/exception/ErrorMessage.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/exception/ErrorMessage.kt @@ -24,6 +24,7 @@ data class ErrorMessage( const val COURSE_REFRESH_CODE = 0x205 const val PRICE_CODE = 0x206 const val NO_SKU_CODE = 0x207 + const val CONSUME_CODE = 0x208 } private fun isPreUpgradeErrorType(): Boolean = @@ -56,6 +57,7 @@ data class ErrorMessage( fun canRetry(): Boolean { return requestType == PRICE_CODE || requestType == EXECUTE_ORDER_CODE || + requestType == CONSUME_CODE || requestType == COURSE_REFRESH_CODE } } diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/extenstion/EncryptionExt.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/extenstion/EncryptionExt.kt index 6c5251ebd6..fe7a5fd08b 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/extenstion/EncryptionExt.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/extenstion/EncryptionExt.kt @@ -6,6 +6,10 @@ fun Long.encodeToString(): String { return Base64.encodeToString(this.toString().toByteArray(), Base64.DEFAULT) } +fun String.encodeToString(): String { + return Base64.encodeToString(this.toByteArray(), Base64.DEFAULT) +} + fun String.decodeToLong(): Long? { return try { Base64.decode(this, Base64.DEFAULT).toString(Charsets.UTF_8).toLong() @@ -13,3 +17,11 @@ fun String.decodeToLong(): Long? { null } } + +fun String.decodeToString(): String? { + return try { + Base64.decode(this, Base64.DEFAULT).toString(Charsets.UTF_8) + } catch (ex: Exception) { + null + } +} diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/inapppurchases/BillingProcessor.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/inapppurchases/BillingProcessor.kt index fe33b88740..7f65eecba9 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/inapppurchases/BillingProcessor.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/inapppurchases/BillingProcessor.kt @@ -11,13 +11,16 @@ import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ConsumeParams import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetailsResult import com.android.billingclient.api.Purchase +import com.android.billingclient.api.Purchase.PurchaseState import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.acknowledgePurchase +import com.android.billingclient.api.consumePurchase import com.android.billingclient.api.queryProductDetails import com.android.billingclient.api.queryPurchasesAsync import dagger.hilt.android.qualifiers.ApplicationContext @@ -26,10 +29,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext +import org.edx.mobile.extenstion.decodeToString import org.edx.mobile.extenstion.encodeToString import org.edx.mobile.extenstion.resumeIfActive import org.edx.mobile.injection.DataSourceDispatcher import org.edx.mobile.logger.Logger +import org.edx.mobile.model.api.EnrolledCoursesResponse.ProductInfo import javax.inject.Inject import javax.inject.Singleton @@ -125,16 +130,20 @@ class BillingProcessor @Inject constructor( * Called to purchase the new product. Query the product details and launch the purchase flow. * * @param activity active activity to launch our billing flow from - * @param productId Product Id to be purchased * @param userId User Id of the purchaser + * @param productInfo Course and Product info to purchase */ - suspend fun purchaseItem(activity: Activity, productId: String, userId: Long) { + suspend fun purchaseItem( + activity: Activity, + userId: Long, + productInfo: ProductInfo, + ) { if (isReadyOrConnect()) { - val response = querySyncDetails(productId) + val response = querySyncDetails(productInfo.storeSku) logger.debug("Getting Purchases -> ${response.billingResult}") response.productDetailsList?.first()?.let { - launchBillingFlow(activity, it, userId) + launchBillingFlow(activity, it, userId, productInfo.courseSku) } } else { listener.onPurchaseCancel(BillingResponseCode.BILLING_UNAVAILABLE, "") @@ -152,7 +161,8 @@ class BillingProcessor @Inject constructor( private fun launchBillingFlow( activity: Activity, productDetails: ProductDetails, - userId: Long + userId: Long, + courseSku: String, ) { val productDetailsParamsList = listOf( ProductDetailsParams.newBuilder() @@ -163,6 +173,7 @@ class BillingProcessor @Inject constructor( val billingFlowParams = BillingFlowParams.newBuilder() .setProductDetailsParamsList(productDetailsParamsList) .setObfuscatedAccountId(userId.encodeToString()) + .setObfuscatedProfileId(courseSku.encodeToString()) .build() billingClient.launchBillingFlow(activity, billingFlowParams) @@ -232,7 +243,18 @@ class BillingProcessor @Inject constructor( QueryPurchasesParams.newBuilder() .setProductType(BillingClient.ProductType.INAPP) .build() - ).purchasesList + ).purchasesList.filter { it.purchaseState == PurchaseState.PURCHASED } + } + + suspend fun consumePurchase(purchaseToken: String): BillingResult { + isReadyOrConnect() + val result = billingClient.consumePurchase( + ConsumeParams + .newBuilder() + .setPurchaseToken(purchaseToken) + .build() + ) + return result.billingResult } companion object { @@ -251,3 +273,7 @@ class BillingProcessor @Inject constructor( fun ProductDetails.OneTimePurchaseOfferDetails.getPriceAmount(): Double = this.priceAmountMicros.toDouble().div(BillingProcessor.MICROS_TO_UNIT) + +fun Purchase.getCourseSku(): String? { + return this.accountIdentifiers?.obfuscatedProfileId?.decodeToString() +} diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/AppConfig.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/AppConfig.kt index 8862377a41..15c19c2de0 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/AppConfig.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/AppConfig.kt @@ -31,6 +31,9 @@ data class IAPConfig( @SerializedName("experiment_enabled") val isExperimentEnabled: Boolean = false, + @SerializedName("android_product_prefix") + val productPrefix: String = "", + @SerializedName("android_disabled_versions") val disableVersions: List = listOf() diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/CourseMode.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/CourseMode.kt index a13d78d530..3027e245ea 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/CourseMode.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/CourseMode.kt @@ -2,6 +2,7 @@ package org.edx.mobile.model.api import com.google.gson.annotations.SerializedName import java.io.Serializable +import kotlin.math.ceil data class CourseMode( @SerializedName("slug") @@ -12,4 +13,20 @@ data class CourseMode( @SerializedName("android_sku") val androidSku: String?, -) : Serializable + + @SerializedName("min_price") + val price: Double?, + + var storeSku: String?, +) : Serializable { + + fun setStoreProductSku(storeProductPrefix: String) { + val ceilPrice = price + ?.let { ceil(it).toInt() } + ?.takeIf { it > 0 } + + if (storeProductPrefix.isNotBlank() && ceilPrice != null) { + storeSku = "$storeProductPrefix$ceilPrice" + } + } +} diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/EnrolledCoursesResponse.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/EnrolledCoursesResponse.kt index 272a8cf775..4dca485d52 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/EnrolledCoursesResponse.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/EnrolledCoursesResponse.kt @@ -5,6 +5,7 @@ import com.google.gson.annotations.SerializedName import org.edx.mobile.interfaces.SectionItemInterface import org.edx.mobile.model.course.EnrollmentMode import org.edx.mobile.util.DateUtil +import java.io.Serializable import java.util.Date data class EnrolledCoursesResponse( @@ -39,11 +40,6 @@ data class EnrolledCoursesResponse( val isCertificateEarned: Boolean get() = certificateURL.isNullOrEmpty().not() - val courseSku: String? - get() = courseModes?.firstOrNull { item -> - EnrollmentMode.VERIFIED.name.equals(item.slug, ignoreCase = true) - }?.androidSku.takeUnless { it.isNullOrEmpty() } - val isAuditMode: Boolean get() = EnrollmentMode.AUDIT.toString().equals(mode, ignoreCase = true) @@ -59,6 +55,29 @@ data class EnrolledCoursesResponse( EnrollmentMode.VERIFIED.toString().equals(it.slug, ignoreCase = true) } != null + val productInfo: ProductInfo? + get() = courseSku?.let { courseSku -> + storeSku?.let { storeSku -> + ProductInfo(courseSku, storeSku) + } + } + + private val courseSku: String? + get() = courseModes?.firstOrNull { item -> + EnrollmentMode.VERIFIED.name.equals(item.slug, ignoreCase = true) + }?.androidSku.takeUnless { it.isNullOrEmpty() } + + private val storeSku: String? + get() = courseModes?.firstOrNull { item -> + EnrollmentMode.VERIFIED.name.equals(item.slug, ignoreCase = true) + }?.storeSku + + fun setStoreSku(storeProductPrefix: String) { + courseModes?.forEach { + it.setStoreProductSku(storeProductPrefix) + } + } + override fun isChapter(): Boolean { return false } @@ -82,6 +101,11 @@ data class EnrolledCoursesResponse( override fun isDownload(): Boolean { return false } + + data class ProductInfo( + val courseSku: String, + val storeSku: String, + ) : Serializable } /** diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/EnrollmentResponse.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/EnrollmentResponse.kt index 4abda8194e..c80c126360 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/EnrollmentResponse.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/model/api/EnrollmentResponse.kt @@ -7,6 +7,7 @@ import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.annotations.SerializedName import com.google.gson.reflect.TypeToken +import org.edx.mobile.extenstion.isNotNullOrEmpty import org.edx.mobile.logger.Logger import org.edx.mobile.model.iap.IAPFlowData import java.io.Serializable @@ -59,6 +60,12 @@ data class EnrollmentResponse( AppConfig::class.java ) + if (appConfig.iapConfig.productPrefix.isNotNullOrEmpty()) { + enrolledCourses.forEach { courseData -> + courseData.setStoreSku(appConfig.iapConfig.productPrefix) + } + } + EnrollmentResponse(appConfig, enrolledCourses) } } catch (ex: Exception) { @@ -76,12 +83,12 @@ data class EnrollmentResponse( */ fun List.getAuditCourses(): List { return this.filter { - it.isAuditMode && it.courseSku.isNullOrBlank().not() + it.isAuditMode && it.productInfo != null }.mapNotNull { course -> - course.courseSku?.let { sku -> + course.productInfo?.let { productInfo -> IAPFlowData( courseId = course.courseId, - productId = sku, + productInfo = productInfo, isCourseSelfPaced = course.course.isSelfPaced ) } diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/model/course/CourseComponent.java b/OpenEdXMobile/src/main/java/org/edx/mobile/model/course/CourseComponent.java index d4fd70412b..558554dc55 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/model/course/CourseComponent.java +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/model/course/CourseComponent.java @@ -1,5 +1,7 @@ package org.edx.mobile.model.course; +import static org.edx.mobile.model.api.EnrolledCoursesResponse.ProductInfo; + import android.text.TextUtils; import androidx.annotation.NonNull; @@ -43,7 +45,7 @@ public class CourseComponent implements IBlock, IPathNode { private String authorizationDenialMessage; private AuthorizationDenialReason authorizationDenialReason; private SpecialExamInfo specialExamInfo; - private String courseSku; + private ProductInfo productInfo; public CourseComponent() { } @@ -69,7 +71,7 @@ public CourseComponent(@NonNull CourseComponent other) { this.authorizationDenialMessage = other.authorizationDenialMessage; this.authorizationDenialReason = other.authorizationDenialReason; this.specialExamInfo = other.specialExamInfo; - this.courseSku = other.courseSku; + this.productInfo = other.productInfo; } /** @@ -572,12 +574,12 @@ public SpecialExamInfo getSpecialExamInfo() { return specialExamInfo; } - public String getCourseSku() { - return courseSku; + public ProductInfo getProductInfo() { + return productInfo; } - public void setCourseSku(String courseSku) { - this.courseSku = courseSku; + public void setProductInfo(ProductInfo productInfo) { + this.productInfo = productInfo; } public ArrayList getSectionData() { diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/model/iap/IAPFlowData.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/model/iap/IAPFlowData.kt index 8f7d7f81be..9a3123058b 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/model/iap/IAPFlowData.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/model/iap/IAPFlowData.kt @@ -1,12 +1,13 @@ package org.edx.mobile.model.iap +import org.edx.mobile.model.api.EnrolledCoursesResponse.ProductInfo import java.io.Serializable data class IAPFlowData( var flowType: IAPFlowType = IAPFlowType.USER_INITIATED, var courseId: String = "", var isCourseSelfPaced: Boolean = false, - var productId: String = "", + var productInfo: ProductInfo = ProductInfo("", ""), var basketId: Long = 0, var purchaseToken: String = "", var price: Double = 0.0, @@ -17,7 +18,7 @@ data class IAPFlowData( fun clear() { courseId = "" isCourseSelfPaced = false - productId = "" + productInfo = ProductInfo("", "") basketId = 0 price = 0.0 currencyCode = "" diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/util/InAppPurchasesUtils.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/util/InAppPurchasesUtils.kt index 9023254bdf..262e0e5563 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/util/InAppPurchasesUtils.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/util/InAppPurchasesUtils.kt @@ -4,6 +4,7 @@ import com.android.billingclient.api.Purchase import org.edx.mobile.R import org.edx.mobile.exception.ErrorMessage import org.edx.mobile.http.HttpStatus +import org.edx.mobile.inapppurchases.getCourseSku import org.edx.mobile.model.iap.IAPFlowData object InAppPurchasesUtils { @@ -20,7 +21,7 @@ object InAppPurchasesUtils { ): MutableList { purchases.forEach { purchase -> auditCourses.find { course -> - purchase.products.first().equals(course.productId) + purchase.getCourseSku() == course.productInfo.courseSku }?.apply { this.purchaseToken = purchase.purchaseToken this.flowType = flowType diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/util/TextUtils.java b/OpenEdXMobile/src/main/java/org/edx/mobile/util/TextUtils.java index adb3c6f6f7..3c57ccd4c6 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/util/TextUtils.java +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/util/TextUtils.java @@ -64,9 +64,7 @@ public static CharSequence join(CharSequence delimiter, Iterable t * @return App specific URI string. */ public static String createAppUri(@NonNull String title, @NonNull String uri) { - final StringBuilder uriString = new StringBuilder(AppConstants.APP_URI_SCHEME); - uriString.append(title).append("?").append(PARAM_INTENT_FILE_LINK).append("=").append(uri); - return uriString.toString(); + return AppConstants.APP_URI_SCHEME + title + "?" + PARAM_INTENT_FILE_LINK + "=" + uri; } /** @@ -96,19 +94,19 @@ public static CharSequence generateLicenseText(@NonNull Config config, if (!android.text.TextUtils.isEmpty(eulaUri)) { eulaSpan.setSpan(new UrlSpanNoUnderline(TextUtils.createAppUri( - context.getResources().getString(R.string.end_user_title), eulaUri)), + context.getResources().getString(R.string.end_user_title), eulaUri)), 0, eula.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (!android.text.TextUtils.isEmpty(tosUri)) { tosSpan.setSpan(new UrlSpanNoUnderline(TextUtils.createAppUri( - context.getResources().getString(R.string.terms_of_service_title), tosUri)), + context.getResources().getString(R.string.terms_of_service_title), tosUri)), 0, tos.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } if (!android.text.TextUtils.isEmpty(privacyPolicyUri)) { privacyPolicySpan.setSpan(new UrlSpanNoUnderline(TextUtils.createAppUri( - context.getResources().getString(R.string.privacy_policy_title), privacyPolicyUri)), + context.getResources().getString(R.string.privacy_policy_title), privacyPolicyUri)), 0, privacyPolicy.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } @@ -214,6 +212,7 @@ public static StringBuilder getFormattedErrorMessage(int requestType, int errorC case ErrorMessage.PAYMENT_SDK_CODE -> "payment"; case ErrorMessage.PRICE_CODE -> "price"; case ErrorMessage.NO_SKU_CODE -> "sku"; + case ErrorMessage.CONSUME_CODE -> "consume"; default -> "unhandledError"; }; body.append(String.format("%s", endpoint)); diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/view/AccountFragment.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/view/AccountFragment.kt index 080164b6fe..39cd00e87f 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/view/AccountFragment.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/view/AccountFragment.kt @@ -242,6 +242,8 @@ class AccountFragment : BaseFragment() { retryListener = DialogInterface.OnClickListener { _, _ -> if (errorMessage.requestType == ErrorMessage.EXECUTE_ORDER_CODE) { iapViewModel.executeOrder() + } else if (errorMessage.requestType == ErrorMessage.CONSUME_CODE) { + iapViewModel.consumeOrderForFurtherPurchases(iapViewModel.iapFlowData) } else if (HttpStatus.NOT_ACCEPTABLE == (errorMessage.throwable as InAppPurchasesException).httpErrorCode) { showFullScreenLoader() } diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/view/CourseTabsDashboardFragment.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/view/CourseTabsDashboardFragment.kt index f54ed1d207..9a6dc95b3e 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/view/CourseTabsDashboardFragment.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/view/CourseTabsDashboardFragment.kt @@ -35,7 +35,6 @@ import org.edx.mobile.event.MyCoursesRefreshEvent import org.edx.mobile.event.RefreshCourseDashboardEvent import org.edx.mobile.exception.ErrorMessage import org.edx.mobile.extenstion.CollapsingToolbarStatListener -import org.edx.mobile.extenstion.isNotNullOrEmpty import org.edx.mobile.extenstion.serializable import org.edx.mobile.extenstion.serializableOrThrow import org.edx.mobile.extenstion.setTitleStateListener @@ -245,7 +244,7 @@ class CourseTabsDashboardFragment : BaseFragment() { private fun setupIAPLayout() { val isPurchaseEnabled = environment.featuresPrefs.isIAPEnabledForUser( environment.loginPrefs.isOddUserId - ) && courseData.courseSku.isNotNullOrEmpty() + ) && courseData.productInfo != null binding.accessError.apply { setVisibility(true) @@ -256,7 +255,7 @@ class CourseTabsDashboardFragment : BaseFragment() { // by adding the some delay fixed that issue for lower-end devices, and for the // proper animation. postDelayed( - { iapViewModel.initializeProductPrice(courseData.courseSku) }, 1500 + { iapViewModel.initializeProductPrice(courseData.productInfo) }, 1500 ) setSecondaryButtonListener(onFindCourseClick()) } else { @@ -273,8 +272,7 @@ class CourseTabsDashboardFragment : BaseFragment() { iapViewModel.launchPurchaseFlow.observe(viewLifecycleOwner, EventObserver { iapViewModel.purchaseItem( - requireActivity(), environment.loginPrefs.userId, - courseData.courseSku + requireActivity(), environment.loginPrefs.userId, courseData.productInfo ) }) @@ -305,9 +303,9 @@ class CourseTabsDashboardFragment : BaseFragment() { ) setPrimaryButtonListener { iapAnalytics.trackIAPEvent(Analytics.Events.IAP_UPGRADE_NOW_CLICKED, "", "") - courseData.courseSku?.let { productId -> + courseData.productInfo?.let { productInfo -> iapViewModel.startPurchaseFlow( - productId, + productInfo, productDetails.getPriceAmount(), productDetails.priceCurrencyCode, ) @@ -329,7 +327,7 @@ class CourseTabsDashboardFragment : BaseFragment() { } else if (errorMessage.canRetry()) { retryListener = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> if (errorMessage.requestType == ErrorMessage.PRICE_CODE) { - iapViewModel.initializeProductPrice(courseData.courseSku) + iapViewModel.initializeProductPrice(courseData.productInfo) } } } @@ -575,7 +573,7 @@ class CourseTabsDashboardFragment : BaseFragment() { newInstance( Analytics.Screens.PLS_COURSE_DASHBOARD, courseData.courseId, - courseData.courseSku, + courseData.productInfo, courseData.course.name, courseData.course.isSelfPaced ).show(childFragmentManager, CourseModalDialogFragment.TAG) diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/view/CourseUnitMobileNotSupportedFragment.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/view/CourseUnitMobileNotSupportedFragment.kt index e4b849072c..675e18cacd 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/view/CourseUnitMobileNotSupportedFragment.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/view/CourseUnitMobileNotSupportedFragment.kt @@ -78,7 +78,7 @@ class CourseUnitMobileNotSupportedFragment : CourseUnitFragment() { private fun showGradedContent() { unit?.let { unit -> val isSelfPaced = getBooleanArgument(Router.EXTRA_IS_SELF_PACED, false) - val isPurchaseEnabled = unit.courseSku.isNullOrEmpty().not() && + val isPurchaseEnabled = unit.productInfo != null && environment.featuresPrefs.isIAPEnabledForUser(environment.loginPrefs.isOddUserId) binding.containerLayoutNotAvailable.root.setVisibility(false) @@ -95,7 +95,7 @@ class CourseUnitMobileNotSupportedFragment : CourseUnitFragment() { environment.analyticsRegistry.trackValuePropMessageViewed( unit.courseId, Screens.COURSE_UNIT, - (unit.courseSku.isNullOrEmpty().not() && environment.featuresPrefs.isIAPEnabled), + (unit.productInfo != null && environment.featuresPrefs.isIAPEnabled), experimentGroup, unit.id ) @@ -112,7 +112,7 @@ class CourseUnitMobileNotSupportedFragment : CourseUnitFragment() { // by adding the some delay fixed that issue for lower-end devices, and for the // proper animation. binding.layoutUpgradeBtn.shimmerViewContainer.postDelayed({ - iapViewModel.initializeProductPrice(unit.courseSku) + iapViewModel.initializeProductPrice(unit.productInfo) }, 1500) binding.layoutUpgradeBtn.btnUpgrade.isEnabled = false } else { @@ -175,7 +175,7 @@ class CourseUnitMobileNotSupportedFragment : CourseUnitFragment() { iapViewModel.purchaseItem( requireActivity(), environment.loginPrefs.userId, - unit?.courseSku + unit?.productInfo, ) }) @@ -207,7 +207,7 @@ class CourseUnitMobileNotSupportedFragment : CourseUnitFragment() { retryListener = DialogInterface.OnClickListener { _, _ -> when (errorMessage.requestType) { ErrorMessage.PRICE_CODE -> { - iapViewModel.initializeProductPrice(unit?.courseSku) + iapViewModel.initializeProductPrice(unit?.productInfo) } } } @@ -239,9 +239,9 @@ class CourseUnitMobileNotSupportedFragment : CourseUnitFragment() { binding.layoutUpgradeBtn.btnUpgrade.setOnClickListener { iapAnalytics.trackIAPEvent(Events.IAP_UPGRADE_NOW_CLICKED) - unit?.courseSku?.let { productId -> + unit?.productInfo?.let { productInfo -> iapViewModel.startPurchaseFlow( - productId, + productInfo, productDetails.getPriceAmount(), productDetails.priceCurrencyCode, ) diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/view/MyCoursesListFragment.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/view/MyCoursesListFragment.kt index c96349c477..2d8a5f4ea5 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/view/MyCoursesListFragment.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/view/MyCoursesListFragment.kt @@ -107,7 +107,7 @@ class MyCoursesListFragment : OfflineSupportBaseFragment(), RefreshListener { CourseModalDialogFragment.newInstance( Analytics.Screens.COURSE_ENROLLMENT, model.courseId, - model.courseSku, + model.productInfo, model.course.name, model.course.isSelfPaced ).show(childFragmentManager, CourseModalDialogFragment.TAG) @@ -246,6 +246,8 @@ class MyCoursesListFragment : OfflineSupportBaseFragment(), RefreshListener { retryListener = DialogInterface.OnClickListener { _, _ -> if (errorMessage.requestType == ErrorMessage.EXECUTE_ORDER_CODE) { iapViewModel.executeOrder() + } else if (errorMessage.requestType == ErrorMessage.CONSUME_CODE) { + iapViewModel.consumeOrderForFurtherPurchases(iapViewModel.iapFlowData) } else if (errorMessage.requestType == ErrorMessage.COURSE_REFRESH_CODE) { courseViewModel.fetchEnrolledCourses( type = CoursesRequestType.LIVE, diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/view/adapters/CourseUnitPagerAdapter.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/view/adapters/CourseUnitPagerAdapter.kt index 5ab4eb4b3c..b523b3284a 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/view/adapters/CourseUnitPagerAdapter.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/view/adapters/CourseUnitPagerAdapter.kt @@ -54,14 +54,14 @@ class CourseUnitPagerAdapter( is HtmlBlockModel -> HtmlBlockModel(unit) else -> CourseComponent(unit) } - minifiedUnit.courseSku = courseData.courseSku + minifiedUnit.productInfo = courseData.productInfo val unitFragment = when { minifiedUnit.authorizationDenialReason == AuthorizationDenialReason.FEATURE_BASED_ENROLLMENTS -> { if (courseUpgradeData == null) { CourseUnitMobileNotSupportedFragment.newInstance(minifiedUnit, courseData) } else { - minifiedUnit.courseSku = courseData.courseSku + minifiedUnit.productInfo = courseData.productInfo LockedCourseUnitFragment.newInstance( minifiedUnit, courseData, diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/view/dialog/CourseModalDialogFragment.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/view/dialog/CourseModalDialogFragment.kt index 8133a29fb8..f2bdd92039 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/view/dialog/CourseModalDialogFragment.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/view/dialog/CourseModalDialogFragment.kt @@ -15,9 +15,11 @@ import org.edx.mobile.core.IEdxEnvironment import org.edx.mobile.databinding.DialogUpgradeFeaturesBinding import org.edx.mobile.event.IAPFlowEvent import org.edx.mobile.exception.ErrorMessage +import org.edx.mobile.extenstion.serializable import org.edx.mobile.extenstion.setVisibility import org.edx.mobile.http.HttpStatus import org.edx.mobile.inapppurchases.getPriceAmount +import org.edx.mobile.model.api.EnrolledCoursesResponse.ProductInfo import org.edx.mobile.model.iap.IAPFlowData import org.edx.mobile.module.analytics.Analytics import org.edx.mobile.module.analytics.Analytics.Events @@ -49,7 +51,7 @@ class CourseModalDialogFragment : DialogFragment() { private var screenName: String = "" private var courseId: String = "" - private var courseSku: String? = null + private var productInfo: ProductInfo? = null private var isSelfPaced: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { @@ -78,7 +80,7 @@ class CourseModalDialogFragment : DialogFragment() { arguments?.let { bundle -> screenName = bundle.getString(KEY_SCREEN_NAME, "") courseId = bundle.getString(KEY_COURSE_ID, "") - courseSku = bundle.getString(KEY_COURSE_SKU) + productInfo = bundle.serializable(KEY_PRODUCT_INFO) isSelfPaced = bundle.getBoolean(KEY_IS_SELF_PACED) trackEvents() } @@ -89,7 +91,7 @@ class CourseModalDialogFragment : DialogFragment() { KEY_COURSE_NAME, arguments?.getString(KEY_COURSE_NAME) ) - val isPurchaseEnabled = courseSku.isNullOrEmpty().not() && + val isPurchaseEnabled = productInfo != null && environment.featuresPrefs.isIAPEnabledForUser(environment.loginPrefs.isOddUserId) binding.layoutUpgradeBtn.root.setVisibility(isPurchaseEnabled) @@ -102,7 +104,7 @@ class CourseModalDialogFragment : DialogFragment() { // by adding the some delay fixed that issue for lower-end devices, and for the // proper animation. binding.layoutUpgradeBtn.shimmerViewContainer.postDelayed({ - iapViewModel.initializeProductPrice(courseSku) + iapViewModel.initializeProductPrice(productInfo) }, 1500) binding.layoutUpgradeBtn.btnUpgrade.isEnabled = false } @@ -130,7 +132,7 @@ class CourseModalDialogFragment : DialogFragment() { environment.analyticsRegistry.trackValuePropMessageViewed( courseId, screenName, - (courseSku.isNullOrEmpty().not() && environment.featuresPrefs.isIAPEnabled), + (productInfo != null && environment.featuresPrefs.isIAPEnabled), experimentGroup, null ) @@ -142,7 +144,11 @@ class CourseModalDialogFragment : DialogFragment() { }) iapViewModel.launchPurchaseFlow.observe(viewLifecycleOwner, EventObserver { - iapViewModel.purchaseItem(requireActivity(), environment.loginPrefs.userId, courseSku) + iapViewModel.purchaseItem( + requireActivity(), + environment.loginPrefs.userId, + productInfo + ) }) iapViewModel.showLoader.observe(viewLifecycleOwner, EventObserver { @@ -178,9 +184,9 @@ class CourseModalDialogFragment : DialogFragment() { binding.layoutUpgradeBtn.btnUpgrade.setOnClickListener { iapAnalytics.trackIAPEvent(eventName = Events.IAP_UPGRADE_NOW_CLICKED) - courseSku?.let { + productInfo?.let { productInfo -> iapViewModel.startPurchaseFlow( - it, + productInfo, productDetails.getPriceAmount(), productDetails.priceCurrencyCode, ) @@ -211,7 +217,7 @@ class CourseModalDialogFragment : DialogFragment() { retryListener = DialogInterface.OnClickListener { _, _ -> when (errorMessage.requestType) { ErrorMessage.PRICE_CODE -> { - iapViewModel.initializeProductPrice(courseSku) + iapViewModel.initializeProductPrice(productInfo) } } } @@ -236,7 +242,7 @@ class CourseModalDialogFragment : DialogFragment() { const val TAG: String = "CourseModalDialogFragment" const val KEY_SCREEN_NAME = "screen_name" const val KEY_COURSE_ID = "course_id" - const val KEY_COURSE_SKU = "course_sku" + const val KEY_PRODUCT_INFO = "product_info" const val KEY_COURSE_NAME = "course_name" const val KEY_IS_SELF_PACED = "is_Self_Paced" @@ -244,7 +250,7 @@ class CourseModalDialogFragment : DialogFragment() { fun newInstance( screenName: String, courseId: String, - courseSku: String?, + productInfo: ProductInfo?, courseName: String, isSelfPaced: Boolean ): CourseModalDialogFragment { @@ -252,9 +258,9 @@ class CourseModalDialogFragment : DialogFragment() { val args = Bundle().apply { putString(KEY_SCREEN_NAME, screenName) putString(KEY_COURSE_ID, courseId) - putString(KEY_COURSE_SKU, courseSku) putString(KEY_COURSE_NAME, courseName) putBoolean(KEY_IS_SELF_PACED, isSelfPaced) + putSerializable(KEY_PRODUCT_INFO, productInfo) } frag.arguments = args return frag diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/view/dialog/FullscreenLoaderDialogFragment.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/view/dialog/FullscreenLoaderDialogFragment.kt index 60e93f0028..87f57f241d 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/view/dialog/FullscreenLoaderDialogFragment.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/view/dialog/FullscreenLoaderDialogFragment.kt @@ -107,10 +107,18 @@ class FullscreenLoaderDialogFragment : DialogFragment() { fragment = this@FullscreenLoaderDialogFragment, errorMessage = errorMessage, retryListener = { _, _ -> - if (errorMessage.requestType == ErrorMessage.EXECUTE_ORDER_CODE) { - iapViewModel.executeOrder(iapFlowData) - } else { - purchaseFlowComplete() + when (errorMessage.requestType) { + ErrorMessage.EXECUTE_ORDER_CODE -> { + iapViewModel.executeOrder(iapFlowData) + } + + ErrorMessage.CONSUME_CODE -> { + iapViewModel.consumeOrderForFurtherPurchases(iapViewModel.iapFlowData) + } + + else -> { + purchaseFlowComplete() + } } }, cancelListener = { _, _ -> diff --git a/OpenEdXMobile/src/main/java/org/edx/mobile/viewModel/InAppPurchasesViewModel.kt b/OpenEdXMobile/src/main/java/org/edx/mobile/viewModel/InAppPurchasesViewModel.kt index 2d4954987e..13c8a4cf94 100644 --- a/OpenEdXMobile/src/main/java/org/edx/mobile/viewModel/InAppPurchasesViewModel.kt +++ b/OpenEdXMobile/src/main/java/org/edx/mobile/viewModel/InAppPurchasesViewModel.kt @@ -5,7 +5,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails import com.android.billingclient.api.Purchase import dagger.hilt.android.lifecycle.HiltViewModel @@ -16,8 +16,10 @@ import org.edx.mobile.extenstion.decodeToLong import org.edx.mobile.http.model.NetworkResponseCallback import org.edx.mobile.http.model.Result import org.edx.mobile.inapppurchases.BillingProcessor +import org.edx.mobile.inapppurchases.getCourseSku import org.edx.mobile.inapppurchases.getPriceAmount import org.edx.mobile.model.api.EnrolledCoursesResponse +import org.edx.mobile.model.api.EnrolledCoursesResponse.ProductInfo import org.edx.mobile.model.api.getAuditCourses import org.edx.mobile.model.iap.AddToBasketResponse import org.edx.mobile.model.iap.CheckoutResponse @@ -76,7 +78,7 @@ class InAppPurchasesViewModel @Inject constructor( override fun onPurchaseComplete(purchase: Purchase) { super.onPurchaseComplete(purchase) - if (purchase.products.first() == iapFlowData.productId) { + if (purchase.getCourseSku() == iapFlowData.productInfo.courseSku) { iapFlowData.purchaseToken = purchase.purchaseToken _productPurchased.postEvent(iapFlowData) iapAnalytics.trackIAPEvent(eventName = Analytics.Events.IAP_PAYMENT_TIME) @@ -89,20 +91,20 @@ class InAppPurchasesViewModel @Inject constructor( billingProcessor.setUpBillingFlowListeners(listener) } - fun initializeProductPrice(courseSku: String?) { + fun initializeProductPrice(productInfo: ProductInfo?) { iapAnalytics.initPriceTime() - if (courseSku == null) { + if (productInfo == null) { dispatchError(requestType = ErrorMessage.PRICE_CODE) return } viewModelScope.launch { - val response = billingProcessor.querySyncDetails(courseSku) + val response = billingProcessor.querySyncDetails(productInfo.storeSku) val productDetail = response.productDetailsList?.firstOrNull() val billingResult = response.billingResult when { - billingResult.responseCode == BillingClient.BillingResponseCode.OK && productDetail == null -> { + billingResult.responseCode == BillingResponseCode.OK && productDetail == null -> { dispatchError( requestType = ErrorMessage.NO_SKU_CODE, throwable = InAppPurchasesException( @@ -112,7 +114,7 @@ class InAppPurchasesViewModel @Inject constructor( ) } - productDetail?.productId == courseSku && productDetail.oneTimePurchaseOfferDetails != null -> { + productDetail?.productId == productInfo.storeSku && productDetail.oneTimePurchaseOfferDetails != null -> { _productPrice.postEvent(productDetail.oneTimePurchaseOfferDetails!!) iapAnalytics.setPrice(productDetail.oneTimePurchaseOfferDetails?.formattedPrice!!) iapAnalytics.trackIAPEvent(Analytics.Events.IAP_LOAD_PRICE_TIME) @@ -131,8 +133,8 @@ class InAppPurchasesViewModel @Inject constructor( } } - fun startPurchaseFlow(productId: String, price: Double, currencyCode: String) { - iapFlowData.productId = productId + fun startPurchaseFlow(productInfo: ProductInfo, price: Double, currencyCode: String) { + iapFlowData.productInfo = productInfo iapFlowData.price = price iapFlowData.currencyCode = currencyCode iapFlowData.flowType = IAPFlowData.IAPFlowType.USER_INITIATED @@ -143,7 +145,7 @@ class InAppPurchasesViewModel @Inject constructor( private fun addProductToBasket() { startLoading() repository.addToBasket( - productId = iapFlowData.productId, + productId = iapFlowData.productInfo.courseSku, callback = object : NetworkResponseCallback { override fun onSuccess(result: Result.Success) { result.data?.let { @@ -187,10 +189,10 @@ class InAppPurchasesViewModel @Inject constructor( }) } - fun purchaseItem(activity: Activity, userId: Long, courseSku: String?) { + fun purchaseItem(activity: Activity, userId: Long, productInfo: ProductInfo?) { viewModelScope.launch { - courseSku?.let { - billingProcessor.purchaseItem(activity, courseSku, userId) + if (productInfo != null) { + billingProcessor.purchaseItem(activity, userId, productInfo) } } } @@ -201,21 +203,15 @@ class InAppPurchasesViewModel @Inject constructor( this.iapFlowData = iapData repository.executeOrder( basketId = iapData.basketId, - productId = iapData.productId, + productId = iapData.productInfo.courseSku, purchaseToken = iapData.purchaseToken, price = iapData.price, currencyCode = iapData.currencyCode, callback = object : NetworkResponseCallback { override fun onSuccess(result: Result.Success) { result.data?.let { - iapData.isVerificationPending = false - if (iapFlowData.flowType.isSilentMode()) { - markPurchaseComplete(iapData) - } else { - _refreshCourseData.postEvent(iapData) - } + consumeOrderForFurtherPurchases(iapData) } - endLoading() } override fun onError(error: Result.Error) { @@ -229,6 +225,29 @@ class InAppPurchasesViewModel @Inject constructor( } } + fun consumeOrderForFurtherPurchases(iapFlowData: IAPFlowData) { + viewModelScope.launch { + val result = billingProcessor.consumePurchase(iapFlowData.purchaseToken) + if (result.responseCode == BillingResponseCode.OK) { + iapFlowData.isVerificationPending = false + if (iapFlowData.flowType.isSilentMode()) { + markPurchaseComplete(iapFlowData) + } else { + _refreshCourseData.postEvent(iapFlowData) + } + } else { + dispatchError( + requestType = ErrorMessage.CONSUME_CODE, + throwable = InAppPurchasesException( + httpErrorCode = result.responseCode, + errorMessage = result.debugMessage, + ) + ) + } + endLoading() + } + } + /** * To detect and handle courses which are purchased but still not Verified * @@ -268,6 +287,10 @@ class InAppPurchasesViewModel @Inject constructor( screenName ) if (incompletePurchases.isEmpty()) { + // Consume purchases for new orders if all previous purchases are executed + purchases.forEach { + billingProcessor.consumePurchase(it.purchaseToken) + } _fakeUnfulfilledCompletion.postEvent(true) } else { startUnfulfilledVerification() @@ -296,9 +319,9 @@ class InAppPurchasesViewModel @Inject constructor( //Start the purchase flow viewModelScope.launch { - val response = billingProcessor.querySyncDetails(iapFlowData.productId) + val response = billingProcessor.querySyncDetails(iapFlowData.productInfo.storeSku) val productDetail = response.productDetailsList?.firstOrNull() - if (productDetail?.productId == iapFlowData.productId) { + if (productDetail?.productId == iapFlowData.productInfo.storeSku) { productDetail.oneTimePurchaseOfferDetails?.let { iapFlowData.currencyCode = it.priceCurrencyCode iapFlowData.price = it.getPriceAmount()