diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index d8d02314b..e912655ab 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -151,7 +151,6 @@ val screenModule = module { get(), get(), get(), - get() ) } @@ -164,7 +163,10 @@ val screenModule = module { get(), get(), get(), - windowSize + get(), + get(), + get(), + windowSize, ) } viewModel { AllEnrolledCoursesViewModel(get(), get(), get(), get(), get(), get(), get()) } @@ -441,7 +443,7 @@ val screenModule = module { } single { IAPRepository(get()) } - factory { IAPInteractor(get(), get()) } + factory { IAPInteractor(get(), get(), get(), get(), get(), get()) } viewModel { (iapFlow: IAPFlow, purchaseFlowData: PurchaseFlowData) -> IAPViewModel( iapFlow = iapFlow, diff --git a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt index 2682f957c..47a040e63 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseEnrollments.kt @@ -39,6 +39,7 @@ data class CourseEnrollments( enrollments.results.forEach { courseData -> courseData.setStoreSku(appConfig.iapConfig.productPrefix) } + primaryCourse?.setStoreSku(appConfig.iapConfig.productPrefix) } return CourseEnrollments(enrollments, appConfig, primaryCourse) diff --git a/core/src/main/java/org/openedx/core/domain/interactor/IAPInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/IAPInteractor.kt index b1f2e2762..dd9c29a2e 100644 --- a/core/src/main/java/org/openedx/core/domain/interactor/IAPInteractor.kt +++ b/core/src/main/java/org/openedx/core/domain/interactor/IAPInteractor.kt @@ -1,23 +1,73 @@ package org.openedx.core.domain.interactor +import android.content.Context import androidx.fragment.app.FragmentActivity import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.Purchase import org.openedx.core.ApiConstants +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.iap.ProductInfo 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.presentation.IAPAnalytics +import org.openedx.core.presentation.IAPAnalyticsEvent +import org.openedx.core.presentation.IAPAnalyticsKeys +import org.openedx.core.presentation.IAPAnalyticsScreen +import org.openedx.core.presentation.global.AppData +import org.openedx.core.presentation.iap.IAPAction +import org.openedx.core.presentation.iap.IAPFlow import org.openedx.core.presentation.iap.IAPRequestType +import org.openedx.core.utils.EmailUtil class IAPInteractor( + private val appData: AppData, private val billingProcessor: BillingProcessor, + private val config: Config, private val repository: IAPRepository, + private val preferencesManager: CorePreferences, + private val iapAnalytics: IAPAnalytics, ) { + private val iapConfig + get() = preferencesManager.appConfig.iapConfig + private val isIAPEnabled + get() = iapConfig.isEnabled && + iapConfig.disableVersions.contains(appData.versionName).not() + + fun logIAPCancelEvent(screen: IAPAnalyticsScreen) { + logIAPEvent( + IAPAnalyticsEvent.IAP_ERROR_ALERT_ACTION, buildMap { + put(IAPAnalyticsKeys.ERROR_ALERT_TYPE.key, IAPAction.ACTION_UNFULFILLED.action) + put(IAPAnalyticsKeys.ERROR_ACTION.key, IAPAction.ACTION_CLOSE.action) + }.toMutableMap(), + screen = screen, + ) + } + + fun showFeedbackScreen(context: Context, message: String, screen: IAPAnalyticsScreen) { + EmailUtil.showFeedbackScreen( + context = context, + feedbackEmailAddress = config.getFeedbackEmailAddress(), + subject = context.getString(R.string.core_error_upgrading_course_in_app), + feedback = message, + appVersion = appData.versionName + ) + logIAPEvent( + IAPAnalyticsEvent.IAP_ERROR_ALERT_ACTION, + buildMap { + put(IAPAnalyticsKeys.ERROR_ALERT_TYPE.key, IAPAction.ACTION_UNFULFILLED.action) + put(IAPAnalyticsKeys.ERROR_ACTION.key, IAPAction.ACTION_GET_HELP.action) + }.toMutableMap(), + screen = screen, + ) + } + suspend fun loadPrice(productId: String): ProductDetails.OneTimePurchaseOfferDetails { val response = billingProcessor.querySyncDetails(productId) val productDetails = response.productDetailsList?.firstOrNull()?.oneTimePurchaseOfferDetails @@ -122,4 +172,47 @@ class IAPInteractor( } } } + + suspend fun detectUnfulfilledPurchase( + screen: IAPAnalyticsScreen, + onSuccess: () -> Unit, + onFailure: (IAPException) -> Unit, + ) { + if (isIAPEnabled) { + preferencesManager.user?.id?.let { userId -> + runCatching { + processUnfulfilledPurchase(userId) + }.onSuccess { + if (it) { + onSuccess() + unfulfilledPurchaseInitiatedEvent(screen) + } + }.onFailure { + if (it is IAPException) { + onFailure(it) + } + } + } + } + } + + private fun unfulfilledPurchaseInitiatedEvent(screen: IAPAnalyticsScreen) { + logIAPEvent( + IAPAnalyticsEvent.IAP_UNFULFILLED_PURCHASE_INITIATED, + screen = screen + ) + } + + private fun logIAPEvent( + event: IAPAnalyticsEvent, + params: MutableMap = mutableMapOf(), + screen: IAPAnalyticsScreen, + ) { + iapAnalytics.logEvent(event.eventName, params.apply { + put(IAPAnalyticsKeys.NAME.key, event.biValue) + put(IAPAnalyticsKeys.SCREEN_NAME.key, screen.screenName) + put(IAPAnalyticsKeys.IAP_FLOW_TYPE.key, IAPFlow.SILENT.value) + put(IAPAnalyticsKeys.CATEGORY.key, IAPAnalyticsKeys.IN_APP_PURCHASES.key) + }) + } } diff --git a/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt b/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt index 8e24936b3..a29c9f91d 100644 --- a/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt +++ b/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt @@ -2,20 +2,26 @@ package org.openedx.core.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.EmojiEvents import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Lock import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter @@ -31,49 +37,80 @@ import org.openedx.core.ui.theme.appTypography fun UpgradeToAccessView( modifier: Modifier = Modifier, type: UpgradeToAccessViewType = UpgradeToAccessViewType.DASHBOARD, + padding: PaddingValues = PaddingValues(vertical = 8.dp, horizontal = 16.dp), onClick: () -> Unit, ) { - val shape = when (type) { - UpgradeToAccessViewType.DASHBOARD -> RoundedCornerShape( - bottomStart = 16.dp, - bottomEnd = 16.dp + val shape: Shape + var primaryIcon = Icons.Filled.Lock + var textColor = MaterialTheme.appColors.primaryButtonText + var backgroundColor = MaterialTheme.appColors.primaryButtonBackground + var secondaryIcon: @Composable () -> Unit = { + Icon( + modifier = Modifier + .padding(start = 16.dp), + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = textColor ) + } + when (type) { + UpgradeToAccessViewType.DASHBOARD -> { + shape = RoundedCornerShape( + bottomStart = 16.dp, + bottomEnd = 16.dp + ) + } + + UpgradeToAccessViewType.COURSE -> { + shape = MaterialTheme.appShapes.buttonShape + } - UpgradeToAccessViewType.COURSE -> MaterialTheme.appShapes.buttonShape + UpgradeToAccessViewType.GALLERY -> { + primaryIcon = Icons.Filled.EmojiEvents + textColor = MaterialTheme.appColors.textDark + shape = RectangleShape + backgroundColor = MaterialTheme.appColors.textFieldBackground + secondaryIcon = { + Icon( + modifier = Modifier + .padding(start = 16.dp) + .size(16.dp), + imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + contentDescription = null, + tint = textColor + ) + } + } } Row( modifier = modifier .clip(shape = shape) .fillMaxWidth() - .background(color = MaterialTheme.appColors.primaryButtonBackground) + .background(color = backgroundColor) .clickable { onClick() } - .padding(vertical = 8.dp, horizontal = 16.dp), + .padding(padding), verticalAlignment = Alignment.CenterVertically ) { Icon( modifier = Modifier.padding(end = 16.dp), - imageVector = Icons.Filled.Lock, + imageVector = primaryIcon, contentDescription = null, - tint = MaterialTheme.appColors.primaryButtonText + tint = textColor ) Text( modifier = Modifier.weight(1f), text = stringResource(id = R.string.iap_upgrade_access_course), - color = MaterialTheme.appColors.primaryButtonText, + color = textColor, style = MaterialTheme.appTypography.labelLarge ) - Icon( - modifier = Modifier.padding(start = 16.dp), - imageVector = Icons.Filled.Info, - contentDescription = null, - tint = MaterialTheme.appColors.primaryButtonText - ) + secondaryIcon() } } enum class UpgradeToAccessViewType { + GALLERY, DASHBOARD, COURSE, } @@ -93,5 +130,6 @@ private class UpgradeToAccessViewTypeParameterProvider : override val values = sequenceOf( UpgradeToAccessViewType.DASHBOARD, UpgradeToAccessViewType.COURSE, + UpgradeToAccessViewType.GALLERY, ) } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index 8f6cc124b..5a45d9911 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -42,6 +42,7 @@ import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -83,10 +84,17 @@ import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.domain.model.Pagination import org.openedx.core.domain.model.Progress +import org.openedx.core.exception.iap.IAPException +import org.openedx.core.presentation.iap.IAPAction +import org.openedx.core.presentation.iap.IAPUIState import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.PurchasesFulfillmentCompletedDialog import org.openedx.core.ui.TextIcon +import org.openedx.core.ui.UpgradeErrorDialog +import org.openedx.core.ui.UpgradeToAccessView +import org.openedx.core.ui.UpgradeToAccessViewType import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -106,10 +114,12 @@ fun DashboardGalleryView( val updating by viewModel.updating.collectAsState(false) val uiMessage by viewModel.uiMessage.collectAsState(null) val uiState by viewModel.uiState.collectAsState(DashboardGalleryUIState.Loading) + val iapUiState by viewModel.iapUiState.collectAsState(IAPUIState.Clear) DashboardGalleryView( uiMessage = uiMessage, uiState = uiState, + iapUiState = iapUiState, updating = updating, apiHostUrl = viewModel.apiHostUrl, hasInternetConnection = viewModel.hasInternetConnection, @@ -154,6 +164,11 @@ fun DashboardGalleryView( ) } } + }, + onIAPAction = { action, course, iapException -> + viewModel.processIAPAction( + fragmentManager, action, course, iapException + ) } ) } @@ -163,9 +178,11 @@ fun DashboardGalleryView( private fun DashboardGalleryView( uiMessage: UIMessage?, uiState: DashboardGalleryUIState, + iapUiState: IAPUIState?, updating: Boolean, apiHostUrl: String, onAction: (DashboardGalleryScreenAction) -> Unit, + onIAPAction: (IAPAction, EnrolledCourse?, IAPException?) -> Unit = { _, _, _ -> }, hasInternetConnection: Boolean ) { val scaffoldState = rememberScaffoldState() @@ -229,8 +246,14 @@ private fun DashboardGalleryView( blockId ) ) - } + }, + onIAPAction = onIAPAction, ) + LaunchedEffect(uiState.userCourses.enrollments.courses) { + if (uiState.userCourses.enrollments.courses.isNotEmpty()) { + onIAPAction(IAPAction.ACTION_UNFULFILLED, null, null) + } + } } is DashboardGalleryUIState.Empty -> { @@ -268,6 +291,40 @@ private fun DashboardGalleryView( } ) } + when (iapUiState) { + is IAPUIState.PurchasesFulfillmentCompleted -> { + PurchasesFulfillmentCompletedDialog(onConfirm = { + onIAPAction(IAPAction.ACTION_COMPLETION, null, null) + }, onDismiss = { + onIAPAction(IAPAction.ACTION_CLOSE, null, null) + }) + } + + is IAPUIState.Error -> { + UpgradeErrorDialog( + title = stringResource(id = CoreR.string.iap_error_title), + description = stringResource(id = CoreR.string.iap_course_not_fullfilled), + confirmText = stringResource(id = CoreR.string.core_cancel), + onConfirm = { + onIAPAction( + IAPAction.ACTION_ERROR_CLOSE, + null, + null + ) + }, + dismissText = stringResource(id = CoreR.string.iap_get_help), + onDismiss = { + onIAPAction( + IAPAction.ACTION_GET_HELP, + null, + iapUiState.iapException + ) + } + ) + } + + else -> {} + } } } } @@ -282,6 +339,7 @@ private fun UserCourses( navigateToDates: (EnrolledCourse) -> Unit, onViewAllClick: () -> Unit, resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, + onIAPAction: (IAPAction, EnrolledCourse?, IAPException?) -> Unit = { _, _, _ -> }, ) { Column( modifier = modifier @@ -290,11 +348,13 @@ private fun UserCourses( val primaryCourse = userCourses.primary if (primaryCourse != null) { PrimaryCourseCard( + isValuePropEnabled = userCourses.configs.isValuePropEnabled, primaryCourse = primaryCourse, apiHostUrl = apiHostUrl, navigateToDates = navigateToDates, resumeBlockId = resumeBlockId, - openCourse = openCourse + openCourse = openCourse, + onIAPAction = onIAPAction, ) } if (userCourses.enrollments.courses.isNotEmpty()) { @@ -507,11 +567,13 @@ private fun AssignmentItem( @Composable private fun PrimaryCourseCard( + isValuePropEnabled: Boolean, primaryCourse: EnrolledCourse, apiHostUrl: String, navigateToDates: (EnrolledCourse) -> Unit, resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, openCourse: (EnrolledCourse) -> Unit, + onIAPAction: (IAPAction, EnrolledCourse?, IAPException?) -> Unit = { _, _, _ -> }, ) { val context = LocalContext.current Card( @@ -605,6 +667,18 @@ private fun PrimaryCourseCard( ) ) } + if (primaryCourse.isUpgradeable && isValuePropEnabled) { + UpgradeToAccessView( + type = UpgradeToAccessViewType.GALLERY, + padding = PaddingValues(vertical = 16.dp, horizontal = 14.dp) + ) { + onIAPAction( + IAPAction.ACTION_USER_INITIATED, + primaryCourse, + null + ) + } + } ResumeButton( primaryCourse = primaryCourse, onClick = { @@ -863,6 +937,7 @@ private fun DashboardGalleryViewPreview() { OpenEdXTheme { DashboardGalleryView( uiState = DashboardGalleryUIState.Courses(mockUserCourses), + iapUiState = null, apiHostUrl = "", uiMessage = null, updating = false, diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index 7f1036e1d..3d3eed630 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -1,32 +1,51 @@ package org.openedx.courses.presentation +import android.annotation.SuppressLint +import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.domain.interactor.IAPInteractor import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.exception.iap.IAPException import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.IAPAnalyticsScreen +import org.openedx.core.presentation.dialog.IAPDialogFragment +import org.openedx.core.presentation.iap.IAPAction +import org.openedx.core.presentation.iap.IAPFlow +import org.openedx.core.presentation.iap.IAPRequestType +import org.openedx.core.presentation.iap.IAPUIState import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.IAPNotifier import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.core.system.notifier.UpdateCourseData import org.openedx.core.ui.WindowSize import org.openedx.core.utils.FileUtil import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.DashboardRouter +@SuppressLint("StaticFieldLeak") class DashboardGalleryViewModel( + private val context: Context, private val config: Config, private val interactor: DashboardInteractor, private val resourceManager: ResourceManager, @@ -34,7 +53,9 @@ class DashboardGalleryViewModel( private val networkConnection: NetworkConnection, private val fileUtil: FileUtil, private val dashboardRouter: DashboardRouter, - private val windowSize: WindowSize + private val iapNotifier: IAPNotifier, + private val iapInteractor: IAPInteractor, + private val windowSize: WindowSize, ) : BaseViewModel() { val apiHostUrl get() = config.getApiHostURL() @@ -55,10 +76,19 @@ class DashboardGalleryViewModel( val hasInternetConnection: Boolean get() = networkConnection.isOnline() + private val _iapUiState = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val iapUiState: SharedFlow + get() = _iapUiState.asSharedFlow() + private var isLoading = false init { collectDiscoveryNotifier() + collectIapNotifier() getCourses() } @@ -132,6 +162,59 @@ class DashboardGalleryViewModel( ) } + fun processIAPAction( + fragmentManager: FragmentManager, action: IAPAction, course: EnrolledCourse?, iapException: IAPException? + ) { + when (action) { + IAPAction.ACTION_USER_INITIATED -> { + if (course != null) { + IAPDialogFragment.newInstance( + iapFlow = IAPFlow.USER_INITIATED, + screenName = IAPAnalyticsScreen.COURSE_ENROLLMENT.screenName, + courseId = course.course.id, + courseName = course.course.name, + isSelfPaced = course.course.isSelfPaced, + productInfo = course.productInfo + ).show( + fragmentManager, + IAPDialogFragment.TAG + ) + } + } + + IAPAction.ACTION_COMPLETION -> { + IAPDialogFragment.newInstance( + IAPFlow.SILENT, + IAPAnalyticsScreen.COURSE_ENROLLMENT.screenName + ).show( + fragmentManager, + IAPDialogFragment.TAG + ) + clearIAPState() + } + + IAPAction.ACTION_UNFULFILLED -> { + detectUnfulfilledPurchase() + } + + IAPAction.ACTION_CLOSE -> { + clearIAPState() + } + + IAPAction.ACTION_ERROR_CLOSE -> { + logIAPCancelEvent() + } + + IAPAction.ACTION_GET_HELP -> { + iapException?.getFormattedErrorMessage() + ?.let { showFeedbackScreen(it) } + } + + else -> { + } + } + } + private fun collectDiscoveryNotifier() { viewModelScope.launch { discoveryNotifier.notifier.collect { @@ -142,6 +225,52 @@ class DashboardGalleryViewModel( } } + private fun collectIapNotifier() { + iapNotifier.notifier.onEach { event -> + when (event) { + is UpdateCourseData -> { + updateCourses() + } + } + }.distinctUntilChanged().launchIn(viewModelScope) + } + + private fun detectUnfulfilledPurchase() { + viewModelScope.launch(Dispatchers.IO) { + iapInteractor.detectUnfulfilledPurchase( + screen = IAPAnalyticsScreen.COURSE_DASHBOARD, + onSuccess = { + _iapUiState.tryEmit(IAPUIState.PurchasesFulfillmentCompleted) + }, + onFailure = { + _iapUiState.tryEmit( + IAPUIState.Error( + IAPException( + IAPRequestType.UNFULFILLED_CODE, + it.httpErrorCode, + it.errorMessage + ) + ) + ) + } + ) + } + } + + private fun showFeedbackScreen(message: String) { + iapInteractor.showFeedbackScreen(context, message, IAPAnalyticsScreen.COURSE_ENROLLMENT) + } + + private fun logIAPCancelEvent() { + iapInteractor.logIAPCancelEvent(IAPAnalyticsScreen.COURSE_ENROLLMENT) + } + + private fun clearIAPState() { + viewModelScope.launch { + _iapUiState.emit(null) + } + } + companion object { private const val PAGE_SIZE_TABLET = 7 private const val PAGE_SIZE_PHONE = 5 diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 7d5a4e360..e89427607 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -85,11 +85,8 @@ import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.domain.model.Progress import org.openedx.core.domain.model.iap.ProductInfo import org.openedx.core.exception.iap.IAPException -import org.openedx.core.presentation.IAPAnalyticsScreen -import org.openedx.core.presentation.dialog.IAPDialogFragment import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRecommendedBox import org.openedx.core.presentation.iap.IAPAction -import org.openedx.core.presentation.iap.IAPFlow import org.openedx.core.presentation.iap.IAPUIState import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage @@ -172,55 +169,9 @@ class DashboardListFragment : Fragment() { }, ), onIAPAction = { action, course, iapException -> - when (action) { - IAPAction.ACTION_USER_INITIATED -> { - if (course != null) { - IAPDialogFragment.newInstance( - iapFlow = IAPFlow.USER_INITIATED, - screenName = IAPAnalyticsScreen.COURSE_ENROLLMENT.screenName, - courseId = course.course.id, - courseName = course.course.name, - isSelfPaced = course.course.isSelfPaced, - productInfo = course.productInfo!! - ).show( - requireActivity().supportFragmentManager, - IAPDialogFragment.TAG - ) - } - } - - IAPAction.ACTION_COMPLETION -> { - IAPDialogFragment.newInstance( - IAPFlow.SILENT, - IAPAnalyticsScreen.COURSE_ENROLLMENT.screenName - ).show( - requireActivity().supportFragmentManager, - IAPDialogFragment.TAG - ) - viewModel.clearIAPState() - } - - IAPAction.ACTION_UNFULFILLED -> { - viewModel.detectUnfulfilledPurchase() - } - - IAPAction.ACTION_CLOSE -> { - viewModel.clearIAPState() - } - - IAPAction.ACTION_ERROR_CLOSE -> { - viewModel.logIAPCancelEvent() - } - - IAPAction.ACTION_GET_HELP -> { - iapException?.getFormattedErrorMessage() - ?.let { viewModel.showFeedbackScreen(requireActivity(), it) } - } - - else -> { - - } - } + viewModel.processIAPAction( + requireActivity().supportFragmentManager, action, course, iapException + ) } ) } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt index a993a7291..e1733d79e 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -1,6 +1,8 @@ package org.openedx.dashboard.presentation +import android.annotation.SuppressLint import android.content.Context +import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -24,11 +26,8 @@ import org.openedx.core.domain.interactor.IAPInteractor import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.exception.iap.IAPException import org.openedx.core.extension.isInternetError -import org.openedx.core.presentation.IAPAnalytics -import org.openedx.core.presentation.IAPAnalyticsEvent -import org.openedx.core.presentation.IAPAnalyticsKeys import org.openedx.core.presentation.IAPAnalyticsScreen -import org.openedx.core.presentation.global.AppData +import org.openedx.core.presentation.dialog.IAPDialogFragment import org.openedx.core.presentation.iap.IAPAction import org.openedx.core.presentation.iap.IAPFlow import org.openedx.core.presentation.iap.IAPRequestType @@ -42,11 +41,11 @@ import org.openedx.core.system.notifier.IAPNotifier import org.openedx.core.system.notifier.UpdateCourseData import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent -import org.openedx.core.utils.EmailUtil import org.openedx.dashboard.domain.interactor.DashboardInteractor +@SuppressLint("StaticFieldLeak") class DashboardListViewModel( - private val appData: AppData, + private val context: Context, private val config: Config, private val networkConnection: NetworkConnection, private val interactor: DashboardInteractor, @@ -56,7 +55,6 @@ class DashboardListViewModel( private val analytics: DashboardAnalytics, private val appNotifier: AppNotifier, private val preferencesManager: CorePreferences, - private val iapAnalytics: IAPAnalytics, private val iapInteractor: IAPInteractor ) : BaseViewModel() { @@ -97,12 +95,6 @@ class DashboardListViewModel( val appUpgradeEvent: LiveData get() = _appUpgradeEvent - private val iapConfig - get() = preferencesManager.appConfig.iapConfig - private val isIAPEnabled - get() = iapConfig.isEnabled && - iapConfig.disableVersions.contains(appData.versionName).not() - override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { @@ -177,6 +169,59 @@ class DashboardListViewModel( } } + fun processIAPAction( + fragmentManager: FragmentManager, action: IAPAction, course: EnrolledCourse?, iapException: IAPException? + ) { + when (action) { + IAPAction.ACTION_USER_INITIATED -> { + if (course != null) { + IAPDialogFragment.newInstance( + iapFlow = IAPFlow.USER_INITIATED, + screenName = IAPAnalyticsScreen.COURSE_ENROLLMENT.screenName, + courseId = course.course.id, + courseName = course.course.name, + isSelfPaced = course.course.isSelfPaced, + productInfo = course.productInfo + ).show( + fragmentManager, + IAPDialogFragment.TAG + ) + } + } + + IAPAction.ACTION_COMPLETION -> { + IAPDialogFragment.newInstance( + IAPFlow.SILENT, + IAPAnalyticsScreen.COURSE_ENROLLMENT.screenName + ).show( + fragmentManager, + IAPDialogFragment.TAG + ) + clearIAPState() + } + + IAPAction.ACTION_UNFULFILLED -> { + detectUnfulfilledPurchase() + } + + IAPAction.ACTION_CLOSE -> { + clearIAPState() + } + + IAPAction.ACTION_ERROR_CLOSE -> { + logIAPCancelEvent() + } + + IAPAction.ACTION_GET_HELP -> { + iapException?.getFormattedErrorMessage() + ?.let { showFeedbackScreen(it) } + } + + else -> { + } + } + } + private fun internalLoadingCourses() { viewModelScope.launch { try { @@ -243,73 +288,37 @@ class DashboardListViewModel( analytics.dashboardCourseClickedEvent(courseId, courseName) } - fun detectUnfulfilledPurchase() { - if (isIAPEnabled) { - viewModelScope.launch(Dispatchers.IO) { - preferencesManager.user?.id?.let { userId -> - runCatching { - iapInteractor.processUnfulfilledPurchase(userId) - }.onSuccess { - if (it) { - unfulfilledPurchaseInitiatedEvent() - _iapUiState.emit(IAPUIState.PurchasesFulfillmentCompleted) - } - }.onFailure { - if (it is IAPException) { - _iapUiState.emit( - IAPUIState.Error( - IAPException( - IAPRequestType.UNFULFILLED_CODE, - it.httpErrorCode, - it.errorMessage - ) - ) + private fun detectUnfulfilledPurchase() { + viewModelScope.launch(Dispatchers.IO) { + iapInteractor.detectUnfulfilledPurchase( + screen = IAPAnalyticsScreen.COURSE_ENROLLMENT, + onSuccess = { + _iapUiState.tryEmit(IAPUIState.PurchasesFulfillmentCompleted) + }, + onFailure = { + _iapUiState.tryEmit( + IAPUIState.Error( + IAPException( + IAPRequestType.UNFULFILLED_CODE, + it.httpErrorCode, + it.errorMessage, ) - } - } + ) + ) } - } + ) } } - private fun unfulfilledPurchaseInitiatedEvent() { - logIAPEvent(IAPAnalyticsEvent.IAP_UNFULFILLED_PURCHASE_INITIATED) - } - - fun showFeedbackScreen(context: Context, message: String) { - EmailUtil.showFeedbackScreen( - context = context, - feedbackEmailAddress = config.getFeedbackEmailAddress(), - subject = context.getString(R.string.core_error_upgrading_course_in_app), - feedback = message, - appVersion = appData.versionName - ) - logIAPEvent(IAPAnalyticsEvent.IAP_ERROR_ALERT_ACTION, buildMap { - put(IAPAnalyticsKeys.ERROR_ALERT_TYPE.key, IAPAction.ACTION_UNFULFILLED.action) - put(IAPAnalyticsKeys.ERROR_ACTION.key, IAPAction.ACTION_GET_HELP.action) - }.toMutableMap()) + private fun showFeedbackScreen(message: String) { + iapInteractor.showFeedbackScreen(context, message, IAPAnalyticsScreen.COURSE_ENROLLMENT) } - fun logIAPCancelEvent() { - logIAPEvent(IAPAnalyticsEvent.IAP_ERROR_ALERT_ACTION, buildMap { - put(IAPAnalyticsKeys.ERROR_ALERT_TYPE.key, IAPAction.ACTION_UNFULFILLED.action) - put(IAPAnalyticsKeys.ERROR_ACTION.key, IAPAction.ACTION_CLOSE.action) - }.toMutableMap()) - } - - private fun logIAPEvent( - event: IAPAnalyticsEvent, - params: MutableMap = mutableMapOf() - ) { - iapAnalytics.logEvent(event.eventName, params.apply { - put(IAPAnalyticsKeys.NAME.key, event.biValue) - put(IAPAnalyticsKeys.SCREEN_NAME.key, IAPAnalyticsScreen.COURSE_ENROLLMENT.screenName) - put(IAPAnalyticsKeys.IAP_FLOW_TYPE.key, IAPFlow.SILENT.value) - put(IAPAnalyticsKeys.CATEGORY.key, IAPAnalyticsKeys.IN_APP_PURCHASES.key) - }) + private fun logIAPCancelEvent() { + iapInteractor.logIAPCancelEvent(IAPAnalyticsScreen.COURSE_ENROLLMENT) } - fun clearIAPState() { + private fun clearIAPState() { viewModelScope.launch { _iapUiState.emit(null) } diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt index e51468605..f92753ab8 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt @@ -1,5 +1,6 @@ package org.openedx.dashboard.presentation +import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -35,7 +36,6 @@ import org.openedx.core.domain.model.DashboardCourseList import org.openedx.core.domain.model.IAPConfig import org.openedx.core.domain.model.Pagination import org.openedx.core.presentation.IAPAnalytics -import org.openedx.core.presentation.global.AppData import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate @@ -65,7 +65,7 @@ class DashboardViewModelTest { private val appNotifier = mockk() private val iapAnalytics = mockk() private val corePreferences = mockk() - private val appData = mockk() + private val context = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -105,7 +105,7 @@ class DashboardViewModelTest { every { corePreferences.appConfig } returns appConfig val viewModel = DashboardListViewModel( - appData, + context, config, networkConnection, interactor, @@ -115,7 +115,6 @@ class DashboardViewModelTest { analytics, appNotifier, corePreferences, - iapAnalytics, iapInteractor ) coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -136,7 +135,7 @@ class DashboardViewModelTest { every { corePreferences.appConfig } returns appConfig val viewModel = DashboardListViewModel( - appData, + context, config, networkConnection, interactor, @@ -146,7 +145,6 @@ class DashboardViewModelTest { analytics, appNotifier, corePreferences, - iapAnalytics, iapInteractor ) @@ -168,7 +166,7 @@ class DashboardViewModelTest { every { corePreferences.appConfig } returns appConfig val viewModel = DashboardListViewModel( - appData, + context, config, networkConnection, interactor, @@ -178,7 +176,6 @@ class DashboardViewModelTest { analytics, appNotifier, corePreferences, - iapAnalytics, iapInteractor ) @@ -200,7 +197,7 @@ class DashboardViewModelTest { every { corePreferences.appConfig } returns appConfig val viewModel = DashboardListViewModel( - appData, + context, config, networkConnection, interactor, @@ -210,7 +207,6 @@ class DashboardViewModelTest { analytics, appNotifier, corePreferences, - iapAnalytics, iapInteractor ) @@ -242,7 +238,7 @@ class DashboardViewModelTest { every { corePreferences.appConfig.iapConfig } returns appConfig.iapConfig val viewModel = DashboardListViewModel( - appData, + context, config, networkConnection, interactor, @@ -252,7 +248,6 @@ class DashboardViewModelTest { analytics, appNotifier, corePreferences, - iapAnalytics, iapInteractor ) @@ -272,7 +267,7 @@ class DashboardViewModelTest { every { corePreferences.appConfig } returns appConfig coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList val viewModel = DashboardListViewModel( - appData, + context, config, networkConnection, interactor, @@ -282,7 +277,6 @@ class DashboardViewModelTest { analytics, appNotifier, corePreferences, - iapAnalytics, iapInteractor ) @@ -306,7 +300,7 @@ class DashboardViewModelTest { every { corePreferences.appConfig } returns appConfig coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList val viewModel = DashboardListViewModel( - appData, + context, config, networkConnection, interactor, @@ -316,7 +310,6 @@ class DashboardViewModelTest { analytics, appNotifier, corePreferences, - iapAnalytics, iapInteractor ) @@ -344,7 +337,7 @@ class DashboardViewModelTest { coEvery { iapNotifier.send(any()) } returns Unit val viewModel = DashboardListViewModel( - appData, + context, config, networkConnection, interactor, @@ -354,7 +347,6 @@ class DashboardViewModelTest { analytics, appNotifier, corePreferences, - iapAnalytics, iapInteractor ) @@ -388,7 +380,7 @@ class DashboardViewModelTest { coEvery { iapNotifier.send(any()) } returns Unit val viewModel = DashboardListViewModel( - appData, + context, config, networkConnection, interactor, @@ -398,7 +390,6 @@ class DashboardViewModelTest { analytics, appNotifier, corePreferences, - iapAnalytics, iapInteractor ) @@ -421,7 +412,7 @@ class DashboardViewModelTest { coEvery { iapNotifier.notifier } returns flow { emit(CourseDataUpdated()) } every { corePreferences.appConfig } returns appConfig val viewModel = DashboardListViewModel( - appData, + context, config, networkConnection, interactor, @@ -431,7 +422,6 @@ class DashboardViewModelTest { analytics, appNotifier, corePreferences, - iapAnalytics, iapInteractor )