diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 9d8169863..b0adbcb22 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -8,6 +8,7 @@ import org.openedx.app.analytics.SegmentAnalytics import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.config.Config import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.IAPAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics import org.openedx.dashboard.presentation.DashboardAnalytics @@ -21,7 +22,7 @@ class AnalyticsManager( config: Config, ) : AppAnalytics, AppReviewAnalytics, AuthAnalytics, CoreAnalytics, CourseAnalytics, DashboardAnalytics, DiscoveryAnalytics, DiscussionAnalytics, ProfileAnalytics, - WhatsNewAnalytics { + WhatsNewAnalytics, IAPAnalytics { private val services: ArrayList = arrayListOf() diff --git a/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt index bd4aa1920..a4145d549 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HandleErrorInterceptor.kt @@ -2,11 +2,11 @@ package org.openedx.app.data.networking import com.google.gson.Gson import com.google.gson.JsonSyntaxException -import org.openedx.core.data.model.ErrorResponse -import org.openedx.core.system.EdxError import okhttp3.Interceptor import okhttp3.Response import okio.IOException +import org.openedx.core.data.model.ErrorResponse +import org.openedx.core.system.EdxError class HandleErrorInterceptor( private val gson: Gson @@ -16,7 +16,7 @@ class HandleErrorInterceptor( val responseCode = response.code if (responseCode in 400..500 && response.body != null) { - val jsonStr = response.body!!.string() + val jsonStr = response.peekBody(Long.MAX_VALUE).string() try { val errorResponse = gson.fromJson(jsonStr, ErrorResponse::class.java) @@ -25,9 +25,11 @@ class HandleErrorInterceptor( ERROR_INVALID_GRANT -> { throw EdxError.InvalidGrantException() } + ERROR_USER_NOT_ACTIVE -> { throw EdxError.UserNotActiveException() } + else -> { return response } diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 9e3a1709d..1f88bea73 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -12,10 +12,10 @@ import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AnalyticsManager import org.openedx.app.AppAnalytics -import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.AppRouter import org.openedx.app.BuildConfig import org.openedx.app.data.storage.PreferencesManager +import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase import org.openedx.app.room.DATABASE_NAME import org.openedx.auth.presentation.AgreementProvider @@ -28,12 +28,15 @@ import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ImageProcessor import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager +import org.openedx.core.module.billing.BillingProcessor import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.IAPAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.AppData @@ -46,6 +49,7 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.IAPNotifier import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.utils.FileUtil @@ -88,6 +92,10 @@ val appModule = module { single { GsonBuilder() .registerTypeAdapter(CourseEnrollments::class.java, CourseEnrollments.Deserializer()) + .registerTypeAdapter( + CourseStructureModel::class.java, + CourseStructureModel.Deserializer(get()) + ) .create() } @@ -98,6 +106,7 @@ val appModule = module { single { DownloadNotifier() } single { VideoNotifier() } single { DiscoveryNotifier() } + single { IAPNotifier() } single { AppRouter() } single { get() } @@ -165,6 +174,8 @@ val appModule = module { single { WhatsNewManager(get(), get(), get(), get()) } single { get() } + single { BillingProcessor(get(), get(named("IODispatcher"))) } + single { AnalyticsManager(get(), get()) } single { get() } single { get() } @@ -176,6 +187,7 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } factory { AgreementProvider(get(), get()) } factory { FacebookAuthHelper() } diff --git a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt index aae32b433..be6b18916 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -2,6 +2,7 @@ package org.openedx.app.di import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.data.api.NotificationsApi import org.openedx.app.data.networking.AppUpgradeInterceptor @@ -13,6 +14,7 @@ import org.openedx.core.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.api.CookiesApi import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.api.iap.InAppPurchasesApi import org.openedx.discovery.data.api.DiscoveryApi import org.openedx.discussion.data.api.DiscussionApi import org.openedx.profile.data.api.ProfileApi @@ -48,6 +50,15 @@ val networkingModule = module { .build() } + single(named("IAPApiInstance")) { + val config = this.get() + Retrofit.Builder() + .baseUrl(config.getEcommerceURL()) + .client(get()) + .addConverterFactory(GsonConverterFactory.create(get())) + .build() + } + single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } @@ -55,9 +66,9 @@ val networkingModule = module { single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } + single { provideApi(get(named("IAPApiInstance"))) } } - inline fun provideApi(retrofit: Retrofit): T { return retrofit.create(T::class.java) } 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 429d048b9..d8d02314b 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -12,7 +12,12 @@ import org.openedx.auth.presentation.restore.RestorePasswordViewModel import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator +import org.openedx.core.data.repository.iap.IAPRepository +import org.openedx.core.domain.interactor.IAPInteractor +import org.openedx.core.domain.model.iap.PurchaseFlowData import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel +import org.openedx.core.presentation.iap.IAPFlow +import org.openedx.core.presentation.iap.IAPViewModel import org.openedx.core.presentation.settings.video.VideoQualityViewModel import org.openedx.core.ui.WindowSize import org.openedx.course.data.repository.CourseRepository @@ -133,7 +138,23 @@ val screenModule = module { factory { DashboardRepository(get(), get(), get(), get()) } factory { DashboardInteractor(get()) } - viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { + DashboardListViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } + viewModel { (windowSize: WindowSize) -> DashboardGalleryViewModel( get(), @@ -190,6 +211,8 @@ val screenModule = module { get(), get(), get(), + get(), + get(), get() ) } @@ -240,6 +263,8 @@ val screenModule = module { get(), get(), get(), + get(), + get(), get() ) } @@ -415,6 +440,22 @@ val screenModule = module { ) } + single { IAPRepository(get()) } + factory { IAPInteractor(get(), get()) } + viewModel { (iapFlow: IAPFlow, purchaseFlowData: PurchaseFlowData) -> + IAPViewModel( + iapFlow = iapFlow, + purchaseFlowData = purchaseFlowData, + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } + viewModel { (descendants: List) -> DownloadQueueViewModel( descendants, diff --git a/build.gradle b/build.gradle index c163d3982..3fbc54f22 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,8 @@ ext { webkit_version = "1.11.0" + billing_version = "6.2.1" + configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) //testing diff --git a/core/build.gradle b/core/build.gradle index c18b5ad0c..1aed7f0a2 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -165,6 +165,9 @@ dependencies { api "com.google.android.gms:play-services-ads-identifier:18.0.1" api "com.android.installreferrer:installreferrer:2.2" + // Google Play Billing Library + api "com.android.billingclient:billing-ktx:$billing_version" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 786d63cc4..41192090e 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -34,4 +34,8 @@ object ApiConstants { const val HONOR_CODE = "honor_code" const val MARKETING_EMAILS = "marketing_emails_opt_in" } + + object IAPFields { + const val PAYMENT_PROCESSOR = "android-iap" + } } diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 528ff4cc8..6c495a569 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -27,6 +27,10 @@ class Config(context: Context) { return getString(API_HOST_URL) } + fun getEcommerceURL(): String { + return getString(ECOMMERCE_URL, "") + } + fun getUriScheme(): String { return getString(URI_SCHEME) } @@ -152,6 +156,7 @@ class Config(context: Context) { companion object { private const val APPLICATION_ID = "APPLICATION_ID" private const val API_HOST_URL = "API_HOST_URL" + private const val ECOMMERCE_URL = "ECOMMERCE_URL" private const val URI_SCHEME = "URI_SCHEME" private const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" private const val TOKEN_TYPE = "TOKEN_TYPE" diff --git a/core/src/main/java/org/openedx/core/data/api/iap/InAppPurchasesApi.kt b/core/src/main/java/org/openedx/core/data/api/iap/InAppPurchasesApi.kt new file mode 100644 index 000000000..4730d3922 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/api/iap/InAppPurchasesApi.kt @@ -0,0 +1,33 @@ +package org.openedx.core.data.api.iap + +import org.openedx.core.data.model.iap.AddToBasketResponse +import org.openedx.core.data.model.iap.CheckoutResponse +import org.openedx.core.data.model.iap.ExecuteOrderResponse +import retrofit2.Response +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Query + +interface InAppPurchasesApi { + @GET("/api/iap/v1/basket/add/") + suspend fun addToBasket(@Query("sku") productId: String): Response + + @FormUrlEncoded + @POST("/api/iap/v1/checkout/") + suspend fun proceedCheckout( + @Field("basket_id") basketId: Long, + @Field("payment_processor") paymentProcessor: String + ): Response + + @FormUrlEncoded + @POST("/api/iap/v1/execute/") + suspend fun executeOrder( + @Field("basket_id") basketId: Long, + @Field("payment_processor") paymentProcessor: String, + @Field("purchase_token") purchaseToken: String, + @Field("price") price: Double, + @Field("currency_code") currencyCode: String, + ): Response +} diff --git a/core/src/main/java/org/openedx/core/data/model/AppConfig.kt b/core/src/main/java/org/openedx/core/data/model/AppConfig.kt index 4fcbe3d89..218a35a4e 100644 --- a/core/src/main/java/org/openedx/core/data/model/AppConfig.kt +++ b/core/src/main/java/org/openedx/core/data/model/AppConfig.kt @@ -6,10 +6,18 @@ import org.openedx.core.domain.model.AppConfig as DomainAppConfig data class AppConfig( @SerializedName("course_dates_calendar_sync") val calendarSyncConfig: CalendarSyncConfig = CalendarSyncConfig(), + + @SerializedName("value_prop_enabled") + val isValuePropEnabled: Boolean = false, + + @SerializedName("iap_config") + val iapConfig: IAPConfig = IAPConfig(), ) { fun mapToDomain(): DomainAppConfig { return DomainAppConfig( courseDatesCalendarSync = calendarSyncConfig.mapToDomain(), + isValuePropEnabled = isValuePropEnabled, + iapConfig = iapConfig.mapToDomain(), ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt new file mode 100644 index 000000000..1b4275f08 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseAccessDetails.kt @@ -0,0 +1,22 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseAccessDetails as DomainCourseAccessDetails + +data class CourseAccessDetails( + @SerializedName("audit_access_expires") + val auditAccessExpires: String?, + @SerializedName("courseware_access") + var coursewareAccess: CoursewareAccess?, +) { + fun mapToDomain(): DomainCourseAccessDetails = + DomainCourseAccessDetails( + TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess?.mapToDomain() + ) + + fun mapToRoomEntity(): CourseAccessDetailsDb = + CourseAccessDetailsDb(auditAccessExpires, coursewareAccess?.mapToRoomEntity()) +} 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 ca28740fe..2682f957c 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 @@ -35,6 +35,12 @@ data class CourseEnrollments( val appConfig = deserializeAppConfig(json) val primaryCourse = deserializePrimaryCourse(json) + if (appConfig.iapConfig.productPrefix.isNotEmpty()) { + enrollments.results.forEach { courseData -> + courseData.setStoreSku(appConfig.iapConfig.productPrefix) + } + } + return CourseEnrollments(enrollments, appConfig, primaryCourse) } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseMode.kt b/core/src/main/java/org/openedx/core/data/model/CourseMode.kt new file mode 100644 index 000000000..d534d67a4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseMode.kt @@ -0,0 +1,34 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import kotlin.math.ceil + +/** + * Data class representing the mode of a course ("audit, verified etc"), with various attributes + * related to its identification and pricing. + * */ +data class CourseMode( + @SerializedName("slug") + val slug: String?, + + @SerializedName("sku") + val sku: String?, + + @SerializedName("android_sku") + val androidSku: String?, + + @SerializedName("min_price") + val price: Double?, + + var storeSku: String?, +) { + 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/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt index d09411d14..1fca6e677 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseStructureModel.kt @@ -1,12 +1,20 @@ package org.openedx.core.data.model +import com.google.gson.Gson +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.BlockDb import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.MediaDb import org.openedx.core.data.model.room.discovery.ProgressDb +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.EnrollmentMode +import org.openedx.core.domain.model.iap.ProductInfo import org.openedx.core.utils.TimeUtils +import java.lang.reflect.Type data class CourseStructureModel( @SerializedName("root") @@ -29,16 +37,20 @@ data class CourseStructureModel( var startType: String?, @SerializedName("end") var end: String?, - @SerializedName("courseware_access") - var coursewareAccess: CoursewareAccess?, @SerializedName("media") var media: Media?, + @SerializedName("course_access_details") + val courseAccessDetails: CourseAccessDetails, @SerializedName("certificate") val certificate: Certificate?, + @SerializedName("enrollment_details") + val enrollmentDetails: EnrollmentDetails, @SerializedName("is_self_paced") var isSelfPaced: Boolean?, @SerializedName("course_progress") val progress: Progress?, + @SerializedName("course_modes") + val courseModes: List?, ) { fun mapToDomain(): CourseStructure { return CourseStructure( @@ -54,11 +66,19 @@ data class CourseStructureModel( startDisplay = startDisplay ?: "", startType = startType ?: "", end = TimeUtils.iso8601ToDate(end ?: ""), - coursewareAccess = coursewareAccess?.mapToDomain(), media = media?.mapToDomain(), + courseAccessDetails = courseAccessDetails.mapToDomain(), certificate = certificate?.mapToDomain(), isSelfPaced = isSelfPaced ?: false, - progress = progress?.mapToDomain() + progress = progress?.mapToDomain(), + enrollmentDetails = enrollmentDetails.mapToDomain(), + productInfo = courseModes?.find { + EnrollmentMode.VERIFIED.toString().equals(it.slug, ignoreCase = true) + }?.takeIf { + it.androidSku.isNullOrEmpty().not() && it.storeSku.isNullOrEmpty().not() + }?.run { + ProductInfo(courseSku = androidSku!!, storeSku = storeSku!!) + } ) } @@ -74,11 +94,29 @@ data class CourseStructureModel( startDisplay = startDisplay ?: "", startType = startType ?: "", end = end ?: "", - coursewareAccess = coursewareAccess?.mapToRoomEntity(), media = MediaDb.createFrom(media), + courseAccessDetails = courseAccessDetails.mapToRoomEntity(), certificate = certificate?.mapToRoomEntity(), isSelfPaced = isSelfPaced ?: false, - progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS + progress = progress?.mapToRoomEntity() ?: ProgressDb.DEFAULT_PROGRESS, + enrollmentDetails = enrollmentDetails.mapToRoomEntity() ) } + + class Deserializer(val corePreferences: CorePreferences) : + JsonDeserializer { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): CourseStructureModel { + val courseStructure = Gson().fromJson(json, CourseStructureModel::class.java) + if (corePreferences.appConfig.iapConfig.productPrefix.isNullOrEmpty().not()) { + courseStructure.courseModes?.forEach { courseModes -> + courseModes.setStoreProductSku(corePreferences.appConfig.iapConfig.productPrefix!!) + } + } + return courseStructure + } + } } diff --git a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt index edf8bbce3..54aa5e88a 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrolledCourse.kt @@ -4,6 +4,8 @@ import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.EnrolledCourse +import org.openedx.core.domain.model.EnrollmentMode +import org.openedx.core.domain.model.iap.ProductInfo import org.openedx.core.utils.TimeUtils import org.openedx.core.domain.model.Progress as ProgressDomain @@ -25,7 +27,9 @@ data class EnrolledCourse( @SerializedName("course_status") val courseStatus: CourseStatus?, @SerializedName("course_assignments") - val courseAssignments: CourseAssignments? + val courseAssignments: CourseAssignments?, + @SerializedName("course_modes") + val courseModes: List?, ) { fun mapToDomain(): EnrolledCourse { return EnrolledCourse( @@ -37,7 +41,14 @@ data class EnrolledCourse( certificate = certificate?.mapToDomain(), progress = progress?.mapToDomain() ?: ProgressDomain.DEFAULT_PROGRESS, courseStatus = courseStatus?.mapToDomain(), - courseAssignments = courseAssignments?.mapToDomain() + courseAssignments = courseAssignments?.mapToDomain(), + productInfo = courseModes?.find { + EnrollmentMode.VERIFIED.toString().equals(it.slug, ignoreCase = true) + }?.takeIf { + it.androidSku.isNullOrEmpty().not() && it.storeSku.isNullOrEmpty().not() + }?.run { + ProductInfo(courseSku = androidSku!!, storeSku = storeSku!!) + } ) } @@ -55,4 +66,10 @@ data class EnrolledCourse( courseAssignments = courseAssignments?.mapToRoomEntity() ) } + + fun setStoreSku(storeProductPrefix: String) { + courseModes?.forEach { + it.setStoreProductSku(storeProductPrefix) + } + } } diff --git a/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt b/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt index 4afc9ef71..714707d88 100644 --- a/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt +++ b/core/src/main/java/org/openedx/core/data/model/EnrolledCourseData.kt @@ -24,7 +24,7 @@ data class EnrolledCourseData( @SerializedName("end") var end: String?, @SerializedName("dynamic_upgrade_deadline") - var dynamicUpgradeDeadline: String?, + var upgradeDeadline: String?, @SerializedName("subscription_id") var subscriptionId: String?, @SerializedName("courseware_access") @@ -59,7 +59,7 @@ data class EnrolledCourseData( startDisplay = startDisplay ?: "", startType = startType ?: "", end = TimeUtils.iso8601ToDate(end ?: ""), - dynamicUpgradeDeadline = dynamicUpgradeDeadline ?: "", + upgradeDeadline = upgradeDeadline ?: "", subscriptionId = subscriptionId ?: "", coursewareAccess = coursewareAccess?.mapToDomain(), media = media?.mapToDomain(), @@ -84,7 +84,7 @@ data class EnrolledCourseData( startDisplay = startDisplay ?: "", startType = startType ?: "", end = end ?: "", - dynamicUpgradeDeadline = dynamicUpgradeDeadline ?: "", + upgradeDeadline = upgradeDeadline ?: "", subscriptionId = subscriptionId ?: "", coursewareAccess = coursewareAccess?.mapToRoomEntity(), media = MediaDb.createFrom(media), diff --git a/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt new file mode 100644 index 000000000..e1172d713 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/EnrollmentDetails.kt @@ -0,0 +1,37 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB +import org.openedx.core.utils.TimeUtils + +import org.openedx.core.domain.model.EnrollmentDetails as DomainEnrollmentDetails + +data class EnrollmentDetails( + @SerializedName("created") + var created: String?, + + @SerializedName("mode") + var mode: String?, + + @SerializedName("is_active") + var isActive: Boolean = false, + + @SerializedName("upgrade_deadline") + var upgradeDeadline: String?, +) { + fun mapToDomain(): DomainEnrollmentDetails { + return DomainEnrollmentDetails( + created = TimeUtils.iso8601ToDate(created ?: ""), + mode = mode, + isActive = isActive, + upgradeDeadline = TimeUtils.iso8601ToDate(upgradeDeadline ?: ""), + ) + } + + fun mapToRoomEntity() = EnrollmentDetailsDB( + created = created, + mode = mode, + isActive = isActive, + upgradeDeadline = upgradeDeadline, + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/IAPConfig.kt b/core/src/main/java/org/openedx/core/data/model/IAPConfig.kt new file mode 100644 index 000000000..2e9a78d91 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/IAPConfig.kt @@ -0,0 +1,28 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.IAPConfig as DomainIAPConfig + +/** + * Model class that contains the Config related to In App Purchases. + */ +data class IAPConfig( + + @SerializedName("enabled") + val isEnabled: Boolean = false, + + @SerializedName("android_product_prefix") + val productPrefix: String = "", + + @SerializedName("android_disabled_versions") + val disableVersions: List = listOf() + +) { + fun mapToDomain(): DomainIAPConfig { + return DomainIAPConfig( + isEnabled = isEnabled, + productPrefix = productPrefix, + disableVersions = disableVersions + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/User.kt b/core/src/main/java/org/openedx/core/data/model/User.kt index 99194624b..fbff8eced 100644 --- a/core/src/main/java/org/openedx/core/data/model/User.kt +++ b/core/src/main/java/org/openedx/core/data/model/User.kt @@ -15,7 +15,7 @@ data class User( ) { fun mapToDomain(): User { return User( - id, username, email, name?:"" + id, username, email, name ?: "" ) } } diff --git a/core/src/main/java/org/openedx/core/data/model/iap/AddToBasketResponse.kt b/core/src/main/java/org/openedx/core/data/model/iap/AddToBasketResponse.kt new file mode 100644 index 000000000..6f7133ffe --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/iap/AddToBasketResponse.kt @@ -0,0 +1,13 @@ +package org.openedx.core.data.model.iap + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.iap.AddToBasketResponse as AddToBasketResponseDomain + +data class AddToBasketResponse( + @SerializedName("success") val success: String, + @SerializedName("basket_id") val basketId: Long +) { + fun mapToDomain(): AddToBasketResponseDomain { + return AddToBasketResponseDomain(basketId) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/iap/CheckoutResponse.kt b/core/src/main/java/org/openedx/core/data/model/iap/CheckoutResponse.kt new file mode 100644 index 000000000..1478df34a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/iap/CheckoutResponse.kt @@ -0,0 +1,15 @@ +package org.openedx.core.data.model.iap + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.iap.CheckoutResponse as CheckoutResponseDomain + + +data class CheckoutResponse( + @SerializedName("payment_form_data") val paymentFormData: MutableMap, + @SerializedName("payment_page_url") val paymentPageUrl: String, + @SerializedName("payment_processor") val paymentProcessor: String +) { + fun mapToDomain(): CheckoutResponseDomain { + return CheckoutResponseDomain + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/iap/ExecuteOrderResponse.kt b/core/src/main/java/org/openedx/core/data/model/iap/ExecuteOrderResponse.kt new file mode 100644 index 000000000..9ba9d4890 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/iap/ExecuteOrderResponse.kt @@ -0,0 +1,30 @@ +package org.openedx.core.data.model.iap + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.iap.ExecuteOrderResponse +import org.openedx.core.domain.model.iap.ExecuteOrderResponse as ExecuteOrderResponseDomain + +data class ExecuteOrderResponse( + @SerializedName("order_data") val orderData: OrderData +) { + fun mapToDomain(): ExecuteOrderResponse { + return ExecuteOrderResponseDomain + } +} + +data class OrderData( + @SerializedName("billing_address") val billingAddress: String, + @SerializedName("currency") val currency: String, + @SerializedName("date_placed") val datePlaced: String, + @SerializedName("discount") val discount: String, + @SerializedName("number") val number: String, + @SerializedName("payment_processor") val paymentProcessor: String, + @SerializedName("status") val status: String, + @SerializedName("total_excl_tax") val totalExclTax: String, + @SerializedName("user") val user: User +) + +data class User( + @SerializedName("email") val email: String, + @SerializedName("username") val username: String +) diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt index 49862d683..d9e50711a 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseStructureEntity.kt @@ -5,7 +5,8 @@ import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey import org.openedx.core.data.model.room.discovery.CertificateDb -import org.openedx.core.data.model.room.discovery.CoursewareAccessDb +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB import org.openedx.core.data.model.room.discovery.ProgressDb import org.openedx.core.domain.model.CourseStructure import org.openedx.core.utils.TimeUtils @@ -34,17 +35,18 @@ data class CourseStructureEntity( @ColumnInfo("end") val end: String?, @Embedded - val coursewareAccess: CoursewareAccessDb?, - @Embedded val media: MediaDb?, @Embedded + val courseAccessDetails: CourseAccessDetailsDb, + @Embedded val certificate: CertificateDb?, + @Embedded + val enrollmentDetails: EnrollmentDetailsDB, @ColumnInfo("isSelfPaced") val isSelfPaced: Boolean, @Embedded val progress: ProgressDb, ) { - fun mapToDomain(): CourseStructure { return CourseStructure( root, @@ -57,12 +59,13 @@ data class CourseStructureEntity( startDisplay, startType, TimeUtils.iso8601ToDate(end ?: ""), - coursewareAccess?.mapToDomain(), media?.mapToDomain(), + courseAccessDetails.mapToDomain(), certificate?.mapToDomain(), isSelfPaced, - progress.mapToDomain() + progress.mapToDomain(), + enrollmentDetails.mapToDomain(), + null, ) } - -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt index e019f6300..fce33e00d 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/discovery/EnrolledCourseEntity.kt @@ -7,6 +7,7 @@ import androidx.room.PrimaryKey import org.openedx.core.data.model.DateType import org.openedx.core.data.model.room.MediaDb import org.openedx.core.domain.model.Certificate +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseAssignments import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseSharingUtmParameters @@ -14,6 +15,7 @@ import org.openedx.core.domain.model.CourseStatus import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.domain.model.Progress import org.openedx.core.utils.TimeUtils import java.util.Date @@ -53,7 +55,8 @@ data class EnrolledCourseEntity( certificate?.mapToDomain(), progress.mapToDomain(), courseStatus?.mapToDomain(), - courseAssignments?.mapToDomain() + courseAssignments?.mapToDomain(), + null ) } } @@ -75,8 +78,8 @@ data class EnrolledCourseDataDb( val startType: String, @ColumnInfo("end") val end: String, - @ColumnInfo("dynamicUpgradeDeadline") - val dynamicUpgradeDeadline: String, + @ColumnInfo("upgradeDeadline") + val upgradeDeadline: String, @ColumnInfo("subscriptionId") val subscriptionId: String, @Embedded @@ -110,7 +113,7 @@ data class EnrolledCourseDataDb( startDisplay, startType, TimeUtils.iso8601ToDate(end), - dynamicUpgradeDeadline, + upgradeDeadline, subscriptionId, coursewareAccess?.mapToDomain(), media?.mapToDomain(), @@ -244,3 +247,35 @@ data class CourseDateBlockDb( assignmentType = assignmentType ) } + +data class EnrollmentDetailsDB( + @ColumnInfo("created") + var created: String?, + @ColumnInfo("mode") + var mode: String?, + @ColumnInfo("isActive") + var isActive: Boolean, + @ColumnInfo("upgradeDeadline") + var upgradeDeadline: String?, +) { + fun mapToDomain() = EnrollmentDetails( + TimeUtils.iso8601ToDate(created ?: ""), + mode, + isActive, + TimeUtils.iso8601ToDate(upgradeDeadline ?: "") + ) +} + +data class CourseAccessDetailsDb( + @ColumnInfo("auditAccessExpires") + var auditAccessExpires: String?, + @Embedded + val coursewareAccess: CoursewareAccessDb?, +) { + fun mapToDomain(): CourseAccessDetails { + return CourseAccessDetails( + TimeUtils.iso8601ToDate(auditAccessExpires ?: ""), + coursewareAccess?.mapToDomain() + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/repository/iap/IAPRepository.kt b/core/src/main/java/org/openedx/core/data/repository/iap/IAPRepository.kt new file mode 100644 index 000000000..a4b28600a --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/repository/iap/IAPRepository.kt @@ -0,0 +1,70 @@ +package org.openedx.core.data.repository.iap + +import org.openedx.core.ApiConstants +import org.openedx.core.data.api.iap.InAppPurchasesApi +import org.openedx.core.domain.model.iap.AddToBasketResponse +import org.openedx.core.domain.model.iap.CheckoutResponse +import org.openedx.core.domain.model.iap.ExecuteOrderResponse +import org.openedx.core.exception.iap.IAPException +import org.openedx.core.exception.iap.getMessage +import org.openedx.core.presentation.iap.IAPRequestType + +class IAPRepository(private val api: InAppPurchasesApi) { + + suspend fun addToBasket(courseSku: String): AddToBasketResponse { + val response = api.addToBasket(courseSku) + if (response.isSuccessful) { + response.body()?.run { + return mapToDomain() + } + } + throw IAPException( + requestType = IAPRequestType.ADD_TO_BASKET_CODE, + httpErrorCode = response.code(), + errorMessage = response.getMessage() + ) + } + + suspend fun proceedCheckout(basketId: Long): CheckoutResponse { + val response = api.proceedCheckout( + basketId = basketId, + paymentProcessor = ApiConstants.IAPFields.PAYMENT_PROCESSOR + ) + if (response.isSuccessful) { + response.body()?.run { + return mapToDomain() + } + } + throw IAPException( + requestType = IAPRequestType.CHECKOUT_CODE, + httpErrorCode = response.code(), + errorMessage = response.getMessage() + ) + } + + suspend fun executeOrder( + basketId: Long, + paymentProcessor: String, + purchaseToken: String, + price: Double, + currencyCode: String, + ): ExecuteOrderResponse { + val response = api.executeOrder( + basketId = basketId, + paymentProcessor = paymentProcessor, + purchaseToken = purchaseToken, + price = price, + currencyCode = currencyCode + ) + if (response.isSuccessful) { + response.body()?.run { + return mapToDomain() + } + } + throw IAPException( + requestType = IAPRequestType.EXECUTE_ORDER_CODE, + httpErrorCode = response.code(), + errorMessage = response.getMessage() + ) + } +} 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 new file mode 100644 index 000000000..b1f2e2762 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/interactor/IAPInteractor.kt @@ -0,0 +1,125 @@ +package org.openedx.core.domain.interactor + +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.data.repository.iap.IAPRepository +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.iap.IAPRequestType + +class IAPInteractor( + private val billingProcessor: BillingProcessor, + private val repository: IAPRepository, +) { + suspend fun loadPrice(productId: String): ProductDetails.OneTimePurchaseOfferDetails { + val response = billingProcessor.querySyncDetails(productId) + val productDetails = response.productDetailsList?.firstOrNull()?.oneTimePurchaseOfferDetails + val billingResult = response.billingResult + + if (billingResult.responseCode == BillingResponseCode.OK) { + if (productDetails != null) { + return productDetails + } else { + throw IAPException( + requestType = IAPRequestType.NO_SKU_CODE, + httpErrorCode = billingResult.responseCode, + errorMessage = billingResult.debugMessage + ) + } + } else { + throw IAPException( + requestType = IAPRequestType.PRICE_CODE, + httpErrorCode = billingResult.responseCode, + errorMessage = billingResult.debugMessage + ) + } + } + + suspend fun addToBasket(courseSku: String): Long { + val basketResponse = repository.addToBasket(courseSku) + return basketResponse.basketId + } + + suspend fun processCheckout(basketId: Long) { + repository.proceedCheckout(basketId) + } + + suspend fun purchaseItem( + activity: FragmentActivity, + id: Long, + productInfo: ProductInfo, + purchaseListeners: BillingProcessor.PurchaseListeners, + ) { + billingProcessor.setPurchaseListener(purchaseListeners) + billingProcessor.purchaseItem(activity, id, productInfo) + } + + suspend fun executeOrder( + basketId: Long, + purchaseToken: String, + price: Double, + currencyCode: String + ) { + repository.executeOrder( + basketId = basketId, + paymentProcessor = ApiConstants.IAPFields.PAYMENT_PROCESSOR, + purchaseToken = purchaseToken, + price = price, + currencyCode = currencyCode, + ) + } + + suspend fun consumePurchase(purchaseToken: String) { + val result = billingProcessor.consumePurchase(purchaseToken) + if (result.responseCode != BillingResponseCode.OK) { + throw IAPException( + requestType = IAPRequestType.CONSUME_CODE, + httpErrorCode = result.responseCode, + errorMessage = result.debugMessage + ) + } + } + + suspend fun processUnfulfilledPurchase(userId: Long): Boolean { + val purchases = billingProcessor.queryPurchases() + val userPurchases = + purchases.filter { it.accountIdentifiers?.obfuscatedAccountId?.decodeToLong() == userId } + if (userPurchases.isNotEmpty()) { + startUnfulfilledVerification(userPurchases) + return true + } else { + purchases.forEach { + billingProcessor.consumePurchase(it.purchaseToken) + } + } + return false + } + + private suspend fun startUnfulfilledVerification(userPurchases: List) { + 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) + processCheckout(basketId) + executeOrder( + basketId = basketId, + purchaseToken = purchase.purchaseToken, + price = oneTimeProductDetails.getPriceAmount(), + currencyCode = oneTimeProductDetails.priceCurrencyCode, + ) + consumePurchase(purchase.purchaseToken) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt index 596fd0619..17ef4a5c5 100644 --- a/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt +++ b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt @@ -4,6 +4,8 @@ import java.io.Serializable data class AppConfig( val courseDatesCalendarSync: CourseDatesCalendarSync, + val isValuePropEnabled: Boolean = false, + val iapConfig: IAPConfig = IAPConfig(), ) : Serializable data class CourseDatesCalendarSync( @@ -12,3 +14,9 @@ data class CourseDatesCalendarSync( val isInstructorPacedEnabled: Boolean, val isDeepLinkEnabled: Boolean, ) : Serializable + +data class IAPConfig( + val isEnabled: Boolean = false, + val productPrefix: String? = null, + val disableVersions: List = listOf() +) : Serializable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt new file mode 100644 index 000000000..d7246d2e1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt @@ -0,0 +1,11 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import java.util.Date + +@Parcelize +data class CourseAccessDetails( + val auditAccessExpires: Date?, + val coursewareAccess: CoursewareAccess?, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt b/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt new file mode 100644 index 000000000..8803c4ce2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseMode.kt @@ -0,0 +1,11 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class CourseMode( + val slug: String?, + val androidSku: String?, + var storeSku: String?, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt b/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt index 4ba3a8419..8430dfdaf 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseStructure.kt @@ -1,5 +1,7 @@ package org.openedx.core.domain.model +import org.openedx.core.domain.model.iap.ProductInfo +import org.openedx.core.utils.TimeUtils import java.util.Date data class CourseStructure( @@ -13,9 +15,21 @@ data class CourseStructure( val startDisplay: String, val startType: String, val end: Date?, - val coursewareAccess: CoursewareAccess?, val media: Media?, + val courseAccessDetails: CourseAccessDetails, val certificate: Certificate?, val isSelfPaced: Boolean, val progress: Progress?, -) + val enrollmentDetails: EnrollmentDetails, + val productInfo: ProductInfo? +) { + private val isStarted: Boolean + get() = TimeUtils.isDatePassed(Date(), start) + + val isUpgradeable: Boolean + get() = enrollmentDetails.isAuditMode && + isStarted && + courseAccessDetails.coursewareAccess?.hasAccess == true && + enrollmentDetails.isUpgradeDeadlinePassed.not() && + productInfo != null +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt b/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt index 187c995b6..5dd48d94e 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt @@ -11,4 +11,4 @@ data class CoursewareAccess( val userMessage: String, val additionalContextUserMessage: String, val userFragment: String -) : Parcelable \ No newline at end of file +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt index 184fc3aa4..7cbc6811c 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourse.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.domain.model.iap.ProductInfo import java.util.Date @Parcelize @@ -14,5 +15,16 @@ data class EnrolledCourse( val certificate: Certificate?, val progress: Progress, val courseStatus: CourseStatus?, - val courseAssignments: CourseAssignments? -) : Parcelable + val courseAssignments: CourseAssignments?, + val productInfo: ProductInfo?, +) : Parcelable { + + private val isAuditMode: Boolean + get() = EnrollmentMode.AUDIT.toString().equals(mode, ignoreCase = true) + + val isUpgradeable: Boolean + get() = isAuditMode && + course.isStarted && + course.isUpgradeDeadlinePassed.not() && + productInfo != null +} diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt index 2a66cccde..f7d5c0963 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrolledCourseData.kt @@ -2,7 +2,8 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize -import java.util.* +import org.openedx.core.utils.TimeUtils +import java.util.Date @Parcelize data class EnrolledCourseData( @@ -14,7 +15,7 @@ data class EnrolledCourseData( val startDisplay: String, val startType: String, val end: Date?, - val dynamicUpgradeDeadline: String, + val upgradeDeadline: String, val subscriptionId: String, val coursewareAccess: CoursewareAccess?, val media: Media?, @@ -26,4 +27,10 @@ data class EnrolledCourseData( val discussionUrl: String, val videoOutline: String, val isSelfPaced: Boolean -) : Parcelable \ No newline at end of file +) : Parcelable { + val isStarted: Boolean + get() = TimeUtils.isDatePassed(Date(), start) + + val isUpgradeDeadlinePassed: Boolean + get() = TimeUtils.isDatePassed(Date(), TimeUtils.iso8601ToDate(upgradeDeadline)) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt new file mode 100644 index 000000000..1bd89d4ef --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt @@ -0,0 +1,20 @@ +package org.openedx.core.domain.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.openedx.core.utils.TimeUtils +import java.util.Date + +@Parcelize +data class EnrollmentDetails( + var created: Date?, + var mode: String?, + var isActive: Boolean, + var upgradeDeadline: Date?, +) : Parcelable { + val isUpgradeDeadlinePassed: Boolean + get() = TimeUtils.isDatePassed(Date(), upgradeDeadline) + + val isAuditMode: Boolean + get() = EnrollmentMode.AUDIT.toString().equals(mode, ignoreCase = true) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentMode.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentMode.kt new file mode 100644 index 000000000..08df4208b --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentMode.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +/** + * Course Enrollment modes + */ +enum class EnrollmentMode(private val mode: String) { + AUDIT("audit"), + VERIFIED("verified"), + NONE("none"); + + override fun toString(): String { + return mode + } +} diff --git a/core/src/main/java/org/openedx/core/domain/model/iap/AddToBasketResponse.kt b/core/src/main/java/org/openedx/core/domain/model/iap/AddToBasketResponse.kt new file mode 100644 index 000000000..391375702 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/iap/AddToBasketResponse.kt @@ -0,0 +1,3 @@ +package org.openedx.core.domain.model.iap + +data class AddToBasketResponse(val basketId: Long) diff --git a/core/src/main/java/org/openedx/core/domain/model/iap/CheckoutResponse.kt b/core/src/main/java/org/openedx/core/domain/model/iap/CheckoutResponse.kt new file mode 100644 index 000000000..7fc893420 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/iap/CheckoutResponse.kt @@ -0,0 +1,3 @@ +package org.openedx.core.domain.model.iap + +object CheckoutResponse diff --git a/core/src/main/java/org/openedx/core/domain/model/iap/ExecuteOrderResponse.kt b/core/src/main/java/org/openedx/core/domain/model/iap/ExecuteOrderResponse.kt new file mode 100644 index 000000000..1f18d327c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/iap/ExecuteOrderResponse.kt @@ -0,0 +1,3 @@ +package org.openedx.core.domain.model.iap + +data object ExecuteOrderResponse diff --git a/core/src/main/java/org/openedx/core/domain/model/iap/ProductInfo.kt b/core/src/main/java/org/openedx/core/domain/model/iap/ProductInfo.kt new file mode 100644 index 000000000..b9e5da3d4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/iap/ProductInfo.kt @@ -0,0 +1,10 @@ +package org.openedx.core.domain.model.iap + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class ProductInfo( + val courseSku: String, + val storeSku: String, +) : Parcelable diff --git a/core/src/main/java/org/openedx/core/domain/model/iap/PurchaseFlowData.kt b/core/src/main/java/org/openedx/core/domain/model/iap/PurchaseFlowData.kt new file mode 100644 index 000000000..1102e2348 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/iap/PurchaseFlowData.kt @@ -0,0 +1,38 @@ +package org.openedx.core.domain.model.iap + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PurchaseFlowData( + var screenName: String? = null, + var courseId: String? = null, + var courseName: String? = null, + var isSelfPaced: Boolean? = null, + var componentId: String? = null, + var productInfo: ProductInfo? = null, +) : Parcelable { + + var currencyCode: String = "" + var price: Double = 0.0 + var formattedPrice: String? = null + var purchaseToken: String? = null + var basketId: Long = -1 + + var flowStartTime: Long = 0 + + fun reset() { + screenName = null + courseId = null + courseName = null + isSelfPaced = null + componentId = null + productInfo = null + currencyCode = "" + price = 0.0 + formattedPrice = null + purchaseToken = null + basketId = -1 + flowStartTime = 0 + } +} diff --git a/core/src/main/java/org/openedx/core/exception/iap/IAPException.kt b/core/src/main/java/org/openedx/core/exception/iap/IAPException.kt new file mode 100644 index 000000000..45a6fd4d6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/exception/iap/IAPException.kt @@ -0,0 +1,160 @@ +package org.openedx.core.exception.iap + +import android.text.TextUtils +import com.android.billingclient.api.BillingClient +import org.json.JSONObject +import org.openedx.core.presentation.iap.IAPErrorDialogType +import org.openedx.core.presentation.iap.IAPRequestType +import retrofit2.Response +import java.util.Locale + +/** + * + * Signals that the user unable to complete the in-app purchases follow it being not parsable or + * incomplete according to what we expect. + * + * @param requestType stores the request type for exception occurs. + * @param httpErrorCode stores the error codes can be either [BillingClient][com.android.billingclient.api.BillingClient] + * OR http error codes for ecommerce end-points, and setting it up to `-1` + * cause some at some service return error code `0`. + * @param errorMessage stores the error messages received from BillingClient & ecommerce end-points. + * */ +class IAPException( + val requestType: IAPRequestType = IAPRequestType.UNKNOWN, + val httpErrorCode: Int = DEFAULT_HTTP_ERROR_CODE, + val errorMessage: String +) : Exception(errorMessage) { + + /** + * Returns a StringBuilder containing the formatted error message. + * i.e Error: error_endpoint-error_code-error_message + * + * @return Formatted error message. + */ + fun getFormattedErrorMessage(): String { + val body = StringBuilder() + if (requestType == IAPRequestType.UNKNOWN) { + return body.toString() + } + body.append(String.format("%s", requestType.request)) + // change the default value to -1 cuz in case of BillingClient return errorCode 0 for price load. + if (httpErrorCode == DEFAULT_HTTP_ERROR_CODE) { + return body.toString() + } + body.append(String.format(Locale.ENGLISH, "-%d", httpErrorCode)) + if (!TextUtils.isEmpty(errorMessage)) body.append(String.format("-%s", errorMessage)) + return body.toString() + } + + fun getIAPErrorDialogType(): IAPErrorDialogType { + return when (requestType) { + IAPRequestType.PRICE_CODE -> { + IAPErrorDialogType.PRICE_ERROR_DIALOG + } + + IAPRequestType.NO_SKU_CODE -> { + IAPErrorDialogType.NO_SKU_ERROR_DIALOG + } + + IAPRequestType.ADD_TO_BASKET_CODE -> { + when (httpErrorCode) { + 400 -> { + IAPErrorDialogType.ADD_TO_BASKET_BAD_REQUEST_ERROR_DIALOG + } + + 403 -> { + IAPErrorDialogType.ADD_TO_BASKET_FORBIDDEN_ERROR_DIALOG + } + + 406 -> { + IAPErrorDialogType.ADD_TO_BASKET_NOT_ACCEPTABLE_ERROR_DIALOG + } + + else -> { + IAPErrorDialogType.ADD_TO_BASKET_GENERAL_ERROR_DIALOG + } + } + } + + IAPRequestType.CHECKOUT_CODE -> { + when (httpErrorCode) { + 400 -> { + IAPErrorDialogType.CHECKOUT_BAD_REQUEST_ERROR_DIALOG + } + + 403 -> { + IAPErrorDialogType.CHECKOUT_FORBIDDEN_ERROR_DIALOG + } + + 406 -> { + IAPErrorDialogType.CHECKOUT_NOT_ACCEPTABLE_ERROR_DIALOG + } + + else -> { + IAPErrorDialogType.CHECKOUT_GENERAL_ERROR_DIALOG + } + + } + } + + IAPRequestType.EXECUTE_ORDER_CODE -> { + when (httpErrorCode) { + 400 -> { + IAPErrorDialogType.EXECUTE_BAD_REQUEST_ERROR_DIALOG + } + + 403 -> { + IAPErrorDialogType.EXECUTE_FORBIDDEN_ERROR_DIALOG + } + + 406 -> { + IAPErrorDialogType.EXECUTE_NOT_ACCEPTABLE_ERROR_DIALOG + } + + 409 -> { + IAPErrorDialogType.EXECUTE_CONFLICT_ERROR_DIALOG + } + + else -> { + IAPErrorDialogType.EXECUTE_GENERAL_ERROR_DIALOG + } + } + } + + IAPRequestType.CONSUME_CODE -> { + IAPErrorDialogType.CONSUME_ERROR_DIALOG + } + + IAPRequestType.PAYMENT_SDK_CODE -> { + if (httpErrorCode == BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) { + IAPErrorDialogType.PAYMENT_SDK_ERROR_DIALOG + } else { + IAPErrorDialogType.GENERAL_DIALOG_ERROR + } + } + + else -> { + IAPErrorDialogType.GENERAL_DIALOG_ERROR + } + } + } + + companion object { + private const val DEFAULT_HTTP_ERROR_CODE = -1 + } +} + +/** + * Attempts to extract error message from api responses and fails gracefully if unable to do so. + * + * @return extracted text message; null if no message was received or was unable to parse it. + */ +fun Response.getMessage(): String { + if (isSuccessful) return message() + return try { + val errors = JSONObject(errorBody()?.string() ?: "{}") + errors.optString("error") + } catch (ex: Exception) { + "" + } +} diff --git a/core/src/main/java/org/openedx/core/extension/EncryptionExt.kt b/core/src/main/java/org/openedx/core/extension/EncryptionExt.kt new file mode 100644 index 000000000..5b00d6a7f --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/EncryptionExt.kt @@ -0,0 +1,27 @@ +package org.openedx.core.extension + +import android.util.Base64 + +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 + } +} diff --git a/core/src/main/java/org/openedx/core/extension/ViewExt.kt b/core/src/main/java/org/openedx/core/extension/ViewExt.kt index ebd007d3d..12155a2b7 100644 --- a/core/src/main/java/org/openedx/core/extension/ViewExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ViewExt.kt @@ -50,6 +50,15 @@ fun DialogFragment.setWidthPercent(percentage: Int) { dialog?.window?.setLayout(percentWidth.toInt(), ViewGroup.LayoutParams.WRAP_CONTENT) } +fun DialogFragment.setFullScreen(percentage: Int) { + val percent = percentage.toFloat() / 100 + val dm = Resources.getSystem().displayMetrics + val rect = dm.run { Rect(0, 0, widthPixels, heightPixels) } + val percentWidth = rect.width() * percent + val percentHeight = rect.height() * percent + dialog?.window?.setLayout(percentWidth.toInt(), percentHeight.toInt()) +} + fun Context.toastMessage(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } diff --git a/core/src/main/java/org/openedx/core/module/billing/BillingProcessor.kt b/core/src/main/java/org/openedx/core/module/billing/BillingProcessor.kt new file mode 100644 index 000000000..dff7a717c --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/billing/BillingProcessor.kt @@ -0,0 +1,205 @@ +package org.openedx.core.module.billing + +import android.app.Activity +import android.content.Context +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +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.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 kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +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.decodeToString +import org.openedx.core.extension.encodeToString +import org.openedx.core.extension.safeResume +import org.openedx.core.utils.Logger + +class BillingProcessor( + context: Context, + private val dispatcher: CoroutineDispatcher, +) : PurchasesUpdatedListener { + + private val logger = Logger(TAG) + + private var listener: PurchaseListeners? = null + + private var billingClient = BillingClient.newBuilder(context) + .setListener(this) + .enablePendingPurchases() + .build() + + override fun onPurchasesUpdated( + billingResult: BillingResult, + purchases: MutableList? + ) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + if (!purchases.first().isAcknowledged) { + CoroutineScope(dispatcher).launch { + acknowledgePurchase(purchases.first()) + } + } else { + listener?.onPurchaseComplete(purchases.first()) + } + } else { + listener?.onPurchaseCancel(billingResult.responseCode, billingResult.debugMessage) + } + } + + fun setPurchaseListener(listener: PurchaseListeners) { + this.listener = listener + } + + private suspend fun isReadyOrConnect(): Boolean { + return billingClient.isReady || connect() + } + + private suspend fun connect(): Boolean { + return suspendCancellableCoroutine { continuation -> + val billingClientStateListener = object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + logger.d { "BillingSetupFinished -> $billingResult" } + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + continuation.safeResume(true) { + continuation.cancel() + } + } else { + continuation.safeResume(false) { + continuation.cancel() + } + } + } + + override fun onBillingServiceDisconnected() { + continuation.safeResume(false) { + continuation.cancel() + } + } + } + billingClient.startConnection(billingClientStateListener) + } + } + + suspend fun querySyncDetails(productId: String): ProductDetailsResult { + isReadyOrConnect() + val productDetails = QueryProductDetailsParams.Product.newBuilder() + .setProductId(productId) + .setProductType(BillingClient.ProductType.INAPP) + .build() + + return withContext(dispatcher) { + billingClient.queryProductDetails( + QueryProductDetailsParams + .newBuilder() + .setProductList(listOf(productDetails)) + .build() + ) + } + } + + suspend fun purchaseItem( + activity: Activity, + userId: Long, + productInfo: ProductInfo, + ) { + if (isReadyOrConnect()) { + val response = querySyncDetails(productInfo.storeSku) + + response.productDetailsList?.first()?.let { + launchBillingFlow(activity, it, userId, productInfo.courseSku) + } + } else { + listener?.onPurchaseCancel(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE, "") + } + } + + private fun launchBillingFlow( + activity: Activity, + productDetails: ProductDetails, + userId: Long, + courseSku: String, + ) { + val productDetailsParamsList = listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .build() + ) + + val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(productDetailsParamsList) + .setObfuscatedAccountId(userId.encodeToString()) + .setObfuscatedProfileId(courseSku.encodeToString()) + .build() + + billingClient.launchBillingFlow(activity, billingFlowParams) + } + + private suspend fun acknowledgePurchase(purchase: Purchase) { + isReadyOrConnect() + val billingResult = billingClient.acknowledgePurchase( + AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + ) + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + listener?.onPurchaseComplete(purchase) + } + } + + suspend fun consumePurchase(purchaseToken: String): BillingResult { + isReadyOrConnect() + val result = billingClient.consumePurchase( + ConsumeParams + .newBuilder() + .setPurchaseToken(purchaseToken) + .build() + ) + return result.billingResult + } + + /** + * Method to query the Purchases async and returns purchases for currently owned items + * bought within the app. + * + * @return List of purchases + **/ + suspend fun queryPurchases(): List { + isReadyOrConnect() + return billingClient.queryPurchasesAsync( + QueryPurchasesParams.newBuilder() + .setProductType(BillingClient.ProductType.INAPP) + .build() + ).purchasesList.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED } + } + + companion object { + private const val TAG = "BillingClientWrapper" + const val MICROS_TO_UNIT = 1_000_000 // 1,000,000 micro-units equal one unit of the currency + } + + interface PurchaseListeners { + fun onPurchaseComplete(purchase: Purchase) + fun onPurchaseCancel(responseCode: Int, message: String) + } +} + +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/core/src/main/java/org/openedx/core/presentation/IAPAnalytics.kt b/core/src/main/java/org/openedx/core/presentation/IAPAnalytics.kt new file mode 100644 index 000000000..68bd4f7c2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/IAPAnalytics.kt @@ -0,0 +1,70 @@ +package org.openedx.core.presentation + +interface IAPAnalytics { + fun logEvent(event: String, params: Map) +} + +enum class IAPAnalyticsEvent(val eventName: String, val biValue: String) { + // In App Purchases Events + IAP_UPGRADE_NOW_CLICKED( + "Payments: Upgrade Now Clicked", + "edx.bi.app.payments.upgrade_now.clicked" + ), + IAP_COURSE_UPGRADE_SUCCESS( + "Payments: Course Upgrade Success", + "edx.bi.app.payments.course_upgrade_success" + ), + IAP_PAYMENT_ERROR( + "Payments: Payment Error", + "edx.bi.app.payments.payment_error" + ), + IAP_PAYMENT_CANCELED( + "Payments: Canceled by User", + "edx.bi.app.payments.canceled_by_user" + ), + IAP_COURSE_UPGRADE_ERROR( + "Payments: Course Upgrade Error", + "edx.bi.app.payments.course_upgrade_error" + ), + IAP_PRICE_LOAD_ERROR( + "Payments: Price Load Error", + "edx.bi.app.payments.price_load_error" + ), + IAP_ERROR_ALERT_ACTION( + "Payments: Error Alert Action", + "edx.bi.app.payments.error_alert_action" + ), + IAP_UNFULFILLED_PURCHASE_INITIATED( + "Payments: Unfulfilled Purchase Initiated", + "edx.bi.app.payments.unfulfilled_purchase.initiated" + ), + IAP_RESTORE_PURCHASE_CLICKED( + "Payments: Restore Purchases Clicked", + "edx.bi.app.payments.restore_purchases.clicked" + ) +} + +enum class IAPAnalyticsKeys(val key: String) { + NAME("name"), + CATEGORY("category"), + IN_APP_PURCHASES("in_app_purchases"), + COURSE_ID("course_id"), + PACING("pacing"), + SELF("self"), + INSTRUCTOR("instructor"), + IAP_FLOW_TYPE("flow_type"), + PRICE("price"), + COMPONENT_ID("component_id"), + ELAPSED_TIME("elapsed_time"), + ERROR("error"), + ERROR_ACTION("error_action"), + ACTION("action"), + SCREEN_NAME("screen_name"), + ERROR_ALERT_TYPE("error_alert_type"), +} + +enum class IAPAnalyticsScreen(val screenName: String) { + COURSE_ENROLLMENT("course_enrollment"), + COURSE_DASHBOARD("course_dashboard"), + PROFILE("profile"), +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/IAPDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/IAPDialogFragment.kt new file mode 100644 index 000000000..c18ce07b2 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dialog/IAPDialogFragment.kt @@ -0,0 +1,273 @@ +package org.openedx.core.presentation.dialog + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.R +import org.openedx.core.domain.model.iap.ProductInfo +import org.openedx.core.domain.model.iap.PurchaseFlowData +import org.openedx.core.extension.parcelable +import org.openedx.core.extension.serializable +import org.openedx.core.extension.setFullScreen +import org.openedx.core.presentation.iap.IAPAction +import org.openedx.core.presentation.iap.IAPFlow +import org.openedx.core.presentation.iap.IAPLoaderType +import org.openedx.core.presentation.iap.IAPRequestType +import org.openedx.core.presentation.iap.IAPUIState +import org.openedx.core.presentation.iap.IAPViewModel +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IAPErrorDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.UnlockingAccessView +import org.openedx.core.ui.ValuePropUpgradeFeatures +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +class IAPDialogFragment : DialogFragment() { + + private val iapViewModel by viewModel { + parametersOf( + requireArguments().serializable(ARG_IAP_FLOW), + requireArguments().parcelable(ARG_PURCHASE_FLOW_DATA) + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val iapState by iapViewModel.uiState.collectAsState() + val uiMessage by iapViewModel.uiMessage.collectAsState(null) + val scaffoldState = rememberScaffoldState() + + val isFullScreenLoader = + (iapState as? IAPUIState.Loading)?.loaderType == IAPLoaderType.FULL_SCREEN + + Scaffold( + modifier = Modifier.fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background, + topBar = { + if (isFullScreenLoader.not()) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Spacer(Modifier.weight(1f)) + Icon( + modifier = Modifier.clickable { onDismiss() }, + imageVector = Icons.Filled.Close, + contentDescription = null + ) + } + } + }, + bottomBar = { + if (isFullScreenLoader.not()) { + Box(modifier = Modifier.padding(all = 16.dp)) { + when { + (iapState is IAPUIState.Loading || + iapState is IAPUIState.PurchaseProduct || + iapState is IAPUIState.Error) -> { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + iapState is IAPUIState.ProductData && + iapViewModel.purchaseData.formattedPrice.isNullOrEmpty() + .not() -> { + OpenEdXButton(modifier = Modifier.fillMaxWidth(), + text = stringResource( + id = R.string.iap_upgrade_price, + iapViewModel.purchaseData.formattedPrice!!, + ), + onClick = { + iapViewModel.startPurchaseFlow() + }) + } + } + } + } + } + ) { contentPadding -> + + HandleUIMessage( + uiMessage = uiMessage, + scaffoldState = scaffoldState, + onDisplayed = { + if (iapState is IAPUIState.CourseDataUpdated) { + onDismiss() + } + } + ) + + when (iapState) { + is IAPUIState.PurchaseProduct -> { + iapViewModel.purchaseItem(requireActivity()) + } + + is IAPUIState.Error -> { + val iapException = (iapState as IAPUIState.Error).iapException + IAPErrorDialog(iapException = iapException, onIAPAction = { iapAction -> + when (iapAction) { + IAPAction.ACTION_RELOAD_PRICE -> { + iapViewModel.logIAPErrorActionEvent( + iapException.requestType.request, + IAPAction.ACTION_RELOAD_PRICE.action + ) + iapViewModel.loadPrice() + } + + IAPAction.ACTION_CLOSE -> { + iapViewModel.logIAPErrorActionEvent( + iapException.requestType.request, + IAPAction.ACTION_CLOSE.action + ) + onDismiss() + } + + IAPAction.ACTION_OK -> { + iapViewModel.logIAPErrorActionEvent( + iapException.requestType.request, + IAPAction.ACTION_OK.action + ) + onDismiss() + } + + IAPAction.ACTION_REFRESH -> { + iapViewModel.logIAPErrorActionEvent( + iapException.requestType.request, + IAPAction.ACTION_REFRESH.action + ) + iapViewModel.refreshCourse() + } + + IAPAction.ACTION_GET_HELP -> { + iapViewModel.showFeedbackScreen( + requireActivity(), + iapException.requestType.request, + iapException.getFormattedErrorMessage() + ) + onDismiss() + } + + IAPAction.ACTION_RETRY -> { + iapViewModel.logIAPErrorActionEvent( + iapException.requestType.request, + IAPAction.ACTION_RETRY.action + ) + if (iapException.requestType == IAPRequestType.CONSUME_CODE) { + iapViewModel.retryToConsumeOrder() + } else if (iapException.requestType == IAPRequestType.EXECUTE_ORDER_CODE) { + iapViewModel.retryExecuteOrder() + } + } + + else -> { + // ignore + } + } + }) + } + + is IAPUIState.Clear -> { + onDismiss() + } + + else -> {} + } + + if (isFullScreenLoader) { + UnlockingAccessView() + } else if (TextUtils.isEmpty(iapViewModel.purchaseData.courseName).not()) { + ValuePropUpgradeFeatures( + Modifier.padding(contentPadding), + iapViewModel.purchaseData.courseName!! + ) + } + } + } + } + } + + override fun onStart() { + super.onStart() + setFullScreen(100) + } + + private fun onDismiss() { + iapViewModel.clearIAPFLow() + dismiss() + } + + companion object { + const val TAG = "IAPDialogFragment" + + private const val ARG_IAP_FLOW = "iap_flow" + private const val ARG_PURCHASE_FLOW_DATA = "purchase_flow_data" + + fun newInstance( + iapFlow: IAPFlow, + screenName: String = "", + courseId: String = "", + courseName: String = "", + isSelfPaced: Boolean = false, + componentId: String? = null, + productInfo: ProductInfo? = null + ): IAPDialogFragment { + val fragment = IAPDialogFragment() + val purchaseFlowData = PurchaseFlowData().apply { + this.screenName = screenName + this.courseId = courseId + this.courseName = courseName + this.isSelfPaced = isSelfPaced + this.componentId = componentId + this.productInfo = productInfo + } + + fragment.arguments = bundleOf( + ARG_IAP_FLOW to iapFlow, + ARG_PURCHASE_FLOW_DATA to purchaseFlowData + ) + return fragment + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/iap/IAPErrorDialogType.kt b/core/src/main/java/org/openedx/core/presentation/iap/IAPErrorDialogType.kt new file mode 100644 index 000000000..079d5736e --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/iap/IAPErrorDialogType.kt @@ -0,0 +1,110 @@ +package org.openedx.core.presentation.iap + +import org.openedx.core.R + +/** + * 400 - BAD_REQUEST + * 403 - FORBIDDEN + * 406 - NOT_ACCEPTABLE + * 409 - CONFLICT + * General - GENERAL + * */ +enum class IAPErrorDialogType( + val messageResId: Int = 0, + val positiveButtonResId: Int = 0, + val negativeButtonResId: Int = 0, + val neutralButtonResId: Int = 0, +) { + PRICE_ERROR_DIALOG( + R.string.iap_error_price_not_fetched, + R.string.core_error_try_again, + R.string.core_cancel + ), + NO_SKU_ERROR_DIALOG, + ADD_TO_BASKET_BAD_REQUEST_ERROR_DIALOG( + R.string.iap_course_not_available_message, + R.string.core_cancel, + R.string.iap_get_help + ), + ADD_TO_BASKET_FORBIDDEN_ERROR_DIALOG( + R.string.iap_unauthenticated_account_message, + R.string.core_cancel, + R.string.iap_get_help + ), + ADD_TO_BASKET_NOT_ACCEPTABLE_ERROR_DIALOG( + R.string.iap_course_already_paid_for_message, + R.string.iap_label_refresh_now, + R.string.core_cancel + ), + ADD_TO_BASKET_GENERAL_ERROR_DIALOG( + R.string.iap_general_upgrade_error_message, + R.string.core_cancel, + R.string.iap_get_help + ), + CHECKOUT_BAD_REQUEST_ERROR_DIALOG( + R.string.iap_payment_could_not_be_processed, + R.string.core_cancel, + R.string.iap_get_help + ), + CHECKOUT_FORBIDDEN_ERROR_DIALOG( + R.string.iap_unauthenticated_account_message, + R.string.core_cancel, + R.string.iap_get_help + ), + CHECKOUT_NOT_ACCEPTABLE_ERROR_DIALOG( + R.string.iap_course_not_available_message, + R.string.iap_label_refresh_now, + R.string.core_cancel + ), + CHECKOUT_GENERAL_ERROR_DIALOG( + R.string.iap_general_upgrade_error_message, + R.string.core_cancel, + R.string.iap_get_help + ), + EXECUTE_BAD_REQUEST_ERROR_DIALOG( + R.string.iap_course_not_fullfilled, + R.string.iap_refresh_to_retry, + R.string.iap_get_help, + R.string.core_cancel + ), + EXECUTE_FORBIDDEN_ERROR_DIALOG( + R.string.iap_course_not_fullfilled, + R.string.iap_refresh_to_retry, + R.string.iap_get_help, + R.string.core_cancel + ), + EXECUTE_NOT_ACCEPTABLE_ERROR_DIALOG( + R.string.iap_course_already_paid_for_message, + R.string.iap_label_refresh_now, + R.string.iap_get_help, + R.string.core_cancel + ), + EXECUTE_CONFLICT_ERROR_DIALOG( + R.string.iap_course_already_paid_for_message, + R.string.core_cancel, + R.string.iap_get_help + ), + EXECUTE_GENERAL_ERROR_DIALOG( + R.string.iap_general_upgrade_error_message, + R.string.iap_refresh_to_retry, + R.string.iap_get_help, + R.string.core_cancel + ), + CONSUME_ERROR_DIALOG( + R.string.iap_course_not_fullfilled, + R.string.iap_refresh_to_retry, + R.string.iap_get_help, + R.string.core_cancel + ), + PAYMENT_SDK_ERROR_DIALOG( + R.string.iap_payment_could_not_be_processed, + R.string.core_cancel, + R.string.iap_get_help + ), + GENERAL_DIALOG_ERROR( + R.string.iap_general_upgrade_error_message, + R.string.core_cancel, + R.string.iap_get_help + ), + NONE; +} diff --git a/core/src/main/java/org/openedx/core/presentation/iap/IAPUIState.kt b/core/src/main/java/org/openedx/core/presentation/iap/IAPUIState.kt new file mode 100644 index 000000000..95bced19f --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/iap/IAPUIState.kt @@ -0,0 +1,61 @@ +package org.openedx.core.presentation.iap + +import org.openedx.core.exception.iap.IAPException + +sealed class IAPUIState { + data class ProductData(val formattedPrice: String) : IAPUIState() + data object PurchaseProduct : IAPUIState() + data object PurchasesFulfillmentCompleted : IAPUIState() + data object FakePurchasesFulfillmentCompleted : IAPUIState() + data object CourseDataUpdated : IAPUIState() + data class Loading(val loaderType: IAPLoaderType) : IAPUIState() + data class Error(val iapException: IAPException) : IAPUIState() + data object Clear : IAPUIState() +} + +enum class IAPLoaderType { + PRICE, PURCHASE_FLOW, FULL_SCREEN, RESTORE_PURCHASES +} + +enum class IAPFlow(val value: String) { + RESTORE("restore"), + SILENT("silent"), + USER_INITIATED("user_initiated"); + + fun value(): String { + return this.name.lowercase() + } +} + +enum class IAPAction(val action: String) { + ACTION_USER_INITIATED("user_initiated"), + ACTION_GET_HELP("get_help"), + ACTION_CLOSE("close"), + ACTION_RELOAD_PRICE("reload_price"), + ACTION_REFRESH("refresh"), + ACTION_RETRY("retry"), + ACTION_UNFULFILLED("unfulfilled"), + ACTION_RESTORE("restore"), + ACTION_ERROR_CLOSE("error_close"), + ACTION_COMPLETION("completion"), + ACTION_OK("ok"), + ACTION_RESTORE_PURCHASE_CANCEL("restore_purchase_cancel") +} + +enum class IAPRequestType(val request: String) { + // Custom Codes for request types + PRICE_CODE("price_fetch"), + ADD_TO_BASKET_CODE("basket"), + CHECKOUT_CODE("checkout"), + PAYMENT_SDK_CODE("payment"), + EXECUTE_ORDER_CODE("execute"), + NO_SKU_CODE("sku"), + CONSUME_CODE("consume"), + UNFULFILLED_CODE("unfulfilled"), + RESTORE_CODE("restore"), + UNKNOWN("unknown"); + + override fun toString(): String { + return request + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/iap/IAPViewModel.kt b/core/src/main/java/org/openedx/core/presentation/iap/IAPViewModel.kt new file mode 100644 index 000000000..2946ef5df --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/iap/IAPViewModel.kt @@ -0,0 +1,366 @@ +package org.openedx.core.presentation.iap + +import android.content.Context +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewModelScope +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.Purchase +import kotlinx.coroutines.Dispatchers +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.storage.CorePreferences +import org.openedx.core.domain.interactor.IAPInteractor +import org.openedx.core.domain.model.iap.PurchaseFlowData +import org.openedx.core.exception.iap.IAPException +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.global.AppData +import org.openedx.core.system.ResourceManager +import org.openedx.core.system.notifier.CourseDataUpdated +import org.openedx.core.system.notifier.IAPNotifier +import org.openedx.core.system.notifier.UpdateCourseData +import org.openedx.core.utils.EmailUtil +import org.openedx.core.utils.TimeUtils + +class IAPViewModel( + iapFlow: IAPFlow, + private val purchaseFlowData: PurchaseFlowData, + private val appData: AppData, + private val iapInteractor: IAPInteractor, + private val corePreferences: CorePreferences, + private val analytics: IAPAnalytics, + private val resourceManager: ResourceManager, + private val config: Config, + private val iapNotifier: IAPNotifier +) : BaseViewModel() { + + private val _uiState = MutableStateFlow(IAPUIState.Loading(IAPLoaderType.PRICE)) + val uiState: StateFlow + get() = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow + get() = _uiMessage.asSharedFlow() + + val purchaseData: PurchaseFlowData + get() = purchaseFlowData + + private val purchaseListeners = object : BillingProcessor.PurchaseListeners { + override fun onPurchaseComplete(purchase: Purchase) { + if (purchase.getCourseSku() == purchaseFlowData.productInfo?.courseSku) { + _uiState.value = + IAPUIState.Loading(loaderType = IAPLoaderType.FULL_SCREEN) + purchaseFlowData.purchaseToken = purchase.purchaseToken + executeOrder(purchaseFlowData) + } + } + + override fun onPurchaseCancel(responseCode: Int, message: String) { + updateErrorState( + IAPException( + IAPRequestType.PAYMENT_SDK_CODE, + httpErrorCode = responseCode, + errorMessage = message + ) + ) + } + } + + init { + viewModelScope.launch(Dispatchers.IO) { + iapNotifier.notifier.onEach { event -> + when (event) { + is CourseDataUpdated -> { + upgradeSuccessEvent() + _uiMessage.emit(UIMessage.ToastMessage(resourceManager.getString(R.string.iap_success_message))) + _uiState.value = IAPUIState.CourseDataUpdated + } + } + }.distinctUntilChanged().launchIn(viewModelScope) + } + + when (iapFlow) { + IAPFlow.USER_INITIATED -> { + loadPrice() + } + + IAPFlow.SILENT, IAPFlow.RESTORE -> { + _uiState.value = IAPUIState.Loading(IAPLoaderType.FULL_SCREEN) + purchaseFlowData.flowStartTime = TimeUtils.getCurrentTime() + updateCourseData() + } + } + } + + fun loadPrice() { + viewModelScope.launch(Dispatchers.IO) { + purchaseFlowData.takeIf { it.courseId != null && it.productInfo != null } + ?.apply { + _uiState.value = IAPUIState.Loading(loaderType = IAPLoaderType.PRICE) + runCatching { + iapInteractor.loadPrice(purchaseFlowData.productInfo?.storeSku!!) + }.onSuccess { + this.formattedPrice = it.formattedPrice + this.price = it.getPriceAmount() + this.currencyCode = it.priceCurrencyCode + _uiState.value = + IAPUIState.ProductData(formattedPrice = this.formattedPrice!!) + }.onFailure { + if (it is IAPException) { + updateErrorState(it) + } + } + } ?: run { + updateErrorState( + IAPException( + requestType = IAPRequestType.PRICE_CODE, + httpErrorCode = IAPRequestType.PRICE_CODE.hashCode(), + errorMessage = "Product SKU is not provided in the request." + ) + ) + } + } + } + + fun startPurchaseFlow() { + upgradeNowClickedEvent() + _uiState.value = IAPUIState.Loading(loaderType = IAPLoaderType.PURCHASE_FLOW) + purchaseFlowData.flowStartTime = TimeUtils.getCurrentTime() + purchaseFlowData.takeIf { purchaseFlowData.courseName != null && it.productInfo != null } + ?.apply { + addToBasket(productInfo?.courseSku!!) + } ?: run { + updateErrorState( + IAPException( + requestType = IAPRequestType.NO_SKU_CODE, + httpErrorCode = IAPRequestType.NO_SKU_CODE.hashCode(), + errorMessage = "" + ) + ) + } + } + + private fun addToBasket(courseSku: String) { + viewModelScope.launch(Dispatchers.IO) { + runCatching { + iapInteractor.addToBasket(courseSku) + }.onSuccess { basketId -> + purchaseFlowData.basketId = basketId + processCheckout(basketId) + }.onFailure { + if (it is IAPException) { + updateErrorState(it) + } + } + } + } + + private fun processCheckout(basketId: Long) { + viewModelScope.launch(Dispatchers.IO) { + runCatching { + iapInteractor.processCheckout(basketId) + }.onSuccess { + _uiState.value = IAPUIState.PurchaseProduct + }.onFailure { + if (it is IAPException) { + updateErrorState(it) + } + } + } + } + + fun purchaseItem(activity: FragmentActivity) { + viewModelScope.launch(Dispatchers.IO) { + takeIf { + corePreferences.user?.id != null && purchaseFlowData.productInfo != null + }?.apply { + iapInteractor.purchaseItem( + activity, + corePreferences.user?.id!!, + purchaseFlowData.productInfo!!, + purchaseListeners + ) + } + } + } + + private fun executeOrder(purchaseFlowData: PurchaseFlowData) { + viewModelScope.launch(Dispatchers.IO) { + runCatching { + iapInteractor.executeOrder( + basketId = purchaseFlowData.basketId, + purchaseToken = purchaseFlowData.purchaseToken!!, + price = purchaseFlowData.price, + currencyCode = purchaseFlowData.currencyCode, + ) + }.onSuccess { + consumeOrderForFurtherPurchases(purchaseFlowData) + }.onFailure { + if (it is IAPException) { + updateErrorState(it) + } + } + } + } + + private fun consumeOrderForFurtherPurchases(purchaseFlowData: PurchaseFlowData) { + viewModelScope.launch(Dispatchers.IO) { + purchaseFlowData.purchaseToken?.let { + runCatching { + iapInteractor.consumePurchase(it) + }.onSuccess { + updateCourseData() + }.onFailure { + if (it is IAPException) { + updateErrorState(it) + } + } + } + } + } + + fun refreshCourse() { + _uiState.value = IAPUIState.Loading(IAPLoaderType.FULL_SCREEN) + purchaseFlowData.flowStartTime = TimeUtils.getCurrentTime() + updateCourseData() + } + + fun retryExecuteOrder() { + executeOrder(purchaseFlowData) + } + + fun retryToConsumeOrder() { + consumeOrderForFurtherPurchases(purchaseFlowData) + } + + private fun updateCourseData() { + viewModelScope.launch(Dispatchers.IO) { + purchaseFlowData.courseId?.let { courseId -> + iapNotifier.send(UpdateCourseData(courseId)) + } + } + } + + fun showFeedbackScreen(context: Context, flowType: String, 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 + ) + logIAPErrorActionEvent(flowType, IAPAction.ACTION_GET_HELP.action) + } + + private fun updateErrorState(iapException: IAPException) { + val feedbackErrorMessage: String = iapException.getFormattedErrorMessage() + when (iapException.requestType) { + IAPRequestType.PAYMENT_SDK_CODE -> { + if (BillingClient.BillingResponseCode.USER_CANCELED == iapException.httpErrorCode) { + canceledByUserEvent() + } else { + purchaseErrorEvent(feedbackErrorMessage) + } + } + + IAPRequestType.PRICE_CODE, + IAPRequestType.NO_SKU_CODE -> { + priceLoadErrorEvent(feedbackErrorMessage) + } + + else -> { + courseUpgradeErrorEvent(feedbackErrorMessage) + } + } + if (BillingClient.BillingResponseCode.USER_CANCELED != iapException.httpErrorCode) { + _uiState.value = IAPUIState.Error(iapException) + } else { + _uiState.value = IAPUIState.Clear + } + } + + private fun upgradeNowClickedEvent() { + logIAPEvent(IAPAnalyticsEvent.IAP_UPGRADE_NOW_CLICKED) + } + + private fun upgradeSuccessEvent() { + val elapsedTime = TimeUtils.getCurrentTime() - purchaseFlowData.flowStartTime + logIAPEvent(IAPAnalyticsEvent.IAP_COURSE_UPGRADE_SUCCESS, buildMap { + put(IAPAnalyticsKeys.ELAPSED_TIME.key, elapsedTime) + }.toMutableMap()) + } + + private fun purchaseErrorEvent(error: String) { + logIAPEvent(IAPAnalyticsEvent.IAP_PAYMENT_ERROR, buildMap { + put(IAPAnalyticsKeys.ERROR.key, error) + }.toMutableMap()) + } + + private fun canceledByUserEvent() { + logIAPEvent(IAPAnalyticsEvent.IAP_PAYMENT_CANCELED) + } + + private fun courseUpgradeErrorEvent(error: String) { + logIAPEvent(IAPAnalyticsEvent.IAP_COURSE_UPGRADE_ERROR, buildMap { + put(IAPAnalyticsKeys.ERROR.key, error) + }.toMutableMap()) + } + + private fun priceLoadErrorEvent(error: String) { + logIAPEvent(IAPAnalyticsEvent.IAP_PRICE_LOAD_ERROR, buildMap { + put(IAPAnalyticsKeys.ERROR.key, error) + }.toMutableMap()) + } + + fun logIAPErrorActionEvent(alertType: String, action: String) { + logIAPEvent(IAPAnalyticsEvent.IAP_ERROR_ALERT_ACTION, buildMap { + put(IAPAnalyticsKeys.ERROR_ALERT_TYPE.key, alertType) + put(IAPAnalyticsKeys.ERROR_ACTION.key, action) + }.toMutableMap()) + } + + private fun logIAPEvent( + event: IAPAnalyticsEvent, + params: MutableMap = mutableMapOf() + ) { + analytics.logEvent(event.eventName, params.apply { + put(IAPAnalyticsKeys.NAME.key, event.biValue) + purchaseFlowData.takeIf { it.courseId.isNullOrBlank().not() }?.let { + put(IAPAnalyticsKeys.COURSE_ID.key, purchaseFlowData.courseId) + put( + IAPAnalyticsKeys.PACING.key, + if (purchaseFlowData.isSelfPaced == true) IAPAnalyticsKeys.SELF.key else IAPAnalyticsKeys.INSTRUCTOR.key + ) + } + purchaseFlowData.formattedPrice?.takeIf { it.isNotBlank() }?.let { formattedPrice -> + put(IAPAnalyticsKeys.PRICE.key, formattedPrice) + } + purchaseFlowData.componentId?.takeIf { it.isNotBlank() }?.let { componentId -> + put(IAPAnalyticsKeys.COMPONENT_ID.key, componentId) + } + put(IAPAnalyticsKeys.SCREEN_NAME.key, purchaseFlowData.screenName) + put(IAPAnalyticsKeys.CATEGORY.key, IAPAnalyticsKeys.IN_APP_PURCHASES.key) + }) + } + + fun clearIAPFLow() { + _uiState.value = IAPUIState.Clear + purchaseFlowData.reset() + } +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseDataUpdated.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseDataUpdated.kt new file mode 100644 index 000000000..e4ab84317 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseDataUpdated.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +class CourseDataUpdated : IAPEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/IAPEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/IAPEvent.kt new file mode 100644 index 000000000..52785ef36 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/IAPEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +interface IAPEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/IAPNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/IAPNotifier.kt new file mode 100644 index 000000000..d32df0b5d --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/IAPNotifier.kt @@ -0,0 +1,14 @@ +package org.openedx.core.system.notifier + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class IAPNotifier { + private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) + + val notifier: Flow = channel.asSharedFlow() + suspend fun send(event: UpdateCourseData) = channel.emit(event) + + suspend fun send(event: CourseDataUpdated) = channel.emit(event) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/UpdateCourseData.kt b/core/src/main/java/org/openedx/core/system/notifier/UpdateCourseData.kt new file mode 100644 index 000000000..3ea3a19b1 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/UpdateCourseData.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +data class UpdateCourseData(val courseId: String) : IAPEvent diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index 26806897f..735ca148a 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -400,6 +400,7 @@ fun SearchBarStateless( fun HandleUIMessage( uiMessage: UIMessage?, scaffoldState: ScaffoldState, + onDisplayed: () -> Unit = {} ) { val context = LocalContext.current LaunchedEffect(uiMessage) { @@ -417,6 +418,7 @@ fun HandleUIMessage( else -> {} } + onDisplayed() } } diff --git a/core/src/main/java/org/openedx/core/ui/IAPUI.kt b/core/src/main/java/org/openedx/core/ui/IAPUI.kt new file mode 100644 index 000000000..dda585369 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/IAPUI.kt @@ -0,0 +1,472 @@ +package org.openedx.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.AlertDialog +import androidx.compose.material.CircularProgressIndicator +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.filled.Check +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import org.openedx.core.R +import org.openedx.core.exception.iap.IAPException +import org.openedx.core.presentation.iap.IAPAction +import org.openedx.core.presentation.iap.IAPErrorDialogType +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography + +@Composable +fun ValuePropUpgradeFeatures(modifier: Modifier = Modifier, courseName: String) { + Column( + modifier = modifier + .padding(all = 16.dp) + ) { + Text( + modifier = Modifier.padding(vertical = 32.dp), + text = stringResource( + id = R.string.iap_upgrade_course, + courseName + ), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + CheckmarkView(stringResource(id = R.string.iap_earn_certificate)) + CheckmarkView(stringResource(id = R.string.iap_unlock_access)) + CheckmarkView(stringResource(id = R.string.iap_full_access_course)) + } +} + +@Composable +fun CheckmarkView(text: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.padding(end = 16.dp), + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.appColors.certificateForeground + ) + Text( + modifier = Modifier.weight(1f), + text = text, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + } +} + +@Composable +fun IAPErrorDialog(iapException: IAPException, onIAPAction: (IAPAction) -> Unit) { + when (val dialogType = iapException.getIAPErrorDialogType()) { + IAPErrorDialogType.PRICE_ERROR_DIALOG -> { + UpgradeErrorDialog( + title = stringResource(id = R.string.iap_error_title), + description = stringResource(id = dialogType.messageResId), + confirmText = stringResource(id = dialogType.positiveButtonResId), + onConfirm = { onIAPAction(IAPAction.ACTION_RELOAD_PRICE) }, + dismissText = stringResource(id = dialogType.negativeButtonResId), + onDismiss = { onIAPAction(IAPAction.ACTION_CLOSE) } + ) + } + + IAPErrorDialogType.NO_SKU_ERROR_DIALOG -> { + NoSkuErrorDialog(onConfirm = { + onIAPAction(IAPAction.ACTION_OK) + }) + } + + IAPErrorDialogType.ADD_TO_BASKET_NOT_ACCEPTABLE_ERROR_DIALOG, + IAPErrorDialogType.CHECKOUT_NOT_ACCEPTABLE_ERROR_DIALOG -> { + UpgradeErrorDialog( + title = stringResource(id = R.string.iap_error_title), + description = stringResource(id = dialogType.messageResId), + confirmText = stringResource(id = dialogType.positiveButtonResId), + onConfirm = { onIAPAction(IAPAction.ACTION_REFRESH) }, + dismissText = stringResource(id = dialogType.negativeButtonResId), + onDismiss = { onIAPAction(IAPAction.ACTION_CLOSE) } + ) + } + + IAPErrorDialogType.EXECUTE_BAD_REQUEST_ERROR_DIALOG, + IAPErrorDialogType.EXECUTE_FORBIDDEN_ERROR_DIALOG, + IAPErrorDialogType.EXECUTE_NOT_ACCEPTABLE_ERROR_DIALOG, + IAPErrorDialogType.EXECUTE_GENERAL_ERROR_DIALOG, + IAPErrorDialogType.CONSUME_ERROR_DIALOG -> { + CourseAlreadyPurchasedExecuteErrorDialog( + description = stringResource(id = dialogType.messageResId), + positiveText = stringResource(id = dialogType.positiveButtonResId), + negativeText = stringResource(id = dialogType.negativeButtonResId), + neutralText = stringResource(id = dialogType.neutralButtonResId), + onPositiveClick = { + if (iapException.httpErrorCode == 406) { + onIAPAction(IAPAction.ACTION_REFRESH) + } else { + onIAPAction(IAPAction.ACTION_RETRY) + } + }, + onNegativeClick = { + onIAPAction(IAPAction.ACTION_GET_HELP) + }, + onNeutralClick = { + onIAPAction(IAPAction.ACTION_CLOSE) + } + ) + } + + else -> { + UpgradeErrorDialog( + title = stringResource(id = R.string.iap_error_title), + description = stringResource(id = dialogType.messageResId), + confirmText = stringResource(id = dialogType.positiveButtonResId), + onConfirm = { onIAPAction(IAPAction.ACTION_CLOSE) }, + dismissText = stringResource(id = dialogType.negativeButtonResId), + onDismiss = { onIAPAction(IAPAction.ACTION_GET_HELP) } + ) + } + } +} + +@Composable +fun NoSkuErrorDialog( + onConfirm: () -> Unit, +) { + AlertDialog( + modifier = Modifier + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + .padding(bottom = 8.dp), + shape = MaterialTheme.appShapes.cardShape, + backgroundColor = MaterialTheme.appColors.background, + title = { + Text( + text = stringResource(id = R.string.iap_error_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + ) + }, + text = { + Text( + text = stringResource(id = R.string.iap_error_price_not_fetched), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium + ) + }, + confirmButton = { + OpenEdXButton( + modifier = Modifier.wrapContentSize(), + text = stringResource(id = R.string.core_ok), + onClick = onConfirm + ) + }, + onDismissRequest = onConfirm + ) +} + +@Composable +fun CourseAlreadyPurchasedExecuteErrorDialog( + description: String, + positiveText: String, + negativeText: String, + neutralText: String, + onPositiveClick: () -> Unit, + onNegativeClick: () -> Unit, + onNeutralClick: () -> Unit +) { + AlertDialog( + modifier = Modifier + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + .padding(bottom = 8.dp), + shape = MaterialTheme.appShapes.cardShape, + backgroundColor = MaterialTheme.appColors.background, + title = { + Text( + text = stringResource(id = R.string.iap_error_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + ) + }, + text = { + Text( + text = description, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium + ) + }, + buttons = { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + OpenEdXButton( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + text = positiveText, + onClick = onPositiveClick + ) + + OpenEdXButton( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + text = negativeText, + onClick = onNegativeClick + ) + + OpenEdXButton( + modifier = Modifier + .fillMaxWidth() + .padding(2.dp), + text = neutralText, + onClick = onNeutralClick + ) + } + }, + onDismissRequest = {} + ) +} + +@Composable +fun UpgradeErrorDialog( + title: String, + description: String, + confirmText: String, + onConfirm: () -> Unit, + dismissText: String, + onDismiss: () -> Unit +) { + AlertDialog( + modifier = Modifier + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + .padding(bottom = 8.dp), + shape = MaterialTheme.appShapes.cardShape, + backgroundColor = MaterialTheme.appColors.background, + title = { + Text( + text = title, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + ) + }, + text = { + Text( + text = description, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium + ) + }, + confirmButton = { + OpenEdXButton( + modifier = Modifier.wrapContentSize(), + text = confirmText, + onClick = onConfirm + ) + }, + dismissButton = { + OpenEdXButton( + modifier = Modifier.wrapContentSize(), + text = dismissText, + onClick = onDismiss + ) + }, + onDismissRequest = onConfirm + ) +} + +@Composable +fun CheckingPurchasesDialog() { + Dialog(onDismissRequest = { }) { + Column( + Modifier + .padding(16.dp) + .fillMaxWidth() + .background( + MaterialTheme.appColors.cardViewBackground, + MaterialTheme.appShapes.cardShape + ) + ) { + Text( + modifier = Modifier.padding(16.dp), + text = stringResource(id = R.string.iap_checking_purchases), + style = MaterialTheme.appTypography.titleMedium + ) + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 16.dp), + color = MaterialTheme.appColors.primary + ) + } + } +} + +@Composable +fun FakePurchasesFulfillmentCompleted(onCancel: () -> Unit, onGetHelp: () -> Unit) { + AlertDialog( + modifier = Modifier + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + .padding(end = 8.dp, bottom = 8.dp), + shape = MaterialTheme.appShapes.cardShape, + backgroundColor = MaterialTheme.appColors.background, + title = { + Text( + text = stringResource(id = R.string.iap_title_purchases_restored), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + ) + }, + text = { + Text( + text = stringResource(id = R.string.iap_message_purchases_restored), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium, + ) + }, + confirmButton = { + OpenEdXButton( + modifier = Modifier.wrapContentSize(), + text = stringResource(id = R.string.core_cancel), + onClick = onCancel + ) + }, + dismissButton = { + OpenEdXButton( + modifier = Modifier.wrapContentSize(), + text = stringResource(id = R.string.iap_get_help), + onClick = onGetHelp + ) + }, + onDismissRequest = onCancel + ) +} + +@Composable +fun PurchasesFulfillmentCompletedDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + modifier = Modifier + .background( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape + ) + .padding(end = 8.dp, bottom = 8.dp), + shape = MaterialTheme.appShapes.cardShape, + backgroundColor = MaterialTheme.appColors.background, + title = { + Text( + text = stringResource(id = R.string.iap_silent_course_upgrade_success_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + ) + }, + text = { + Text( + text = stringResource(id = R.string.iap_silent_course_upgrade_success_message), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium, + ) + }, + confirmButton = { + OpenEdXButton( + modifier = Modifier.wrapContentSize(), + text = stringResource(id = R.string.iap_label_refresh_now), + onClick = onConfirm + ) + }, + dismissButton = { + OpenEdXButton( + modifier = Modifier.wrapContentSize(), + text = stringResource(id = R.string.iap_label_continue_without_update), + onClick = onDismiss + ) + }, + onDismissRequest = onDismiss + ) +} + +@Preview +@Composable +private fun PreviewValuePropUpgradeFeatures() { + ValuePropUpgradeFeatures(modifier = Modifier.background(Color.White), "Test Course") +} + +@Preview +@Composable +private fun PreviewUpgradeErrorDialog() { + UpgradeErrorDialog( + title = "Error while Upgrading", + description = "Description of the error", + confirmText = "Confirm", + onConfirm = {}, + dismissText = "Dismiss", + onDismiss = {}) +} + +@Preview +@Composable +private fun PreviewPurchasesFulfillmentCompletedDialog() { + PurchasesFulfillmentCompletedDialog(onConfirm = {}, onDismiss = {}) +} + +@Preview +@Composable +private fun PreviewCheckingPurchasesDialog() { + CheckingPurchasesDialog() +} + +@Preview +@Composable +private fun PreviewFakePurchasesFulfillmentCompleted() { + FakePurchasesFulfillmentCompleted(onCancel = {}, onGetHelp = {}) +} + +@Preview +@Composable +private fun PreviewCourseAlreadyPurchasedExecuteErrorDialog() { + CourseAlreadyPurchasedExecuteErrorDialog( + description = stringResource(id = R.string.iap_course_not_fullfilled), + positiveText = stringResource(id = R.string.iap_label_refresh_now), + negativeText = stringResource(id = R.string.core_contact_support), + neutralText = stringResource(id = R.string.core_cancel), + onPositiveClick = {}, onNegativeClick = {}, onNeutralClick = {}) +} + +@Preview +@Composable +private fun PreviewNoSkuErrorDialog() { + NoSkuErrorDialog(onConfirm = {}) +} diff --git a/core/src/main/java/org/openedx/core/ui/UnlockingAccessView.kt b/core/src/main/java/org/openedx/core/ui/UnlockingAccessView.kt new file mode 100644 index 000000000..bf3d5bb9a --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/UnlockingAccessView.kt @@ -0,0 +1,83 @@ +package org.openedx.core.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography + +@Composable +fun UnlockingAccessView() { + Column( + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.appColors.background), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + colorFilter = ColorFilter.tint(MaterialTheme.appColors.progressBarBackgroundColor), + painter = painterResource(id = R.drawable.core_ic_rocket_launch), + contentDescription = null, + ) + + val annotatedString = buildAnnotatedString { + append(stringResource(id = R.string.iap_unloacking_text)) + append("\n") + withStyle( + style = SpanStyle( + fontWeight = FontWeight.Bold, + color = MaterialTheme.appColors.primary + ) + ) { + append(stringResource(id = R.string.iap_full_access_text)) + } + append("\n") + append(stringResource(id = R.string.iap_your_course_text)) + } + + Text( + modifier = Modifier.padding(vertical = 24.dp), + text = annotatedString, + textAlign = TextAlign.Center, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 64.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } +} + +@Preview +@Composable +private fun PreviewUnlockingAccessView() { + UnlockingAccessView() +} diff --git a/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt b/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt new file mode 100644 index 000000000..8e24936b3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/UpgradeToAccessView.kt @@ -0,0 +1,97 @@ +package org.openedx.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.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.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography + +@Composable +fun UpgradeToAccessView( + modifier: Modifier = Modifier, + type: UpgradeToAccessViewType = UpgradeToAccessViewType.DASHBOARD, + onClick: () -> Unit, +) { + val shape = when (type) { + UpgradeToAccessViewType.DASHBOARD -> RoundedCornerShape( + bottomStart = 16.dp, + bottomEnd = 16.dp + ) + + UpgradeToAccessViewType.COURSE -> MaterialTheme.appShapes.buttonShape + } + Row( + modifier = modifier + .clip(shape = shape) + .fillMaxWidth() + .background(color = MaterialTheme.appColors.primaryButtonBackground) + .clickable { + onClick() + } + .padding(vertical = 8.dp, horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier.padding(end = 16.dp), + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = MaterialTheme.appColors.primaryButtonText + ) + Text( + modifier = Modifier.weight(1f), + text = stringResource(id = R.string.iap_upgrade_access_course), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + Icon( + modifier = Modifier.padding(start = 16.dp), + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.appColors.primaryButtonText + ) + } +} + +enum class UpgradeToAccessViewType { + DASHBOARD, + COURSE, +} + +@Preview +@Composable +private fun UpgradeToAccessViewPreview( + @PreviewParameter(UpgradeToAccessViewTypeParameterProvider::class) type: UpgradeToAccessViewType +) { + OpenEdXTheme { + UpgradeToAccessView(type = type) {} + } +} + +private class UpgradeToAccessViewTypeParameterProvider : + PreviewParameterProvider { + override val values = sequenceOf( + UpgradeToAccessViewType.DASHBOARD, + UpgradeToAccessViewType.COURSE, + ) +} diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index 5327b8cf5..5e951f9a7 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -79,7 +79,7 @@ object TimeUtils { * @return true if the other date is past today, * false otherwise. */ - private fun isDatePassed(today: Date, otherDate: Date?): Boolean { + fun isDatePassed(today: Date, otherDate: Date?): Boolean { return otherDate != null && today.after(otherDate) } diff --git a/core/src/main/res/drawable/core_ic_rocket_launch.xml b/core/src/main/res/drawable/core_ic_rocket_launch.xml new file mode 100644 index 000000000..06920c6b4 --- /dev/null +++ b/core/src/main/res/drawable/core_ic_rocket_launch.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 931d2c6da..a781df123 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -46,6 +46,7 @@ OS version: Device model: Feedback + Error upgrading course in app MMM dd, yyyy dd MMM yyyy hh:mm aaa App Update @@ -174,6 +175,7 @@ Your course calendar has been removed. Your course calendar has been updated. Error Adding Calendar, Please try later + Contact Support @@ -182,4 +184,35 @@ Discussions More Dates + + + Upgrade to access more features + Upgrade %s + Upgrade now for %s + Unlocking + full access + to your course + Get help + Earn a verified certificate of completion to showcase on your resume + Unlock access to all course activities, including graded assignments + Full access to course content and course material even after the course ends + An Error occurred + Refresh to retry + It looks like something went wrong when upgrading your course. If this error continues, please contact Support. + Error upgrading course in app + Thank you for your purchase. Enjoy full access to your course! + New experience available + An update is available to unlock a purchased course. To update, we need to quickly refresh your app. If you choose not to update now, we’ll try again later. + Refresh now + Continue without update + Checking purchases\.\.\. + Purchases have been successfully restored + All purchases are up to date. If you’re not seeing your purchases restored, please try restarting your app to refresh the experience. + Your request could not be completed at this time. If this error continues, please reach out to Support. + Your account could not be authenticated. Try signing out and signing back into the app. If this error continues, please contact Support. + The course you are looking to upgrade could not be found. Please try your upgrade again. If this error continues, contact Support. + The course you are looking to upgrade has already been paid for. For additional help, reach out to Support. + Something happened when we tried to update your course experience. If this error continues, reach out to Support for help. + Your payment could not be processed at this time. Please try again. For additional help, reach out to Support. + diff --git a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt index 08f6cf96a..52c87456f 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CollapsingLayout.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -67,6 +68,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.openedx.core.R import org.openedx.core.ui.RoundTabsBar +import org.openedx.core.ui.UpgradeToAccessView +import org.openedx.core.ui.UpgradeToAccessViewType import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.statusBarsInset @@ -81,6 +84,7 @@ internal fun CollapsingLayout( imageHeight: Int, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, + upgradeButton: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, bodyContent: @Composable BoxScope.() -> Unit, onBackClick: () -> Unit, @@ -223,6 +227,7 @@ internal fun CollapsingLayout( imageHeight = imageHeight, onBackClick = onBackClick, expandedTop = expandedTop, + upgradeButton = upgradeButton, navigation = navigation, bodyContent = bodyContent ) @@ -247,6 +252,7 @@ internal fun CollapsingLayout( onBackClick = onBackClick, expandedTop = expandedTop, collapsedTop = collapsedTop, + upgradeButton = upgradeButton, navigation = navigation, bodyContent = bodyContent ) @@ -267,6 +273,7 @@ private fun CollapsingLayoutTablet( imageHeight: Int, onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, + upgradeButton: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, bodyContent: @Composable BoxScope.() -> Unit, ) { @@ -393,20 +400,19 @@ private fun CollapsingLayoutTablet( contentDescription = null ) - - Box( - modifier = Modifier - .offset { - IntOffset( - x = 0, - y = (backgroundImageHeight.value + expandedTopHeight.value).roundToInt() - ) - } - .onSizeChanged { size -> - navigationHeight.value = size.height.toFloat() - }, - content = navigation, - ) + Column(modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (backgroundImageHeight.value + expandedTopHeight.value).roundToInt() + ) + } + .onSizeChanged { size -> + navigationHeight.value = size.height.toFloat() + }) { + Box(content = upgradeButton) + Box(content = navigation) + } Box( modifier = Modifier @@ -442,6 +448,7 @@ private fun CollapsingLayoutMobile( onBackClick: () -> Unit, expandedTop: @Composable BoxScope.() -> Unit, collapsedTop: @Composable BoxScope.() -> Unit, + upgradeButton: @Composable BoxScope.() -> Unit, navigation: @Composable BoxScope.() -> Unit, bodyContent: @Composable BoxScope.() -> Unit, ) { @@ -533,15 +540,15 @@ private fun CollapsingLayoutMobile( } - Box( - modifier = Modifier - .displayCutoutForLandscape() - .offset { IntOffset(x = 0, y = (collapsedTopHeight.value).roundToInt()) } - .onSizeChanged { size -> - navigationHeight.value = size.height.toFloat() - }, - content = navigation, - ) + Column(modifier = Modifier + .displayCutoutForLandscape() + .offset { IntOffset(x = 0, y = (collapsedTopHeight.value).roundToInt()) } + .onSizeChanged { size -> + navigationHeight.value = size.height.toFloat() + }) { + Box(content = upgradeButton) + Box(content = navigation) + } Box( modifier = Modifier @@ -698,19 +705,19 @@ private fun CollapsingLayoutMobile( } val adaptiveImagePadding = blurImagePaddingPx * factor - Box( - modifier = Modifier - .offset { - IntOffset( - x = 0, - y = (offset.value + backgroundImageHeight.value + expandedTopHeight.value - adaptiveImagePadding).roundToInt() - ) - } - .onSizeChanged { size -> - navigationHeight.value = size.height.toFloat() - }, - content = navigation, - ) + Column(modifier = Modifier + .offset { + IntOffset( + x = 0, + y = (offset.value + backgroundImageHeight.value + expandedTopHeight.value - adaptiveImagePadding).roundToInt() + ) + } + .onSizeChanged { size -> + navigationHeight.value = size.height.toFloat() + }) { + Box(content = upgradeButton) + Box(content = navigation) + } Box( modifier = Modifier @@ -757,6 +764,18 @@ private fun CollapsingLayoutPreview() { courseTitle = "courseName" ) }, + upgradeButton = { + val windowSize = rememberWindowSize() + val horizontalPadding = if (!windowSize.isTablet) 16.dp else 98.dp + UpgradeToAccessView( + modifier = Modifier.padding( + start = horizontalPadding, + end = 16.dp, + top = 16.dp + ), + type = UpgradeToAccessViewType.COURSE, + ) {} + }, navigation = { RoundTabsBar( items = CourseContainerTab.entries, diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index d83cd0c18..7580b2404 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -56,12 +56,17 @@ import org.koin.androidx.compose.koinViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.extension.takeIfNotEmpty +import org.openedx.core.presentation.IAPAnalyticsScreen +import org.openedx.core.presentation.dialog.IAPDialogFragment import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.presentation.iap.IAPFlow import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialog import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.RoundTabsBar +import org.openedx.core.ui.UpgradeToAccessView +import org.openedx.core.ui.UpgradeToAccessViewType import org.openedx.core.ui.WindowSize import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme @@ -115,9 +120,6 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) initCourseView() - if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { - setUpCourseCalendar() - } observe() } @@ -138,6 +140,8 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { requireActivity().supportFragmentManager, viewModel.courseName ) + } else if (viewModel.calendarSyncUIState.value.isCalendarSyncEnabled) { + setUpCourseCalendar() } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { pushNotificationPermissionLauncher.launch( @@ -305,7 +309,7 @@ fun CourseDashboard( isNavigationEnabled: Boolean, isResumed: Boolean, fragmentManager: FragmentManager, - bundle: Bundle + bundle: Bundle, ) { OpenEdXTheme { val windowSize = rememberWindowSize() @@ -321,7 +325,8 @@ fun CourseDashboard( val refreshing by viewModel.refreshing.collectAsState(true) val courseImage by viewModel.courseImage.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) - val openTab = bundle.getString(CourseContainerFragment.ARG_OPEN_TAB, CourseContainerTab.HOME.name) + val openTab = + bundle.getString(CourseContainerFragment.ARG_OPEN_TAB, CourseContainerTab.HOME.name) val requiredTab = when (openTab.uppercase()) { CourseContainerTab.HOME.name -> CourseContainerTab.HOME CourseContainerTab.VIDEOS.name -> CourseContainerTab.VIDEOS @@ -336,6 +341,7 @@ fun CourseDashboard( pageCount = { CourseContainerTab.entries.size } ) val dataReady = viewModel.dataReady.observeAsState() + val canShowUpgradeButton by viewModel.canShowUpgradeButton.collectAsState() val tabState = rememberLazyListState() val snackState = remember { SnackbarHostState() } val pullRefreshState = rememberPullRefreshState( @@ -366,21 +372,51 @@ fun CourseDashboard( courseImage = courseImage, imageHeight = 200, expandedTop = { - ExpandedHeaderContent( - courseTitle = viewModel.courseName, - org = viewModel.organization - ) + if (dataReady.value == true) { + ExpandedHeaderContent( + courseTitle = viewModel.courseName, + org = viewModel.courseStructure?.org!! + ) + } }, collapsedTop = { CollapsedHeaderContent( courseTitle = viewModel.courseName ) }, + upgradeButton = { + if (dataReady.value == true && canShowUpgradeButton) { + val horizontalPadding = if (!windowSize.isTablet) 16.dp else 98.dp + UpgradeToAccessView( + modifier = Modifier.padding( + start = horizontalPadding, + end = 16.dp, + top = 16.dp + ), + type = UpgradeToAccessViewType.COURSE, + ) { + IAPDialogFragment.newInstance( + iapFlow = IAPFlow.USER_INITIATED, + screenName = IAPAnalyticsScreen.COURSE_DASHBOARD.screenName, + courseId = viewModel.courseId, + courseName = viewModel.courseName, + isSelfPaced = viewModel.courseStructure?.isSelfPaced!!, + productInfo = viewModel.courseStructure?.productInfo!! + ).show( + fragmentManager, + IAPDialogFragment.TAG + ) + } + } + }, navigation = { if (isNavigationEnabled) { RoundTabsBar( items = CourseContainerTab.entries, - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 16.dp), + contentPadding = PaddingValues( + horizontal = 12.dp, + vertical = 16.dp + ), rowState = tabState, pagerState = pagerState, withPager = true, @@ -459,7 +495,7 @@ fun DashboardPager( isNavigationEnabled: Boolean, isResumed: Boolean, fragmentManager: FragmentManager, - bundle: Bundle, + bundle: Bundle ) { HorizontalPager( state = pagerState, diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt index 60813d29a..b4a846b09 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerViewModel.kt @@ -15,6 +15,9 @@ 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.flow.update import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel @@ -23,8 +26,10 @@ import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.settings.calendarsync.CalendarSyncDialogType import org.openedx.core.presentation.settings.calendarsync.CalendarSyncUIState import org.openedx.core.system.CalendarManager @@ -33,13 +38,16 @@ import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CalendarSyncEvent.CheckCalendarSyncEvent import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent import org.openedx.core.system.notifier.CourseCompletionSet +import org.openedx.core.system.notifier.CourseDataUpdated import org.openedx.core.system.notifier.CourseDatesShifted import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseOpenBlock import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.IAPNotifier import org.openedx.core.system.notifier.RefreshDates import org.openedx.core.system.notifier.RefreshDiscussions +import org.openedx.core.system.notifier.UpdateCourseData import org.openedx.core.utils.TimeUtils import org.openedx.course.DatesShiftedSnackBar import org.openedx.course.data.storage.CoursePreferences @@ -59,11 +67,13 @@ class CourseContainerViewModel( var courseName: String, private var resumeBlockId: String, private val enrollmentMode: String, + private val appData: AppData, private val config: Config, private val interactor: CourseInteractor, private val calendarManager: CalendarManager, private val resourceManager: ResourceManager, private val courseNotifier: CourseNotifier, + private val iapNotifier: IAPNotifier, private val networkConnection: NetworkConnection, private val corePreferences: CorePreferences, private val coursePreferences: CoursePreferences, @@ -96,20 +106,30 @@ class CourseContainerViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private var _isSelfPaced: Boolean = true - val isSelfPaced: Boolean - get() = _isSelfPaced + private val isValuePropEnabled: Boolean + get() = corePreferences.appConfig.isValuePropEnabled - private var _organization: String = "" - val organization: String - get() = _organization + private val iapConfig + get() = corePreferences.appConfig.iapConfig + + private val isIAPEnabled + get() = iapConfig.isEnabled && + iapConfig.disableVersions.contains(appData.versionName).not() + + private var _canShowUpgradeButton = MutableStateFlow(false) + val canShowUpgradeButton: StateFlow + get() = _canShowUpgradeButton.asStateFlow() + + private var _courseStructure: CourseStructure? = null + val courseStructure: CourseStructure? + get() = _courseStructure val calendarPermissions: Array get() = calendarManager.permissions private val _calendarSyncUIState = MutableStateFlow( CalendarSyncUIState( - isCalendarSyncEnabled = isCalendarSyncEnabled(), + isCalendarSyncEnabled = false, calendarTitle = calendarManager.getCourseCalendarTitle(courseName), courseDates = emptyList(), dialogType = CalendarSyncDialogType.NONE, @@ -162,6 +182,14 @@ class CourseContainerViewModel( } } } + + iapNotifier.notifier.onEach { event -> + when (event) { + is UpdateCourseData -> { + updateData(true) + } + } + }.distinctUntilChanged().launchIn(viewModelScope) } fun preloadCourseStructure() { @@ -169,21 +197,26 @@ class CourseContainerViewModel( if (_dataReady.value != null) { return } - _showProgress.value = true viewModelScope.launch { try { - val courseStructure = interactor.getCourseStructure(courseId, true) - courseName = courseStructure.name - _organization = courseStructure.org - _isSelfPaced = courseStructure.isSelfPaced - loadCourseImage(courseStructure.media?.image?.large) - _dataReady.value = courseStructure.start?.let { start -> - val isReady = start < Date() - if (isReady) { - _isNavigationEnabled.value = true + _courseStructure = interactor.getCourseStructure(courseId, true) + _courseStructure?.let { + courseName = it.name + loadCourseImage(courseStructure?.media?.image?.large) + _calendarSyncUIState.update { state -> + state.copy(isCalendarSyncEnabled = isCalendarSyncEnabled()) } - isReady + _dataReady.value = courseStructure?.start?.let { start -> + val isReady = start < Date() + if (isReady) { + _isNavigationEnabled.value = true + } + isReady + } + _canShowUpgradeButton.value = isIAPEnabled && + isValuePropEnabled && + courseStructure?.isUpgradeable == true } if (_dataReady.value == true && resumeBlockId.isNotEmpty()) { delay(500L) @@ -247,10 +280,14 @@ class CourseContainerViewModel( } } - fun updateData() { + fun updateData(isIAPFlow: Boolean = false) { viewModelScope.launch { try { - interactor.getCourseStructure(courseId, isNeedRefresh = true) + _courseStructure = interactor.getCourseStructure(courseId, isNeedRefresh = true) + _canShowUpgradeButton.value = isIAPEnabled && + isValuePropEnabled && + courseStructure?.productInfo != null && + courseStructure?.isUpgradeable == true } catch (e: Exception) { if (e.isInternetError()) { _errorMessage.value = @@ -262,6 +299,9 @@ class CourseContainerViewModel( } _refreshing.value = false courseNotifier.send(CourseStructureUpdated(courseId)) + if (isIAPFlow) { + iapNotifier.send(CourseDataUpdated()) + } } } @@ -391,8 +431,8 @@ class CourseContainerViewModel( private fun isCalendarSyncEnabled(): Boolean { val calendarSync = corePreferences.appConfig.courseDatesCalendarSync - return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && isSelfPaced) || - (calendarSync.isInstructorPacedEnabled && !isSelfPaced)) + return calendarSync.isEnabled && ((calendarSync.isSelfPacedEnabled && _courseStructure?.isSelfPaced == true) || + (calendarSync.isInstructorPacedEnabled && _courseStructure?.isSelfPaced == false)) } private fun courseDashboardViewed() { @@ -484,7 +524,7 @@ class CourseContainerViewModel( put(CourseAnalyticsKey.ENROLLMENT_MODE.key, enrollmentMode) put( CourseAnalyticsKey.PACING.key, - if (isSelfPaced) CourseAnalyticsKey.SELF_PACED.key + if (_courseStructure?.isSelfPaced == true) CourseAnalyticsKey.SELF_PACED.key else CourseAnalyticsKey.INSTRUCTOR_PACED.key ) putAll(param) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt index 1f31b32de..69e930d9c 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineScreen.kt @@ -49,9 +49,11 @@ import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.domain.model.Progress import org.openedx.core.extension.takeIfNotEmpty import org.openedx.core.presentation.course.CourseViewMode @@ -80,9 +82,9 @@ fun CourseOutlineScreen( fragmentManager: FragmentManager, onResetDatesClick: () -> Unit ) { + val resumeBlockId by viewModel.resumeBlockId.collectAsState("") val uiState by viewModel.uiState.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(null) - val resumeBlockId by viewModel.resumeBlockId.collectAsState("") val context = LocalContext.current LaunchedEffect(resumeBlockId) { @@ -168,7 +170,7 @@ private fun CourseOutlineUI( onResumeClick: (String) -> Unit, onDownloadClick: (blockIds: List) -> Unit, onResetDatesClick: () -> Unit, - onCertificateClick: (String) -> Unit, + onCertificateClick: (String) -> Unit ) { val scaffoldState = rememberScaffoldState() @@ -512,7 +514,7 @@ private fun CourseOutlineScreenPreview() { onResumeClick = {}, onDownloadClick = {}, onResetDatesClick = {}, - onCertificateClick = {}, + onCertificateClick = {} ) } } @@ -545,7 +547,7 @@ private fun CourseOutlineScreenTabletPreview() { onResumeClick = {}, onDownloadClick = {}, onResetDatesClick = {}, - onCertificateClick = {}, + onCertificateClick = {} ) } } @@ -603,6 +605,21 @@ private val mockSequentialBlock = Block( due = Date() ) +private val mockEnrollmentDetails = + EnrollmentDetails(created = Date(), mode = "audit", isActive = false, upgradeDeadline = Date()) + +private val mockCourseAccessDetails = CourseAccessDetails( + Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ) +) + private val mockCourseStructure = CourseStructure( root = "", blockData = listOf(mockSequentialBlock, mockSequentialBlock), @@ -614,16 +631,11 @@ private val mockCourseStructure = CourseStructure( startDisplay = "", startType = "", end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), media = null, certificate = null, isSelfPaced = false, - progress = Progress(1, 3) + progress = Progress(1, 3), + productInfo = null, + enrollmentDetails = mockEnrollmentDetails, + courseAccessDetails = mockCourseAccessDetails ) diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt index b65b3b62a..1dcf7dd25 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineViewModel.kt @@ -117,7 +117,7 @@ class CourseOutlineViewModel( courseSubSections = courseSubSections, courseSectionsState = state.courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = state.datesBannerInfo, + datesBannerInfo = state.datesBannerInfo ) } } @@ -164,7 +164,7 @@ class CourseOutlineViewModel( courseSubSections = courseSubSections, courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = state.datesBannerInfo, + datesBannerInfo = state.datesBannerInfo ) courseSectionsState[blockId] ?: false @@ -220,7 +220,7 @@ class CourseOutlineViewModel( courseSubSections = courseSubSections, courseSectionsState = courseSectionsState, subSectionsDownloadsCount = subSectionsDownloadsCount, - datesBannerInfo = datesBannerInfo, + datesBannerInfo = datesBannerInfo ) courseNotifier.send(CourseLoading(false)) } catch (e: Exception) { diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index b111dd1f0..5dd7c0d2b 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -664,7 +664,8 @@ fun CourseExpandableChapterCard( if (block.isCompleted()) { val completedIconPainter = painterResource(R.drawable.course_ic_task_alt) val completedIconColor = MaterialTheme.appColors.successGreen - val completedIconDescription = stringResource(id = R.string.course_accessibility_section_completed) + val completedIconDescription = + stringResource(id = R.string.course_accessibility_section_completed) Icon( painter = completedIconPainter, @@ -750,13 +751,16 @@ fun CourseSubSectionItem( ) { val context = LocalContext.current val icon = - if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource(coreR.drawable.ic_core_chapter_icon) + if (block.isCompleted()) painterResource(R.drawable.course_ic_task_alt) else painterResource( + coreR.drawable.ic_core_chapter_icon + ) val iconColor = if (block.isCompleted()) MaterialTheme.appColors.successGreen else MaterialTheme.appColors.onSurface val due by rememberSaveable { mutableStateOf(block.due?.let { TimeUtils.getAssignmentFormattedDate(context, it) }) } - val isAssignmentEnable = !block.isCompleted() && block.assignmentProgress != null && !due.isNullOrEmpty() + val isAssignmentEnable = + !block.isCompleted() && block.assignmentProgress != null && !due.isNullOrEmpty() Column( modifier = modifier .fillMaxWidth() diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt index 1a406181d..f7c0f85b6 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -60,8 +60,10 @@ import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.domain.model.Progress import org.openedx.core.domain.model.VideoSettings import org.openedx.core.extension.toFileSize @@ -741,6 +743,21 @@ private val mockSequentialBlock = Block( due = Date() ) +private val mockEnrollmentDetails = + EnrollmentDetails(created = Date(), mode = "audit", isActive = false, upgradeDeadline = Date()) + +private val mockCourseAccessDetails = CourseAccessDetails( + Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ) +) + private val mockCourseStructure = CourseStructure( root = "", blockData = listOf(mockSequentialBlock, mockChapterBlock), @@ -752,16 +769,11 @@ private val mockCourseStructure = CourseStructure( startDisplay = "", startType = "", end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), media = null, + courseAccessDetails = mockCourseAccessDetails, certificate = null, isSelfPaced = false, - progress = Progress(1, 3) + progress = Progress(1, 3), + productInfo = null, + enrollmentDetails = mockEnrollmentDetails ) diff --git a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index f049e3751..158fb5440 100644 --- a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt @@ -30,14 +30,18 @@ import org.openedx.core.data.model.CourseStructureModel import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrollmentDetails +import org.openedx.core.presentation.global.AppData import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.IAPNotifier import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -59,7 +63,8 @@ class CourseContainerViewModelTest { private val interactor = mockk() private val calendarManager = mockk() private val networkConnection = mockk() - private val notifier = spyk() + private val courseNotifier = spyk() + private val iapNotifier = spyk() private val analytics = mockk() private val corePreferences = mockk() private val coursePreferences = mockk() @@ -67,6 +72,7 @@ class CourseContainerViewModelTest { private val imageProcessor = mockk() private val courseRouter = mockk() private val courseApi = mockk() + private val appData = mockk() private val openEdx = "OpenEdx" private val calendarTitle = "OpenEdx - Abc" @@ -98,18 +104,27 @@ class CourseContainerViewModelTest { startDisplay = "", startType = "", end = null, - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), media = null, + courseAccessDetails = CourseAccessDetails( + Date(), coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ) + ), certificate = null, isSelfPaced = false, - progress = null + progress = null, + enrollmentDetails = EnrollmentDetails( + created = Date(), + mode = "audit", + isActive = false, + upgradeDeadline = Date() + ), + productInfo = null ) private val courseStructureModel = CourseStructureModel( @@ -123,11 +138,16 @@ class CourseContainerViewModelTest { startDisplay = "", startType = "", end = null, - coursewareAccess = null, + courseAccessDetails = org.openedx.core.data.model.CourseAccessDetails( + "", + coursewareAccess = null + ), media = null, certificate = null, isSelfPaced = false, - progress = null + progress = null, + enrollmentDetails = org.openedx.core.data.model.EnrollmentDetails("", "", false, ""), + courseModes = arrayListOf() ) @Before @@ -138,7 +158,7 @@ class CourseContainerViewModelTest { every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { corePreferences.user } returns user every { corePreferences.appConfig } returns appConfig - every { notifier.notifier } returns emptyFlow() + every { courseNotifier.notifier } returns emptyFlow() every { calendarManager.getCourseCalendarTitle(any()) } returns calendarTitle every { config.getApiHostURL() } returns "baseUrl" every { imageProcessor.loadImage(any(), any(), any()) } returns Unit @@ -157,11 +177,13 @@ class CourseContainerViewModelTest { "", "", "", + appData, config, interactor, calendarManager, resourceManager, - notifier, + courseNotifier, + iapNotifier, networkConnection, corePreferences, coursePreferences, @@ -191,11 +213,13 @@ class CourseContainerViewModelTest { "", "", "", + appData, config, interactor, calendarManager, resourceManager, - notifier, + courseNotifier, + iapNotifier, networkConnection, corePreferences, coursePreferences, @@ -225,11 +249,13 @@ class CourseContainerViewModelTest { "", "", "", + appData, config, interactor, calendarManager, resourceManager, - notifier, + courseNotifier, + iapNotifier, networkConnection, corePreferences, coursePreferences, @@ -258,11 +284,13 @@ class CourseContainerViewModelTest { "", "", "", + appData, config, interactor, calendarManager, resourceManager, - notifier, + courseNotifier, + iapNotifier, networkConnection, corePreferences, coursePreferences, @@ -294,11 +322,13 @@ class CourseContainerViewModelTest { "", "", "", + appData, config, interactor, calendarManager, resourceManager, - notifier, + courseNotifier, + iapNotifier, networkConnection, corePreferences, coursePreferences, @@ -307,7 +337,7 @@ class CourseContainerViewModelTest { courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } throws UnknownHostException() - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() @@ -325,11 +355,13 @@ class CourseContainerViewModelTest { "", "", "", + appData, config, interactor, calendarManager, resourceManager, - notifier, + courseNotifier, + iapNotifier, networkConnection, corePreferences, coursePreferences, @@ -338,7 +370,7 @@ class CourseContainerViewModelTest { courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } throws Exception() - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() @@ -356,11 +388,13 @@ class CourseContainerViewModelTest { "", "", "", + appData, config, interactor, calendarManager, resourceManager, - notifier, + courseNotifier, + iapNotifier, networkConnection, corePreferences, coursePreferences, @@ -369,7 +403,7 @@ class CourseContainerViewModelTest { courseRouter ) coEvery { interactor.getCourseStructure(any(), true) } returns courseStructure - coEvery { notifier.send(CourseStructureUpdated("")) } returns Unit + coEvery { courseNotifier.send(CourseStructureUpdated("")) } returns Unit viewModel.updateData() advanceUntilIdle() diff --git a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt index 11ffb4932..38ae28d8d 100644 --- a/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/dates/CourseDatesViewModelTest.kt @@ -30,6 +30,7 @@ import org.openedx.core.data.model.DateType import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo import org.openedx.core.domain.model.CourseDatesCalendarSync @@ -37,6 +38,7 @@ import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.DatesSection +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.system.CalendarManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CalendarSyncEvent.CreateCalendarSyncEvent @@ -125,18 +127,25 @@ class CourseDatesViewModelTest { startDisplay = "", startType = "", end = null, - coursewareAccess = CoursewareAccess( + media = null, + courseAccessDetails = CourseAccessDetails(Date(), CoursewareAccess( true, "", "", "", "", "" - ), - media = null, + )), certificate = null, isSelfPaced = true, - progress = null + progress = null, + enrollmentDetails = EnrollmentDetails( + created = Date(), + mode = "audit", + isActive = false, + upgradeDeadline = Date() + ), + productInfo = null ) @Before diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index aad650b28..4fe06e1a7 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -36,6 +36,7 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDateBlock import org.openedx.core.domain.model.CourseDatesBannerInfo @@ -43,6 +44,7 @@ import org.openedx.core.domain.model.CourseDatesResult import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.DatesSection +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel @@ -159,18 +161,22 @@ class CourseOutlineViewModelTest { startDisplay = "", startType = "", end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), media = null, + courseAccessDetails = CourseAccessDetails( + Date(), CoursewareAccess( + true, + "", + "", + "", + "", + "" + ) + ), + enrollmentDetails = EnrollmentDetails(Date(), "audit", false, Date()), certificate = null, isSelfPaced = false, - progress = null + progress = null, + productInfo = null ) private val dateBlock = CourseDateBlock( @@ -604,4 +610,4 @@ class CourseOutlineViewModelTest { assert(message.await()?.message.isNullOrEmpty()) } -} +} \ No newline at end of file diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 01c685c48..1b0d01fd0 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -30,8 +30,10 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel @@ -144,18 +146,27 @@ class CourseSectionViewModelTest { startDisplay = "", startType = "", end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), media = null, + courseAccessDetails = CourseAccessDetails( + Date(), CoursewareAccess( + true, + "", + "", + "", + "", + "" + ) + ), certificate = null, isSelfPaced = false, - progress = null + progress = null, + enrollmentDetails = EnrollmentDetails( + created = Date(), + mode = "audit", + isActive = false, + upgradeDeadline = Date() + ), + productInfo = null ) private val downloadModel = DownloadModel( diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index 166d7751e..577c3d41d 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -23,8 +23,10 @@ import org.openedx.core.config.Config import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor @@ -138,18 +140,27 @@ class CourseUnitContainerViewModelTest { startDisplay = "", startType = "", end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), media = null, + courseAccessDetails = CourseAccessDetails( + Date(), CoursewareAccess( + true, + "", + "", + "", + "", + "" + ) + ), certificate = null, isSelfPaced = false, - progress = null + progress = null, + enrollmentDetails = EnrollmentDetails( + created = Date(), + mode = "audit", + isActive = false, + upgradeDeadline = Date() + ), + productInfo = null ) @Before diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index 9bb8d0f5f..26417a9d1 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -35,8 +35,10 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.domain.model.VideoSettings import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao @@ -153,18 +155,22 @@ class CourseVideoViewModelTest { startDisplay = "", startType = "", end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), media = null, + courseAccessDetails = CourseAccessDetails( + Date(), CoursewareAccess( + true, + "", + "", + "", + "", + "" + ) + ), + enrollmentDetails = EnrollmentDetails(Date(), "audit", false, Date()), certificate = null, isSelfPaced = false, - progress = null + progress = null, + productInfo = null ) private val downloadModelEntity = @@ -350,84 +356,86 @@ class CourseVideoViewModelTest { } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - courseRouter, - coreAnalytics, - downloadDao, - workerController - ) - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.readAllData() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } - every { coreAnalytics.logEvent(any(), any()) } returns Unit - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, with connection`() = + runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default + val viewModel = CourseVideoViewModel( + "", + "", + config, + interactor, + resourceManager, + networkConnection, + preferencesManager, + courseNotifier, + videoNotifier, + analytics, + courseRouter, + coreAnalytics, + downloadDao, + workerController + ) + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns true + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } + every { coreAnalytics.logEvent(any(), any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "") - advanceUntilIdle() + viewModel.saveDownloadModels("", "") + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - courseRouter, - coreAnalytics, - downloadDao, - workerController - ) - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } - coEvery { workerController.saveModels(any()) } returns Unit - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, without connection`() = + runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default + val viewModel = CourseVideoViewModel( + "", + "", + config, + interactor, + resourceManager, + networkConnection, + preferencesManager, + courseNotifier, + videoNotifier, + analytics, + courseRouter, + coreAnalytics, + downloadDao, + workerController + ) + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns false + every { networkConnection.isOnline() } returns false + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { workerController.saveModels(any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "") - advanceUntilIdle() + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } -} +} \ No newline at end of file diff --git a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt index f3b6a5aee..3a1395f5c 100644 --- a/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt +++ b/dashboard/src/androidTest/java/org/openedx/dashboard/presentation/MyCoursesScreenTest.kt @@ -17,6 +17,7 @@ import org.openedx.core.domain.model.CourseSharingUtmParameters import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData +import org.openedx.core.presentation.iap.IAPUIState import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import java.util.Date @@ -42,7 +43,7 @@ class MyCoursesScreenTest { startDisplay = "", startType = "", end = null, - dynamicUpgradeDeadline = "", + upgradeDeadline = "", subscriptionId = "", coursewareAccess = CoursewareAccess( true, @@ -61,7 +62,8 @@ class MyCoursesScreenTest { discussionUrl = "", videoOutline = "", isSelfPaced = false - ) + ), + productInfo = null ) //endregion @@ -71,17 +73,23 @@ class MyCoursesScreenTest { DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", - state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), + state = DashboardUIState.Courses( + listOf(mockCourseEnrolled, mockCourseEnrolled), + false + ), uiMessage = null, - refreshing = false, canLoadMore = false, + refreshing = false, hasInternetConnection = true, onReloadClick = {}, onSwipeRefresh = {}, paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} + onSettingsClick = {}, + iapCallback = { _, _ -> }, + onGetHelp = {}, + iapState = IAPUIState.Clear, ) } @@ -104,17 +112,23 @@ class MyCoursesScreenTest { DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", - state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), + state = DashboardUIState.Courses( + listOf(mockCourseEnrolled, mockCourseEnrolled), + false + ), uiMessage = null, - refreshing = false, canLoadMore = false, + refreshing = false, hasInternetConnection = true, onReloadClick = {}, onSwipeRefresh = {}, paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} + onSettingsClick = {}, + iapCallback = { _, _ -> }, + onGetHelp = {}, + iapState = IAPUIState.Clear, ) } @@ -130,17 +144,23 @@ class MyCoursesScreenTest { DashboardListView( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", - state = DashboardUIState.Courses(listOf(mockCourseEnrolled, mockCourseEnrolled)), + state = DashboardUIState.Courses( + listOf(mockCourseEnrolled, mockCourseEnrolled), + false + ), uiMessage = null, - refreshing = true, canLoadMore = false, + refreshing = true, hasInternetConnection = true, onReloadClick = {}, onSwipeRefresh = {}, paginationCallback = {}, onItemClick = {}, appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), - onSettingsClick = {} + onSettingsClick = {}, + iapCallback = { _, _ -> }, + onGetHelp = {}, + iapState = IAPUIState.Clear, ) } @@ -162,5 +182,4 @@ class MyCoursesScreenTest { ) } } - } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 3392ed7bd..41e49e7a4 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -152,7 +152,11 @@ fun AllEnrolledCoursesView( ) } -@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@OptIn( + ExperimentalMaterialApi::class, + ExperimentalComposeUiApi::class, + ExperimentalFoundationApi::class +) @Composable private fun AllEnrolledCoursesView( apiHostUrl: String, @@ -271,7 +275,9 @@ private fun AllEnrolledCoursesView( Header( modifier = Modifier .padding( - start = contentPaddings.calculateStartPadding(layoutDirection), + start = contentPaddings.calculateStartPadding( + layoutDirection + ), end = contentPaddings.calculateEndPadding(layoutDirection) ), onSearchClick = { @@ -324,7 +330,11 @@ private fun AllEnrolledCoursesView( course = course, apiHostUrl = apiHostUrl, onClick = { - onAction(AllEnrolledCoursesAction.OpenCourse(it)) + onAction( + AllEnrolledCoursesAction.OpenCourse( + it + ) + ) } ) } @@ -616,7 +626,7 @@ private val mockCourseEnrolled = EnrolledCourse( startDisplay = "", startType = "", end = Date(), - dynamicUpgradeDeadline = "", + upgradeDeadline = "", subscriptionId = "", coursewareAccess = CoursewareAccess( false, @@ -635,5 +645,6 @@ private val mockCourseEnrolled = EnrolledCourse( discussionUrl = "", videoOutline = "", isSelfPaced = false - ) + ), + productInfo = null ) 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 7401f6304..2dbdeae55 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -223,7 +223,12 @@ private fun DashboardGalleryView( onAction(DashboardGalleryScreenAction.NavigateToDates(it)) }, resumeBlockId = { course, blockId -> - onAction(DashboardGalleryScreenAction.OpenBlock(course, blockId)) + onAction( + DashboardGalleryScreenAction.OpenBlock( + course, + blockId + ) + ) } ) } @@ -606,7 +611,10 @@ private fun PrimaryCourseCard( if (primaryCourse.courseStatus == null) { openCourse(primaryCourse) } else { - resumeBlockId(primaryCourse, primaryCourse.courseStatus?.lastVisitedBlockId ?: "") + resumeBlockId( + primaryCourse, + primaryCourse.courseStatus?.lastVisitedBlockId ?: "" + ) } } ) @@ -803,7 +811,7 @@ private val mockCourse = EnrolledCourse( startDisplay = "", startType = "", end = Date(), - dynamicUpgradeDeadline = "", + upgradeDeadline = "", subscriptionId = "", coursewareAccess = CoursewareAccess( true, @@ -822,8 +830,10 @@ private val mockCourse = EnrolledCourse( discussionUrl = "", videoOutline = "", isSelfPaced = false - ) + ), + productInfo = null ) + private val mockPagination = Pagination(10, "", 4, "1") private val mockDashboardCourseList = DashboardCourseList( pagination = mockPagination, 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 2d8e81d6b..e3bfe2dc3 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -40,6 +40,8 @@ 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.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf @@ -81,10 +83,20 @@ import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse 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 import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.PurchasesFulfillmentCompletedDialog +import org.openedx.core.ui.UpgradeErrorDialog +import org.openedx.core.ui.UpgradeToAccessView import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -124,12 +136,14 @@ class DashboardListFragment : Fragment() { val refreshing by viewModel.updating.observeAsState(false) val canLoadMore by viewModel.canLoadMore.observeAsState(false) val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() + val iapUiState by viewModel.iapUiState.collectAsState(null) DashboardListView( windowSize = windowSize, viewModel.apiHostUrl, - uiState!!, - uiMessage, + state = uiState!!, + uiMessage = uiMessage, + iapUiState = iapUiState, canLoadMore = canLoadMore, refreshing = refreshing, hasInternetConnection = viewModel.hasInternetConnection, @@ -157,6 +171,57 @@ class DashboardListFragment : Fragment() { AppUpdateState.openPlayMarket(requireContext()) }, ), + 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 -> { + + } + } + } ) } } @@ -170,6 +235,7 @@ internal fun DashboardListView( apiHostUrl: String, state: DashboardUIState, uiMessage: UIMessage?, + iapUiState: IAPUIState?, canLoadMore: Boolean, refreshing: Boolean, hasInternetConnection: Boolean, @@ -177,6 +243,7 @@ internal fun DashboardListView( onSwipeRefresh: () -> Unit, paginationCallback: () -> Unit, onItemClick: (EnrolledCourse) -> Unit, + onIAPAction: (IAPAction, EnrolledCourse?, IAPException?) -> Unit, appUpgradeParameters: AppUpdateState.AppUpgradeParameters, ) { val scaffoldState = rememberScaffoldState() @@ -280,6 +347,19 @@ internal fun DashboardListView( course, windowSize, onClick = { onItemClick(it) }) + if (course.isUpgradeable && state.isValuePropEnabled) { + UpgradeToAccessView( + modifier = Modifier.padding( + bottom = 16.dp + ) + ) { + onIAPAction( + IAPAction.ACTION_USER_INITIATED, + course, + null + ) + } + } Divider() } item { @@ -299,6 +379,12 @@ internal fun DashboardListView( paginationCallback() } } + + LaunchedEffect(state.courses) { + if (state.courses.isNotEmpty()) { + onIAPAction(IAPAction.ACTION_UNFULFILLED, null, null) + } + } } is DashboardUIState.Empty -> { @@ -352,6 +438,41 @@ internal fun DashboardListView( ) } } + + 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 -> {} + } } } } @@ -524,23 +645,25 @@ private fun DashboardListViewPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( - listOf( + courses = listOf( mockCourseEnrolled, mockCourseEnrolled, mockCourseEnrolled, mockCourseEnrolled, mockCourseEnrolled, mockCourseEnrolled - ) + ), isValuePropEnabled = false ), uiMessage = null, - onSwipeRefresh = {}, - onItemClick = {}, - onReloadClick = {}, - hasInternetConnection = true, - refreshing = false, + iapUiState = null, canLoadMore = false, + refreshing = false, + hasInternetConnection = true, + onReloadClick = {}, + onSwipeRefresh = {}, paginationCallback = {}, + onItemClick = {}, + onIAPAction = { _, _, _ -> }, appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } @@ -555,23 +678,25 @@ private fun DashboardListViewTabletPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), apiHostUrl = "http://localhost:8000", state = DashboardUIState.Courses( - listOf( + courses = listOf( mockCourseEnrolled, mockCourseEnrolled, mockCourseEnrolled, mockCourseEnrolled, mockCourseEnrolled, mockCourseEnrolled - ) + ), isValuePropEnabled = false ), uiMessage = null, - onSwipeRefresh = {}, - onItemClick = {}, - onReloadClick = {}, - hasInternetConnection = true, - refreshing = false, + iapUiState = null, canLoadMore = false, + refreshing = false, + hasInternetConnection = true, + onReloadClick = {}, + onSwipeRefresh = {}, paginationCallback = {}, + onItemClick = {}, + onIAPAction = { _, _, _ -> }, appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } @@ -596,7 +721,7 @@ private val mockCourseEnrolled = EnrolledCourse( startDisplay = "", startType = "", end = Date(), - dynamicUpgradeDeadline = "", + upgradeDeadline = "", subscriptionId = "", coursewareAccess = CoursewareAccess( true, @@ -615,5 +740,6 @@ private val mockCourseEnrolled = EnrolledCourse( discussionUrl = "", videoOutline = "", isSelfPaced = false - ) + ), + productInfo = ProductInfo(courseSku = "example_sku", storeSku = "mobile.android.example_100") ) 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 bfafc81c4..a993a7291 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -1,33 +1,63 @@ package org.openedx.dashboard.presentation +import android.content.Context import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +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.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +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.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.app.AppUpgradeEvent import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.CourseDataUpdated import org.openedx.core.system.notifier.DiscoveryNotifier +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 class DashboardListViewModel( + private val appData: AppData, private val config: Config, private val networkConnection: NetworkConnection, private val interactor: DashboardInteractor, private val resourceManager: ResourceManager, private val discoveryNotifier: DiscoveryNotifier, + private val iapNotifier: IAPNotifier, private val analytics: DashboardAnalytics, - private val appNotifier: AppNotifier + private val appNotifier: AppNotifier, + private val preferencesManager: CorePreferences, + private val iapAnalytics: IAPAnalytics, + private val iapInteractor: IAPInteractor ) : BaseViewModel() { private val coursesList = mutableListOf() @@ -44,6 +74,14 @@ class DashboardListViewModel( val uiMessage: LiveData get() = _uiMessage + private val _iapUiState = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val iapUiState: SharedFlow + get() = _iapUiState.asSharedFlow() + private val _updating = MutableLiveData() val updating: LiveData get() = _updating @@ -59,6 +97,12 @@ 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 { @@ -68,6 +112,14 @@ class DashboardListViewModel( } } } + + iapNotifier.notifier.onEach { event -> + when (event) { + is UpdateCourseData -> { + updateCourses(true) + } + } + }.distinctUntilChanged().launchIn(viewModelScope) } init { @@ -81,7 +133,7 @@ class DashboardListViewModel( internalLoadingCourses() } - fun updateCourses() { + fun updateCourses(isIAPFlow: Boolean = false) { if (isLoading) { return } @@ -103,7 +155,13 @@ class DashboardListViewModel( if (coursesList.isEmpty()) { _uiState.value = DashboardUIState.Empty } else { - _uiState.value = DashboardUIState.Courses(ArrayList(coursesList)) + _uiState.value = DashboardUIState.Courses( + courses = ArrayList(coursesList), + isValuePropEnabled = preferencesManager.appConfig.isValuePropEnabled + ) + } + if (isIAPFlow) { + iapNotifier.send(CourseDataUpdated()) } } catch (e: Exception) { if (e.isInternetError()) { @@ -146,7 +204,10 @@ class DashboardListViewModel( if (coursesList.isEmpty()) { _uiState.value = DashboardUIState.Empty } else { - _uiState.value = DashboardUIState.Courses(ArrayList(coursesList)) + _uiState.value = DashboardUIState.Courses( + courses = ArrayList(coursesList), + isValuePropEnabled = preferencesManager.appConfig.isValuePropEnabled + ) } } catch (e: Exception) { if (e.isInternetError()) { @@ -182,4 +243,75 @@ 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 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()) + } + + 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) + }) + } + + fun clearIAPState() { + viewModelScope.launch { + _iapUiState.emit(null) + } + } } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardUIState.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardUIState.kt index 9f35594db..aa3ba1a31 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardUIState.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardUIState.kt @@ -3,7 +3,9 @@ package org.openedx.dashboard.presentation import org.openedx.core.domain.model.EnrolledCourse sealed class DashboardUIState { - data class Courses(val courses: List) : DashboardUIState() + data class Courses(val courses: List, val isValuePropEnabled: Boolean) : + DashboardUIState() + object Empty : DashboardUIState() object Loading : DashboardUIState() } 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 2a1131392..e51468605 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardViewModelTest.kt @@ -27,12 +27,21 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.IAPInteractor +import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.CourseDatesCalendarSync 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 +import org.openedx.core.system.notifier.CourseDataUpdated import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.IAPNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor import java.net.UnknownHostException @@ -48,10 +57,15 @@ class DashboardViewModelTest { private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() + private val iapInteractor = mockk() private val networkConnection = mockk() private val discoveryNotifier = mockk() + private val iapNotifier = mockk() private val analytics = mockk() private val appNotifier = mockk() + private val iapAnalytics = mockk() + private val corePreferences = mockk() + private val appData = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -61,6 +75,16 @@ class DashboardViewModelTest { listOf(mockk()) ) + private val appConfig = AppConfig( + courseDatesCalendarSync = CourseDatesCalendarSync( + isEnabled = true, + isSelfPacedEnabled = true, + isInstructorPacedEnabled = true, + isDeepLinkEnabled = false, + ), + iapConfig = IAPConfig(false, "prefix", listOf()) + ) + @Before fun setUp() { Dispatchers.setMain(dispatcher) @@ -77,16 +101,23 @@ class DashboardViewModelTest { @Test fun `getCourses no internet connection`() = runTest { + every { networkConnection.isOnline() } returns true + every { corePreferences.appConfig } returns appConfig + val viewModel = DashboardListViewModel( + appData, config, networkConnection, interactor, resourceManager, discoveryNotifier, + iapNotifier, analytics, - appNotifier + appNotifier, + corePreferences, + iapAnalytics, + iapInteractor ) - every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() advanceUntilIdle() @@ -101,16 +132,24 @@ class DashboardViewModelTest { @Test fun `getCourses unknown error`() = runTest { + every { networkConnection.isOnline() } returns true + every { corePreferences.appConfig } returns appConfig + val viewModel = DashboardListViewModel( + appData, config, networkConnection, interactor, resourceManager, discoveryNotifier, + iapNotifier, analytics, - appNotifier + appNotifier, + corePreferences, + iapAnalytics, + iapInteractor ) - every { networkConnection.isOnline() } returns true + coEvery { interactor.getEnrolledCourses(any()) } throws Exception() advanceUntilIdle() @@ -125,16 +164,24 @@ class DashboardViewModelTest { @Test fun `getCourses from network`() = runTest { + every { networkConnection.isOnline() } returns true + every { corePreferences.appConfig } returns appConfig + val viewModel = DashboardListViewModel( + appData, config, networkConnection, interactor, resourceManager, discoveryNotifier, + iapNotifier, analytics, - appNotifier + appNotifier, + corePreferences, + iapAnalytics, + iapInteractor ) - every { networkConnection.isOnline() } returns true + coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) advanceUntilIdle() @@ -149,16 +196,24 @@ class DashboardViewModelTest { @Test fun `getCourses from network with next page`() = runTest { + every { networkConnection.isOnline() } returns true + every { corePreferences.appConfig } returns appConfig + val viewModel = DashboardListViewModel( + appData, config, networkConnection, interactor, resourceManager, discoveryNotifier, + iapNotifier, analytics, - appNotifier + appNotifier, + corePreferences, + iapAnalytics, + iapInteractor ) - every { networkConnection.isOnline() } returns true + coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( Pagination( 10, @@ -182,15 +237,23 @@ class DashboardViewModelTest { @Test fun `getCourses from cache`() = runTest { every { networkConnection.isOnline() } returns false + every { corePreferences.appConfig.isValuePropEnabled } returns false coEvery { interactor.getEnrolledCoursesFromCache() } returns listOf(mockk()) + every { corePreferences.appConfig.iapConfig } returns appConfig.iapConfig + val viewModel = DashboardListViewModel( + appData, config, networkConnection, interactor, resourceManager, discoveryNotifier, + iapNotifier, analytics, - appNotifier + appNotifier, + corePreferences, + iapAnalytics, + iapInteractor ) advanceUntilIdle() @@ -206,15 +269,21 @@ class DashboardViewModelTest { @Test fun `updateCourses no internet error`() = runTest { every { networkConnection.isOnline() } returns true + every { corePreferences.appConfig } returns appConfig coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList val viewModel = DashboardListViewModel( + appData, config, networkConnection, interactor, resourceManager, discoveryNotifier, + iapNotifier, analytics, - appNotifier + appNotifier, + corePreferences, + iapAnalytics, + iapInteractor ) coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -234,15 +303,21 @@ class DashboardViewModelTest { @Test fun `updateCourses unknown exception`() = runTest { every { networkConnection.isOnline() } returns true + every { corePreferences.appConfig } returns appConfig coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList val viewModel = DashboardListViewModel( + appData, config, networkConnection, interactor, resourceManager, discoveryNotifier, + iapNotifier, analytics, - appNotifier + appNotifier, + corePreferences, + iapAnalytics, + iapInteractor ) coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -262,15 +337,25 @@ class DashboardViewModelTest { @Test fun `updateCourses success`() = runTest { every { networkConnection.isOnline() } returns true + every { corePreferences.appConfig.isValuePropEnabled } returns false + every { corePreferences.appConfig.iapConfig } returns appConfig.iapConfig coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList + coEvery { iapNotifier.notifier } returns flow { emit(CourseDataUpdated()) } + coEvery { iapNotifier.send(any()) } returns Unit + val viewModel = DashboardListViewModel( + appData, config, networkConnection, interactor, resourceManager, discoveryNotifier, + iapNotifier, analytics, - appNotifier + appNotifier, + corePreferences, + iapAnalytics, + iapInteractor ) viewModel.updateCourses() @@ -279,6 +364,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } verify(exactly = 1) { appNotifier.notifier } + verify(exactly = 0) { iapNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) @@ -288,6 +374,8 @@ class DashboardViewModelTest { @Test fun `updateCourses success with next page`() = runTest { every { networkConnection.isOnline() } returns true + every { corePreferences.appConfig.iapConfig } returns appConfig.iapConfig + every { corePreferences.appConfig.isValuePropEnabled } returns false coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( Pagination( 10, @@ -296,14 +384,22 @@ class DashboardViewModelTest { "" ) ) + coEvery { iapNotifier.notifier } returns flow { emit(CourseDataUpdated()) } + coEvery { iapNotifier.send(any()) } returns Unit + val viewModel = DashboardListViewModel( + appData, config, networkConnection, interactor, resourceManager, discoveryNotifier, + iapNotifier, analytics, - appNotifier + appNotifier, + corePreferences, + iapAnalytics, + iapInteractor ) viewModel.updateCourses() @@ -312,6 +408,7 @@ class DashboardViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } verify(exactly = 1) { appNotifier.notifier } + verify(exactly = 0) { iapNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) @@ -321,14 +418,21 @@ class DashboardViewModelTest { @Test fun `CourseDashboardUpdate notifier test`() = runTest { coEvery { discoveryNotifier.notifier } returns flow { emit(CourseDashboardUpdate()) } + coEvery { iapNotifier.notifier } returns flow { emit(CourseDataUpdated()) } + every { corePreferences.appConfig } returns appConfig val viewModel = DashboardListViewModel( + appData, config, networkConnection, interactor, resourceManager, discoveryNotifier, + iapNotifier, analytics, - appNotifier + appNotifier, + corePreferences, + iapAnalytics, + iapInteractor ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -340,6 +444,6 @@ class DashboardViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } verify(exactly = 1) { appNotifier.notifier } + verify(exactly = 1) { iapNotifier.notifier } } - } diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index a97d7c351..56b8f2fe0 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -88,3 +88,5 @@ SOCIAL_AUTH_ENABLED: false UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false + +ECOMMERCE_URL: 'http://localhost:8000' diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index a97d7c351..56b8f2fe0 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -88,3 +88,5 @@ SOCIAL_AUTH_ENABLED: false UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false + +ECOMMERCE_URL: 'http://localhost:8000' diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index a97d7c351..56b8f2fe0 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -88,3 +88,5 @@ SOCIAL_AUTH_ENABLED: false UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false + +ECOMMERCE_URL: 'http://localhost:8000' diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt index 9fc56f6af..cbd6d1386 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/topics/DiscussionTopicsViewModelTest.kt @@ -29,8 +29,10 @@ import org.openedx.core.UIMessage import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier @@ -130,18 +132,27 @@ class DiscussionTopicsViewModelTest { startDisplay = "", startType = "", end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), media = null, + courseAccessDetails = CourseAccessDetails( + Date(), coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ) + ), certificate = null, isSelfPaced = false, - progress = null + progress = null, + enrollmentDetails = EnrollmentDetails( + created = Date(), + mode = "audit", + isActive = false, + upgradeDeadline = Date() + ), + productInfo = null ) @Before @@ -160,7 +171,15 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel( + "id", + "", + interactor, + resourceManager, + analytics, + courseNotifier, + router + ) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -177,7 +196,15 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel( + "id", + "", + interactor, + resourceManager, + analytics, + courseNotifier, + router + ) coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -194,7 +221,15 @@ class DiscussionTopicsViewModelTest { @Test fun `getCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel( + "id", + "", + interactor, + resourceManager, + analytics, + courseNotifier, + router + ) coEvery { interactor.getCourseTopics(any()) } returns mockk() advanceUntilIdle() @@ -211,7 +246,15 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics no internet exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel( + "id", + "", + interactor, + resourceManager, + analytics, + courseNotifier, + router + ) coEvery { interactor.getCourseTopics(any()) } throws UnknownHostException() val message = async { @@ -228,7 +271,15 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics unknown exception`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel( + "id", + "", + interactor, + resourceManager, + analytics, + courseNotifier, + router + ) coEvery { interactor.getCourseTopics(any()) } throws Exception() val message = async { @@ -245,7 +296,15 @@ class DiscussionTopicsViewModelTest { @Test fun `updateCourseTopics success`() = runTest(UnconfinedTestDispatcher()) { - val viewModel = DiscussionTopicsViewModel("id", "", interactor, resourceManager, analytics, courseNotifier, router) + val viewModel = DiscussionTopicsViewModel( + "id", + "", + interactor, + resourceManager, + analytics, + courseNotifier, + router + ) coEvery { interactor.getCourseTopics(any()) } returns mockk() val message = async { @@ -260,5 +319,4 @@ class DiscussionTopicsViewModelTest { assert(message.await()?.message.isNullOrEmpty()) assert(viewModel.uiState.value is DiscussionTopicsUIState.Topics) } - } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt index 7ac402330..7a3f3f7eb 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt @@ -10,6 +10,12 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.presentation.IAPAnalyticsEvent +import org.openedx.core.presentation.IAPAnalyticsKeys +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.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme @@ -27,12 +33,14 @@ class SettingsFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.collectAsState() + val iapUiState by viewModel.iapUiState.collectAsState() val logoutSuccess by viewModel.successLogout.collectAsState(false) val appUpgradeEvent by viewModel.appUpgradeEvent.collectAsState(null) SettingsScreen( windowSize = windowSize, uiState = uiState, + iapUiState = iapUiState, appUpgradeEvent = appUpgradeEvent, onBackClick = { requireActivity().supportFragmentManager.popBackStack() @@ -94,6 +102,48 @@ class SettingsFragment : Fragment() { requireActivity().supportFragmentManager ) } + + SettingsScreenAction.RestorePurchaseClick -> { + viewModel.restorePurchase() + } + } + }, + onIAPAction = { action, iapException -> + when (action) { + IAPAction.ACTION_ERROR_CLOSE -> { + viewModel.logIAPCancelEvent() + } + + IAPAction.ACTION_GET_HELP -> { + viewModel.clearIAPState() + val errorMessage = iapException?.getFormattedErrorMessage() ?: "" + viewModel.showFeedbackScreen(requireActivity(), errorMessage) + } + + IAPAction.ACTION_RESTORE -> { + IAPDialogFragment.newInstance( + IAPFlow.RESTORE, + IAPAnalyticsScreen.PROFILE.screenName + ).show( + requireActivity().supportFragmentManager, + IAPDialogFragment.TAG + ) + } + + IAPAction.ACTION_RESTORE_PURCHASE_CANCEL -> { + viewModel.logIAPEvent( + IAPAnalyticsEvent.IAP_ERROR_ALERT_ACTION, + buildMap { + put( + IAPAnalyticsKeys.ACTION.key, + IAPAction.ACTION_CLOSE.action + ) + }.toMutableMap() + ) + viewModel.clearIAPState() + } + + else -> {} } } ) @@ -120,5 +170,6 @@ internal interface SettingsScreenAction { object VideoSettingsClick : SettingsScreenAction object ManageAccountClick : SettingsScreenAction object CalendarSettingsClick : SettingsScreenAction + object RestorePurchaseClick : SettingsScreenAction } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index 5e044ca46..a2629ccb1 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -52,10 +52,17 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import org.openedx.core.R import org.openedx.core.domain.model.AgreementUrls +import org.openedx.core.exception.iap.IAPException import org.openedx.core.presentation.global.AppData +import org.openedx.core.presentation.iap.IAPAction +import org.openedx.core.presentation.iap.IAPLoaderType +import org.openedx.core.presentation.iap.IAPUIState import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.ui.CheckingPurchasesDialog +import org.openedx.core.ui.FakePurchasesFulfillmentCompleted import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.Toolbar +import org.openedx.core.ui.UpgradeErrorDialog import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType import org.openedx.core.ui.displayCutoutForLandscape @@ -75,9 +82,11 @@ import org.openedx.profile.R as profileR internal fun SettingsScreen( windowSize: WindowSize, uiState: SettingsUIState, + iapUiState: IAPUIState?, appUpgradeEvent: AppUpgradeEvent?, onBackClick: () -> Unit, onAction: (SettingsScreenAction) -> Unit, + onIAPAction: (IAPAction, IAPException?) -> Unit, ) { var showLogoutDialog by rememberSaveable { mutableStateOf(false) } @@ -186,6 +195,12 @@ internal fun SettingsScreen( Spacer(modifier = Modifier.height(24.dp)) + PurchaseSection(onRestorePurchaseClick = { + onAction(SettingsScreenAction.RestorePurchaseClick) + }) + + Spacer(modifier = Modifier.height(24.dp)) + SupportInfoSection( uiState = uiState, onAction = onAction, @@ -206,6 +221,44 @@ internal fun SettingsScreen( } } } + + when (iapUiState) { + is IAPUIState.FakePurchasesFulfillmentCompleted -> { + FakePurchasesFulfillmentCompleted(onCancel = { + onIAPAction(IAPAction.ACTION_RESTORE_PURCHASE_CANCEL, null) + }, onGetHelp = { + onIAPAction(IAPAction.ACTION_GET_HELP, null) + }) + } + + is IAPUIState.Loading -> { + if (iapUiState.loaderType == IAPLoaderType.RESTORE_PURCHASES) { + CheckingPurchasesDialog() + } + } + + is IAPUIState.PurchasesFulfillmentCompleted -> { + onIAPAction(IAPAction.ACTION_RESTORE, null) + } + + is IAPUIState.Error -> { + UpgradeErrorDialog( + title = stringResource(id = R.string.iap_error_title), + description = stringResource(id = R.string.iap_course_not_fullfilled), + confirmText = stringResource(id = R.string.core_cancel), + onConfirm = { onIAPAction(IAPAction.ACTION_ERROR_CLOSE, null) }, + dismissText = stringResource(id = R.string.iap_get_help), + onDismiss = { + onIAPAction( + IAPAction.ACTION_GET_HELP, + iapUiState.iapException + ) + } + ) + } + + else -> {} + } } } @@ -243,6 +296,32 @@ private fun SettingsSection( } } +@Composable +private fun PurchaseSection(onRestorePurchaseClick: () -> Unit) { + Column { + Text( + modifier = Modifier.testTag("txt_purchases"), + text = stringResource(id = profileR.string.profile_purchases), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + modifier = Modifier, + shape = MaterialTheme.appShapes.cardShape, + elevation = 0.dp, + backgroundColor = MaterialTheme.appColors.cardViewBackground + ) { + Column(Modifier.fillMaxWidth()) { + SettingsItem( + text = stringResource(id = profileR.string.profile_restore_purchaes), + onClick = onRestorePurchaseClick + ) + } + } + } +} + @Composable private fun ManageAccountSection(onManageAccountClick: () -> Unit) { Column { @@ -283,7 +362,7 @@ private fun SupportInfoSection( ) { Column(Modifier.fillMaxWidth()) { if (uiState.configuration.supportEmail.isNotBlank()) { - SettingsItem(text = stringResource(id = profileR.string.profile_contact_support)) { + SettingsItem(text = stringResource(id = R.string.core_contact_support)) { onAction(SettingsScreenAction.SupportClick) } SettingsDivider() @@ -687,11 +766,13 @@ private fun LogoutDialogPreview() { private fun SettingsScreenPreview() { OpenEdXTheme { SettingsScreen( - onBackClick = {}, windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = mockUiState, - onAction = {}, + iapUiState = null, appUpgradeEvent = null, + onBackClick = {}, + onAction = {}, + onIAPAction = { _, _ -> }, ) } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 6e622e2cc..305797cfd 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.text.intl.Locale import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -18,9 +19,20 @@ 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.storage.CorePreferences +import org.openedx.core.domain.interactor.IAPInteractor +import org.openedx.core.exception.iap.IAPException import org.openedx.core.extension.isInternetError import org.openedx.core.module.DownloadWorkerController +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.IAPLoaderType +import org.openedx.core.presentation.iap.IAPRequestType +import org.openedx.core.presentation.iap.IAPUIState import org.openedx.core.system.AppCookieManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.app.AppNotifier @@ -40,7 +52,9 @@ class SettingsViewModel( private val appData: AppData, private val config: Config, private val resourceManager: ResourceManager, + private val corePreferences: CorePreferences, private val interactor: ProfileInteractor, + private val iapInteractor: IAPInteractor, private val cookieManager: AppCookieManager, private val workerController: DownloadWorkerController, private val analytics: ProfileAnalytics, @@ -49,9 +63,14 @@ class SettingsViewModel( private val profileNotifier: ProfileNotifier, ) : BaseViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(SettingsUIState.Data(configuration)) + private val _uiState: MutableStateFlow = + MutableStateFlow(SettingsUIState.Data(configuration)) internal val uiState: StateFlow = _uiState.asStateFlow() + private val _iapUiState: MutableStateFlow = MutableStateFlow(null) + val iapUiState: StateFlow + get() = _iapUiState.asStateFlow() + private val _successLogout = MutableSharedFlow() val successLogout: SharedFlow get() = _successLogout.asSharedFlow() @@ -176,6 +195,7 @@ class SettingsViewModel( EmailUtil.showFeedbackScreen( context = context, feedbackEmailAddress = config.getFeedbackEmailAddress(), + subject = context.getString(R.string.core_error_upgrading_course_in_app), appVersion = appData.versionName ) logProfileEvent(ProfileAnalyticsEvent.CONTACT_SUPPORT_CLICKED) @@ -213,4 +233,83 @@ class SettingsViewModel( } ) } + + fun restorePurchase() { + logIAPEvent(IAPAnalyticsEvent.IAP_RESTORE_PURCHASE_CLICKED) + viewModelScope.launch(Dispatchers.IO) { + val userId = corePreferences.user?.id ?: return@launch + + _iapUiState.emit(IAPUIState.Loading(IAPLoaderType.RESTORE_PURCHASES)) + // delay to show loading state + delay(2000) + + runCatching { + iapInteractor.processUnfulfilledPurchase(userId) + }.onSuccess { + if (it) { + logIAPEvent(IAPAnalyticsEvent.IAP_UNFULFILLED_PURCHASE_INITIATED, buildMap { + put( + IAPAnalyticsKeys.SCREEN_NAME.key, + IAPAnalyticsScreen.PROFILE.screenName + ) + put(IAPAnalyticsKeys.IAP_FLOW_TYPE.key, IAPFlow.RESTORE.value) + }.toMutableMap()) + _iapUiState.emit(IAPUIState.PurchasesFulfillmentCompleted) + } else { + _iapUiState.emit(IAPUIState.FakePurchasesFulfillmentCompleted) + } + }.onFailure { + if (it is IAPException) { + _iapUiState.emit( + IAPUIState.Error( + IAPException( + IAPRequestType.RESTORE_CODE, + it.httpErrorCode, + it.errorMessage + ) + ) + ) + } + } + } + } + + fun logIAPCancelEvent() { + logIAPEvent(IAPAnalyticsEvent.IAP_ERROR_ALERT_ACTION, buildMap { + put(IAPAnalyticsKeys.ERROR_ALERT_TYPE.key, IAPAction.ACTION_RESTORE.action) + put(IAPAnalyticsKeys.ERROR_ACTION.key, IAPAction.ACTION_CLOSE.action) + }.toMutableMap()) + } + + 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()) + } + + fun logIAPEvent( + event: IAPAnalyticsEvent, + params: MutableMap = mutableMapOf() + ) { + analytics.logEvent(event.eventName, params.apply { + put(IAPAnalyticsKeys.NAME.key, event.biValue) + put(IAPAnalyticsKeys.SCREEN_NAME.key, IAPAnalyticsScreen.PROFILE.screenName) + put(IAPAnalyticsKeys.IAP_FLOW_TYPE.key, IAPFlow.RESTORE.value) + put(IAPAnalyticsKeys.CATEGORY.key, IAPAnalyticsKeys.IN_APP_PURCHASES.key) + }) + } + + fun clearIAPState() { + viewModelScope.launch { + _iapUiState.emit(null) + } + } } diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 60f0e4060..84cca0855 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -34,7 +34,6 @@ Changes you have made will be discarded. Log Out Are you sure you want to log out? - Contact Support Support Video Dates & Calendar @@ -60,5 +59,7 @@ Accent Course Dates Color + Purchases + Restore Purchases