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

Commit 89d8d4e

Browse files
authored
Merge pull request #1846 from openedx/hamza/LEARNER-9818
feat: Consumable In-App Purchases
2 parents bd6299c + d74b621 commit 89d8d4e

19 files changed

+221
-88
lines changed

OpenEdXMobile/src/main/java/org/edx/mobile/exception/ErrorMessage.kt

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ data class ErrorMessage(
2424
const val COURSE_REFRESH_CODE = 0x205
2525
const val PRICE_CODE = 0x206
2626
const val NO_SKU_CODE = 0x207
27+
const val CONSUME_CODE = 0x208
2728
}
2829

2930
private fun isPreUpgradeErrorType(): Boolean =
@@ -56,6 +57,7 @@ data class ErrorMessage(
5657
fun canRetry(): Boolean {
5758
return requestType == PRICE_CODE ||
5859
requestType == EXECUTE_ORDER_CODE ||
60+
requestType == CONSUME_CODE ||
5961
requestType == COURSE_REFRESH_CODE
6062
}
6163
}

OpenEdXMobile/src/main/java/org/edx/mobile/extenstion/EncryptionExt.kt

+12
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,22 @@ fun Long.encodeToString(): String {
66
return Base64.encodeToString(this.toString().toByteArray(), Base64.DEFAULT)
77
}
88

9+
fun String.encodeToString(): String {
10+
return Base64.encodeToString(this.toByteArray(), Base64.DEFAULT)
11+
}
12+
913
fun String.decodeToLong(): Long? {
1014
return try {
1115
Base64.decode(this, Base64.DEFAULT).toString(Charsets.UTF_8).toLong()
1216
} catch (ex: Exception) {
1317
null
1418
}
1519
}
20+
21+
fun String.decodeToString(): String? {
22+
return try {
23+
Base64.decode(this, Base64.DEFAULT).toString(Charsets.UTF_8)
24+
} catch (ex: Exception) {
25+
null
26+
}
27+
}

OpenEdXMobile/src/main/java/org/edx/mobile/inapppurchases/BillingProcessor.kt

+32-6
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@ import com.android.billingclient.api.BillingClientStateListener
1111
import com.android.billingclient.api.BillingFlowParams
1212
import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams
1313
import com.android.billingclient.api.BillingResult
14+
import com.android.billingclient.api.ConsumeParams
1415
import com.android.billingclient.api.ProductDetails
1516
import com.android.billingclient.api.ProductDetailsResult
1617
import com.android.billingclient.api.Purchase
18+
import com.android.billingclient.api.Purchase.PurchaseState
1719
import com.android.billingclient.api.PurchasesUpdatedListener
1820
import com.android.billingclient.api.QueryProductDetailsParams
1921
import com.android.billingclient.api.QueryPurchasesParams
2022
import com.android.billingclient.api.acknowledgePurchase
23+
import com.android.billingclient.api.consumePurchase
2124
import com.android.billingclient.api.queryProductDetails
2225
import com.android.billingclient.api.queryPurchasesAsync
2326
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -26,10 +29,12 @@ import kotlinx.coroutines.CoroutineScope
2629
import kotlinx.coroutines.launch
2730
import kotlinx.coroutines.suspendCancellableCoroutine
2831
import kotlinx.coroutines.withContext
32+
import org.edx.mobile.extenstion.decodeToString
2933
import org.edx.mobile.extenstion.encodeToString
3034
import org.edx.mobile.extenstion.resumeIfActive
3135
import org.edx.mobile.injection.DataSourceDispatcher
3236
import org.edx.mobile.logger.Logger
37+
import org.edx.mobile.model.api.EnrolledCoursesResponse.ProductInfo
3338
import javax.inject.Inject
3439
import javax.inject.Singleton
3540

