Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add missing course properties in IAP restore & unfulfilled events #82

Merged
merged 5 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/org/openedx/app/di/ScreenModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ val screenModule = module {
get(),
get(),
get(),
get(),
windowSize,
get(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ import org.openedx.core.R
import org.openedx.core.config.Config
import org.openedx.core.data.repository.iap.IAPRepository
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.domain.model.EnrolledCourse
import org.openedx.core.domain.model.iap.ProductInfo
import org.openedx.core.domain.model.iap.PurchaseFlowData
import org.openedx.core.exception.iap.IAPException
import org.openedx.core.extension.decodeToLong
import org.openedx.core.module.billing.BillingProcessor
import org.openedx.core.module.billing.getCourseSku
import org.openedx.core.module.billing.getPriceAmount
import org.openedx.core.module.billing.getUserId
import org.openedx.core.presentation.global.AppData
import org.openedx.core.presentation.iap.IAPRequestType
import org.openedx.core.utils.EmailUtil
import org.openedx.core.utils.TimeUtils

class IAPInteractor(
private val appData: AppData,
Expand Down Expand Up @@ -109,52 +112,83 @@ class IAPInteractor(
}
}

suspend fun processUnfulfilledPurchase(userId: Long): Boolean {
suspend fun processUnfulfilledPurchase(
userId: Long,
enrolledCourses: List<EnrolledCourse>,
verificationInitiated: (PurchaseFlowData) -> Unit = {},
): PurchaseFlowData? {
val purchases = billingProcessor.queryPurchases()
val userPurchases =
purchases.filter { it.accountIdentifiers?.obfuscatedAccountId?.decodeToLong() == userId }
val userPurchases = purchases.filter { purchase ->
val userAccountId = purchase.getUserId()
val courseSku = purchase.getCourseSku()

userAccountId == userId && enrolledCourses.any { enrolledCourse ->
courseSku == enrolledCourse.productInfo?.courseSku
}
}
if (userPurchases.isNotEmpty()) {
startUnfulfilledVerification(userPurchases)
return true
userPurchases.first().let { purchase ->
val courseVerified = enrolledCourses.find { enrolledCourse ->
enrolledCourse.productInfo?.courseSku == purchase.getCourseSku()
}
HamzaIsrar12 marked this conversation as resolved.
Show resolved Hide resolved
courseVerified?.let {
val productDetails =
billingProcessor.querySyncDetails(purchase.products[0]).productDetailsList?.firstOrNull()
val purchaseProductFlow = PurchaseFlowData(
courseId = courseVerified.course.id,
isSelfPaced = courseVerified.course.isSelfPaced,
productInfo = courseVerified.productInfo
).apply {
productDetails?.oneTimePurchaseOfferDetails?.let {
this.price = it.getPriceAmount()
this.currencyCode = it.priceCurrencyCode
}
this.flowStartTime = TimeUtils.getCurrentTime()
}
verificationInitiated(purchaseProductFlow)
startUnfulfilledVerification(purchase)
return purchaseProductFlow
}
}
} else {
purchases.forEach {
billingProcessor.consumePurchase(it.purchaseToken)
}
}
return false
return null
}

private suspend fun startUnfulfilledVerification(userPurchases: List<Purchase>) {
userPurchases.forEach { purchase ->
val productDetail =
billingProcessor.querySyncDetails(purchase.products.first()).productDetailsList?.firstOrNull()
productDetail?.oneTimePurchaseOfferDetails?.takeIf {
purchase.getCourseSku().isNullOrEmpty().not()
}?.let { oneTimeProductDetails ->
val courseSku = purchase.getCourseSku() ?: return@let
val basketId = addToBasket(courseSku)
executeOrder(
basketId = basketId,
purchaseToken = purchase.purchaseToken,
price = oneTimeProductDetails.getPriceAmount(),
currencyCode = oneTimeProductDetails.priceCurrencyCode,
)
consumePurchase(purchase.purchaseToken)
}
private suspend fun startUnfulfilledVerification(userPurchase: Purchase) {
val productDetail =
billingProcessor.querySyncDetails(userPurchase.products.first()).productDetailsList?.firstOrNull()
productDetail?.oneTimePurchaseOfferDetails?.takeIf {
userPurchase.getCourseSku().isNullOrEmpty().not()
}?.let { oneTimeProductDetails ->
val courseSku = userPurchase.getCourseSku() ?: return@let
val basketId = addToBasket(courseSku)
executeOrder(
basketId = basketId,
purchaseToken = userPurchase.purchaseToken,
price = oneTimeProductDetails.getPriceAmount(),
currencyCode = oneTimeProductDetails.priceCurrencyCode,
)
consumePurchase(userPurchase.purchaseToken)
}
}

suspend fun detectUnfulfilledPurchase(
onSuccess: () -> Unit,
enrolledCourses: List<EnrolledCourse>,
verificationInitiated: (PurchaseFlowData) -> Unit,
onSuccess: (PurchaseFlowData) -> Unit,
onFailure: (IAPException) -> Unit,
) {
if (isIAPEnabled) {
preferencesManager.user?.id?.let { userId ->
runCatching {
processUnfulfilledPurchase(userId)
}.onSuccess {
if (it) {
onSuccess()
processUnfulfilledPurchase(userId, enrolledCourses, verificationInitiated)
}.onSuccess { purchaseFlowData ->
purchaseFlowData?.let {
onSuccess(purchaseFlowData)
}
}.onFailure {
if (it is IAPException) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ data class CourseEnrollments(
val enrollments: DashboardCourseList,
val configs: AppConfig,
val primary: EnrolledCourse?,
)
) {
fun hasEnrolledCourses(): Boolean {
return primary != null || enrollments.courses.isNotEmpty()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ data class PurchaseFlowData(
basketId = -1
flowStartTime = 0
}

fun isSilentIAPFlow(): Boolean? {
return when (iapFlow) {
IAPFlow.SILENT -> {
true
}
IAPFlow.RESTORE -> {
false
}
else -> {
null
}
}
}
}

enum class IAPFlow(val value: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.openedx.core.domain.model.iap.ProductInfo
import org.openedx.core.extension.decodeToLong
import org.openedx.core.extension.decodeToString
import org.openedx.core.extension.encodeToString
import org.openedx.core.extension.safeResume
Expand Down Expand Up @@ -203,3 +204,7 @@ fun ProductDetails.OneTimePurchaseOfferDetails.getPriceAmount(): Double =
fun Purchase.getCourseSku(): String? {
return this.accountIdentifiers?.obfuscatedProfileId?.decodeToString()
}

fun Purchase.getUserId(): Long? {
return this.accountIdentifiers?.obfuscatedAccountId?.decodeToLong()
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import org.openedx.core.utils.TimeUtils

class IAPEventLogger(
private val analytics: IAPAnalytics,
private val purchaseFlowData: PurchaseFlowData? = null,
private val isSilentIAPFlow: Boolean? = null,
var isSilentIAPFlow: Boolean? = null,
var purchaseFlowData: PurchaseFlowData? = null,
) {
fun upgradeNowClickedEvent() {
logIAPEvent(IAPAnalyticsEvent.IAP_UPGRADE_NOW_CLICKED)
Expand Down Expand Up @@ -64,7 +64,7 @@ class IAPEventLogger(

IAPRequestType.PRICE_CODE,
IAPRequestType.NO_SKU_CODE,
-> {
-> {
priceLoadErrorEvent(feedbackErrorMessage)
}

Expand Down Expand Up @@ -186,7 +186,7 @@ class IAPEventLogger(
putAll(params)
putAll(getIAPEventParams())
putAll(getUnfulfilledIAPEventParams())
},
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import org.openedx.core.domain.model.iap.IAPFlow
import org.openedx.core.domain.model.iap.IAPFlowSource
import org.openedx.core.domain.model.iap.PurchaseFlowData
import org.openedx.core.exception.iap.IAPException
import org.openedx.core.extension.isNull
import org.openedx.core.module.billing.BillingProcessor
import org.openedx.core.module.billing.getCourseSku
import org.openedx.core.module.billing.getPriceAmount
Expand Down Expand Up @@ -55,6 +56,7 @@ class IAPViewModel(

val eventLogger = IAPEventLogger(
analytics = analytics,
isSilentIAPFlow = purchaseData.isSilentIAPFlow(),
purchaseFlowData = purchaseData
)

Expand Down Expand Up @@ -84,7 +86,9 @@ class IAPViewModel(
iapNotifier.notifier.onEach { event ->
when (event) {
is CourseDataUpdated -> {
eventLogger.upgradeSuccessEvent()
if (eventLogger.isSilentIAPFlow.isNull()) {
eventLogger.upgradeSuccessEvent()
}
_uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(R.string.iap_success_message)))
_uiState.value = IAPUIState.CourseDataUpdated
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package org.openedx.core.system.notifier.app

import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow

class AppNotifier {

private val channel = MutableSharedFlow<AppEvent>(replay = 0, extraBufferCapacity = 0)
private val channel = MutableSharedFlow<AppEvent>(
replay = 0,
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)

val notifier: Flow<AppEvent> = channel.asSharedFlow()

Expand All @@ -16,4 +21,7 @@ class AppNotifier {

suspend fun send(event: AppUpgradeEvent) = channel.emit(event)

suspend fun send(event: EnrolledCourseEvent) = channel.emit(event)

suspend fun send(event: RequestEnrolledCourseEvent) = channel.emit(event)
HamzaIsrar12 marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package org.openedx.core.system.notifier.app

import org.openedx.core.domain.model.EnrolledCourse

class EnrolledCourseEvent(val enrolledCourses: List<EnrolledCourse>,) : AppEvent
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package org.openedx.core.system.notifier.app

object RequestEnrolledCourseEvent : AppEvent
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import org.openedx.core.domain.model.iap.PurchaseFlowData
import org.openedx.core.exception.iap.IAPException
import org.openedx.core.extension.isFalse
import org.openedx.core.extension.isNotNull
import org.openedx.core.extension.isNull
import org.openedx.core.extension.isTrue
import org.openedx.core.module.billing.BillingProcessor
import org.openedx.core.module.billing.getCourseSku
Expand Down Expand Up @@ -286,10 +287,14 @@ class CourseContainerViewModel(
}
if (isIAPFlow) {
if (isExpiredCoursePurchase) {
eventLogger.upgradeSuccessEvent()
HamzaIsrar12 marked this conversation as resolved.
Show resolved Hide resolved
if (eventLogger.isSilentIAPFlow.isNull()) {
eventLogger.upgradeSuccessEvent()
}
_uiMessage.emit(
UIMessage.ToastMessage(
resourceManager.getString(CoreR.string.iap_success_message)
resourceManager.getString(
CoreR.string.iap_success_message
)
)
)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,8 @@ private fun DashboardGalleryView(
},
onIAPAction = onIAPAction,
)
LaunchedEffect(uiState.userCourses.enrollments.courses) {
if (uiState.userCourses.enrollments.courses.isNotEmpty()) {
LaunchedEffect(uiState.userCourses.hasEnrolledCourses()) {
if (uiState.userCourses.hasEnrolledCourses()) {
onIAPAction(IAPAction.ACTION_UNFULFILLED, null, null)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,12 @@ import org.openedx.core.system.notifier.NavigationToDiscovery
import org.openedx.core.system.notifier.PushEvent
import org.openedx.core.system.notifier.PushNotifier
import org.openedx.core.system.notifier.UpdateCourseData
import org.openedx.core.system.notifier.app.AppNotifier
import org.openedx.core.system.notifier.app.EnrolledCourseEvent
import org.openedx.core.system.notifier.app.RequestEnrolledCourseEvent
import org.openedx.core.ui.WindowSize
import org.openedx.core.utils.FileUtil
import org.openedx.dashboard.domain.CourseStatusFilter
import org.openedx.dashboard.domain.interactor.DashboardInteractor
import org.openedx.dashboard.presentation.DashboardRouter

Expand All @@ -60,6 +64,7 @@ class DashboardGalleryViewModel(
private val dashboardRouter: DashboardRouter,
private val iapNotifier: IAPNotifier,
private val pushNotifier: PushNotifier,
private val appNotifier: AppNotifier,
private val iapInteractor: IAPInteractor,
private val windowSize: WindowSize,
iapAnalytics: IAPAnalytics,
Expand All @@ -76,7 +81,7 @@ class DashboardGalleryViewModel(
val uiMessage: SharedFlow<UIMessage?>
get() = _uiMessage.asSharedFlow()

private val _updating = MutableStateFlow<Boolean>(false)
private val _updating = MutableStateFlow(false)
val updating: StateFlow<Boolean>
get() = _updating.asStateFlow()

Expand All @@ -96,11 +101,25 @@ class DashboardGalleryViewModel(
private var isLoading = false

init {
collectAppEvent()
collectDiscoveryNotifier()
collectIapNotifier()
getCourses()
}

private fun collectAppEvent() {
appNotifier.notifier
.onEach {
if (it is RequestEnrolledCourseEvent) {
val enrolledCourses =
interactor.getAllUserCourses(status = CourseStatusFilter.ALL).courses
appNotifier.send(EnrolledCourseEvent(enrolledCourses))
}
}
.distinctUntilChanged()
.launchIn(viewModelScope)
}

fun getCourses(isIAPFlow: Boolean = false) {
viewModelScope.launch {
try {
Expand Down Expand Up @@ -258,9 +277,21 @@ class DashboardGalleryViewModel(

private fun detectUnfulfilledPurchase() {
viewModelScope.launch(Dispatchers.IO) {
val enrolledCourses =
interactor.getAllUserCourses(status = CourseStatusFilter.ALL).courses
iapInteractor.detectUnfulfilledPurchase(
onSuccess = {
eventLogger.logUnfulfilledPurchaseInitiatedEvent()
enrolledCourses = enrolledCourses,
verificationInitiated = { purchaseFlowData ->
eventLogger.apply {
this.purchaseFlowData = purchaseFlowData
this.logUnfulfilledPurchaseInitiatedEvent()
}
},
onSuccess = { purchaseFlowData ->
eventLogger.apply {
this.purchaseFlowData = purchaseFlowData
this.upgradeSuccessEvent()
}
_iapUiState.tryEmit(IAPUIState.PurchasesFulfillmentCompleted)
},
onFailure = {
Expand Down
Loading
Loading