Skip to content

Commit

Permalink
feat: Payments and upgrade execution for the course (#1)
Browse files Browse the repository at this point in the history
* feat: Payments and upgrade execution for the course

- Learners can purchase a course and unlock the gated content from the main dashboard
- Loading course price on upgrade buttons successfully
- Calling necessary APIs on interaction with the upgrade button
- Processing the course upgrade
- IAP Analytics
fixes: LEARNER-9956

* feat: IAP for course dashboard

* feat: added IAP UI views

* feat: Add success message after upgrade

- Code improvements & fixes

* feat: handle UnfulfilledPurchase

- In this case, the app relaunch must rerun the course upgrade flow with the available course upgrade data.
fixes: LEARNER-9917

* feat: restore purchases from settings screen

fixes: LEARNER-9915

* refactor: Error Alert Action Analytics Handling

- fix IAP Analytics issues
- Code improvements

fixes: LEARNER-10042

* feat: update rocket icon for full screen loader

- Full screen loader whille course is upgarding
- Code improvements

---------

Co-authored-by: k1rill <[email protected]>
  • Loading branch information
farhan-arshad-dev and k1rill authored Jul 10, 2024
1 parent 1d7a091 commit 8b5722f
Show file tree
Hide file tree
Showing 89 changed files with 4,006 additions and 353 deletions.
3 changes: 2 additions & 1 deletion app/src/main/java/org/openedx/app/AnalyticsManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,7 +22,7 @@ class AnalyticsManager(
config: Config,
) : AppAnalytics, AppReviewAnalytics, AuthAnalytics, CoreAnalytics, CourseAnalytics,
DashboardAnalytics, DiscoveryAnalytics, DiscussionAnalytics, ProfileAnalytics,
WhatsNewAnalytics {
WhatsNewAnalytics, IAPAnalytics {

private val services: ArrayList<Analytics> = arrayListOf()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -25,9 +25,11 @@ class HandleErrorInterceptor(
ERROR_INVALID_GRANT -> {
throw EdxError.InvalidGrantException()
}

ERROR_USER_NOT_ACTIVE -> {
throw EdxError.UserNotActiveException()
}

else -> {
return response
}
Expand Down
14 changes: 13 additions & 1 deletion app/src/main/java/org/openedx/app/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -88,6 +92,10 @@ val appModule = module {
single<Gson> {
GsonBuilder()
.registerTypeAdapter(CourseEnrollments::class.java, CourseEnrollments.Deserializer())
.registerTypeAdapter(
CourseStructureModel::class.java,
CourseStructureModel.Deserializer(get())
)
.create()
}

Expand All @@ -98,6 +106,7 @@ val appModule = module {
single { DownloadNotifier() }
single { VideoNotifier() }
single { DiscoveryNotifier() }
single { IAPNotifier() }

single { AppRouter() }
single<AuthRouter> { get<AppRouter>() }
Expand Down Expand Up @@ -165,6 +174,8 @@ val appModule = module {
single { WhatsNewManager(get(), get(), get(), get()) }
single<WhatsNewGlobalManager> { get<WhatsNewManager>() }

single<BillingProcessor> { BillingProcessor(get(), get(named("IODispatcher"))) }

single { AnalyticsManager(get(), get()) }
single<AppAnalytics> { get<AnalyticsManager>() }
single<AuthAnalytics> { get<AnalyticsManager>() }
Expand All @@ -176,6 +187,7 @@ val appModule = module {
single<DiscussionAnalytics> { get<AnalyticsManager>() }
single<ProfileAnalytics> { get<AnalyticsManager>() }
single<WhatsNewAnalytics> { get<AnalyticsManager>() }
single<IAPAnalytics> { get<AnalyticsManager>() }

factory { AgreementProvider(get(), get()) }
factory { FacebookAuthHelper() }
Expand Down
13 changes: 12 additions & 1 deletion app/src/main/java/org/openedx/app/di/NetworkingModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -48,16 +50,25 @@ val networkingModule = module {
.build()
}

single<Retrofit>(named("IAPApiInstance")) {
val config = this.get<Config>()
Retrofit.Builder()
.baseUrl(config.getEcommerceURL())
.client(get())
.addConverterFactory(GsonConverterFactory.create(get()))
.build()
}

single { provideApi<AuthApi>(get()) }
single { provideApi<CookiesApi>(get()) }
single { provideApi<CourseApi>(get()) }
single { provideApi<ProfileApi>(get()) }
single { provideApi<DiscussionApi>(get()) }
single { provideApi<DiscoveryApi>(get()) }
single { provideApi<NotificationsApi>(get()) }
single { provideApi<InAppPurchasesApi>(get(named("IAPApiInstance"))) }
}


inline fun <reified T> provideApi(retrofit: Retrofit): T {
return retrofit.create(T::class.java)
}
43 changes: 42 additions & 1 deletion app/src/main/java/org/openedx/app/di/ScreenModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -190,6 +211,8 @@ val screenModule = module {
get(),
get(),
get(),
get(),
get(),
get()
)
}
Expand Down Expand Up @@ -240,6 +263,8 @@ val screenModule = module {
get(),
get(),
get(),
get(),
get(),
get()
)
}
Expand Down Expand Up @@ -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<String>) ->
DownloadQueueViewModel(
descendants,
Expand Down
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ ext {

webkit_version = "1.11.0"

billing_version = "6.2.1"

configHelper = new ConfigHelper(projectDir, getCurrentFlavor())

//testing
Expand Down
3 changes: 3 additions & 0 deletions core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions core/src/main/java/org/openedx/core/ApiConstants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
5 changes: 5 additions & 0 deletions core/src/main/java/org/openedx/core/config/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AddToBasketResponse>

@FormUrlEncoded
@POST("/api/iap/v1/checkout/")
suspend fun proceedCheckout(
@Field("basket_id") basketId: Long,
@Field("payment_processor") paymentProcessor: String
): Response<CheckoutResponse>

@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<ExecuteOrderResponse>
}
8 changes: 8 additions & 0 deletions core/src/main/java/org/openedx/core/data/model/AppConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading

0 comments on commit 8b5722f

Please sign in to comment.