diff --git a/Documentation/APIs_Compatibility.md b/Documentation/APIs_Compatibility.md new file mode 100644 index 000000000..2867b07df --- /dev/null +++ b/Documentation/APIs_Compatibility.md @@ -0,0 +1,11 @@ +# APIs Compatibility + +This documentation offers guidance on a workaround for utilizing mobile APIs with earlier versions of Open edX releases. + +In December 2023, the [FC-0031 project](https://github.com/openedx/edx-platform/issues/33304) introduced new APIs, and the Open edX mobile apps were transitioned to utilize them. + +If your platform version is older than December 2023, follow the instructions below: + +1. Setup the [mobile-api-extensions](https://github.com/raccoongang/mobile-api-extensions) plugin to your platform. +The Plugin contains extended Open edX APIs for mobile applications. +2. Roll back the modifications that brought in the new APIs [42f518a](https://github.com/openedx/openedx-app-android/commit/42f518a264d4300c8c2ca349072addd7d16ff91a). diff --git a/Documentation/ConfigurationManagement.md b/Documentation/ConfigurationManagement.md new file mode 100644 index 000000000..1aed9a5ab --- /dev/null +++ b/Documentation/ConfigurationManagement.md @@ -0,0 +1,114 @@ +# Configuration Management + +This documentation provides a comprehensive solution for integrating and managing configuration files in Open edX Android project. + +## Features +- Parsing config.yaml files +- Adding essential keys to `AndroidManifest.xml` (e.g. Microsoft keys) +- Generating Android build config fields. +- Generating config.json file to use the configuration fields at runtime. +- Generating google-services.json with Firebase keys. + +Inside the `Config.kt`, parsing and populating relevant keys and classes are done, e.g. `AgreementUrlsConfig.kt` and `FirebaseConfig.kt`. + +## Getting Started + +### Configuration Setup + +Edit the `config_settings.yaml` in the `default_config` folder. It should contain data as follows: + +```yaml +config_directory: '{path_to_config_folder}' +config_mapping: + prod: 'prod' + stage: 'stage' + dev: 'dev' +# These mappings are configurable, e.g. dev: 'prod_test' +``` + +- `config_directory` provides the path of the config directory. +- `config_mappings` provides mappings that can be utilized to map the Android Build Variant to a defined folder within the config directory, and it will be referenced. + +Note: You can specify `config_directory` to any folder outside the repository to store the configs as a separate project. + +### Configuration Files +In the `default_config` folder, select your environment folder: prod, stage, dev or any other you have created. +Open `config.yaml` and fill in the required fields. + +Example: + +```yaml +API_HOST_URL: 'https://mylmsexample.com' +APPLICATION_ID: 'org.openedx.app' +ENVIRONMENT_DISPLAY_NAME: 'MyLMSExample' +FEEDBACK_EMAIL_ADDRESS: 'support@mylmsexample.com' +OAUTH_CLIENT_ID: 'YOUR_OAUTH_CLIENT_ID' + +PLATFORM_NAME: "MyLMS" +TOKEN_TYPE: "JWT" + +FIREBASE: + ENABLED: false + ANALYTICS_SOURCE: '' + CLOUD_MESSAGING_ENABLED: false + PROJECT_NUMBER: '' + PROJECT_ID: '' + APPLICATION_ID: '' + API_KEY: '' + +MICROSOFT: + ENABLED: false + CLIENT_ID: 'microsoftClientID' +``` + +Also, all envirenment folders contain a `file_mappings.yaml` file that points to the config files to be parsed. + +By modifying `file_mappings.yaml`, you can achieve splitting of the base `config.yaml` or add additional configuration files. + +Example: + +```yaml +android: + files: + - auth_client.yaml + - config.yaml + - feature_flags.yaml +``` + +## Available Third-Party Services +- **Firebase:** Analytics, Crashlytics, Cloud Messaging +- **Google:** Sign in and Sign up via Google +- **Microsoft:** Sign in and Sign up via Microsoft +- **Facebook:** Sign in and Sign up via Facebook +- **Branch:** Deeplinks +- **Braze:** Could Messaging +- **SegmentIO:** Analytics + +## Available Feature Flags +- **PRE_LOGIN_EXPERIENCE_ENABLED:** Enables the pre login courses discovery experience. +- **WHATS_NEW_ENABLED:** Enables the "What's New" feature to present the latest changes to the user. +- **SOCIAL_AUTH_ENABLED:** Enables SSO buttons on the SignIn and SignUp screens. +- **COURSE_NESTED_LIST_ENABLED:** Enables an alternative visual representation for the course structure. +- **COURSE_BANNER_ENABLED:** Enables the display of the course image on the Course Home screen. +- **COURSE_TOP_TAB_BAR_ENABLED:** Enables an alternative navigation on the Course Home screen. +- **COURSE_UNIT_PROGRESS_ENABLED:** Enables the display of the unit progress within the courseware. + +## Future Support +- To add config related to some other service, create a class, e.g. `ServiceNameConfig.kt`, to be able to populate related fields. +- Create a `function` in the `Config.kt` to be able to parse and use the newly created service from the main Config. + +Example: + +```Kotlin +fun getServiceNameConfig(): ServiceNameConfig { + return getObjectOrNewInstance(SERVICE_NAME_KEY, ServiceNameConfig::class.java) +} +``` + +```yaml +SERVICE_NAME: + ENABLED: false + KEY: '' +``` + +The `default_config` directory is added to the project to provide an idea of how to write config YAML files. diff --git a/README.md b/README.md index 1d4039253..265d49683 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# EducationX Android +# Open edX Android -Modern vision of the mobile application for the Open EdX platform from Raccoon Gang. +Modern vision of the mobile application for the Open edX platform from Raccoon Gang. [Documentation](Documentation/Documentation.md) @@ -14,17 +14,16 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G 3. Choose ``openedx-app-android``. -4. Configure the [config.yaml](default_config/dev/config.yaml) with URLs and OAuth credentials for your Open edX instance. +4. Configure `config_settings.yaml` inside `default_config` and `config.yaml` inside sub direcroties to point to your Open edX configuration. [Configuration Docuementation](./Documentation/ConfigurationManagement.md) 5. Select the build variant ``develop``, ``stage``, or ``prod``. 6. Click the **Run** button. -## API plugin +## API +This project targets on the latest Open edX release and rely on the relevant mobile APIs. -This project uses custom APIs to improve performance and reduce the number of requests to the server. - -You can find the plugin with the API and installation guide [here](https://github.com/raccoongang/mobile-api-extensions). +If your platform version is older than December 2023, please follow the instructions to use the [API Plugin](./Documentation/APIs_Compatibility.md). ## License diff --git a/app/.gitignore b/app/.gitignore index 42afabfd2..2abde4aab 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +/google-services.json diff --git a/app/build.gradle b/app/build.gradle index a0f268eb6..2b0ab4f74 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,14 +1,29 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' - id 'kotlin-parcelize' - id 'kotlin-kapt' - id 'com.google.firebase.crashlytics' -} - def config = configHelper.fetchConfig() def appId = config.getOrDefault("APPLICATION_ID", "org.openedx.app") def platformName = config.getOrDefault("PLATFORM_NAME", "OpenEdx").toLowerCase() +def firebaseConfig = config.get('FIREBASE') +def firebaseEnabled = firebaseConfig?.getOrDefault('ENABLED', false) + +apply plugin: 'com.android.application' +apply plugin: 'org.jetbrains.kotlin.android' +apply plugin: 'kotlin-parcelize' +apply plugin: 'kotlin-kapt' +if (firebaseEnabled) { + apply plugin: 'com.google.gms.google-services' + apply plugin: 'com.google.firebase.crashlytics' + + tasks.register('generateGoogleServicesJson') { + configHelper.generateGoogleServicesJson(appId) + } + + preBuild.dependsOn(generateGoogleServicesJson) +} else { + tasks.register('removeGoogleServicesJson') { + configHelper.removeGoogleServicesJson() + } + + preBuild.dependsOn(removeGoogleServicesJson) +} android { compileSdk 34 @@ -31,12 +46,18 @@ android { productFlavors { prod { dimension 'env' + setupBranchConfigFields(it) + setupFirebaseConfigFields(it) } develop { dimension 'env' + setupBranchConfigFields(it) + setupFirebaseConfigFields(it) } stage { dimension 'env' + setupBranchConfigFields(it) + setupFirebaseConfigFields(it) } } @@ -57,8 +78,10 @@ android { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - firebaseCrashlytics { - mappingFileUploadEnabled false + if (firebaseEnabled) { + firebaseCrashlytics { + mappingFileUploadEnabled false + } } } } @@ -72,6 +95,7 @@ android { buildFeatures { viewBinding true compose true + buildConfig true } composeOptions { kotlinCompilerExtensionVersion = "$compose_compiler_version" @@ -106,6 +130,22 @@ dependencies { implementation 'androidx.core:core-splashscreen:1.0.1' + // Segment Library + implementation "com.segment.analytics.kotlin:android:1.14.2" + // Segment's Firebase integration + implementation 'com.segment.analytics.kotlin.destinations:firebase:1.5.2' + // Braze SDK Integration + implementation "com.braze:braze-segment-kotlin:1.4.2" + implementation "com.braze:android-sdk-ui:30.2.0" + + // Firebase Cloud Messaging Integration for Braze + implementation 'com.google.firebase:firebase-messaging-ktx:23.4.1' + + // Branch SDK Integration + implementation 'io.branch.sdk.android:library:5.9.0' + implementation 'com.google.android.gms:play-services-ads-identifier:18.0.1' + implementation "com.android.installreferrer:installreferrer:2.2" + androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" @@ -115,4 +155,38 @@ dependencies { testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" -} \ No newline at end of file +} + +private def setupBranchConfigFields(buildType) { + def branchConfig = configHelper.fetchConfig().get("BRANCH") + def branchKey = "" + def branchUriScheme = "" + def branchHost = "" + def branchAlternateHost = "" + + if (branchConfig && branchConfig.get("ENABLED")) { + branchKey = branchConfig.getOrDefault("KEY", "") + branchUriScheme = branchConfig.getOrDefault("URI_SCHEME", "") + branchHost = branchConfig.getOrDefault("HOST", "") + branchAlternateHost = branchConfig.getOrDefault("ALTERNATE_HOST", "") + + // Validation: Throw exception if any field is empty + if (branchKey.isEmpty() || branchUriScheme.isEmpty() || branchHost.isEmpty() || + branchAlternateHost.isEmpty()) { + throw new IllegalStateException("One or more Branch configuration fields are empty.") + } + } + + buildType.resValue "string", "branch_key", branchKey + buildType.resValue "string", "branch_uri_scheme", branchUriScheme + buildType.resValue "string", "branch_host", branchHost + buildType.resValue "string", "branch_alternate_host", branchAlternateHost +} + +private def setupFirebaseConfigFields(buildType) { + def firebaseConfig = configHelper.fetchConfig().get('FIREBASE') + def firebaseEnabled = firebaseConfig?.getOrDefault('ENABLED', false) + def cloudMessagingEnabled = firebaseConfig?.getOrDefault('CLOUD_MESSAGING_ENABLED', false) + + buildType.manifestPlaceholders = [fcmEnabled: firebaseEnabled && cloudMessagingEnabled] +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b5581c340..8020f6b74 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + @@ -12,6 +13,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 4f152677c..356a23459 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -1,43 +1,56 @@ package org.openedx.app import android.content.Context -import android.os.Bundle -import androidx.core.os.bundleOf import org.openedx.app.analytics.Analytics import org.openedx.app.analytics.FirebaseAnalytics +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.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics -import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics +import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.profile.presentation.ProfileAnalytics +import org.openedx.whatsnew.presentation.WhatsNewAnalytics class AnalyticsManager( context: Context, config: Config, -) : DashboardAnalytics, AuthAnalytics, AppAnalytics, - DiscoveryAnalytics, ProfileAnalytics, CourseAnalytics, DiscussionAnalytics { +) : AppAnalytics, AppReviewAnalytics, AuthAnalytics, CoreAnalytics, CourseAnalytics, + DashboardAnalytics, DiscoveryAnalytics, DiscussionAnalytics, ProfileAnalytics, + WhatsNewAnalytics { private val services: ArrayList = arrayListOf() init { // Initialise all the analytics libraries here - if (config.getFirebaseConfig().projectId.isNotEmpty()) { + if (config.getFirebaseConfig().enabled) { addAnalyticsTracker(FirebaseAnalytics(context = context)) } + val segmentConfig = config.getSegmentConfig() + if (segmentConfig.enabled && segmentConfig.segmentWriteKey.isNotBlank()) { + addAnalyticsTracker(SegmentAnalytics(context = context, config = config)) + } } private fun addAnalyticsTracker(analytic: Analytics) { services.add(analytic) } - private fun logEvent(event: Event, params: Bundle = bundleOf()) { + private fun logEvent(event: Event, params: Map = mapOf()) { services.forEach { analytics -> analytics.logEvent(event.eventName, params) } } + override fun logEvent(event: String, params: Map) { + services.forEach { analytics -> + analytics.logEvent(event, params) + } + } + private fun setUserId(userId: Long) { services.forEach { analytics -> analytics.logUserId(userId) @@ -45,76 +58,16 @@ class AnalyticsManager( } override fun dashboardCourseClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DASHBOARD_COURSE_CLICKED, - bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) - } - - override fun userLoginEvent(method: String) { - logEvent( - Event.USER_LOGIN, - bundleOf( - Key.METHOD.keyName to method - ) - ) - } - - override fun signUpClickedEvent() { - logEvent(Event.SIGN_UP_CLICKED) - } - - override fun createAccountClickedEvent(provider: String) { - logEvent( - Event.CREATE_ACCOUNT_CLICKED, - bundleOf(Key.PROVIDER.keyName to provider) - ) - } - - override fun registrationSuccessEvent(provider: String) { - logEvent( - Event.REGISTRATION_SUCCESS, - bundleOf(Key.PROVIDER.keyName to provider) - ) - } - - override fun forgotPasswordClickedEvent() { - logEvent(Event.FORGOT_PASSWORD_CLICKED) - } - - override fun resetPasswordClickedEvent(success: Boolean) { - logEvent( - Event.RESET_PASSWORD_CLICKED, bundleOf( - Key.SUCCESS.keyName to success - ) - ) + logEvent(Event.DASHBOARD_COURSE_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun logoutEvent(force: Boolean) { - logEvent( - Event.USER_LOGOUT, bundleOf( - Key.FORCE.keyName to force - ) - ) - } - - override fun discoveryTabClickedEvent() { - logEvent(Event.DISCOVERY_TAB_CLICKED) - } - - override fun dashboardTabClickedEvent() { - logEvent(Event.DASHBOARD_TAB_CLICKED) - } - - override fun programsTabClickedEvent() { - logEvent(Event.PROGRAMS_TAB_CLICKED) - } - - override fun profileTabClickedEvent() { - logEvent(Event.PROFILE_TAB_CLICKED) + logEvent(Event.USER_LOGOUT, buildMap { + put(Key.FORCE.keyName, force) + }) } override fun setUserIdForSession(userId: Long) { @@ -126,328 +79,120 @@ class AnalyticsManager( } override fun discoveryCourseSearchEvent(label: String, coursesCount: Int) { - logEvent( - Event.DISCOVERY_COURSE_SEARCH, bundleOf( - Key.LABEL.keyName to label, - Key.COURSE_COUNT.keyName to coursesCount - ) - ) + logEvent(Event.DISCOVERY_COURSE_SEARCH, buildMap { + put(Key.LABEL.keyName, label) + put(Key.COURSE_COUNT.keyName, coursesCount) + }) } override fun discoveryCourseClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DISCOVERY_COURSE_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) - } - - override fun profileEditClickedEvent() { - logEvent(Event.PROFILE_EDIT_CLICKED) - } - - override fun profileEditDoneClickedEvent() { - logEvent(Event.PROFILE_EDIT_DONE_CLICKED) - } - - override fun profileDeleteAccountClickedEvent() { - logEvent(Event.PROFILE_DELETE_ACCOUNT_CLICKED) - } - - override fun profileVideoSettingsClickedEvent() { - logEvent(Event.PROFILE_VIDEO_SETTINGS_CLICKED) - } - - override fun privacyPolicyClickedEvent() { - logEvent(Event.PRIVACY_POLICY_CLICKED) - } - - override fun termsOfUseClickedEvent() { - logEvent(Event.TERMS_OF_USE_CLICKED) - } - - override fun cookiePolicyClickedEvent() { - logEvent(Event.COOKIE_POLICY_CLICKED) - } - - override fun dataSellClickedEvent() { - logEvent(Event.DATE_SELL_CLICKED) - } - - override fun faqClickedEvent() { - logEvent(Event.FAQ_CLICKED) - } - - override fun emailSupportClickedEvent() { - logEvent(Event.EMAIL_SUPPORT_CLICKED) - } - - override fun courseEnrollClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.COURSE_ENROLL_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - ) - ) - } - - override fun courseEnrollSuccessEvent(courseId: String, courseName: String) { - logEvent( - Event.COURSE_ENROLL_SUCCESS, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - ) - ) - } - - override fun viewCourseClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.VIEW_COURSE_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - ) - ) - } - - override fun resumeCourseTappedEvent(courseId: String, courseName: String, blockId: String) { - logEvent( - Event.RESUME_COURSE_TAPPED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId - ) - ) + logEvent(Event.DISCOVERY_COURSE_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun sequentialClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String - ) { - logEvent( - Event.SEQUENTIAL_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) - } - - override fun verticalClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String, ) { - logEvent( - Event.VERTICAL_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.SEQUENTIAL_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun nextBlockClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String, ) { - logEvent( - Event.NEXT_BLOCK_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.NEXT_BLOCK_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun prevBlockClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String, ) { - logEvent( - Event.PREV_BLOCK_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.PREV_BLOCK_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun finishVerticalClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String, ) { - logEvent( - Event.FINISH_VERTICAL_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.FINISH_VERTICAL_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun finishVerticalNextClickedEvent( - courseId: String, - courseName: String, - blockId: String, - blockName: String + courseId: String, courseName: String, blockId: String, blockName: String, ) { - logEvent( - Event.FINISH_VERTICAL_NEXT_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.BLOCK_ID.keyName to blockId, - Key.BLOCK_NAME.keyName to blockName, - ) - ) + logEvent(Event.FINISH_VERTICAL_NEXT_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.BLOCK_ID.keyName, blockId) + put(Key.BLOCK_NAME.keyName, blockName) + }) } override fun finishVerticalBackClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.FINISH_VERTICAL_BACK_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) - } - - override fun courseTabClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.COURSE_TAB_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) - } - - override fun videoTabClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.VIDEO_TAB_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) - } - - override fun discussionTabClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DISCUSSION_TAB_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) - } - - override fun datesTabClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DATES_TAB_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) - } - - override fun handoutsTabClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.HANDOUTS_TAB_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.FINISH_VERTICAL_BACK_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun discussionAllPostsClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DISCUSSION_ALL_POSTS_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.DISCUSSION_ALL_POSTS_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun discussionFollowingClickedEvent(courseId: String, courseName: String) { - logEvent( - Event.DISCUSSION_FOLLOWING_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName - ) - ) + logEvent(Event.DISCUSSION_FOLLOWING_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + }) } override fun discussionTopicClickedEvent( - courseId: String, - courseName: String, - topicId: String, - topicName: String + courseId: String, courseName: String, topicId: String, topicName: String, ) { - logEvent( - Event.DISCUSSION_TOPIC_CLICKED, bundleOf( - Key.COURSE_ID.keyName to courseId, - Key.COURSE_NAME.keyName to courseName, - Key.TOPIC_ID.keyName to topicId, - Key.TOPIC_NAME.keyName to topicName - ) - ) + logEvent(Event.DISCUSSION_TOPIC_CLICKED, buildMap { + put(Key.COURSE_ID.keyName, courseId) + put(Key.COURSE_NAME.keyName, courseName) + put(Key.TOPIC_ID.keyName, topicId) + put(Key.TOPIC_NAME.keyName, topicName) + }) } - } -private enum class Event(val eventName: String) { - USER_LOGIN("User_Login"), - SIGN_UP_CLICKED("Sign_up_Clicked"), - CREATE_ACCOUNT_CLICKED("Create_Account_Clicked"), - REGISTRATION_SUCCESS("Registration_Success"), +enum class Event(val eventName: String) { USER_LOGOUT("User_Logout"), - FORGOT_PASSWORD_CLICKED("Forgot_password_Clicked"), - RESET_PASSWORD_CLICKED("Reset_password_Clicked"), - DISCOVERY_TAB_CLICKED("Main_Discovery_tab_Clicked"), - DASHBOARD_TAB_CLICKED("Main_Dashboard_tab_Clicked"), - PROGRAMS_TAB_CLICKED("Main_Programs_tab_Clicked"), - PROFILE_TAB_CLICKED("Main_Profile_tab_Clicked"), DISCOVERY_SEARCH_BAR_CLICKED("Discovery_Search_Bar_Clicked"), DISCOVERY_COURSE_SEARCH("Discovery_Courses_Search"), DISCOVERY_COURSE_CLICKED("Discovery_Course_Clicked"), DASHBOARD_COURSE_CLICKED("Dashboard_Course_Clicked"), - PROFILE_EDIT_CLICKED("Profile_Edit_Clicked"), - PROFILE_EDIT_DONE_CLICKED("Profile_Edit_Done_Clicked"), - PROFILE_DELETE_ACCOUNT_CLICKED("Profile_Delete_Account_Clicked"), - PROFILE_VIDEO_SETTINGS_CLICKED("Profile_Video_settings_Clicked"), - PRIVACY_POLICY_CLICKED("Privacy_Policy_Clicked"), - TERMS_OF_USE_CLICKED("Terms_Of_Use_Clicked"), - COOKIE_POLICY_CLICKED("Cookie_Policy_Clicked"), - DATE_SELL_CLICKED("Data_Sell_Clicked"), - FAQ_CLICKED("FAQ_Clicked"), - EMAIL_SUPPORT_CLICKED("Email_Support_Clicked"), - COURSE_ENROLL_CLICKED("Course_Enroll_Clicked"), - COURSE_ENROLL_SUCCESS("Course_Enroll_Success"), - VIEW_COURSE_CLICKED("View_Course_Clicked"), - RESUME_COURSE_TAPPED("Resume_Course_Tapped"), + SEQUENTIAL_CLICKED("Sequential_Clicked"), - VERTICAL_CLICKED("Vertical_Clicked"), NEXT_BLOCK_CLICKED("Next_Block_Clicked"), PREV_BLOCK_CLICKED("Prev_Block_Clicked"), FINISH_VERTICAL_CLICKED("Finish_Vertical_Clicked"), FINISH_VERTICAL_NEXT_CLICKED("Finish_Vertical_Next_section_Clicked"), FINISH_VERTICAL_BACK_CLICKED("Finish_Vertical_Back_to_outline_Clicked"), - COURSE_TAB_CLICKED("Course_Outline_Course_tab_Clicked"), - VIDEO_TAB_CLICKED("Course_Outline_Videos_tab_Clicked"), - DISCUSSION_TAB_CLICKED("Course_Outline_Discussion_tab_Clicked"), - DATES_TAB_CLICKED("Course_Outline_Dates_tab_Clicked"), - HANDOUTS_TAB_CLICKED("Course_Outline_Handouts_tab_Clicked"), DISCUSSION_ALL_POSTS_CLICKED("Discussion_All_Posts_Clicked"), DISCUSSION_FOLLOWING_CLICKED("Discussion_Following_Clicked"), DISCUSSION_TOPIC_CLICKED("Discussion_Topic_Clicked"), @@ -460,9 +205,6 @@ private enum class Key(val keyName: String) { BLOCK_NAME("block_name"), TOPIC_ID("topic_id"), TOPIC_NAME("topic_name"), - METHOD("method"), - SUCCESS("success"), - PROVIDER("provider"), FORCE("force"), LABEL("label"), COURSE_COUNT("courses_count"), diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 8646c7491..5ab0d0b0e 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -1,5 +1,6 @@ package org.openedx.app +import android.content.Intent import android.content.res.Configuration import android.graphics.Color import android.os.Bundle @@ -12,6 +13,8 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.window.layout.WindowMetricsCalculator +import io.branch.referral.Branch +import io.branch.referral.Branch.BranchUniversalReferralInitListener import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.ActivityAppBinding @@ -23,6 +26,7 @@ import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.presentation.global.WindowSizeHolder import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType +import org.openedx.core.utils.Logger import org.openedx.profile.presentation.ProfileRouter import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -45,6 +49,8 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private val corePreferencesManager by inject() private val profileRouter by inject() + private val branchLogger = Logger(BRANCH_TAG) + private var _insetTop = 0 private var _insetBottom = 0 private var _insetCutout = 0 @@ -63,6 +69,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { installSplashScreen() binding = ActivityAppBinding.inflate(layoutInflater) lifecycle.addObserver(viewModel) + viewModel.logAppLaunchEvent() setContentView(binding.root) val container = binding.rootLayout @@ -134,6 +141,43 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { } } + override fun onStart() { + super.onStart() + + if (viewModel.isBranchEnabled) { + val callback = BranchUniversalReferralInitListener { _, linkProperties, error -> + if (linkProperties != null) { + branchLogger.i { "Branch init complete." } + branchLogger.i { linkProperties.controlParams.toString() } + } else if (error != null) { + branchLogger.e { "Branch init failed. Caused by -" + error.message } + } + } + + Branch.sessionBuilder(this) + .withCallback(callback) + .withData(this.intent.data) + .init() + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + this.intent = intent + + if (viewModel.isBranchEnabled) { + if (intent?.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false) == true) { + Branch.sessionBuilder(this).withCallback { referringParams, error -> + if (error != null) { + branchLogger.e { error.message } + } else if (referringParams != null) { + branchLogger.i { referringParams.toString() } + } + }.reInit() + } + } + } + private fun addFragment(fragment: Fragment) { supportFragmentManager.beginTransaction() .add(R.id.container, fragment) @@ -173,5 +217,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { const val TOP_INSET = "topInset" const val BOTTOM_INSET = "bottomInset" const val CUTOUT_INSET = "cutoutInset" + const val BRANCH_TAG = "Branch" + const val BRANCH_FORCE_NEW_SESSION = "branch_force_new_session" } } diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 1114ccd8e..51278ef13 100644 --- a/app/src/main/java/org/openedx/app/AppAnalytics.kt +++ b/app/src/main/java/org/openedx/app/AppAnalytics.kt @@ -2,9 +2,33 @@ package org.openedx.app interface AppAnalytics { fun logoutEvent(force: Boolean) - fun discoveryTabClickedEvent() - fun dashboardTabClickedEvent() - fun programsTabClickedEvent() - fun profileTabClickedEvent() fun setUserIdForSession(userId: Long) -} \ No newline at end of file + fun logEvent(event: String, params: Map) +} + +enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { + LAUNCH( + "Launch", + "edx.bi.app.launch" + ), + DISCOVER( + "MainDashboard:Discover", + "edx.bi.app.main_dashboard.discover" + ), + MY_COURSES( + "MainDashboard:My Courses", + "edx.bi.app.main_dashboard.my_course" + ), + MY_PROGRAMS( + "MainDashboard:My Programs", + "edx.bi.app.main_dashboard.my_program" + ), + PROFILE( + "MainDashboard:Profile", + "edx.bi.app.main_dashboard.profile" + ), +} + +enum class AppAnalyticsKey(val key: String) { + NAME("name"), +} diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 15f7be5a0..474f4a8e9 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -13,21 +13,25 @@ import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.WebContentFragment +import org.openedx.core.presentation.settings.VideoQualityFragment +import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.container.NoAccessCourseContainerFragment -import org.openedx.course.presentation.detail.CourseDetailsFragment import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.handouts.HandoutsWebViewFragment -import org.openedx.course.presentation.info.CourseInfoFragment import org.openedx.course.presentation.section.CourseSectionFragment import org.openedx.course.presentation.unit.container.CourseUnitContainerFragment import org.openedx.course.presentation.unit.video.VideoFullScreenFragment import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment +import org.openedx.course.settings.download.DownloadQueueFragment import org.openedx.dashboard.presentation.DashboardRouter -import org.openedx.dashboard.presentation.program.ProgramFragment import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.NativeDiscoveryFragment +import org.openedx.discovery.presentation.WebViewDiscoveryFragment +import org.openedx.discovery.presentation.detail.CourseDetailsFragment +import org.openedx.discovery.presentation.info.CourseInfoFragment +import org.openedx.discovery.presentation.program.ProgramFragment import org.openedx.discovery.presentation.search.CourseSearchFragment import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.domain.model.Thread @@ -43,7 +47,6 @@ import org.openedx.profile.presentation.anothers_account.AnothersProfileFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.profile.ProfileFragment -import org.openedx.profile.presentation.settings.video.VideoQualityFragment import org.openedx.profile.presentation.settings.video.VideoSettingsFragment import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -52,37 +55,45 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di ProfileRouter, AppUpgradeRouter, WhatsNewRouter { //region AuthRouter - override fun navigateToMain(fm: FragmentManager, courseId: String?) { + override fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) { fm.popBackStack() fm.beginTransaction() - .replace(R.id.container, MainFragment.newInstance(courseId)) + .replace(R.id.container, MainFragment.newInstance(courseId, infoType)) .commit() } - override fun navigateToSignIn(fm: FragmentManager, courseId: String?) { - replaceFragmentWithBackStack(fm, SignInFragment.newInstance(courseId)) + override fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) { + replaceFragmentWithBackStack(fm, SignInFragment.newInstance(courseId, infoType)) } - override fun navigateToSignUp(fm: FragmentManager, courseId: String?) { - replaceFragmentWithBackStack(fm, SignUpFragment.newInstance(courseId)) + override fun navigateToSignUp(fm: FragmentManager, courseId: String?, infoType: String?) { + replaceFragmentWithBackStack(fm, SignUpFragment.newInstance(courseId, infoType)) } override fun navigateToLogistration(fm: FragmentManager, courseId: String?) { replaceFragmentWithBackStack(fm, LogistrationFragment.newInstance(courseId)) } + override fun navigateToDownloadQueue(fm: FragmentManager, descendants: List) { + replaceFragmentWithBackStack(fm, DownloadQueueFragment.newInstance(descendants)) + } + override fun navigateToRestorePassword(fm: FragmentManager) { replaceFragmentWithBackStack(fm, RestorePasswordFragment()) } - override fun navigateToDiscoverCourses(fm: FragmentManager, querySearch: String) { + override fun navigateToNativeDiscoverCourses(fm: FragmentManager, querySearch: String) { replaceFragmentWithBackStack(fm, NativeDiscoveryFragment.newInstance(querySearch)) } - override fun navigateToWhatsNew(fm: FragmentManager, courseId: String?) { + override fun navigateToWebDiscoverCourses(fm: FragmentManager, querySearch: String) { + replaceFragmentWithBackStack(fm, WebViewDiscoveryFragment.newInstance(querySearch)) + } + + override fun navigateToWhatsNew(fm: FragmentManager, courseId: String?, infoType: String?) { fm.popBackStack() fm.beginTransaction() - .replace(R.id.container, WhatsNewFragment.newInstance(courseId)) + .replace(R.id.container, WhatsNewFragment.newInstance(courseId, infoType)) .commit() } @@ -123,15 +134,16 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di override fun navigateToCourseOutline( fm: FragmentManager, courseId: String, - courseTitle: String + courseTitle: String, + enrollmentMode: String, ) { replaceFragmentWithBackStack( fm, - CourseContainerFragment.newInstance(courseId, courseTitle) + CourseContainerFragment.newInstance(courseId, courseTitle, enrollmentMode) ) } - override fun navigateToProgramInfo(fm: FragmentManager, pathId: String) { + override fun navigateToEnrolledProgramInfo(fm: FragmentManager, pathId: String) { replaceFragmentWithBackStack(fm, ProgramFragment.newInstance(pathId)) } @@ -320,8 +332,8 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, VideoSettingsFragment()) } - override fun navigateToVideoQuality(fm: FragmentManager) { - replaceFragmentWithBackStack(fm, VideoQualityFragment()) + override fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) { + replaceFragmentWithBackStack(fm, VideoQualityFragment.newInstance(videoQualityType.name)) } override fun navigateToDeleteAccount(fm: FragmentManager) { diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 9092f603c..1febbd15a 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -20,7 +20,7 @@ class AppViewModel( private val room: RoomDatabase, private val preferencesManager: CorePreferences, private val dispatcher: CoroutineDispatcher, - private val analytics: AppAnalytics + private val analytics: AppAnalytics, ) : BaseViewModel() { private val _logoutUser = SingleEventLiveData() @@ -31,6 +31,8 @@ class AppViewModel( private var logoutHandledAt: Long = 0 + val isBranchEnabled get() = config.getBranchConfig().enabled + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) setUserId() @@ -49,10 +51,18 @@ class AppViewModel( } } + fun logAppLaunchEvent() { + analytics.logEvent( + event = AppAnalyticsEvent.LAUNCH.eventName, + params = buildMap { + put(AppAnalyticsKey.NAME.key, AppAnalyticsEvent.LAUNCH.biValue) + } + ) + } + private fun setUserId() { preferencesManager.user?.let { analytics.setUserIdForSession(it.id) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt b/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt index d99fef53b..d8ca717d4 100644 --- a/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt +++ b/app/src/main/java/org/openedx/app/InDevelopmentFragment.kt @@ -11,15 +11,20 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Text import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.fragment.app.Fragment import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography class InDevelopmentFragment : Fragment() { + @OptIn(ExperimentalComposeUiApi::class) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -27,7 +32,11 @@ class InDevelopmentFragment : Fragment() { ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - Scaffold { + Scaffold( + modifier = Modifier.semantics { + testTagsAsResourceId = true + }, + ) { Box( modifier = Modifier .fillMaxSize() @@ -36,6 +45,7 @@ class InDevelopmentFragment : Fragment() { contentAlignment = Alignment.Center ) { Text( + modifier = Modifier.testTag("txt_in_development"), text = "Will be available soon", style = MaterialTheme.appTypography.headlineMedium ) @@ -43,4 +53,4 @@ class InDevelopmentFragment : Fragment() { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 2021e038f..a798c4a3f 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -13,20 +13,21 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.adapter.MainNavigationFragmentAdapter import org.openedx.app.databinding.FragmentMainBinding +import org.openedx.core.config.Config import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding -import org.openedx.dashboard.presentation.dashboard.DashboardFragment -import org.openedx.dashboard.presentation.program.ProgramFragment +import org.openedx.dashboard.presentation.DashboardFragment import org.openedx.discovery.presentation.DiscoveryNavigator import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.discovery.presentation.program.ProgramFragment import org.openedx.profile.presentation.profile.ProfileFragment class MainFragment : Fragment(R.layout.fragment_main) { private val binding by viewBinding(FragmentMainBinding::bind) - private val analytics by inject() private val viewModel by viewModel() private val router by inject() + private val config by inject() private lateinit var adapter: MainNavigationFragmentAdapter @@ -47,27 +48,29 @@ class MainFragment : Fragment(R.layout.fragment_main) { binding.bottomNavView.setOnItemSelectedListener { when (it.itemId) { R.id.fragmentHome -> { - analytics.discoveryTabClickedEvent() + viewModel.logDiscoveryTabClickedEvent() binding.viewPager.setCurrentItem(0, false) } R.id.fragmentDashboard -> { - analytics.dashboardTabClickedEvent() + viewModel.logMyCoursesTabClickedEvent() binding.viewPager.setCurrentItem(1, false) } R.id.fragmentPrograms -> { - analytics.programsTabClickedEvent() + viewModel.logMyProgramsTabClickedEvent() binding.viewPager.setCurrentItem(2, false) } R.id.fragmentProfile -> { - analytics.profileTabClickedEvent() + viewModel.logProfileTabClickedEvent() binding.viewPager.setCurrentItem(3, false) } } true } + // Trigger click event for the first tab on initial load + binding.bottomNavView.selectedItemId = binding.bottomNavView.selectedItemId viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> enableBottomBar(isBottomBarEnabled) @@ -82,12 +85,19 @@ class MainFragment : Fragment(R.layout.fragment_main) { } requireArguments().apply { - this.getString(ARG_COURSE_ID, null)?.let { - if (it.isNotBlank()) { - router.navigateToCourseDetail(parentFragmentManager, it) + getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> + val infoType = getString(ARG_INFO_TYPE) + + if (config.getDiscoveryConfig().isViewTypeWebView() && infoType != null) { + router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) + } else { + router.navigateToCourseDetail(parentFragmentManager, courseId) } + + // Clear arguments after navigation + putString(ARG_COURSE_ID, "") + putString(ARG_INFO_TYPE, "") } - this.putString(ARG_COURSE_ID, null) } } @@ -121,10 +131,12 @@ class MainFragment : Fragment(R.layout.fragment_main) { companion object { private const val ARG_COURSE_ID = "courseId" - fun newInstance(courseId: String? = null): MainFragment { + private const val ARG_INFO_TYPE = "info_type" + fun newInstance(courseId: String? = null, infoType: String? = null): MainFragment { val fragment = MainFragment() fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId + ARG_COURSE_ID to courseId, + ARG_INFO_TYPE to infoType ) return fragment } diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 3b36cc2be..6a30533ea 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -12,12 +12,13 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import org.openedx.core.BaseViewModel import org.openedx.core.config.Config -import org.openedx.dashboard.notifier.DashboardEvent -import org.openedx.dashboard.notifier.DashboardNotifier +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.NavigationToDiscovery class MainViewModel( private val config: Config, - private val notifier: DashboardNotifier, + private val notifier: DiscoveryNotifier, + private val analytics: AppAnalytics, ) : BaseViewModel() { private val _isBottomBarEnabled = MutableLiveData(true) @@ -35,7 +36,7 @@ class MainViewModel( override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) notifier.notifier.onEach { - if (it is DashboardEvent.NavigationToDiscovery) { + if (it is NavigationToDiscovery) { _navigateToDiscovery.emit(true) } }.distinctUntilChanged().launchIn(viewModelScope) @@ -44,4 +45,28 @@ class MainViewModel( fun enableBottomBar(enable: Boolean) { _isBottomBarEnabled.value = enable } + + fun logDiscoveryTabClickedEvent() { + logEvent(AppAnalyticsEvent.DISCOVER) + } + + fun logMyCoursesTabClickedEvent() { + logEvent(AppAnalyticsEvent.MY_COURSES) + } + + fun logMyProgramsTabClickedEvent() { + logEvent(AppAnalyticsEvent.MY_PROGRAMS) + } + + fun logProfileTabClickedEvent() { + logEvent(AppAnalyticsEvent.PROFILE) + } + + private fun logEvent(event: AppAnalyticsEvent) { + analytics.logEvent(event.eventName, + buildMap { + put(AppAnalyticsKey.NAME.key, event.biValue) + } + ) + } } diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 9f1f95977..7d1b81d32 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -1,9 +1,10 @@ package org.openedx.app import android.app.Application -import com.google.firebase.FirebaseOptions -import com.google.firebase.ktx.Firebase -import com.google.firebase.ktx.initialize +import com.braze.Braze +import com.braze.configuration.BrazeConfig +import com.google.firebase.FirebaseApp +import io.branch.referral.Branch import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -26,16 +27,29 @@ class OpenEdXApp : Application() { screenModule ) } - val firebaseConfig = config.getFirebaseConfig() - if (firebaseConfig.enabled) { - val options = FirebaseOptions.Builder() - .setProjectId(firebaseConfig.projectId) - .setApplicationId(firebaseConfig.applicationId) - .setApiKey(firebaseConfig.apiKey) - .setGcmSenderId(firebaseConfig.gcmSenderId) + if (config.getFirebaseConfig().enabled) { + FirebaseApp.initializeApp(this) + } + + if (config.getBranchConfig().enabled) { + if (BuildConfig.DEBUG) { + Branch.enableTestMode() + Branch.enableLogging() + } + Branch.getAutoInstance(this) + } + + if (config.getBrazeConfig().isEnabled && config.getFirebaseConfig().enabled) { + val isCloudMessagingEnabled = config.getFirebaseConfig().isCloudMessagingEnabled && + config.getBrazeConfig().isPushNotificationsEnabled + + val brazeConfig = BrazeConfig.Builder() + .setIsFirebaseCloudMessagingRegistrationEnabled(isCloudMessagingEnabled) + .setFirebaseCloudMessagingSenderIdKey(config.getFirebaseConfig().projectNumber) + .setHandlePushDeepLinksAutomatically(true) + .setIsFirebaseMessagingServiceOnNewTokenRegistrationEnabled(true) .build() - Firebase.initialize(this, options) + Braze.configure(this, brazeConfig) } } - -} \ No newline at end of file +} diff --git a/app/src/main/java/org/openedx/app/analytics/Analytics.kt b/app/src/main/java/org/openedx/app/analytics/Analytics.kt index ed34ec41a..01ac01860 100644 --- a/app/src/main/java/org/openedx/app/analytics/Analytics.kt +++ b/app/src/main/java/org/openedx/app/analytics/Analytics.kt @@ -1,9 +1,7 @@ package org.openedx.app.analytics -import android.os.Bundle - interface Analytics { - fun logScreenEvent(screenName: String, bundle: Bundle) - fun logEvent(eventName: String, bundle: Bundle) + fun logScreenEvent(screenName: String, params: Map) + fun logEvent(eventName: String, params: Map) fun logUserId(userId: Long) } diff --git a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt index 6e4db40a0..503f3d1ef 100644 --- a/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt +++ b/app/src/main/java/org/openedx/app/analytics/FirebaseAnalytics.kt @@ -1,30 +1,35 @@ package org.openedx.app.analytics import android.content.Context -import android.os.Bundle -import android.util.Log import com.google.firebase.analytics.FirebaseAnalytics +import org.openedx.core.extension.toBundle +import org.openedx.core.utils.Logger class FirebaseAnalytics(context: Context) : Analytics { - private var tracker: FirebaseAnalytics? = null + private val logger = Logger(TAG) + private var tracker: FirebaseAnalytics init { tracker = FirebaseAnalytics.getInstance(context) - Log.d("Analytics", "Firebase Builder Initialised") + logger.d { "Firebase Analytics Builder Initialised" } } - override fun logScreenEvent(screenName: String, bundle: Bundle) { - Log.d("Analytics", "Firebase log Screen Event: $screenName + $bundle") + override fun logScreenEvent(screenName: String, params: Map) { + logger.d { "Firebase Analytics log Screen Event: $screenName + $params" } } - override fun logEvent(eventName: String, bundle: Bundle) { - tracker?.logEvent(eventName, bundle) - Log.d("Analytics", "Firebase log Event $eventName: $bundle") + override fun logEvent(eventName: String, params: Map) { + tracker.logEvent(eventName, params.toBundle()) + logger.d { "Firebase Analytics log Event $eventName: $params" } } override fun logUserId(userId: Long) { - tracker?.setUserId(userId.toString()) - Log.d("Analytics", "Firebase User Id log Event") + tracker.setUserId(userId.toString()) + logger.d { "Firebase Analytics User Id log Event" } + } + + private companion object { + const val TAG = "FirebaseAnalytics" } } diff --git a/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt b/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt new file mode 100644 index 000000000..3a9532a71 --- /dev/null +++ b/app/src/main/java/org/openedx/app/analytics/SegmentAnalytics.kt @@ -0,0 +1,56 @@ +package org.openedx.app.analytics + +import android.content.Context +import com.segment.analytics.kotlin.destinations.braze.BrazeDestination +import com.segment.analytics.kotlin.destinations.firebase.FirebaseDestination +import org.openedx.app.BuildConfig +import org.openedx.core.config.Config +import org.openedx.core.utils.Logger +import com.segment.analytics.kotlin.android.Analytics as SegmentAnalyticsBuilder +import com.segment.analytics.kotlin.core.Analytics as SegmentTracker + +class SegmentAnalytics(context: Context, config: Config) : Analytics { + + private val logger = Logger(TAG) + private var tracker: SegmentTracker + + init { + // Create an analytics client with the given application context and Segment write key. + tracker = SegmentAnalyticsBuilder(config.getSegmentConfig().segmentWriteKey, context) { + // Automatically track Lifecycle events + trackApplicationLifecycleEvents = true + flushAt = 20 + flushInterval = 30 + } + if (config.getFirebaseConfig().isSegmentAnalyticsSource()) { + tracker.add(plugin = FirebaseDestination(context = context)) + } + + if (config.getFirebaseConfig() + .isSegmentAnalyticsSource() && config.getBrazeConfig().isEnabled + ) { + tracker.add(plugin = BrazeDestination(context)) + } + SegmentTracker.debugLogsEnabled = BuildConfig.DEBUG + logger.d { "Segment Analytics Builder Initialised" } + } + + override fun logScreenEvent(screenName: String, params: Map) { + logger.d { "Segment Analytics log Screen Event: $screenName + $params" } + tracker.screen(screenName, params) + } + + override fun logEvent(eventName: String, params: Map) { + logger.d { "Segment Analytics log Event $eventName: $params" } + tracker.track(eventName, params) + } + + override fun logUserId(userId: Long) { + logger.d { "Segment Analytics User Id log Event: $userId" } + tracker.identify(userId.toString()) + } + + private companion object { + const val TAG = "SegmentAnalytics" + } +} diff --git a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt index 7b1e203e2..c91b27184 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt @@ -24,8 +24,11 @@ class HeadersInterceptor( } addHeader("Accept", "application/json") + + val httpAgent = System.getProperty("http.agent") ?: "" addHeader( - "User-Agent", System.getProperty("http.agent") + " " + + "User-Agent", + httpAgent + " " + context.getString(org.openedx.core.R.string.app_name) + "/" + BuildConfig.APPLICATION_ID + "/" + BuildConfig.VERSION_NAME diff --git a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt index f17677f19..3cc6b82ae 100644 --- a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt +++ b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt @@ -68,10 +68,12 @@ class OauthRefreshTokenAuthenticator( return null } - val errorCode = getErrorCode(response.peekBody(200).string()) + val errorCode = getErrorCode(response.peekBody(Long.MAX_VALUE).string()) if (errorCode != null) { when (errorCode) { - TOKEN_EXPIRED_ERROR_MESSAGE, JWT_TOKEN_EXPIRED -> { + TOKEN_EXPIRED_ERROR_MESSAGE, + JWT_TOKEN_EXPIRED, + -> { try { val newAuth = refreshAccessToken(refreshToken) if (newAuth != null) { @@ -98,7 +100,10 @@ class OauthRefreshTokenAuthenticator( } } - TOKEN_NONEXISTENT_ERROR_MESSAGE, TOKEN_INVALID_GRANT_ERROR_MESSAGE, JWT_INVALID_TOKEN -> { + TOKEN_NONEXISTENT_ERROR_MESSAGE, + TOKEN_INVALID_GRANT_ERROR_MESSAGE, + JWT_INVALID_TOKEN, + -> { // Retry request with the current access_token if the original access_token used in // request does not match the current access_token. This case can occur when // asynchronous calls are made and are attempting to refresh the access_token where @@ -118,7 +123,10 @@ class OauthRefreshTokenAuthenticator( } } - DISABLED_USER_ERROR_MESSAGE, JWT_DISABLED_USER_ERROR_MESSAGE -> { + DISABLED_USER_ERROR_MESSAGE, + JWT_DISABLED_USER_ERROR_MESSAGE, + JWT_USER_EMAIL_MISMATCH, + -> { runBlocking { appNotifier.send(LogoutEvent()) } @@ -241,6 +249,8 @@ class OauthRefreshTokenAuthenticator( private const val JWT_TOKEN_EXPIRED = "Token has expired." private const val JWT_INVALID_TOKEN = "Invalid token." private const val JWT_DISABLED_USER_ERROR_MESSAGE = "User account is disabled." + private const val JWT_USER_EMAIL_MISMATCH = + "Failing JWT authentication due to jwt user email mismatch with lms user email." private const val FIELD_ERROR_CODE = "error_code" private const val FIELD_DETAIL = "detail" diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index bd7eb17e5..603876d54 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -6,13 +6,17 @@ import org.openedx.app.BuildConfig import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences +import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings +import org.openedx.core.extension.replaceSpace +import org.openedx.course.data.storage.CoursePreferences import org.openedx.profile.data.model.Account import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.whatsnew.data.storage.WhatsNewPreferences class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences, - WhatsNewPreferences, InAppReviewPreferences { + WhatsNewPreferences, InAppReviewPreferences, CoursePreferences { private val sharedPreferences = context.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE) @@ -23,7 +27,9 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } - private fun getString(key: String): String = sharedPreferences.getString(key, "") ?: "" + private fun getString(key: String, defValue: String = ""): String { + return sharedPreferences.getString(key, defValue) ?: defValue + } private fun saveLong(key: String, value: Long) { sharedPreferences.edit().apply { @@ -39,7 +45,9 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } - private fun getBoolean(key: String): Boolean = sharedPreferences.getBoolean(key, false) + private fun getBoolean(key: String, defValue: Boolean = false): Boolean { + return sharedPreferences.getBoolean(key, defValue) + } override fun clear() { sharedPreferences.edit().apply { @@ -90,13 +98,32 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences override var videoSettings: VideoSettings set(value) { - val videoSettingsJson = Gson().toJson(value) - saveString(VIDEO_SETTINGS, videoSettingsJson) + saveBoolean(VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY, value.wifiDownloadOnly) + saveString(VIDEO_SETTINGS_STREAMING_QUALITY, value.videoStreamingQuality.name) + saveString(VIDEO_SETTINGS_DOWNLOAD_QUALITY, value.videoDownloadQuality.name) + } + get() { + val wifiDownloadOnly = getBoolean(VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY, defValue = true) + val streamingQualityString = + getString(VIDEO_SETTINGS_STREAMING_QUALITY, defValue = VideoQuality.AUTO.name) + val downloadQualityString = + getString(VIDEO_SETTINGS_DOWNLOAD_QUALITY, defValue = VideoQuality.AUTO.name) + + return VideoSettings( + wifiDownloadOnly = wifiDownloadOnly, + videoStreamingQuality = VideoQuality.valueOf(streamingQualityString), + videoDownloadQuality = VideoQuality.valueOf(downloadQualityString) + ) + } + + override var appConfig: AppConfig + set(value) { + val appConfigJson = Gson().toJson(value) + saveString(APP_CONFIG, appConfigJson) } get() { - val videoSettingsString = getString(VIDEO_SETTINGS) - return Gson().fromJson(videoSettingsString, VideoSettings::class.java) - ?: VideoSettings.default + val appConfigString = getString(APP_CONFIG) + return Gson().fromJson(appConfigString, AppConfig::class.java) } override var lastWhatsNewVersion: String @@ -119,22 +146,31 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences ?: InAppReviewPreferences.VersionName.default } - override var wasPositiveRated: Boolean set(value) { saveBoolean(APP_WAS_POSITIVE_RATED, value) } get() = getBoolean(APP_WAS_POSITIVE_RATED) + override fun setCalendarSyncEventsDialogShown(courseName: String) { + saveBoolean(courseName.replaceSpace("_"), true) + } + + override fun isCalendarSyncEventsDialogShown(courseName: String): Boolean = + getBoolean(courseName.replaceSpace("_")) + companion object { private const val ACCESS_TOKEN = "access_token" private const val REFRESH_TOKEN = "refresh_token" private const val EXPIRES_IN = "expires_in" private const val USER = "user" private const val ACCOUNT = "account" - private const val VIDEO_SETTINGS = "video_settings" private const val LAST_WHATS_NEW_VERSION = "last_whats_new_version" private const val LAST_REVIEW_VERSION = "last_review_version" private const val APP_WAS_POSITIVE_RATED = "app_was_positive_rated" + private const val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY = "video_settings_wifi_download_only" + private const val VIDEO_SETTINGS_STREAMING_QUALITY = "video_settings_streaming_quality" + private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" + private const val APP_CONFIG = "app_config" } } 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 c5a267ece..dc0a70335 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -18,6 +18,7 @@ import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.room.AppDatabase import org.openedx.app.room.DATABASE_NAME import org.openedx.app.system.notifier.AppNotifier +import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.FacebookAuthHelper @@ -25,12 +26,14 @@ import org.openedx.auth.presentation.sso.GoogleAuthHelper import org.openedx.auth.presentation.sso.MicrosoftAuthHelper import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.config.Config +import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences -import org.openedx.core.interfaces.EnrollInCourseInteractor import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager import org.openedx.core.module.download.FileDownloader +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewManager import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.WhatsNewGlobalManager @@ -40,11 +43,14 @@ import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.system.notifier.CourseNotifier -import org.openedx.course.domain.interactor.CourseInteractor -import org.openedx.dashboard.notifier.DashboardNotifier +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics +import org.openedx.course.presentation.calendarsync.CalendarManager +import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter @@ -58,6 +64,7 @@ import org.openedx.profile.system.notifier.ProfileNotifier import org.openedx.whatsnew.WhatsNewManager import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.data.storage.WhatsNewPreferences +import org.openedx.whatsnew.presentation.WhatsNewAnalytics val appModule = module { @@ -67,19 +74,27 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } single { ResourceManager(get()) } single { AppCookieManager(get(), get()) } single { ReviewManagerFactory.create(get()) } + single { CalendarManager(get(), get(), get()) } - single { GsonBuilder().create() } + single { + GsonBuilder() + .registerTypeAdapter(CourseEnrollments::class.java, CourseEnrollments.Deserializer()) + .create() + } single { AppNotifier() } single { CourseNotifier() } single { DiscussionNotifier() } single { ProfileNotifier() } single { AppUpgradeNotifier() } - single { DashboardNotifier() } + single { DownloadNotifier() } + single { VideoNotifier() } + single { DiscoveryNotifier() } single { AppRouter() } single { get() } @@ -147,18 +162,20 @@ val appModule = module { single { get() } single { AnalyticsManager(get(), get()) } - single { get() } - single { get() } single { get() } - single { get() } - single { get() } + single { get() } + single { get() } + single { get() } single { get() } + single { get() } + single { get() } single { get() } + single { get() } + single { get() } + factory { AgreementProvider(get(), get()) } factory { FacebookAuthHelper() } factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } factory { OAuthHelper(get(), get(), get()) } - - factory { CourseInteractor(get()) } } 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 b74deefbb..c281d0465 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -12,6 +12,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.discovery.data.api.DiscoveryApi import org.openedx.discussion.data.api.DiscussionApi import org.openedx.profile.data.api.ProfileApi import retrofit2.Retrofit @@ -51,6 +52,7 @@ val networkingModule = module { single { provideApi(get()) } single { provideApi(get()) } single { provideApi(get()) } + single { provideApi(get()) } } 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 9ccac7be7..b4547a583 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -7,34 +7,38 @@ import org.openedx.app.AppViewModel import org.openedx.app.MainViewModel import org.openedx.auth.data.repository.AuthRepository import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.presentation.logistration.LogistrationViewModel 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.presentation.dialog.selectorbottomsheet.SelectDialogViewModel +import org.openedx.core.presentation.settings.VideoQualityViewModel import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel import org.openedx.course.presentation.dates.CourseDatesViewModel -import org.openedx.course.presentation.detail.CourseDetailsViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel -import org.openedx.course.presentation.info.CourseInfoViewModel import org.openedx.course.presentation.outline.CourseOutlineViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel import org.openedx.course.presentation.unit.html.HtmlUnitViewModel +import org.openedx.course.presentation.unit.video.BaseVideoViewModel import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel import org.openedx.course.presentation.videos.CourseVideoViewModel +import org.openedx.course.settings.download.DownloadQueueViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor -import org.openedx.dashboard.presentation.dashboard.DashboardViewModel -import org.openedx.dashboard.presentation.program.ProgramViewModel +import org.openedx.dashboard.presentation.DashboardViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel import org.openedx.discovery.presentation.WebViewDiscoveryViewModel +import org.openedx.discovery.presentation.detail.CourseDetailsViewModel +import org.openedx.discovery.presentation.info.CourseInfoViewModel +import org.openedx.discovery.presentation.program.ProgramViewModel import org.openedx.discovery.presentation.search.CourseSearchViewModel import org.openedx.discussion.data.repository.DiscussionRepository import org.openedx.discussion.domain.interactor.DiscussionInteractor @@ -52,19 +56,28 @@ import org.openedx.profile.presentation.anothers_account.AnothersProfileViewMode import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.profile.ProfileViewModel -import org.openedx.profile.presentation.settings.video.VideoQualityViewModel import org.openedx.profile.presentation.settings.video.VideoSettingsViewModel import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel val screenModule = module { viewModel { AppViewModel(get(), get(), get(), get(), get(named("IODispatcher")), get()) } - viewModel { MainViewModel(get(), get()) } + viewModel { MainViewModel(get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } factory { Validator() } - viewModel { (courseId: String?) -> + + viewModel { (courseId: String) -> + LogistrationViewModel( + courseId, + get(), + get(), + get(), + ) + } + + viewModel { (courseId: String?, infoType: String?) -> SignInViewModel( get(), get(), @@ -74,22 +87,48 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), courseId, + infoType, ) } - viewModel { (courseId: String?) -> - SignUpViewModel(get(), get(), get(), get(), get(), get(), get(), courseId) + + viewModel { (courseId: String?, infoType: String?) -> + SignUpViewModel( + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + courseId, + infoType + ) } viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } factory { DashboardRepository(get(), get(), get()) } factory { DashboardInteractor(get()) } - viewModel { DashboardViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { DashboardViewModel(get(), get(), get(), get(), get(), get(), get()) } - factory { DiscoveryRepository(get(), get()) } + factory { DiscoveryRepository(get(), get(), get()) } factory { DiscoveryInteractor(get()) } viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get(), get()) } - viewModel { WebViewDiscoveryViewModel(get(), get(), get()) } + viewModel { (querySearch: String) -> + WebViewDiscoveryViewModel( + querySearch, + get(), + get(), + get(), + get(), + get(), + ) + } factory { ProfileRepository(get(), get(), get(), get(), get()) } factory { ProfileInteractor(get()) } @@ -109,14 +148,27 @@ val screenModule = module { ) } viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), account) } - viewModel { VideoSettingsViewModel(get(), get()) } - viewModel { VideoQualityViewModel(get(), get()) } - viewModel { DeleteProfileViewModel(get(), get(), get(), get()) } + viewModel { VideoSettingsViewModel(get(), get(), get(), get()) } + viewModel { (qualityType: String) -> VideoQualityViewModel(qualityType, get(), get(), get()) } + viewModel { DeleteProfileViewModel(get(), get(), get(), get(), get()) } viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } single { CourseRepository(get(), get(), get(), get()) } factory { CourseInteractor(get()) } - viewModel { CourseInfoViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { (pathId: String, infoType: String) -> + CourseInfoViewModel( + pathId, + infoType, + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + ) + } viewModel { (courseId: String) -> CourseDetailsViewModel( courseId, @@ -129,16 +181,20 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, courseTitle: String) -> + viewModel { (courseId: String, courseTitle: String, enrollmentMode: String) -> CourseContainerViewModel( courseId, courseTitle, + enrollmentMode, + get(), + get(), + get(), + get(), get(), get(), get(), get(), get(), - get() ) } viewModel { (courseId: String) -> @@ -152,11 +208,14 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { (courseId: String) -> CourseSectionViewModel( + courseId, + get(), get(), get(), get(), @@ -165,16 +224,16 @@ val screenModule = module { get(), get(), get(), - courseId ) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, unitId: String) -> CourseUnitContainerViewModel( + courseId, + unitId, get(), get(), get(), get(), - courseId ) } viewModel { (courseId: String) -> @@ -188,11 +247,23 @@ val screenModule = module { get(), get(), get(), + get(), + get(), + get(), + ) + } + viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } + viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get(), get()) } + viewModel { (courseId: String) -> + VideoUnitViewModel( + courseId, + get(), + get(), + get(), + get(), get() ) } - viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get()) } - viewModel { (courseId: String) -> VideoUnitViewModel(courseId, get(), get(), get(), get()) } viewModel { (courseId: String, blockId: String) -> EncodedVideoUnitViewModel( courseId, @@ -202,32 +273,49 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } - viewModel { (courseId: String, isSelfPaced: Boolean) -> + viewModel { (courseId: String, courseName: String, isSelfPaced: Boolean, enrollmentMode: String) -> CourseDatesViewModel( courseId, + courseName, isSelfPaced, + enrollmentMode, + get(), + get(), + get(), + get(), + get(), + get(), get(), get(), - get() ) } viewModel { (courseId: String, handoutsType: String) -> HandoutsViewModel( courseId, - get(), handoutsType, - get() + get(), + get(), + get(), ) } viewModel { CourseSearchViewModel(get(), get(), get(), get(), get()) } viewModel { SelectDialogViewModel(get()) } - single { DiscussionRepository(get(), get()) } + single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } - viewModel { (courseId: String) -> DiscussionTopicsViewModel(get(), get(), get(), courseId) } + viewModel { (courseId: String) -> + DiscussionTopicsViewModel( + get(), + get(), + get(), + get(), + courseId + ) + } viewModel { (courseId: String, topicId: String, threadType: String) -> DiscussionThreadsViewModel( get(), @@ -264,7 +352,28 @@ val screenModule = module { ) } - viewModel { (courseId: String?) -> WhatsNewViewModel(courseId, get()) } + viewModel { (courseId: String?, infoType: String?) -> + WhatsNewViewModel( + courseId, + infoType, + get(), + get(), + get(), + get(), + get(), + ) + } + + viewModel { (descendants: List) -> + DownloadQueueViewModel( + descendants, + get(), + get(), + get(), + get(), + get(), + ) + } viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } diff --git a/app/src/main/java/org/openedx/app/room/AppDatabase.kt b/app/src/main/java/org/openedx/app/room/AppDatabase.kt index f28bd8192..be320bae7 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -3,7 +3,6 @@ package org.openedx.app.room import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import org.openedx.core.data.model.room.CourseEntity import org.openedx.core.data.model.room.CourseStructureEntity import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity import org.openedx.core.module.db.DownloadDao @@ -12,6 +11,7 @@ import org.openedx.course.data.storage.CourseConverter import org.openedx.course.data.storage.CourseDao import org.openedx.dashboard.data.DashboardDao import org.openedx.discovery.data.converter.DiscoveryConverter +import org.openedx.discovery.data.model.room.CourseEntity import org.openedx.discovery.data.storage.DiscoveryDao const val DATABASE_VERSION = 1 diff --git a/auth/build.gradle b/auth/build.gradle index 02b94a587..7cf4d0a86 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -58,9 +58,14 @@ dependencies { implementation "androidx.credentials:credentials:1.2.0" implementation "androidx.credentials:credentials-play-services-auth:1.2.0" implementation "com.facebook.android:facebook-login:16.2.0" - implementation "com.google.android.gms:play-services-auth:20.7.0" + implementation "com.google.android.gms:play-services-auth:21.0.0" implementation "com.google.android.libraries.identity.googleid:googleid:1.1.0" - implementation 'com.microsoft.identity.client:msal:4.9.0' + implementation("com.microsoft.identity.client:msal:4.9.0") { + //Workaround for the error Failed to resolve: 'io.opentelemetry:opentelemetry-bom' for AS Iguana + exclude(group: "io.opentelemetry") + } + implementation("io.opentelemetry:opentelemetry-api:1.18.0") + implementation("io.opentelemetry:opentelemetry-context:1.18.0") androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" diff --git a/auth/src/androidTest/java/org/openedx/auth/presentation/signup/RegistrationScreenTest.kt b/auth/src/androidTest/java/org/openedx/auth/presentation/signup/RegistrationScreenTest.kt deleted file mode 100644 index 845e39034..000000000 --- a/auth/src/androidTest/java/org/openedx/auth/presentation/signup/RegistrationScreenTest.kt +++ /dev/null @@ -1,165 +0,0 @@ -package org.openedx.auth.presentation.signup - -import androidx.activity.ComponentActivity -import androidx.compose.ui.semantics.ProgressBarRangeInfo -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import org.openedx.auth.R -import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.domain.model.RegistrationFieldType -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType -import org.junit.Rule -import org.junit.Test - -class RegistrationScreenTest { - - @get:Rule - val composeTestRule = createAndroidComposeRule() - - //region mockField - private val option = RegistrationField.Option("def", "Bachelor", "Android") - - private val mockField = RegistrationField( - "Fullname", - "Fullname", - RegistrationFieldType.TEXT, - "Fullname", - instructions = "Enter your fullname", - exposed = false, - required = true, - restrictions = RegistrationField.Restrictions(), - options = listOf(option, option), - errorInstructions = "" - ) - //endregion - - - @Test - fun signUpLoadingFields() { - composeTestRule.setContent { - org.openedx.auth.presentation.signup.RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = org.openedx.auth.presentation.signup.SignUpUIState.Loading, - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } - with(composeTestRule) { - onRoot().printToLog("ROOT_TAG") - - onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo(0f, 0f..0f))).assertExists() - onNode(hasScrollAction().and(hasAnyChild(hasText(activity.getString(R.string.auth_sign_up))))).assertDoesNotExist() - } - } - - @Test - fun signUpNoOptionalFields() { - composeTestRule.setContent { - org.openedx.auth.presentation.signup.RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = org.openedx.auth.presentation.signup.SignUpUIState.Fields( - fields = listOf( - mockField, - mockField.copy(name = "Age", label = "Age", errorInstructions = "error") - ), - optionalFields = emptyList() - ), - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } - with(composeTestRule) { - onNode(hasText("Fullname").and(hasSetTextAction())).assertExists() - - onNode(hasText("Age").and(hasSetTextAction())).assertExists() - - onNodeWithText(activity.getString(R.string.auth_show_optional_fields)).assertDoesNotExist() - - onNode(hasText(activity.getString(R.string.auth_create_account)).and(hasClickAction())).assertExists() - } - } - - @Test - fun signUpHasOptionalFields() { - composeTestRule.setContent { - org.openedx.auth.presentation.signup.RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = org.openedx.auth.presentation.signup.SignUpUIState.Fields( - fields = listOf( - mockField, - mockField.copy(name = "Age", label = "Age", errorInstructions = "error") - ), - optionalFields = listOf(mockField) - ), - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } - with(composeTestRule) { - onNode(hasText("Age").and(hasSetTextAction())).assertExists() - - onNodeWithText(activity.getString(R.string.auth_show_optional_fields)).assertExists() - - onNode(hasText(activity.getString(R.string.auth_create_account)).and(hasClickAction())).assertExists() - } - } - - @Test - fun signUpFieldsWithError() { - composeTestRule.setContent { - org.openedx.auth.presentation.signup.RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = org.openedx.auth.presentation.signup.SignUpUIState.Fields( - fields = listOf( - mockField, - mockField.copy(name = "Age", label = "Age", errorInstructions = "error") - ), - optionalFields = emptyList() - ), - uiMessage = null, - isButtonClicked = false, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } - with(composeTestRule) { - onNode(hasText("error")).assertExists() - } - } - - @Test - fun signUpCreateAccountClicked() { - composeTestRule.setContent { - org.openedx.auth.presentation.signup.RegistrationScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiState = org.openedx.auth.presentation.signup.SignUpUIState.Fields( - fields = listOf( - mockField, - mockField.copy(name = "Age", label = "Age", errorInstructions = "error") - ), - optionalFields = listOf(mockField) - ), - uiMessage = null, - isButtonClicked = true, - validationError = false, - onBackClick = {}, - onRegisterClick = {} - ) - } - with(composeTestRule) { - onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo(0f, 0f..0f))).assertExists() - onNode(hasText(activity.getString(R.string.auth_create_account)).and(hasClickAction())).assertDoesNotExist() - } - } -} \ No newline at end of file diff --git a/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt b/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt new file mode 100644 index 000000000..0141df227 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/AgreementProvider.kt @@ -0,0 +1,44 @@ +package org.openedx.auth.presentation + +import androidx.compose.ui.text.intl.Locale +import org.openedx.auth.R +import org.openedx.core.config.Config +import org.openedx.core.system.ResourceManager + +class AgreementProvider( + private val config: Config, + private val resourceManager: ResourceManager, +) { + internal fun getAgreement(isSignIn: Boolean): String? { + val agreementConfig = config.getAgreement(Locale.current.language) + if (agreementConfig.eulaUrl.isBlank()) return null + val platformName = config.getPlatformName() + val agreementRes = if (isSignIn) { + R.string.auth_agreement_signin_in + } else { + R.string.auth_agreement_creating_account + } + val eula = resourceManager.getString( + R.string.auth_cdata_template, + agreementConfig.eulaUrl, + "$platformName ${resourceManager.getString(R.string.auth_agreement_eula)}" + ) + val tos = resourceManager.getString( + R.string.auth_cdata_template, + agreementConfig.tosUrl, + "$platformName ${resourceManager.getString(R.string.auth_agreement_tos)}" + ) + val privacy = resourceManager.getString( + R.string.auth_cdata_template, + agreementConfig.privacyPolicyUrl, + "$platformName ${resourceManager.getString(R.string.auth_agreement_privacy)}" + ) + return resourceManager.getString( + agreementRes, + eula, + tos, + config.getPlatformName(), + privacy, + ) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt index 669c749b4..e87ad9674 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthAnalytics.kt @@ -1,11 +1,60 @@ package org.openedx.auth.presentation interface AuthAnalytics { - fun userLoginEvent(method: String) - fun signUpClickedEvent() - fun createAccountClickedEvent(provider: String) - fun registrationSuccessEvent(provider: String) - fun forgotPasswordClickedEvent() - fun resetPasswordClickedEvent(success: Boolean) fun setUserIdForSession(userId: Long) -} \ No newline at end of file + fun logEvent(event: String, params: Map) +} + +enum class AuthAnalyticsEvent(val eventName: String, val biValue: String) { + DISCOVERY_COURSES_SEARCH( + "Logistration:Courses Search", + "edx.bi.app.logistration.courses_search" + ), + EXPLORE_ALL_COURSES( + "Logistration:Explore All Courses", + "edx.bi.app.logistration.explore.all.courses" + ), + REGISTER_CLICKED( + "Logistration:Register Clicked", + "edx.bi.app.logistration.register.clicked" + ), + CREATE_ACCOUNT_CLICKED( + "Logistration:Create Account Clicked", + "edx.bi.app.logistration.user.create_account.clicked" + ), + REGISTER_SUCCESS( + "Logistration:Register Success", + "edx.bi.app.user.register.success" + ), + SIGN_IN_CLICKED( + "Logistration:Sign In Clicked", + "edx.bi.app.logistration.signin.clicked" + ), + USER_SIGN_IN_CLICKED( + "Logistration:User Sign In Clicked", + "edx.bi.app.logistration.user.signin.clicked" + ), + SIGN_IN_SUCCESS( + "Logistration:Sign In Success", + "edx.bi.app.user.signin.success" + ), + FORGOT_PASSWORD_CLICKED( + "Logistration:Forgot Password Clicked", + "edx.bi.app.logistration.forgot_password.clicked" + ), + RESET_PASSWORD_CLICKED( + "Logistration:Reset Password Clicked", + "edx.bi.app.user.reset_password.clicked" + ), + RESET_PASSWORD_SUCCESS( + "Logistration:Reset Password Success", + "edx.bi.app.user.reset_password.success" + ), +} + +enum class AuthAnalyticsKey(val key: String) { + NAME("name"), + SEARCH_QUERY("search_query"), + SUCCESS("success"), + METHOD("method"), +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt index ff73b7a44..9b1266119 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -4,19 +4,23 @@ import androidx.fragment.app.FragmentManager interface AuthRouter { - fun navigateToMain(fm: FragmentManager, courseId: String?) + fun navigateToMain(fm: FragmentManager, courseId: String?, infoType: String?) - fun navigateToSignIn(fm: FragmentManager, courseId: String?) + fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) fun navigateToLogistration(fm: FragmentManager, courseId: String?) - fun navigateToSignUp(fm: FragmentManager, courseId: String?) + fun navigateToSignUp(fm: FragmentManager, courseId: String?, infoType: String?) fun navigateToRestorePassword(fm: FragmentManager) - fun navigateToWhatsNew(fm: FragmentManager, courseId: String? = null) + fun navigateToWhatsNew(fm: FragmentManager, courseId: String? = null, infoType: String? = null) - fun navigateToDiscoverCourses(fm: FragmentManager, querySearch: String) + fun navigateToWebDiscoverCourses(fm: FragmentManager, querySearch: String) + + fun navigateToNativeDiscoverCourses(fm: FragmentManager, querySearch: String) + + fun navigateToWebContent(fm: FragmentManager, title: String, url: String) fun clearBackStack(fm: FragmentManager) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index 6379b246c..738364c34 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -38,9 +38,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.openedx.auth.R -import org.openedx.auth.presentation.AuthRouter import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.SearchBar import org.openedx.core.ui.displayCutoutForLandscape @@ -52,7 +52,9 @@ import org.openedx.core.ui.theme.compose.LogistrationLogoView class LogistrationFragment : Fragment() { - private val router: AuthRouter by inject() + private val viewModel: LogistrationViewModel by viewModel { + parametersOf(arguments?.getString(ARG_COURSE_ID, "") ?: "") + } override fun onCreateView( inflater: LayoutInflater, @@ -62,16 +64,15 @@ class LogistrationFragment : Fragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { - val courseId = arguments?.getString(ARG_COURSE_ID, "") LogistrationScreen( onSignInClick = { - router.navigateToSignIn(parentFragmentManager, courseId) + viewModel.navigateToSignIn(parentFragmentManager) }, onRegisterClick = { - router.navigateToSignUp(parentFragmentManager, courseId) + viewModel.navigateToSignUp(parentFragmentManager) }, onSearchClick = { querySearch -> - router.navigateToDiscoverCourses(parentFragmentManager, querySearch) + viewModel.navigateToDiscovery(parentFragmentManager, querySearch) } ) } @@ -153,6 +154,7 @@ private fun LogistrationScreen( label = stringResource(id = R.string.pre_auth_search_hint), requestFocus = false, searchValue = textFieldValue, + clearOnSubmit = true, keyboardActions = { focusManager.clearFocus() onSearchClick(textFieldValue.text) diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt new file mode 100644 index 000000000..e48a5e8be --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt @@ -0,0 +1,65 @@ +package org.openedx.auth.presentation.logistration + +import androidx.fragment.app.FragmentManager +import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthAnalyticsEvent +import org.openedx.auth.presentation.AuthAnalyticsKey +import org.openedx.auth.presentation.AuthRouter +import org.openedx.core.BaseViewModel +import org.openedx.core.config.Config +import org.openedx.core.extension.takeIfNotEmpty + +class LogistrationViewModel( + private val courseId: String, + private val router: AuthRouter, + private val config: Config, + private val analytics: AuthAnalytics, +) : BaseViewModel() { + + private val discoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() + + fun navigateToSignIn(parentFragmentManager: FragmentManager) { + router.navigateToSignIn(parentFragmentManager, courseId, null) + logEvent(AuthAnalyticsEvent.SIGN_IN_CLICKED) + } + + fun navigateToSignUp(parentFragmentManager: FragmentManager) { + router.navigateToSignUp(parentFragmentManager, courseId, null) + logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) + } + + fun navigateToDiscovery(parentFragmentManager: FragmentManager, querySearch: String) { + if (discoveryTypeWebView) { + router.navigateToWebDiscoverCourses( + parentFragmentManager, + querySearch + ) + } else { + router.navigateToNativeDiscoverCourses( + parentFragmentManager, + querySearch + ) + } + querySearch.takeIfNotEmpty()?.let { + logEvent( + event = AuthAnalyticsEvent.DISCOVERY_COURSES_SEARCH, + params = buildMap { + put(AuthAnalyticsKey.SEARCH_QUERY.key, querySearch) + } + ) + } ?: logEvent(event = AuthAnalyticsEvent.EXPLORE_ALL_COURSES) + } + + private fun logEvent( + event: AuthAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + putAll(params) + } + ) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index dd530bfa0..18cf169bc 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -81,7 +81,7 @@ class RestorePasswordFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? + savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { @@ -123,7 +123,7 @@ private fun RestorePasswordScreen( uiState: RestorePasswordUIState, uiMessage: UIMessage?, onBackClick: () -> Unit, - onRestoreButtonClick: (String) -> Unit + onRestoreButtonClick: (String) -> Unit, ) { val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() @@ -289,7 +289,7 @@ private fun RestorePasswordScreen( } } else { OpenEdXButton( - width = buttonWidth.testTag("btn_reset_password"), + modifier = buttonWidth.testTag("btn_reset_password"), text = stringResource(id = authR.string.auth_reset_password), onClick = { onRestoreButtonClick(email) @@ -337,7 +337,7 @@ private fun RestorePasswordScreen( ) Spacer(Modifier.height(48.dp)) OpenEdXButton( - width = buttonWidth, + modifier = buttonWidth, text = stringResource(id = R.string.core_sign_in), onClick = { onBackClick() diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt index 427f2f263..b21c694da 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt @@ -6,6 +6,8 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthAnalyticsEvent +import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.core.BaseViewModel import org.openedx.core.R import org.openedx.core.SingleEventLiveData @@ -21,7 +23,7 @@ class RestorePasswordViewModel( private val interactor: AuthInteractor, private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier + private val appUpgradeNotifier: AppUpgradeNotifier, ) : BaseViewModel() { private val _uiState = MutableLiveData() @@ -41,28 +43,29 @@ class RestorePasswordViewModel( } fun passwordReset(email: String) { + logEvent(AuthAnalyticsEvent.RESET_PASSWORD_CLICKED) _uiState.value = RestorePasswordUIState.Loading viewModelScope.launch { try { if (email.isNotEmpty() && email.isEmailValid()) { if (interactor.passwordReset(email)) { _uiState.value = RestorePasswordUIState.Success(email) - analytics.resetPasswordClickedEvent(true) + logResetPasswordEvent(true) } else { _uiState.value = RestorePasswordUIState.Initial _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) - analytics.resetPasswordClickedEvent(false) + logResetPasswordEvent(false) } } else { _uiState.value = RestorePasswordUIState.Initial _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email)) - analytics.resetPasswordClickedEvent(false) + logResetPasswordEvent(false) } } catch (e: Exception) { _uiState.value = RestorePasswordUIState.Initial - analytics.resetPasswordClickedEvent(false) + logResetPasswordEvent(false) if (e is EdxError.ValidationException) { _uiMessage.value = UIMessage.SnackBarMessage(e.error) } else if (e.isInternetError()) { @@ -84,4 +87,25 @@ class RestorePasswordViewModel( } } -} \ No newline at end of file + private fun logResetPasswordEvent(success: Boolean) { + logEvent( + event = AuthAnalyticsEvent.RESET_PASSWORD_SUCCESS, + params = buildMap { + put(AuthAnalyticsKey.SUCCESS.key, success) + } + ) + } + + private fun logEvent( + event: AuthAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + putAll(params) + } + ) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index 1adcaa3a1..fabd8a40b 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -11,14 +11,11 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.auth.data.model.AuthType -import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.signin.compose.LoginScreen import org.openedx.core.AppUpdateState -import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme @@ -26,10 +23,11 @@ import org.openedx.core.ui.theme.OpenEdXTheme class SignInFragment : Fragment() { private val viewModel: SignInViewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, null)) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_INFO_TYPE, "") + ) } - private val router: AuthRouter by inject() - private val whatsNewGlobalManager by inject() override fun onCreateView( inflater: LayoutInflater, @@ -58,33 +56,27 @@ class SignInFragment : Fragment() { ) AuthEvent.ForgotPasswordClick -> { - viewModel.forgotPasswordClickedEvent() - router.navigateToRestorePassword(parentFragmentManager) + viewModel.navigateToForgotPassword(parentFragmentManager) } AuthEvent.RegisterClick -> { - viewModel.signUpClickedEvent() - router.navigateToSignUp(parentFragmentManager, null) + viewModel.navigateToSignUp(parentFragmentManager) } AuthEvent.BackClick -> { requireActivity().supportFragmentManager.popBackStackImmediate() } + + is AuthEvent.OpenLink -> viewModel.openLink( + parentFragmentManager, + event.links, + event.link + ) } }, ) LaunchedEffect(state.loginSuccess) { - val isNeedToShowWhatsNew = - whatsNewGlobalManager.shouldShowWhatsNew() - if (state.loginSuccess) { - router.clearBackStack(parentFragmentManager) - if (isNeedToShowWhatsNew) { - router.navigateToWhatsNew(parentFragmentManager, viewModel.courseId) - } else { - router.navigateToMain(parentFragmentManager, viewModel.courseId) - } - } - + viewModel.proceedWhatsNew(parentFragmentManager) } } else { AppUpgradeRequiredScreen( @@ -99,10 +91,12 @@ class SignInFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" - fun newInstance(courseId: String?): SignInFragment { + private const val ARG_INFO_TYPE = "info_type" + fun newInstance(courseId: String?, infoType: String?): SignInFragment { val fragment = SignInFragment() fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId + ARG_COURSE_ID to courseId, + ARG_INFO_TYPE to infoType ) return fragment } @@ -112,6 +106,7 @@ class SignInFragment : Fragment() { internal sealed interface AuthEvent { data class SignIn(val login: String, val password: String) : AuthEvent data class SocialSignIn(val authType: AuthType) : AuthEvent + data class OpenLink(val links: Map, val link: String) : AuthEvent object RegisterClick : AuthEvent object ForgotPasswordClick : AuthEvent object BackClick : AuthEvent diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt index 829d376f1..9ce5cfc98 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -1,5 +1,7 @@ package org.openedx.auth.presentation.signin +import org.openedx.core.domain.model.RegistrationField + /** * Data class to store UI state of the SignIn screen * @@ -18,4 +20,5 @@ internal data class SignInUIState( val isLogistrationEnabled: Boolean = false, val showProgress: Boolean = false, val loginSuccess: Boolean = false, + val agreement: RegistrationField? = null, ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index e5532429b..7ebc5a569 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -1,6 +1,7 @@ package org.openedx.auth.presentation.signin import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope @@ -14,7 +15,11 @@ import org.openedx.auth.R import org.openedx.auth.data.model.AuthType import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.domain.model.SocialAuthResponse +import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthAnalyticsEvent +import org.openedx.auth.presentation.AuthAnalyticsKey +import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.BaseViewModel import org.openedx.core.SingleEventLiveData @@ -22,7 +27,9 @@ import org.openedx.core.UIMessage import org.openedx.core.Validator import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.createHonorCodeField import org.openedx.core.extension.isInternetError +import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeEvent @@ -38,8 +45,12 @@ class SignInViewModel( private val appUpgradeNotifier: AppUpgradeNotifier, private val analytics: AuthAnalytics, private val oAuthHelper: OAuthHelper, + private val router: AuthRouter, + private val whatsNewGlobalManager: WhatsNewGlobalManager, + agreementProvider: AgreementProvider, config: Config, val courseId: String?, + val infoType: String?, ) : BaseViewModel() { private val logger = Logger("SignInViewModel") @@ -51,6 +62,7 @@ class SignInViewModel( isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), isSocialAuthEnabled = config.isSocialAuthEnabled(), isLogistrationEnabled = config.isPreLoginExperienceEnabled(), + agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), ) ) internal val uiState: StateFlow = _uiState @@ -68,6 +80,7 @@ class SignInViewModel( } fun login(username: String, password: String) { + logEvent(AuthAnalyticsEvent.USER_SIGN_IN_CLICKED) if (!validator.isEmailOrUserNameValid(username)) { _uiMessage.value = UIMessage.SnackBarMessage(resourceManager.getString(R.string.auth_invalid_email_username)) @@ -85,7 +98,15 @@ class SignInViewModel( interactor.login(username, password) _uiState.update { it.copy(loginSuccess = true) } setUserId() - analytics.userLoginEvent(AuthType.PASSWORD.methodName) + logEvent( + AuthAnalyticsEvent.SIGN_IN_SUCCESS, + buildMap { + put( + AuthAnalyticsKey.METHOD.key, + AuthType.PASSWORD.methodName.lowercase() + ) + } + ) } catch (e: Exception) { if (e is EdxError.InvalidGrantException) { _uiMessage.value = @@ -123,12 +144,14 @@ class SignInViewModel( } } - fun signUpClickedEvent() { - analytics.signUpClickedEvent() + fun navigateToSignUp(parentFragmentManager: FragmentManager) { + router.navigateToSignUp(parentFragmentManager, null, null) + logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) } - fun forgotPasswordClickedEvent() { - analytics.forgotPasswordClickedEvent() + fun navigateToForgotPassword(parentFragmentManager: FragmentManager) { + router.navigateToRestorePassword(parentFragmentManager) + logEvent(AuthAnalyticsEvent.FORGOT_PASSWORD_CLICKED) } override fun onCleared() { @@ -146,7 +169,6 @@ class SignInViewModel( logger.d { "Social login (${authType.methodName}) success" } _uiState.update { it.copy(loginSuccess = true) } setUserId() - analytics.userLoginEvent(authType.methodName) _uiState.update { it.copy(showProgress = false) } } } @@ -176,4 +198,46 @@ class SignInViewModel( } } ?: onUnknownError() } + + fun openLink(fragmentManager: FragmentManager, links: Map, link: String) { + links.forEach { (key, value) -> + if (value == link) { + router.navigateToWebContent(fragmentManager, key, value) + return + } + } + } + + fun proceedWhatsNew(parentFragmentManager: FragmentManager) { + val isNeedToShowWhatsNew = whatsNewGlobalManager.shouldShowWhatsNew() + if (uiState.value.loginSuccess) { + router.clearBackStack(parentFragmentManager) + if (isNeedToShowWhatsNew) { + router.navigateToWhatsNew( + parentFragmentManager, + courseId, + infoType + ) + } else { + router.navigateToMain( + parentFragmentManager, + courseId, + infoType + ) + } + } + } + + private fun logEvent( + event: AuthAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + putAll(params) + } + ) + } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 0abcf0bf9..77e290994 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -59,8 +59,10 @@ import org.openedx.auth.presentation.signin.SignInUIState import org.openedx.auth.presentation.ui.LoginTextField import org.openedx.auth.presentation.ui.SocialAuthView import org.openedx.core.UIMessage +import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.HyperlinkText import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.WindowSize import org.openedx.core.ui.WindowType @@ -185,6 +187,20 @@ internal fun LoginScreen( state, onEvent, ) + state.agreement?.let { + Spacer(modifier = Modifier.height(24.dp)) + val linkedText = + TextConverter.htmlTextToLinkedText(state.agreement.label) + HyperlinkText( + modifier = Modifier.testTag("txt_${state.agreement.name}"), + fullText = linkedText.text, + hyperLinks = linkedText.links, + linkTextColor = MaterialTheme.appColors.primary, + action = { link -> + onEvent(AuthEvent.OpenLink(linkedText.links, link)) + }, + ) + } } } } @@ -257,7 +273,7 @@ private fun AuthForm( CircularProgressIndicator(color = MaterialTheme.appColors.primary) } else { OpenEdXButton( - width = buttonWidth.testTag("btn_sign_in"), + modifier = buttonWidth.testTag("btn_sign_in"), text = stringResource(id = coreR.string.core_sign_in), onClick = { onEvent(AuthEvent.SignIn(login = login, password = password)) @@ -349,7 +365,6 @@ private fun SignInScreenPreview() { } } - @Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) @Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) @Composable diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt index 97bfe45d9..fa27d7d60 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt @@ -24,7 +24,10 @@ import org.openedx.core.ui.theme.OpenEdXTheme class SignUpFragment : Fragment() { private val viewModel by viewModel { - parametersOf(requireArguments().getString(ARG_COURSE_ID, "")) + parametersOf( + requireArguments().getString(ARG_COURSE_ID, ""), + requireArguments().getString(ARG_INFO_TYPE, "") + ) } private val router by inject() @@ -67,13 +70,20 @@ class SignUpFragment : Fragment() { }, onFieldUpdated = { key, value -> viewModel.updateField(key, value) + }, + onHyperLinkClick = { links, link -> + viewModel.openLink(parentFragmentManager, links, link) } ) LaunchedEffect(uiState.successLogin) { if (uiState.successLogin) { router.clearBackStack(requireActivity().supportFragmentManager) - router.navigateToMain(parentFragmentManager, viewModel.courseId) + router.navigateToMain( + parentFragmentManager, + viewModel.courseId, + viewModel.infoType + ) } } } else { @@ -89,10 +99,12 @@ class SignUpFragment : Fragment() { companion object { private const val ARG_COURSE_ID = "courseId" - fun newInstance(courseId: String?): SignUpFragment { + private const val ARG_INFO_TYPE = "info_type" + fun newInstance(courseId: String?, infoType: String?): SignUpFragment { val fragment = SignUpFragment() fragment.arguments = bundleOf( - ARG_COURSE_ID to courseId + ARG_COURSE_ID to courseId, + ARG_INFO_TYPE to infoType ) return fragment } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt index 23e0458d9..0f7873b78 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpUIState.kt @@ -6,6 +6,9 @@ import org.openedx.core.system.notifier.AppUpgradeEvent data class SignUpUIState( val allFields: List = emptyList(), + val requiredFields: List = emptyList(), + val optionalFields: List = emptyList(), + val agreementFields: List = emptyList(), val isFacebookAuthEnabled: Boolean = false, val isGoogleAuthEnabled: Boolean = false, val isMicrosoftAuthEnabled: Boolean = false, diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt index af1b8e094..8fafe40ff 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt @@ -1,6 +1,7 @@ package org.openedx.auth.presentation.signup import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow @@ -14,20 +15,25 @@ import kotlinx.coroutines.withContext import org.openedx.auth.data.model.AuthType import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.domain.model.SocialAuthResponse +import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthAnalyticsEvent +import org.openedx.auth.presentation.AuthAnalyticsKey +import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants 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.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType +import org.openedx.core.domain.model.createHonorCodeField import org.openedx.core.extension.isInternetError import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.utils.Logger +import org.openedx.core.R as coreR class SignUpViewModel( private val interactor: AuthInteractor, @@ -35,9 +41,12 @@ class SignUpViewModel( private val analytics: AuthAnalytics, private val preferencesManager: CorePreferences, private val appUpgradeNotifier: AppUpgradeNotifier, + private val agreementProvider: AgreementProvider, private val oAuthHelper: OAuthHelper, private val config: Config, + private val router: AuthRouter, val courseId: String?, + val infoType: String?, ) : BaseViewModel() { private val logger = Logger("SignUpViewModel") @@ -68,35 +77,65 @@ class SignUpViewModel( _uiState.update { it.copy(isLoading = true) } viewModelScope.launch { try { - val allFields = interactor.getRegistrationFields() - _uiState.update { state -> - state.copy( - allFields = allFields, - isLoading = false, - ) - } + updateFields(interactor.getRegistrationFields()) } catch (e: Exception) { if (e.isInternetError()) { _uiMessage.emit( UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) + resourceManager.getString(coreR.string.core_error_no_connection) ) ) } else { _uiMessage.emit( UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) + resourceManager.getString(coreR.string.core_error_unknown_error) ) ) } + } finally { + _uiState.update { state -> + state.copy(isLoading = false) + } } } } + private fun updateFields(allFields: List) { + val mutableAllFields = allFields.toMutableList() + val requiredFields = mutableListOf() + val optionalFields = mutableListOf() + val agreementFields = mutableListOf() + val agreementText = agreementProvider.getAgreement(isSignIn = false) + if (agreementText != null) { + val honourCode = + allFields.find { it.name == ApiConstants.RegistrationFields.HONOR_CODE } + val marketingEmails = + allFields.find { it.name == ApiConstants.RegistrationFields.MARKETING_EMAILS } + mutableAllFields.remove(honourCode) + requiredFields.addAll(mutableAllFields.filter { it.required }) + optionalFields.addAll(mutableAllFields.filter { !it.required }) + requiredFields.remove(marketingEmails) + optionalFields.remove(marketingEmails) + marketingEmails?.let { agreementFields.add(it) } + agreementFields.add(agreementText.createHonorCodeField()) + } else { + requiredFields.addAll(mutableAllFields.filter { it.required }) + optionalFields.addAll(mutableAllFields.filter { !it.required }) + } + _uiState.update { state -> + state.copy( + allFields = mutableAllFields, + requiredFields = requiredFields, + optionalFields = optionalFields, + agreementFields = agreementFields, + ) + } + } + fun register() { - analytics.createAccountClickedEvent("") + logEvent(AuthAnalyticsEvent.CREATE_ACCOUNT_CLICKED) val mapFields = uiState.value.allFields.associate { it.name to it.placeholder } + - mapOf(ApiConstants.HONOR_CODE to true.toString()) + mapOf(ApiConstants.RegistrationFields.HONOR_CODE to true.toString()) val resultMap = mapFields.toMutableMap() uiState.value.allFields.filter { !it.required }.forEach { (k, _) -> if (mapFields[k].isNullOrEmpty()) { @@ -119,7 +158,16 @@ class SignUpViewModel( resultMap[ApiConstants.CLIENT_ID] = config.getOAuthClientId() } interactor.register(resultMap.toMap()) - analytics.registrationSuccessEvent(socialAuth?.authType?.postfix.orEmpty()) + logEvent( + event = AuthAnalyticsEvent.REGISTER_SUCCESS, + params = buildMap { + put( + AuthAnalyticsKey.METHOD.key, + (socialAuth?.authType?.methodName + ?: AuthType.PASSWORD.methodName).lowercase() + ) + } + ) if (socialAuth == null) { interactor.login( resultMap.getValue(ApiConstants.EMAIL), @@ -136,13 +184,13 @@ class SignUpViewModel( if (e.isInternetError()) { _uiMessage.emit( UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) + resourceManager.getString(coreR.string.core_error_no_connection) ) ) } else { _uiMessage.emit( UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) + resourceManager.getString(coreR.string.core_error_unknown_error) ) ) } @@ -177,21 +225,29 @@ class SignUpViewModel( runCatching { interactor.loginSocial(socialAuth.accessToken, socialAuth.authType) }.onFailure { + val fields = uiState.value.allFields.toMutableList() + .filter { field -> field.type != RegistrationFieldType.PASSWORD } + updateField(ApiConstants.NAME, socialAuth.name) + updateField(ApiConstants.EMAIL, socialAuth.email) + setErrorInstructions(emptyMap()) _uiState.update { - val fields = it.allFields.toMutableList() - .filter { field -> field.type != RegistrationFieldType.PASSWORD } - updateField(ApiConstants.NAME, socialAuth.name) - updateField(ApiConstants.EMAIL, socialAuth.email) - setErrorInstructions(emptyMap()) it.copy( isLoading = false, socialAuth = socialAuth, - allFields = fields ) } + updateFields(fields) }.onSuccess { setUserId() - analytics.userLoginEvent(socialAuth.authType.methodName) + logEvent( + AuthAnalyticsEvent.SIGN_IN_SUCCESS, + buildMap { + put( + AuthAnalyticsKey.METHOD.key, + socialAuth.authType.methodName.lowercase() + ) + } + ) _uiState.update { it.copy(successLogin = true) } logger.d { "Social login (${socialAuth.authType.methodName}) success" } } @@ -207,12 +263,8 @@ class SignUpViewModel( updatedFields.add(it.copy(errorInstructions = "")) } } - _uiState.update { state -> - state.copy( - allFields = updatedFields, - isLoading = false, - ) - } + updateFields(updatedFields) + _uiState.update { it.copy(isLoading = false) } } private fun collectAppUpgradeEvent() { @@ -230,15 +282,35 @@ class SignUpViewModel( } fun updateField(key: String, value: String) { - _uiState.update { - val updatedFields = uiState.value.allFields.toMutableList().map { field -> - if (field.name == key) { - field.copy(placeholder = value) - } else { - field - } + val updatedFields = uiState.value.allFields.toMutableList().map { field -> + if (field.name == key) { + field.copy(placeholder = value) + } else { + field } - it.copy(allFields = updatedFields) } + updateFields(updatedFields) + } + + fun openLink(fragmentManager: FragmentManager, links: Map, link: String) { + links.forEach { (key, value) -> + if (value == link) { + router.navigateToWebContent(fragmentManager, key, value) + return + } + } + } + + private fun logEvent( + event: AuthAnalyticsEvent, + params: Map = emptyMap(), + ) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(AuthAnalyticsKey.NAME.key, event.biValue) + putAll(params) + } + ) } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 0658396e2..2e2180d83 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -97,6 +97,7 @@ internal fun SignUpView( onBackClick: () -> Unit, onFieldUpdated: (String, String) -> Unit, onRegisterClick: (authType: AuthType) -> Unit, + onHyperLinkClick: (Map, String) -> Unit, ) { val scaffoldState = rememberScaffoldState() val focusManager = LocalFocusManager.current @@ -137,9 +138,6 @@ internal fun SignUpView( val isImeVisible by isImeVisibleState() - val fields = uiState.allFields.filter { it.required } - val optionalFields = uiState.allFields.filter { !it.required } - LaunchedEffect(uiState.validationError) { if (uiState.validationError) { coroutine.launch { @@ -294,7 +292,6 @@ internal fun SignUpView( modifier = Modifier .fillMaxHeight() .background(MaterialTheme.appColors.background), - verticalArrangement = Arrangement.spacedBy(24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { if (uiState.isLoading) { @@ -350,7 +347,7 @@ internal fun SignUpView( } } RequiredFields( - fields = fields, + fields = uiState.requiredFields, showErrorMap = showErrorMap, selectableNamesMap = selectableNamesMap, onSelectClick = { serverName, field, list -> @@ -369,7 +366,7 @@ internal fun SignUpView( }, onFieldUpdated = onFieldUpdated ) - if (optionalFields.isNotEmpty()) { + if (uiState.optionalFields.isNotEmpty()) { ExpandableText( modifier = Modifier.testTag("txt_optional_field"), isExpanded = showOptionalFields, @@ -377,32 +374,55 @@ internal fun SignUpView( showOptionalFields = !showOptionalFields } ) - Surface(color = MaterialTheme.appColors.background) { - AnimatedVisibility(visible = showOptionalFields) { - OptionalFields( - fields = optionalFields, - showErrorMap = showErrorMap, - selectableNamesMap = selectableNamesMap, - onSelectClick = { serverName, field, list -> - keyboardController?.hide() - serverFieldName.value = - serverName - expandedList = list - coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() - } else { - bottomDialogTitle = field.label - showErrorMap[field.name] = false - bottomSheetScaffoldState.show() - } + AnimatedVisibility(visible = showOptionalFields) { + OptionalFields( + fields = uiState.optionalFields, + showErrorMap = showErrorMap, + selectableNamesMap = selectableNamesMap, + onSelectClick = { serverName, field, list -> + keyboardController?.hide() + serverFieldName.value = + serverName + expandedList = list + coroutine.launch { + if (bottomSheetScaffoldState.isVisible) { + bottomSheetScaffoldState.hide() + } else { + bottomDialogTitle = field.label + showErrorMap[field.name] = false + bottomSheetScaffoldState.show() } - }, - onFieldUpdated = onFieldUpdated, - ) - } + } + }, + onFieldUpdated = onFieldUpdated, + ) } } + if (uiState.agreementFields.isNotEmpty()) { + OptionalFields( + fields = uiState.agreementFields, + showErrorMap = showErrorMap, + selectableNamesMap = selectableNamesMap, + onSelectClick = { serverName, field, list -> + keyboardController?.hide() + serverFieldName.value = serverName + expandedList = list + coroutine.launch { + if (bottomSheetScaffoldState.isVisible) { + bottomSheetScaffoldState.hide() + } else { + bottomDialogTitle = field.label + showErrorMap[field.name] = false + bottomSheetScaffoldState.show() + } + } + }, + onFieldUpdated = onFieldUpdated, + hyperLinkAction = { links, link -> + onHyperLinkClick(links, link) + }, + ) + } if (uiState.isButtonLoading) { Box( @@ -415,7 +435,7 @@ internal fun SignUpView( } } else { OpenEdXButton( - width = buttonWidth.testTag("btn_create_account"), + modifier = buttonWidth.testTag("btn_create_account"), text = stringResource(id = R.string.auth_create_account), onClick = { showErrorMap.clear() @@ -460,6 +480,7 @@ private fun RegistrationScreenPreview() { onBackClick = {}, onRegisterClick = {}, onFieldUpdated = { _, _ -> }, + onHyperLinkClick = { _, _ -> }, ) } } @@ -472,12 +493,16 @@ private fun RegistrationScreenTabletPreview() { SignUpView( windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = SignUpUIState( - allFields = listOf(field, field, field.copy(required = false)), + allFields = listOf(field), + requiredFields = listOf(field, field), + optionalFields = listOf(field, field), + agreementFields = listOf(field), ), uiMessage = null, onBackClick = {}, onRegisterClick = {}, onFieldUpdated = { _, _ -> }, + onHyperLinkClick = { _, _ -> }, ) } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index e875a4539..4f98ea50c 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -50,6 +50,7 @@ import org.openedx.auth.R import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.extension.TextConverter +import org.openedx.core.extension.tagId import org.openedx.core.ui.HyperlinkText import org.openedx.core.ui.SheetContent import org.openedx.core.ui.noRippleClickable @@ -86,7 +87,7 @@ fun RequiredFields( val linkedText = TextConverter.htmlTextToLinkedText(field.label) HyperlinkText( - modifier = Modifier.testTag("txt_${field.name}"), + modifier = Modifier.testTag("txt_${field.name.tagId()}"), fullText = linkedText.text, hyperLinks = linkedText.links, linkTextColor = MaterialTheme.appColors.primary @@ -94,7 +95,9 @@ fun RequiredFields( } RegistrationFieldType.CHECKBOX -> { - //Text("checkbox") + CheckboxField(text = field.label, defaultValue = field.defaultValue) { + onFieldUpdated(field.name, it.toString()) + } } RegistrationFieldType.SELECT -> { @@ -138,6 +141,7 @@ fun OptionalFields( selectableNamesMap: MutableMap, onSelectClick: (String, RegistrationField, List) -> Unit, onFieldUpdated: (String, String) -> Unit, + hyperLinkAction: ((Map, String) -> Unit)? = null, ) { Column { fields.forEach { field -> @@ -166,12 +170,17 @@ fun OptionalFields( HyperlinkText( fullText = linkedText.text, hyperLinks = linkedText.links, - linkTextColor = MaterialTheme.appColors.primary + linkTextColor = MaterialTheme.appColors.primary, + action = { + hyperLinkAction?.invoke(linkedText.links, it) + }, ) } RegistrationFieldType.CHECKBOX -> { - //Text("checkbox") + CheckboxField(text = field.label, defaultValue = field.defaultValue) { + onFieldUpdated(field.name, it.toString()) + } } RegistrationFieldType.SELECT -> { @@ -305,7 +314,7 @@ fun InputRegistrationField( Column { Text( modifier = Modifier - .testTag("txt_${registrationField.name}_label") + .testTag("txt_${registrationField.name.tagId()}_label") .fillMaxWidth(), text = registrationField.label, style = MaterialTheme.appTypography.labelLarge, @@ -329,7 +338,7 @@ fun InputRegistrationField( shape = MaterialTheme.appShapes.textFieldShape, placeholder = { Text( - modifier = modifier.testTag("txt_${registrationField.name}_placeholder"), + modifier = modifier.testTag("txt_${registrationField.name.tagId()}_placeholder"), text = registrationField.label, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium @@ -345,11 +354,11 @@ fun InputRegistrationField( }, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = isSingleLine, - modifier = modifier.testTag("tf_${registrationField.name}") + modifier = modifier.testTag("tf_${registrationField.name.tagId()}") ) Spacer(modifier = Modifier.height(6.dp)) Text( - modifier = Modifier.testTag("txt_${registrationField.name}_description"), + modifier = Modifier.testTag("txt_${registrationField.name.tagId()}_description"), text = helperText, style = MaterialTheme.appTypography.bodySmall, color = helperTextColor @@ -392,7 +401,7 @@ fun SelectableRegisterField( ) { Text( modifier = Modifier - .testTag("txt_${registrationField.name}_label") + .testTag("txt_${registrationField.name.tagId()}_label") .fillMaxWidth(), text = registrationField.label, style = MaterialTheme.appTypography.labelLarge, @@ -414,14 +423,14 @@ fun SelectableRegisterField( textStyle = MaterialTheme.appTypography.bodyMedium, onValueChange = { }, modifier = Modifier - .testTag("tf_${registrationField.name}") + .testTag("tf_${registrationField.name.tagId()}") .fillMaxWidth() .noRippleClickable { onClick(registrationField.name, registrationField.options) }, placeholder = { Text( - modifier = Modifier.testTag("txt_${registrationField.name}_placeholder"), + modifier = Modifier.testTag("txt_${registrationField.name.tagId()}_placeholder"), text = registrationField.label, color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.bodyMedium @@ -437,7 +446,7 @@ fun SelectableRegisterField( ) Spacer(modifier = Modifier.height(6.dp)) Text( - modifier = Modifier.testTag("txt_${registrationField.name}_description"), + modifier = Modifier.testTag("txt_${registrationField.name.tagId()}_description"), text = helperText, style = MaterialTheme.appTypography.bodySmall, color = helperTextColor diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt new file mode 100644 index 000000000..b134cb59a --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt @@ -0,0 +1,62 @@ +package org.openedx.auth.presentation.ui + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Row +import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import org.openedx.core.ui.noRippleClickable +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography + +@Composable +internal fun CheckboxField( + text: String, + defaultValue: Boolean, + onValueChanged: (Boolean) -> Unit +) { + var checkedState by remember { mutableStateOf(defaultValue) } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checkedState, + colors = CheckboxDefaults.colors( + checkedColor = MaterialTheme.appColors.primary, + uncheckedColor = MaterialTheme.appColors.textFieldText + ), + onCheckedChange = { + checkedState = it + onValueChanged(it) + } + ) + Text( + modifier = Modifier.noRippleClickable { + checkedState = !checkedState + onValueChanged(checkedState) + }, + text = text, + style = MaterialTheme.appTypography.bodySmall, + ) + } +} + +@Preview(widthDp = 375, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(widthDp = 375, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CheckboxFieldPreview() { + OpenEdXTheme { + CheckboxField( + text = "Test", + defaultValue = true, + ) {} + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt index c9d73662b..336c09f8f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt @@ -74,7 +74,7 @@ internal fun SocialAuthView( R.string.auth_continue_facebook } OpenEdXButton( - width = Modifier + modifier = Modifier .testTag("btn_facebook_auth") .padding(top = 12.dp) .fillMaxWidth(), @@ -106,7 +106,7 @@ internal fun SocialAuthView( R.string.auth_continue_microsoft } OpenEdXButton( - width = Modifier + modifier = Modifier .testTag("btn_microsoft_auth") .padding(top = 12.dp) .fillMaxWidth(), diff --git a/auth/src/main/res/drawable/auth_ic_email.xml b/auth/src/main/res/drawable/auth_ic_email.xml index c76bdb4c0..3fdb37de2 100644 --- a/auth/src/main/res/drawable/auth_ic_email.xml +++ b/auth/src/main/res/drawable/auth_ic_email.xml @@ -1,34 +1,10 @@ - - - - - - - - - + android:width="85dp" + android:height="66dp" + android:viewportWidth="85" + android:viewportHeight="66"> + diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 85eb3a47f..4f8ce12d8 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -33,4 +33,10 @@ Continue with Microsoft You\'ve successfully signed in with %s. We just need a little more information before you start learning with %s. + End User Licence Agreement + Terms of Service and Honor Code + Privacy Policy + By creating an account, you agree to the %1$s and %2$s and you acknowledge that %3$s and each Member process your personal data in accordance with the %4$s. + By signing in to this app, you agree to the %1$s and %2$s and you acknowledge that %3$s and each Member process your personal data in accordance with the %4$s. + %2$s]]> diff --git a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt index e80b93db7..4c92b317f 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt @@ -1,12 +1,6 @@ package org.openedx.auth.presentation.restore import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.auth.domain.interactor.AuthInteractor -import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -15,13 +9,23 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.core.R +import org.openedx.core.UIMessage +import org.openedx.core.system.EdxError +import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeNotifier import java.net.UnknownHostException @@ -66,13 +70,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset empty email validation error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(emptyEmail) } returns true - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(emptyEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -83,13 +88,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset invalid email validation error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(invalidEmail) } returns true - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(invalidEmail) advanceUntilIdle() coVerify(exactly = 0) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -100,13 +106,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset validation error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } throws EdxError.ValidationException("error") - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -117,13 +124,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset no internet error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } throws UnknownHostException() - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -134,13 +142,14 @@ class RestorePasswordViewModelTest { @Test fun `passwordReset unknown error`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } throws Exception() - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -151,13 +160,14 @@ class RestorePasswordViewModelTest { @Test fun `unSuccess restore password`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } returns false - every { analytics.resetPasswordClickedEvent(false) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(false) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -169,13 +179,14 @@ class RestorePasswordViewModelTest { @Test fun `success restore password`() = runTest { - val viewModel = RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) + val viewModel = + RestorePasswordViewModel(interactor, resourceManager, analytics, appUpgradeNotifier) coEvery { interactor.passwordReset(correctEmail) } returns true - every { analytics.resetPasswordClickedEvent(true) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit viewModel.passwordReset(correctEmail) advanceUntilIdle() coVerify(exactly = 1) { interactor.passwordReset(any()) } - verify(exactly = 1) { analytics.resetPasswordClickedEvent(true) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val state = viewModel.uiState.value as? RestorePasswordUIState.Success @@ -185,6 +196,4 @@ class RestorePasswordViewModelTest { assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Success) assertEquals(null, message) } - - -} \ No newline at end of file +} diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index 16b5032c4..b36aabb10 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -23,7 +23,9 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.auth.R import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.UIMessage import org.openedx.core.Validator @@ -33,6 +35,7 @@ import org.openedx.core.config.GoogleConfig import org.openedx.core.config.MicrosoftConfig import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeNotifier @@ -54,7 +57,10 @@ class SignInViewModelTest { private val interactor = mockk() private val analytics = mockk() private val appUpgradeNotifier = mockk() + private val agreementProvider = mockk() private val oAuthHelper = mockk() + private val router = mockk() + private val whatsNewGlobalManager = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -73,6 +79,7 @@ class SignInViewModelTest { every { resourceManager.getString(R.string.auth_invalid_email_username) } returns invalidEmailOrUsername every { resourceManager.getString(R.string.auth_invalid_password) } returns invalidPassword every { appUpgradeNotifier.notifier } returns emptyFlow() + every { agreementProvider.getAgreement(true) } returns null every { config.isPreLoginExperienceEnabled() } returns false every { config.isSocialAuthEnabled() } returns false every { config.getFacebookConfig() } returns FacebookConfig() @@ -90,6 +97,7 @@ class SignInViewModelTest { every { validator.isEmailOrUserNameValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -98,12 +106,17 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", + infoType = "", ) viewModel.login("", "") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -117,6 +130,7 @@ class SignInViewModelTest { every { validator.isEmailOrUserNameValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -125,8 +139,12 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", + infoType = "", ) viewModel.login("acc@test.o", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -145,6 +163,7 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit coVerify(exactly = 0) { interactor.login(any(), any()) } val viewModel = SignInViewModel( interactor = interactor, @@ -154,8 +173,12 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", + infoType = "", ) viewModel.login("acc@test.org", "") @@ -174,6 +197,7 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -182,13 +206,18 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", + infoType = "", ) viewModel.login("acc@test.org", "ed") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -201,9 +230,9 @@ class SignInViewModelTest { fun `login success`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true - every { analytics.userLoginEvent(any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -212,16 +241,20 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", + infoType = "", ) coEvery { interactor.login("acc@test.org", "edx") } returns Unit viewModel.login("acc@test.org", "edx") advanceUntilIdle() coVerify(exactly = 1) { interactor.login(any(), any()) } - verify(exactly = 1) { analytics.userLoginEvent(any()) } verify(exactly = 1) { analytics.setUserIdForSession(any()) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) @@ -235,6 +268,7 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -243,8 +277,12 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", + infoType = "", ) coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException() viewModel.login("acc@test.org", "edx") @@ -252,6 +290,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage @@ -267,6 +306,7 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -275,8 +315,12 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", + infoType = "", ) coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException() viewModel.login("acc@test.org", "edx") @@ -285,6 +329,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value @@ -299,6 +344,7 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( interactor = interactor, resourceManager = resourceManager, @@ -307,8 +353,12 @@ class SignInViewModelTest { analytics = analytics, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, + whatsNewGlobalManager = whatsNewGlobalManager, courseId = "", + infoType = "", ) coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException() viewModel.login("acc@test.org", "edx") @@ -317,6 +367,7 @@ class SignInViewModelTest { coVerify(exactly = 1) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } + verify(exactly = 1) { analytics.logEvent(any(), any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage val uiState = viewModel.uiState.value diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index bd048902c..f304f7363 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt @@ -1,6 +1,7 @@ package org.openedx.auth.presentation.signup import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.ui.text.intl.Locale import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -26,7 +27,9 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.auth.data.model.ValidationFields import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants import org.openedx.core.R @@ -37,13 +40,13 @@ import org.openedx.core.config.GoogleConfig import org.openedx.core.config.MicrosoftConfig import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeNotifier import java.net.UnknownHostException - @ExperimentalCoroutinesApi class SignUpViewModelTest { @@ -57,7 +60,9 @@ class SignUpViewModelTest { private val interactor = mockk() private val analytics = mockk() private val appUpgradeNotifier = mockk() + private val agreementProvider = mockk() private val oAuthHelper = mockk() + private val router = mockk() //region parameters @@ -107,10 +112,13 @@ class SignUpViewModelTest { every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong every { appUpgradeNotifier.notifier } returns emptyFlow() + every { agreementProvider.getAgreement(false) } returns null every { config.isSocialAuthEnabled() } returns false + every { config.getAgreement(Locale.current.language) } returns AgreementUrls() every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() + every { config.getMicrosoftConfig() } returns MicrosoftConfig() } @After @@ -127,14 +135,17 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", + infoType = "", ) coEvery { interactor.validateRegistrationFields(parametersMap) } returns ValidationFields( parametersMap ) coEvery { interactor.getRegistrationFields() } returns listOfFields - every { analytics.createAccountClickedEvent(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit every { preferencesManager.user } returns user @@ -147,7 +158,7 @@ class SignUpViewModelTest { viewModel.register() advanceUntilIdle() coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } - verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } @@ -167,8 +178,11 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", + infoType = "", ) val deferred = async { viewModel.uiMessage.first() } @@ -181,7 +195,7 @@ class SignUpViewModelTest { parametersMap.getValue(ApiConstants.PASSWORD) ) } returns Unit - every { analytics.createAccountClickedEvent(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit viewModel.getRegistrationFields() @@ -191,7 +205,7 @@ class SignUpViewModelTest { } viewModel.register() advanceUntilIdle() - verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } @@ -213,21 +227,24 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", + infoType = "", ) val deferred = async { viewModel.uiMessage.first() } coEvery { interactor.validateRegistrationFields(parametersMap) } throws Exception() coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit - every { analytics.createAccountClickedEvent(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit viewModel.register() advanceUntilIdle() verify(exactly = 0) { analytics.setUserIdForSession(any()) } - verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } + verify(exactly = 1) { analytics.logEvent(any(), any()) } coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 0) { interactor.register(any()) } coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -239,7 +256,6 @@ class SignUpViewModelTest { assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } - @Test fun `success register`() = runTest { val viewModel = SignUpViewModel( @@ -249,14 +265,17 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", + infoType = "", ) coEvery { interactor.validateRegistrationFields(parametersMap) } returns ValidationFields( emptyMap() ) - every { analytics.createAccountClickedEvent(any()) } returns Unit - every { analytics.registrationSuccessEvent(any()) } returns Unit + every { analytics.logEvent(any(), any()) } returns Unit + coEvery { analytics.logEvent(any(), any()) } returns Unit coEvery { interactor.getRegistrationFields() } returns listOfFields coEvery { interactor.register(parametersMap) } returns Unit coEvery { @@ -278,8 +297,7 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.validateRegistrationFields(any()) } coVerify(exactly = 1) { interactor.register(any()) } coVerify(exactly = 1) { interactor.login(any(), any()) } - verify(exactly = 1) { analytics.createAccountClickedEvent(any()) } - verify(exactly = 1) { analytics.registrationSuccessEvent(any()) } + verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } assertFalse(viewModel.uiState.value.validationError) @@ -296,8 +314,11 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", + infoType = "", ) val deferred = async { viewModel.uiMessage.first() } @@ -307,7 +328,7 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.getRegistrationFields() } verify(exactly = 1) { appUpgradeNotifier.notifier } - assertTrue(viewModel.uiState.value.isLoading) + assertFalse(viewModel.uiState.value.isLoading) assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @@ -320,8 +341,11 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", + infoType = "", ) val deferred = async { viewModel.uiMessage.first() } @@ -331,7 +355,7 @@ class SignUpViewModelTest { coVerify(exactly = 1) { interactor.getRegistrationFields() } verify(exactly = 1) { appUpgradeNotifier.notifier } - assertTrue(viewModel.uiState.value.isLoading) + assertFalse(viewModel.uiState.value.isLoading) assertEquals(somethingWrong, (deferred.await() as? UIMessage.SnackBarMessage)?.message) } @@ -344,8 +368,11 @@ class SignUpViewModelTest { preferencesManager = preferencesManager, appUpgradeNotifier = appUpgradeNotifier, oAuthHelper = oAuthHelper, + agreementProvider = agreementProvider, config = config, + router = router, courseId = "", + infoType = "", ) coEvery { interactor.getRegistrationFields() } returns listOfFields viewModel.getRegistrationFields() diff --git a/build.gradle b/build.gradle index 462369006..6ebb49a56 100644 --- a/build.gradle +++ b/build.gradle @@ -5,31 +5,31 @@ import java.util.regex.Pattern buildscript { ext { - kotlin_version = '1.8.21' + kotlin_version = '1.9.22' coroutines_version = '1.7.1' - compose_version = '1.5.0' - compose_compiler_version = '1.4.7' + compose_version = '1.6.2' + compose_compiler_version = '1.5.10' } } plugins { - id 'com.android.application' version '8.1.1' apply false - id 'com.android.library' version '8.1.1' apply false + id 'com.android.application' version '8.3.0' apply false + id 'com.android.library' version '8.3.0' apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false id 'com.google.gms.google-services' version '4.3.15' apply false id "com.google.firebase.crashlytics" version "2.9.6" apply false } -task clean(type: Delete) { - delete rootProject.buildDir +tasks.register('clean', Delete) { + delete rootProject.layout.buildDirectory } ext { core_version = "1.10.1" appcompat_version = "1.6.1" - material_version = "1.9.0" - lifecycle_version = "2.6.1" - fragment_version = "1.6.1" + material_version = "1.11.0" + lifecycle_version = "2.7.0" + fragment_version = "1.6.2" constraintlayout_version = "2.1.4" viewpager2_version = "1.0.0" media3_version = "1.1.1" @@ -46,11 +46,11 @@ ext { jsoup_version = '1.13.1' - room_version = '2.5.2' + room_version = '2.6.1' - work_version = '2.8.1' + work_version = '2.9.0' - window_version = '1.1.0' + window_version = '1.2.0' in_app_review = '2.0.1' diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 9cc60ef40..f1d8de5cb 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -1,7 +1,16 @@ +plugins { + id 'java-library' +} + repositories { mavenCentral() } +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + dependencies { implementation localGroovy() implementation gradleApi() diff --git a/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy b/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy index ed78319bc..10ca3e8db 100644 --- a/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy +++ b/buildSrc/src/main/groovy/org/edx/builder/ConfigHelper.groovy @@ -95,4 +95,57 @@ class ConfigHelper { it.write(new JsonBuilder(configJson).toPrettyString()) } } + + def generateGoogleServicesJson(applicationId) { + def config = fetchConfig() + def firebase = config.get("FIREBASE") + if (!firebase) { + return + } + if (!firebase.getOrDefault("ENABLED", false)) { + return + } + + def googleServicesJsonPath = projectDir.path + "/app/" + new File(googleServicesJsonPath).mkdirs() + + def projectInfo = [ + project_number: firebase.getOrDefault("PROJECT_NUMBER", ""), + project_id : firebase.getOrDefault("PROJECT_ID", ""), + storage_bucket: "${firebase.getOrDefault("PROJECT_ID", "")}.appspot.com" + ] + def clientInfo = [ + mobilesdk_app_id : firebase.getOrDefault("APPLICATION_ID", ""), + android_client_info: [ + package_name: applicationId + ] + ] + def client = [ + client_info : clientInfo, + oauth_client: [], + api_key : [[current_key: firebase.getOrDefault("API_KEY", "")]], + services : [ + appinvite_service: [ + other_platform_oauth_client: [] + ] + ] + ] + def configJson = [ + project_info : projectInfo, + client : [client], + configuration_version: "1" + ] + + new FileWriter(googleServicesJsonPath + "/google-services.json").withWriter { + it.write(new JsonBuilder(configJson).toPrettyString()) + } + } + + def removeGoogleServicesJson() { + def googleServicesJsonPath = projectDir.path + "/app/google-services.json" + def file = new File(googleServicesJsonPath) + if (file.exists()) { + file.delete() + } + } } diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 000000000..87c6bc3ad --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,16 @@ +# This file records information about this repo. Its use is described in OEP-55: +# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html + +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: 'openedx-app-android' + description: "The mobile app for Android for the Open EdX Platform" + links: + - url: "https://github.com/openedx/openedx-app-android/tree/main/Documentation" + title: "Documentation" + icon: "PhoneAndroid" +spec: + owner: group:openedx-mobile-maintainers + type: 'mobile' + lifecycle: 'production' diff --git a/core/build.gradle b/core/build.gradle index 93928bfc8..8c4bdcc6f 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -85,6 +85,7 @@ android { buildFeatures { viewBinding true compose true + buildConfig true } composeOptions { kotlinCompilerExtensionVersion = "$compose_compiler_version" diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 558df5434..786d63cc4 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -20,7 +20,6 @@ object ApiConstants { const val ACCESS_TOKEN = "access_token" const val CLIENT_ID = "client_id" const val EMAIL = "email" - const val HONOR_CODE = "honor_code" const val NAME = "name" const val PASSWORD = "password" const val PROVIDER = "provider" @@ -30,4 +29,9 @@ object ApiConstants { const val AUTH_TYPE_MICROSOFT = "azuread-oauth2" const val COURSE_KEY = "course_key" + + object RegistrationFields { + const val HONOR_CODE = "honor_code" + const val MARKETING_EMAILS = "marketing_emails_opt_in" + } } diff --git a/core/src/main/java/org/openedx/core/AppDataConstants.kt b/core/src/main/java/org/openedx/core/AppDataConstants.kt index 0bb5a95d0..eb2580e99 100644 --- a/core/src/main/java/org/openedx/core/AppDataConstants.kt +++ b/core/src/main/java/org/openedx/core/AppDataConstants.kt @@ -10,4 +10,7 @@ object AppDataConstants { const val VIDEO_FORMAT_M3U8 = ".m3u8" const val VIDEO_FORMAT_MP4 = ".mp4" -} \ No newline at end of file + + // Equal 1GB + const val DOWNLOADS_CONFIRMATION_SIZE = 1024 * 1024 * 1024L +} diff --git a/core/src/main/java/org/openedx/core/UIMessage.kt b/core/src/main/java/org/openedx/core/UIMessage.kt index 6f26d6f65..8a9267f36 100644 --- a/core/src/main/java/org/openedx/core/UIMessage.kt +++ b/core/src/main/java/org/openedx/core/UIMessage.kt @@ -2,11 +2,11 @@ package org.openedx.core import androidx.compose.material.SnackbarDuration -sealed class UIMessage { +open class UIMessage { class SnackBarMessage( val message: String, val duration: SnackbarDuration = SnackbarDuration.Long, ) : UIMessage() class ToastMessage(val message: String) : UIMessage() -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt b/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt new file mode 100644 index 000000000..b3ee82211 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/AnalyticsSource.kt @@ -0,0 +1,11 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +enum class AnalyticsSource { + @SerializedName("segment") + SEGMENT, + + @SerializedName("none") + NONE, +} diff --git a/core/src/main/java/org/openedx/core/config/BranchConfig.kt b/core/src/main/java/org/openedx/core/config/BranchConfig.kt new file mode 100644 index 000000000..363e89fa6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/BranchConfig.kt @@ -0,0 +1,20 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class BranchConfig( + @SerializedName("ENABLED") + val enabled: Boolean = false, + + @SerializedName("KEY") + val key: String = "", + + @SerializedName("URI_SCHEME") + val uriScheme: String = "", + + @SerializedName("HOST") + val host: String = "", + + @SerializedName("ALTERNATE_HOST") + val alternateHost: String = "", +) diff --git a/core/src/main/java/org/openedx/core/config/BrazeConfig.kt b/core/src/main/java/org/openedx/core/config/BrazeConfig.kt new file mode 100644 index 000000000..62bb2e9be --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/BrazeConfig.kt @@ -0,0 +1,11 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class BrazeConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = false, + + @SerializedName("PUSH_NOTIFICATIONS_ENABLED") + val isPushNotificationsEnabled: Boolean = false +) 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 8739c4cfa..9f626cc2e 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -47,6 +47,10 @@ class Config(context: Context) { return getString(FEEDBACK_EMAIL_ADDRESS, "") } + fun getPlatformName(): String { + return getString(PLATFORM_NAME, "") + } + fun getAgreement(locale: String): AgreementUrls { val agreement = getObjectOrNewInstance(AGREEMENT_URLS, AgreementUrlsConfig::class.java).mapToDomain() @@ -57,6 +61,14 @@ class Config(context: Context) { return getObjectOrNewInstance(FIREBASE, FirebaseConfig::class.java) } + fun getSegmentConfig(): SegmentConfig { + return getObjectOrNewInstance(SEGMENT_IO, SegmentConfig::class.java) + } + + fun getBrazeConfig(): BrazeConfig { + return getObjectOrNewInstance(BRAZE, BrazeConfig::class.java) + } + fun getFacebookConfig(): FacebookConfig { return getObjectOrNewInstance(FACEBOOK, FacebookConfig::class.java) } @@ -79,6 +91,10 @@ class Config(context: Context) { return getObjectOrNewInstance(PROGRAM, ProgramConfig::class.java) } + fun getBranchConfig(): BranchConfig { + return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) + } + fun isWhatsNewEnabled(): Boolean { return getBoolean(WHATS_NEW_ENABLED, false) } @@ -148,16 +164,20 @@ class Config(context: Context) { private const val WHATS_NEW_ENABLED = "WHATS_NEW_ENABLED" private const val SOCIAL_AUTH_ENABLED = "SOCIAL_AUTH_ENABLED" private const val FIREBASE = "FIREBASE" + private const val SEGMENT_IO = "SEGMENT_IO" + private const val BRAZE = "BRAZE" private const val FACEBOOK = "FACEBOOK" private const val GOOGLE = "GOOGLE" private const val MICROSOFT = "MICROSOFT" private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" + private const val BRANCH = "BRANCH" private const val COURSE_NESTED_LIST_ENABLED = "COURSE_NESTED_LIST_ENABLED" private const val COURSE_BANNER_ENABLED = "COURSE_BANNER_ENABLED" private const val COURSE_TOP_TAB_BAR_ENABLED = "COURSE_TOP_TAB_BAR_ENABLED" private const val COURSE_UNIT_PROGRESS_ENABLED = "COURSE_UNIT_PROGRESS_ENABLED" + private const val PLATFORM_NAME = "PLATFORM_NAME" } enum class ViewType { diff --git a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt index b003c3230..f5b2e9136 100644 --- a/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt +++ b/core/src/main/java/org/openedx/core/config/FirebaseConfig.kt @@ -6,6 +6,15 @@ data class FirebaseConfig( @SerializedName("ENABLED") val enabled: Boolean = false, + @SerializedName("ANALYTICS_SOURCE") + val analyticsSource: AnalyticsSource = AnalyticsSource.NONE, + + @SerializedName("CLOUD_MESSAGING_ENABLED") + val isCloudMessagingEnabled: Boolean = false, + + @SerializedName("PROJECT_NUMBER") + val projectNumber: String = "", + @SerializedName("PROJECT_ID") val projectId: String = "", @@ -14,7 +23,8 @@ data class FirebaseConfig( @SerializedName("API_KEY") val apiKey: String = "", - - @SerializedName("GCM_SENDER_ID") - val gcmSenderId: String = "", -) +) { + fun isSegmentAnalyticsSource(): Boolean { + return enabled && analyticsSource == AnalyticsSource.SEGMENT + } +} diff --git a/core/src/main/java/org/openedx/core/config/SegmentConfig.kt b/core/src/main/java/org/openedx/core/config/SegmentConfig.kt new file mode 100644 index 000000000..ffa43e8bc --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/SegmentConfig.kt @@ -0,0 +1,11 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class SegmentConfig( + @SerializedName("ENABLED") + val enabled: Boolean = false, + + @SerializedName("SEGMENT_IO_WRITE_KEY") + val segmentWriteKey: String = "", +) diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index 64e749a26..4a19c383d 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -1,8 +1,20 @@ package org.openedx.core.data.api -import okhttp3.ResponseBody -import org.openedx.core.data.model.* -import retrofit2.http.* +import org.openedx.core.data.model.AnnouncementModel +import org.openedx.core.data.model.BlocksCompletionBody +import org.openedx.core.data.model.CourseComponentStatus +import org.openedx.core.data.model.CourseDates +import org.openedx.core.data.model.CourseDatesBannerInfo +import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.model.CourseStructureModel +import org.openedx.core.data.model.HandoutsModel +import org.openedx.core.data.model.ResetCourseDates +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query interface CourseApi { @@ -14,27 +26,6 @@ interface CourseApi { @Query("page") page: Int ): CourseEnrollments - @GET("/api/courses/v1/courses/") - suspend fun getCourseList( - @Query("search_term") searchQuery: String? = null, - @Query("page") page: Int, - @Query("mobile") mobile: Boolean, - @Query("mobile_search") mobileSearch: Boolean, - @Query("username") username: String? = null, - @Query("org") org: String? = null, - @Query("permissions") permission: List = listOf( - "enroll", - "see_in_catalog", - "see_about_page" - ) - ): CourseList - - @GET("/api/courses/v1/courses/{course_id}") - suspend fun getCourseDetail( - @Path("course_id") courseId: String?, - @Query("username") username: String? = null - ): CourseDetails - @GET( "/api/mobile/{api_version}/course_info/blocks/?" + "depth=all&" + @@ -50,9 +41,6 @@ interface CourseApi { @Query("course_id") courseId: String, ): CourseStructureModel - @POST("/api/enrollment/v1/enrollment") - suspend fun enrollInACourse(@Body enrollBody: EnrollBody): ResponseBody - @GET("/api/mobile/v1/users/{username}/course_status_info/{course_id}") suspend fun getCourseStatus( @Path("username") username: String, 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 new file mode 100644 index 000000000..4fcbe3d89 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/AppConfig.kt @@ -0,0 +1,15 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.AppConfig as DomainAppConfig + +data class AppConfig( + @SerializedName("course_dates_calendar_sync") + val calendarSyncConfig: CalendarSyncConfig = CalendarSyncConfig(), +) { + fun mapToDomain(): DomainAppConfig { + return DomainAppConfig( + courseDatesCalendarSync = calendarSyncConfig.mapToDomain(), + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CalendarSyncConfig.kt b/core/src/main/java/org/openedx/core/data/model/CalendarSyncConfig.kt new file mode 100644 index 000000000..bfd09b3d3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CalendarSyncConfig.kt @@ -0,0 +1,29 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.domain.model.CourseDatesCalendarSync + +data class CalendarSyncConfig( + @SerializedName("android") + val platformConfig: CalendarSyncPlatform = CalendarSyncPlatform(), +) { + fun mapToDomain(): CourseDatesCalendarSync { + return CourseDatesCalendarSync( + isEnabled = platformConfig.enabled, + isSelfPacedEnabled = platformConfig.selfPacedEnabled, + isInstructorPacedEnabled = platformConfig.instructorPacedEnabled, + isDeepLinkEnabled = platformConfig.deepLinksEnabled, + ) + } +} + +data class CalendarSyncPlatform( + @SerializedName("enabled") + val enabled: Boolean = false, + @SerializedName("self_paced_enabled") + val selfPacedEnabled: Boolean = false, + @SerializedName("instructor_paced_enabled") + val instructorPacedEnabled: Boolean = false, + @SerializedName("deep_links_enabled") + val deepLinksEnabled: Boolean = false, +) 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 1c10cfa92..89ecdcab4 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 @@ -1,8 +1,69 @@ 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.JsonObject import com.google.gson.annotations.SerializedName +import java.lang.reflect.Type data class CourseEnrollments( @SerializedName("enrollments") - val enrollments: DashboardCourseList -) + val enrollments: DashboardCourseList, + + @SerializedName("config") + val configs: AppConfig, +) { + class Deserializer : JsonDeserializer { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): CourseEnrollments { + val enrollments = deserializeEnrollments(json) + val appConfig = deserializeAppConfig(json) + + return CourseEnrollments(enrollments, appConfig) + } + + private fun deserializeEnrollments(json: JsonElement?): DashboardCourseList { + return try { + Gson().fromJson( + (json as JsonObject).get("enrollments"), + DashboardCourseList::class.java + ) + } catch (ex: Exception) { + DashboardCourseList( + next = null, + previous = null, + count = 0, + numPages = 0, + currentPage = 0, + results = listOf() + ) + } + } + + /** + * To remove dependency on the backend, all the data related to Remote Config + * will be received under the `configs` key. The `config` is the key under + * 'configs` which defines the data that is related to the configuration of the + * app. + */ + private fun deserializeAppConfig(json: JsonElement?): AppConfig { + return try { + val config = (json as JsonObject) + .getAsJsonObject("configs") + .getAsJsonPrimitive("config") + + Gson().fromJson( + config.asString, + AppConfig::class.java + ) + } catch (ex: Exception) { + AppConfig() + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 11f21c661..48999ab4e 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -1,6 +1,7 @@ package org.openedx.core.data.storage import org.openedx.core.data.model.User +import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.VideoSettings interface CorePreferences { @@ -9,6 +10,7 @@ interface CorePreferences { var accessTokenExpiresAt: Long var user: User? var videoSettings: VideoSettings + var appConfig: AppConfig fun clear() } 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 new file mode 100644 index 000000000..596fd0619 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/AppConfig.kt @@ -0,0 +1,14 @@ +package org.openedx.core.domain.model + +import java.io.Serializable + +data class AppConfig( + val courseDatesCalendarSync: CourseDatesCalendarSync, +) : Serializable + +data class CourseDatesCalendarSync( + val isEnabled: Boolean, + val isSelfPacedEnabled: Boolean, + val isInstructorPacedEnabled: Boolean, + val isDeepLinkEnabled: Boolean, +) : Serializable diff --git a/core/src/main/java/org/openedx/core/domain/model/Block.kt b/core/src/main/java/org/openedx/core/domain/model/Block.kt index 1c69142a8..2f1766ecb 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Block.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Block.kt @@ -117,13 +117,19 @@ data class EncodedVideos( || fallback?.url != null val videoUrl: String - get() = mobileHigh?.url - ?: mobileLow?.url - ?: desktopMp4?.url + get() = fallback?.url ?: hls?.url - ?: fallback?.url + ?: desktopMp4?.url + ?: mobileHigh?.url + ?: mobileLow?.url ?: "" + val hasVideoUrl: Boolean + get() = videoUrl.isNotEmpty() + + val hasYoutubeUrl: Boolean + get() = youtube?.url?.isNotEmpty() == true + fun getPreferredVideoInfoForDownloading(preferredVideoQuality: VideoQuality): VideoInfo? { var preferredVideoInfo = when (preferredVideoQuality) { VideoQuality.OPTION_360P -> mobileLow diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseList.kt b/core/src/main/java/org/openedx/core/domain/model/CourseList.kt deleted file mode 100644 index 6c38ef924..000000000 --- a/core/src/main/java/org/openedx/core/domain/model/CourseList.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.openedx.core.domain.model - -data class CourseList( - val pagination: Pagination, - val results: List, -) \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt b/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt index 20b1af6eb..e4892edb5 100644 --- a/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt +++ b/core/src/main/java/org/openedx/core/domain/model/RegistrationField.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.ApiConstants data class RegistrationField( val name: String, @@ -13,7 +14,8 @@ data class RegistrationField( val required: Boolean, val restrictions: Restrictions, val options: List