Skip to content
This repository has been archived by the owner on May 6, 2024. It is now read-only.

Commit

Permalink
Merge pull request #1846 from openedx/hamza/LEARNER-9818
Browse files Browse the repository at this point in the history
feat: Consumable In-App Purchases
  • Loading branch information
HamzaIsrar12 authored Mar 6, 2024
2 parents bd6299c + d74b621 commit 89d8d4e
Show file tree
Hide file tree
Showing 19 changed files with 221 additions and 88 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,22 @@ 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()
} catch (ex: Exception) {
null
}
}

fun String.decodeToString(): String? {
return try {
Base64.decode(this, Base64.DEFAULT).toString(Charsets.UTF_8)
} catch (ex: Exception) {
null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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, "")
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = listOf()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand All @@ -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
}
Expand All @@ -82,6 +101,11 @@ data class EnrolledCoursesResponse(
override fun isDownload(): Boolean {
return false
}

data class ProductInfo(
val courseSku: String,
val storeSku: String,
) : Serializable
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -76,12 +83,12 @@ data class EnrollmentResponse(
*/
fun List<EnrolledCoursesResponse>.getAuditCourses(): List<IAPFlowData> {
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
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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() {
}
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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<SectionRow> getSectionData() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,7 +18,7 @@ data class IAPFlowData(
fun clear() {
courseId = ""
isCourseSelfPaced = false
productId = ""
productInfo = ProductInfo("", "")
basketId = 0
price = 0.0
currencyCode = ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,7 +21,7 @@ object InAppPurchasesUtils {
): MutableList<IAPFlowData> {
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
Expand Down
Loading

0 comments on commit 89d8d4e

Please sign in to comment.