@@ -125,16 +130,20 @@ class BillingProcessor @Inject constructor(
125130
* Called to purchase the new product. Query the product details and launch the purchase flow.
126131
*
127132
* @param activity active activity to launch our billing flow from
128-
* @param productId Product Id to be purchased
129133
* @param userId User Id of the purchaser
134+
* @param productInfo Course and Product info to purchase
130135
*/
131-
suspend fun purchaseItem(activity: Activity, productId: String, userId: Long) {
136+
suspend fun purchaseItem(
137+
activity: Activity,
138+
userId: Long,
139+
productInfo: ProductInfo,
140+
) {
132141
if (isReadyOrConnect()) {
133-
val response = querySyncDetails(productId)
142+
val response = querySyncDetails(productInfo.storeSku)
134143
logger.debug("Getting Purchases -> ${response.billingResult}")
135144

136145
response.productDetailsList?.first()?.let {
137-
launchBillingFlow(activity, it, userId)
146+
launchBillingFlow(activity, it, userId, productInfo.courseSku)
138147
}
139148
} else {
140149
listener.onPurchaseCancel(BillingResponseCode.BILLING_UNAVAILABLE, "")
@@ -152,7 +161,8 @@ class BillingProcessor @Inject constructor(
152161
private fun launchBillingFlow(
153162
activity: Activity,
154163
productDetails: ProductDetails,
155-
userId: Long
164+
userId: Long,
165+
courseSku: String,
156166
) {
157167
val productDetailsParamsList = listOf(
158168
ProductDetailsParams.newBuilder()
@@ -163,6 +173,7 @@ class BillingProcessor @Inject constructor(
163173
val billingFlowParams = BillingFlowParams.newBuilder()
164174
.setProductDetailsParamsList(productDetailsParamsList)
165175
.setObfuscatedAccountId(userId.encodeToString())
176+
.setObfuscatedProfileId(courseSku.encodeToString())
166177
.build()
167178

168179
billingClient.launchBillingFlow(activity, billingFlowParams)
@@ -232,7 +243,18 @@ class BillingProcessor @Inject constructor(
232243
QueryPurchasesParams.newBuilder()
233244
.setProductType(BillingClient.ProductType.INAPP)
234245
.build()
235-
).purchasesList
246+
).purchasesList.filter { it.purchaseState == PurchaseState.PURCHASED }
247+
}
248+
249+
suspend fun consumePurchase(purchaseToken: String): BillingResult {
250+
isReadyOrConnect()
251+
val result = billingClient.consumePurchase(
252+
ConsumeParams
253+
.newBuilder()
254+
.setPurchaseToken(purchaseToken)
255+
.build()
256+
)
257+
return result.billingResult
236258
}
237259

238260
companion object {
@@ -251,3 +273,7 @@ class BillingProcessor @Inject constructor(
251273

252274
fun ProductDetails.OneTimePurchaseOfferDetails.getPriceAmount(): Double =
253275
this.priceAmountMicros.toDouble().div(BillingProcessor.MICROS_TO_UNIT)
276+
277+
fun Purchase.getCourseSku(): String? {
278+
return this.accountIdentifiers?.obfuscatedProfileId?.decodeToString()
279+
}

OpenEdXMobile/src/main/java/org/edx/mobile/model/api/AppConfig.kt

+3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ data class IAPConfig(
3131
@SerializedName("experiment_enabled")
3232
val isExperimentEnabled: Boolean = false,
3333

34+
@SerializedName("android_product_prefix")
35+
val productPrefix: String = "",
36+
3437
@SerializedName("android_disabled_versions")
3538
val disableVersions: List<String> = listOf()
3639

OpenEdXMobile/src/main/java/org/edx/mobile/model/api/CourseMode.kt

+18-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package org.edx.mobile.model.api
22

33
import com.google.gson.annotations.SerializedName
44
import java.io.Serializable
5+
import kotlin.math.ceil
56

67
data class CourseMode(
78
@SerializedName("slug")
@@ -12,4 +13,20 @@ data class CourseMode(
1213

1314
@SerializedName("android_sku")
1415
val androidSku: String?,
15-
) : Serializable
16+
17+
@SerializedName("min_price")
18+
val price: Double?,
19+
20+
var storeSku: String?,
21+
) : Serializable {
22+
23+
fun setStoreProductSku(storeProductPrefix: String) {
24+
val ceilPrice = price
25+
?.let { ceil(it).toInt() }
26+
?.takeIf { it > 0 }
27+
28+
if (storeProductPrefix.isNotBlank() && ceilPrice != null) {
29+
storeSku = "$storeProductPrefix$ceilPrice"
30+
}
31+
}
32+
}

OpenEdXMobile/src/main/java/org/edx/mobile/model/api/EnrolledCoursesResponse.kt

+29-5
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import com.google.gson.annotations.SerializedName
55
import org.edx.mobile.interfaces.SectionItemInterface
66
import org.edx.mobile.model.course.EnrollmentMode
77
import org.edx.mobile.util.DateUtil
8+
import java.io.Serializable
89
import java.util.Date
910

1011
data class EnrolledCoursesResponse(
@@ -39,11 +40,6 @@ data class EnrolledCoursesResponse(
3940
val isCertificateEarned: Boolean
4041
get() = certificateURL.isNullOrEmpty().not()
4142

42-
val courseSku: String?
43-
get() = courseModes?.firstOrNull { item ->
44-
EnrollmentMode.VERIFIED.name.equals(item.slug, ignoreCase = true)
45-
}?.androidSku.takeUnless { it.isNullOrEmpty() }
46-
4743
val isAuditMode: Boolean
4844
get() = EnrollmentMode.AUDIT.toString().equals(mode, ignoreCase = true)
4945

@@ -59,6 +55,29 @@ data class EnrolledCoursesResponse(
5955
EnrollmentMode.VERIFIED.toString().equals(it.slug, ignoreCase = true)
6056
} != null
6157

58+
val productInfo: ProductInfo?
59+
get() = courseSku?.let { courseSku ->
60+
storeSku?.let { storeSku ->
61+
ProductInfo(courseSku, storeSku)
62+
}
63+
}
64+
65+
private val courseSku: String?
66+
get() = courseModes?.firstOrNull { item ->
67+
EnrollmentMode.VERIFIED.name.equals(item.slug, ignoreCase = true)
68+
}?.androidSku.takeUnless { it.isNullOrEmpty() }
69+
70+
private val storeSku: String?
71+
get() = courseModes?.firstOrNull { item ->
72+
EnrollmentMode.VERIFIED.name.equals(item.slug, ignoreCase = true)
73+
}?.storeSku
74+
75+
fun setStoreSku(storeProductPrefix: String) {
76+
courseModes?.forEach {
77+
it.setStoreProductSku(storeProductPrefix)
78+
}
79+
}
80+
6281
override fun isChapter(): Boolean {
6382
return false
6483
}
@@ -82,6 +101,11 @@ data class EnrolledCoursesResponse(
82101
override fun isDownload(): Boolean {
83102
return false
84103
}
104+
105+
data class ProductInfo(
106+
val courseSku: String,
107+
val storeSku: String,
108+
) : Serializable
85109
}
86110

87111
/**

OpenEdXMobile/src/main/java/org/edx/mobile/model/api/EnrollmentResponse.kt

+10-3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.google.gson.JsonElement
77
import com.google.gson.JsonObject
88
import com.google.gson.annotations.SerializedName
99
import com.google.gson.reflect.TypeToken
10+
import org.edx.mobile.extenstion.isNotNullOrEmpty
1011
import org.edx.mobile.logger.Logger
1112
import org.edx.mobile.model.iap.IAPFlowData
1213
import java.io.Serializable
@@ -59,6 +60,12 @@ data class EnrollmentResponse(
5960
AppConfig::class.java
6061
)
6162

63+
if (appConfig.iapConfig.productPrefix.isNotNullOrEmpty()) {
64+
enrolledCourses.forEach { courseData ->
65+
courseData.setStoreSku(appConfig.iapConfig.productPrefix)
66+
}
67+
}
68+
6269
EnrollmentResponse(appConfig, enrolledCourses)
6370
}
6471
} catch (ex: Exception) {
@@ -76,12 +83,12 @@ data class EnrollmentResponse(
7683
*/
7784
fun List<EnrolledCoursesResponse>.getAuditCourses(): List<IAPFlowData> {
7885
return this.filter {
79-
it.isAuditMode && it.courseSku.isNullOrBlank().not()
86+
it.isAuditMode && it.productInfo != null
8087
}.mapNotNull { course ->
81-
course.courseSku?.let { sku ->
88+
course.productInfo?.let { productInfo ->
8289
IAPFlowData(
8390
courseId = course.courseId,
84-
productId = sku,
91+
productInfo = productInfo,
8592
isCourseSelfPaced = course.course.isSelfPaced
8693
)
8794
}

OpenEdXMobile/src/main/java/org/edx/mobile/model/course/CourseComponent.java

+8-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package org.edx.mobile.model.course;
22

3+
import static org.edx.mobile.model.api.EnrolledCoursesResponse.ProductInfo;
4+
35
import android.text.TextUtils;
46

57
import androidx.annotation.NonNull;
@@ -43,7 +45,7 @@ public class CourseComponent implements IBlock, IPathNode {
4345
private String authorizationDenialMessage;
4446
private AuthorizationDenialReason authorizationDenialReason;
4547
private SpecialExamInfo specialExamInfo;
46-
private String courseSku;
48+
private ProductInfo productInfo;
4749

4850
public CourseComponent() {
4951
}
@@ -69,7 +71,7 @@ public CourseComponent(@NonNull CourseComponent other) {
6971
this.authorizationDenialMessage = other.authorizationDenialMessage;
7072
this.authorizationDenialReason = other.authorizationDenialReason;
7173
this.specialExamInfo = other.specialExamInfo;
72-
this.courseSku = other.courseSku;
74+
this.productInfo = other.productInfo;
7375
}
7476

7577
/**
@@ -572,12 +574,12 @@ public SpecialExamInfo getSpecialExamInfo() {
572574
return specialExamInfo;
573575
}
574576

575-
public String getCourseSku() {
576-
return courseSku;
577+
public ProductInfo getProductInfo() {
578+
return productInfo;
577579
}
578580

579-
public void setCourseSku(String courseSku) {
580-
this.courseSku = courseSku;
581+
public void setProductInfo(ProductInfo productInfo) {
582+
this.productInfo = productInfo;
581583
}
582584

583585
public ArrayList<SectionRow> getSectionData() {

OpenEdXMobile/src/main/java/org/edx/mobile/model/iap/IAPFlowData.kt

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
package org.edx.mobile.model.iap
22

3+
import org.edx.mobile.model.api.EnrolledCoursesResponse.ProductInfo
34
import java.io.Serializable
45

56
data class IAPFlowData(
67
var flowType: IAPFlowType = IAPFlowType.USER_INITIATED,
78
var courseId: String = "",
89
var isCourseSelfPaced: Boolean = false,
9-
var productId: String = "",
10+
var productInfo: ProductInfo = ProductInfo("", ""),
1011
var basketId: Long = 0,
1112
var purchaseToken: String = "",
1213
var price: Double = 0.0,
@@ -17,7 +18,7 @@ data class IAPFlowData(
1718
fun clear() {
1819
courseId = ""
1920
isCourseSelfPaced = false
20-
productId = ""
21+
productInfo = ProductInfo("", "")
2122
basketId = 0
2223
price = 0.0
2324
currencyCode = ""

OpenEdXMobile/src/main/java/org/edx/mobile/util/InAppPurchasesUtils.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.android.billingclient.api.Purchase
44
import org.edx.mobile.R
55
import org.edx.mobile.exception.ErrorMessage
66
import org.edx.mobile.http.HttpStatus
7+
import org.edx.mobile.inapppurchases.getCourseSku
78
import org.edx.mobile.model.iap.IAPFlowData
89

910
object InAppPurchasesUtils {
@@ -20,7 +21,7 @@ object InAppPurchasesUtils {
2021
): MutableList<IAPFlowData> {
2122
purchases.forEach { purchase ->
2223
auditCourses.find { course ->
23-
purchase.products.first().equals(course.productId)
24+
purchase.getCourseSku() == course.productInfo.courseSku
2425
}?.apply {
2526
this.purchaseToken = purchase.purchaseToken
2627
this.flowType = flowType

0 commit comments

Comments
 (0)