diff --git a/app/build.gradle b/app/build.gradle index baabb18d2..f7ad7ef16 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -28,12 +28,13 @@ if (firebaseEnabled) { } android { - compileSdk 34 + namespace 'org.openedx.app' + compileSdkVersion compile_sdk_version defaultConfig { applicationId appId - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version versionCode 1 versionName "1.0.0" @@ -42,7 +43,6 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } - namespace 'org.openedx.app' flavorDimensions += "env" productFlavors { @@ -88,12 +88,14 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { viewBinding true @@ -125,22 +127,23 @@ dependencies { implementation project(path: ':profile') implementation project(path: ':discussion') implementation project(path: ':whatsnew') + implementation project(path: ':downloads') ksp "androidx.room:room-compiler:$room_version" - implementation 'androidx.core:core-splashscreen:1.0.1' + implementation "androidx.core:core-splashscreen:$core_splashscreen_version" api platform("com.google.firebase:firebase-bom:$firebase_version") api "com.google.firebase:firebase-messaging" // Braze SDK Integration - implementation "com.braze:android-sdk-ui:30.2.0" + implementation "com.braze:android-sdk-ui:$braze_sdk_version" // Plugins - implementation("com.github.openedx:openedx-app-firebase-analytics-android:1.0.0") + implementation("com.github.openedx:openedx-app-firebase-analytics-android:$openedx_firebase_analytics_version") - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" @@ -180,3 +183,7 @@ private def setupFirebaseConfigFields(buildType) { buildType.manifestPlaceholders = [fcmEnabled: firebaseEnabled && cloudMessagingEnabled] } + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 825176c61..9e4670b9e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -3,6 +3,10 @@ # removes such information by default, so configure it to keep all of it. -keepattributes Signature +# CRITICAL: Keep generic type information for TypeToken to work properly +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations +-keepattributes *Annotation* + # For using GSON @Expose annotation -keepattributes *Annotation* @@ -23,8 +27,74 @@ } # Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. --keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken --keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken +# CRITICAL: Do NOT allow obfuscation or shrinking of TypeToken - it needs to preserve generic type information +-keep class com.google.gson.reflect.TypeToken +-keep class * extends com.google.gson.reflect.TypeToken + +# Keep TypeToken constructors and methods to preserve generic type information +-keepclassmembers class com.google.gson.reflect.TypeToken { + (...); + ; +} + +# Keep all Gson reflection classes that handle generic types +-keep class com.google.gson.reflect.** { *; } + +# CRITICAL: Keep Google Guava TypeToken and TypeCapture classes (used by Gson) +-keep class com.google.common.reflect.TypeToken { *; } +-keep class com.google.common.reflect.TypeCapture { *; } +-keep class com.google.common.reflect.TypeToken$* { *; } +-keep class com.google.common.reflect.TypeCapture$* { *; } + +# Keep all anonymous subclasses of TypeToken (created by object : TypeToken() {}) +-keep class * extends com.google.common.reflect.TypeToken { *; } +-keep class * extends com.google.gson.reflect.TypeToken { *; } + +# Keep Gson TypeAdapter classes used by Room TypeConverters +-keep class * extends com.google.gson.TypeAdapter +-keep class * implements com.google.gson.TypeAdapterFactory + +# Keep Room TypeConverters that use Gson (important for complex types like List) +-keep @androidx.room.TypeConverter class * { *; } +-keepclassmembers class * { + @androidx.room.TypeConverter ; +} + +# Keep generic type information for Room entities with complex types +-keepclassmembers class org.openedx.**.data.model.room.** { + ; + (...); + * mapToDomain(); + * mapToRoomEntity(); + * mapToEntity(); +} + +# CRITICAL: Keep the CourseConverter and all its TypeToken usage +-keep class org.openedx.course.data.storage.CourseConverter { *; } +-keepclassmembers class org.openedx.course.data.storage.CourseConverter { + (...); + ; +} + +# Keep anonymous TypeToken subclasses created in CourseConverter +-keep class org.openedx.course.data.storage.CourseConverter$* { *; } + +# CRITICAL: Prevent obfuscation of CourseConverter methods that use TypeToken +-keepclassmembers,allowobfuscation class org.openedx.course.data.storage.CourseConverter { + @androidx.room.TypeConverter ; +} + +# Keep all TypeConverter classes that use Gson +-keep class org.openedx.discovery.data.converter.DiscoveryConverter { *; } + +# Keep the specific TypeToken usage patterns in TypeConverters +-keepclassmembers class org.openedx.**.data.storage.** { + @androidx.room.TypeConverter ; +} + +-keepclassmembers class org.openedx.**.data.converter.** { + @androidx.room.TypeConverter ; +} ##---------------End: proguard configuration for Gson ---------- -keepclassmembers class * extends java.lang.Enum { @@ -33,6 +103,45 @@ public static ** valueOf(java.lang.String); } +##---------------Begin: proguard configuration for Kotlin Coroutines ---------- +# Keep all coroutine-related classes and methods +-keep class kotlinx.coroutines.** { *; } +-keep class kotlin.coroutines.** { *; } +-keep class kotlin.coroutines.intrinsics.** { *; } + +# Keep suspend functions and coroutine builders +-keepclassmembers class * { + kotlin.coroutines.Continuation *(...); +} + +# Keep coroutine context and related classes +-keep class kotlinx.coroutines.CoroutineContext$* { *; } + +# Keep Flow and StateFlow classes +-keep class kotlinx.coroutines.flow.** { *; } + +# Keep coroutine dispatchers +-keep class kotlinx.coroutines.Dispatchers { *; } +-keep class kotlinx.coroutines.Dispatchers$* { *; } + +# Keep coroutine scope and job classes +-keep class kotlinx.coroutines.CoroutineScope { *; } +-keep class kotlinx.coroutines.Job { *; } +-keep class kotlinx.coroutines.Job$* { *; } + +# Keep coroutine intrinsics that are causing the error +-keep class kotlin.coroutines.intrinsics.IntrinsicsKt { *; } +-keep class kotlin.coroutines.intrinsics.IntrinsicsKt$* { *; } + +# Keep suspend function markers +-keepclassmembers class * { + @kotlin.coroutines.RestrictsSuspension ; +} + +# Keep coroutine-related annotations +-keep @kotlin.coroutines.RestrictsSuspension class * { *; } +##---------------End: proguard configuration for Kotlin Coroutines ---------- + -dontwarn org.bouncycastle.jsse.BCSSLParameters -dontwarn org.bouncycastle.jsse.BCSSLSocket -dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider @@ -67,3 +176,9 @@ -dontwarn org.bouncycastle.openssl.PEMKeyPair -dontwarn org.bouncycastle.openssl.PEMParser -dontwarn org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter +-dontwarn com.android.billingclient.api.BillingClientStateListener +-dontwarn com.android.billingclient.api.PurchasesUpdatedListener +-dontwarn com.google.crypto.tink.subtle.XChaCha20Poly1305 +-dontwarn net.jcip.annotations.GuardedBy +-dontwarn net.jcip.annotations.Immutable +-dontwarn net.jcip.annotations.ThreadSafe diff --git a/app/schemas/org.openedx.app.room.AppDatabase/1.json b/app/schemas/org.openedx.app.room.AppDatabase/1.json new file mode 100644 index 000000000..c249fa741 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/1.json @@ -0,0 +1,772 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "bcac519e74e751a75f3e6fa5d39ac5a3", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bcac519e74e751a75f3e6fa5d39ac5a3')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openedx.app.room.AppDatabase/2.json b/app/schemas/org.openedx.app.room.AppDatabase/2.json new file mode 100644 index 000000000..002abc547 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/2.json @@ -0,0 +1,978 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "ed545aec6739ec7692c4bb72179331c4", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed545aec6739ec7692c4bb72179331c4')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openedx.app.room.AppDatabase/3.json b/app/schemas/org.openedx.app.room.AppDatabase/3.json new file mode 100644 index 000000000..0b47d8504 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/3.json @@ -0,0 +1,1198 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "bcf7a22441e12e4c8b6fb332754827bf", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bcf7a22441e12e4c8b6fb332754827bf')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openedx.app.room.AppDatabase/4.json b/app/schemas/org.openedx.app.room.AppDatabase/4.json new file mode 100644 index 000000000..0bf47775d --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/4.json @@ -0,0 +1,1236 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "7ea446decde04c9c16700cb3981703c2", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "video_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER, `duration` INTEGER, PRIMARY KEY(`block_id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "block_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "video_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoTime", + "columnName": "video_time", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "block_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7ea446decde04c9c16700cb3981703c2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.openedx.app.room.AppDatabase/5.json b/app/schemas/org.openedx.app.room.AppDatabase/5.json new file mode 100644 index 000000000..3b42cabf3 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/5.json @@ -0,0 +1,1152 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "09f6fc49a2f7a494d27f3290d7bae350", + "entities": [ + { + "tableName": "course_discovery_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `blocksUrl` TEXT NOT NULL, `courseId` TEXT NOT NULL, `effort` TEXT NOT NULL, `enrollmentStart` TEXT NOT NULL, `enrollmentEnd` TEXT NOT NULL, `hidden` INTEGER NOT NULL, `invitationOnly` INTEGER NOT NULL, `mobileAvailable` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `pacing` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `start` TEXT NOT NULL, `end` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `overview` TEXT NOT NULL, `isEnrolled` INTEGER NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocksUrl", + "columnName": "blocksUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "effort", + "columnName": "effort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentStart", + "columnName": "enrollmentStart", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentEnd", + "columnName": "enrollmentEnd", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hidden", + "columnName": "hidden", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invitationOnly", + "columnName": "invitationOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mobileAvailable", + "columnName": "mobileAvailable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pacing", + "columnName": "pacing", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "overview", + "columnName": "overview", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnrolled", + "columnName": "isEnrolled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "course_enrolled_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `auditAccessExpires` TEXT NOT NULL, `created` TEXT NOT NULL, `mode` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT NOT NULL, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT NOT NULL, `dynamicUpgradeDeadline` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `course_image_link` TEXT NOT NULL, `courseAbout` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `videoOutline` TEXT NOT NULL, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, `lastVisitedModuleId` TEXT, `lastVisitedModulePath` TEXT, `lastVisitedBlockId` TEXT, `lastVisitedUnitDisplayName` TEXT, `futureAssignments` TEXT, `pastAssignments` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.start", + "columnName": "start", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.dynamicUpgradeDeadline", + "columnName": "dynamicUpgradeDeadline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.subscriptionId", + "columnName": "subscriptionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseImage", + "columnName": "course_image_link", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.videoOutline", + "columnName": "videoOutline", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "course.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "course.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "course.media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "course.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "course.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseStatus.lastVisitedModuleId", + "columnName": "lastVisitedModuleId", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedModulePath", + "columnName": "lastVisitedModulePath", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedBlockId", + "columnName": "lastVisitedBlockId", + "affinity": "TEXT" + }, + { + "fieldPath": "courseStatus.lastVisitedUnitDisplayName", + "columnName": "lastVisitedUnitDisplayName", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAssignments.futureAssignments", + "columnName": "futureAssignments", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAssignments.pastAssignments", + "columnName": "pastAssignments", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + } + }, + { + "tableName": "course_structure_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`root` TEXT NOT NULL, `id` TEXT NOT NULL, `blocks` TEXT NOT NULL, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` TEXT, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` TEXT, `isSelfPaced` INTEGER NOT NULL, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `certificateURL` TEXT, `assignments_completed` INTEGER NOT NULL, `total_assignments_count` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "root", + "columnName": "root", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blocks", + "columnName": "blocks", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "TEXT" + }, + { + "fieldPath": "startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT" + }, + { + "fieldPath": "isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "progress.assignmentsCompleted", + "columnName": "assignments_completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progress.totalAssignmentsCount", + "columnName": "total_assignments_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "download_model", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `courseId` TEXT NOT NULL, `size` INTEGER NOT NULL, `path` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `downloadedState` TEXT NOT NULL, `lastModified` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadedState", + "columnName": "downloadedState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "offline_x_block_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseId` TEXT NOT NULL, `url` TEXT NOT NULL, `type` TEXT NOT NULL, `data` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "jsonProgress.data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "course_calendar_event_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`event_id` INTEGER NOT NULL, `course_id` TEXT NOT NULL, PRIMARY KEY(`event_id`))", + "fields": [ + { + "fieldPath": "eventId", + "columnName": "event_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "event_id" + ] + } + }, + { + "tableName": "course_calendar_state_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `checksum` INTEGER NOT NULL, `is_course_sync_enabled` INTEGER NOT NULL, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCourseSyncEnabled", + "columnName": "is_course_sync_enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + } + }, + { + "tableName": "download_course_preview_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`course_id` TEXT NOT NULL, `course_name` TEXT, `course_image` TEXT, `total_size` INTEGER, PRIMARY KEY(`course_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "course_name", + "affinity": "TEXT" + }, + { + "fieldPath": "image", + "columnName": "course_image", + "affinity": "TEXT" + }, + { + "fieldPath": "totalSize", + "columnName": "total_size", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "course_id" + ] + } + }, + { + "tableName": "course_enrollment_details_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `courseUpdates` TEXT NOT NULL, `courseHandouts` TEXT NOT NULL, `discussionUrl` TEXT NOT NULL, `hasUnmetPrerequisites` INTEGER NOT NULL, `isTooEarly` INTEGER NOT NULL, `isStaff` INTEGER NOT NULL, `auditAccessExpires` TEXT, `hasAccess` INTEGER, `errorCode` TEXT, `developerMessage` TEXT, `userMessage` TEXT, `additionalContextUserMessage` TEXT, `userFragment` TEXT, `certificateURL` TEXT, `created` TEXT, `mode` TEXT, `isActive` INTEGER NOT NULL, `upgradeDeadline` TEXT, `name` TEXT NOT NULL, `number` TEXT NOT NULL, `org` TEXT NOT NULL, `start` INTEGER, `startDisplay` TEXT NOT NULL, `startType` TEXT NOT NULL, `end` INTEGER, `isSelfPaced` INTEGER NOT NULL, `courseAbout` TEXT NOT NULL, `bannerImage` TEXT, `courseImage` TEXT, `courseVideo` TEXT, `image` TEXT, `facebook` TEXT NOT NULL, `twitter` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseUpdates", + "columnName": "courseUpdates", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseHandouts", + "columnName": "courseHandouts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "discussionUrl", + "columnName": "discussionUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.hasUnmetPrerequisites", + "columnName": "hasUnmetPrerequisites", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isTooEarly", + "columnName": "isTooEarly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.isStaff", + "columnName": "isStaff", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseAccessDetails.auditAccessExpires", + "columnName": "auditAccessExpires", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", + "columnName": "hasAccess", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", + "columnName": "errorCode", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", + "columnName": "developerMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", + "columnName": "userMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", + "columnName": "additionalContextUserMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", + "columnName": "userFragment", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate.certificateURL", + "columnName": "certificateURL", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.created", + "columnName": "created", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.mode", + "columnName": "mode", + "affinity": "TEXT" + }, + { + "fieldPath": "enrollmentDetails.isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enrollmentDetails.upgradeDeadline", + "columnName": "upgradeDeadline", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.number", + "columnName": "number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.org", + "columnName": "org", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.start", + "columnName": "start", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseInfoOverview.startDisplay", + "columnName": "startDisplay", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.startType", + "columnName": "startType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.end", + "columnName": "end", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseInfoOverview.isSelfPaced", + "columnName": "isSelfPaced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseAbout", + "columnName": "courseAbout", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.media.bannerImage", + "columnName": "bannerImage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.courseImage", + "columnName": "courseImage", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.courseVideo", + "columnName": "courseVideo", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.media.image", + "columnName": "image", + "affinity": "TEXT" + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", + "columnName": "facebook", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "courseInfoOverview.courseSharingUtmParameters.twitter", + "columnName": "twitter", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "video_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`block_id` TEXT NOT NULL, `video_url` TEXT NOT NULL, `video_time` INTEGER, `duration` INTEGER, PRIMARY KEY(`block_id`))", + "fields": [ + { + "fieldPath": "blockId", + "columnName": "block_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoUrl", + "columnName": "video_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoTime", + "columnName": "video_time", + "affinity": "INTEGER" + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "block_id" + ] + } + }, + { + "tableName": "course_progress_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` TEXT NOT NULL, `verifiedMode` TEXT NOT NULL, `accessExpiration` TEXT NOT NULL, `creditCourseRequirements` TEXT NOT NULL, `end` TEXT NOT NULL, `enrollmentMode` TEXT NOT NULL, `hasScheduledContent` INTEGER NOT NULL, `sectionScores` TEXT NOT NULL, `studioUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `userHasPassingGrade` INTEGER NOT NULL, `disableProgressGraph` INTEGER NOT NULL, `certificate_certStatus` TEXT, `certificate_certWebViewUrl` TEXT, `certificate_downloadUrl` TEXT, `certificate_certificateAvailableDate` TEXT, `completion_completeCount` INTEGER, `completion_incompleteCount` INTEGER, `completion_lockedCount` INTEGER, `grade_letterGrade` TEXT, `grade_percent` REAL, `grade_isPassing` INTEGER, `grading_assignmentPolicies` TEXT, `grading_gradeRange` TEXT, `grading_assignmentColors` TEXT, `verification_link` TEXT, `verification_status` TEXT, `verification_statusDate` TEXT, PRIMARY KEY(`courseId`))", + "fields": [ + { + "fieldPath": "courseId", + "columnName": "courseId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "verifiedMode", + "columnName": "verifiedMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessExpiration", + "columnName": "accessExpiration", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "creditCourseRequirements", + "columnName": "creditCourseRequirements", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "end", + "columnName": "end", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enrollmentMode", + "columnName": "enrollmentMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasScheduledContent", + "columnName": "hasScheduledContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sectionScores", + "columnName": "sectionScores", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "studioUrl", + "columnName": "studioUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userHasPassingGrade", + "columnName": "userHasPassingGrade", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableProgressGraph", + "columnName": "disableProgressGraph", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "certificateData.certStatus", + "columnName": "certificate_certStatus", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.certWebViewUrl", + "columnName": "certificate_certWebViewUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.downloadUrl", + "columnName": "certificate_downloadUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "certificateData.certificateAvailableDate", + "columnName": "certificate_certificateAvailableDate", + "affinity": "TEXT" + }, + { + "fieldPath": "completionSummary.completeCount", + "columnName": "completion_completeCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionSummary.incompleteCount", + "columnName": "completion_incompleteCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "completionSummary.lockedCount", + "columnName": "completion_lockedCount", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseGrade.letterGrade", + "columnName": "grade_letterGrade", + "affinity": "TEXT" + }, + { + "fieldPath": "courseGrade.percent", + "columnName": "grade_percent", + "affinity": "REAL" + }, + { + "fieldPath": "courseGrade.isPassing", + "columnName": "grade_isPassing", + "affinity": "INTEGER" + }, + { + "fieldPath": "gradingPolicy.assignmentPolicies", + "columnName": "grading_assignmentPolicies", + "affinity": "TEXT" + }, + { + "fieldPath": "gradingPolicy.gradeRange", + "columnName": "grading_gradeRange", + "affinity": "TEXT" + }, + { + "fieldPath": "gradingPolicy.assignmentColors", + "columnName": "grading_assignmentColors", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.link", + "columnName": "verification_link", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.status", + "columnName": "verification_status", + "affinity": "TEXT" + }, + { + "fieldPath": "verificationData.statusDate", + "columnName": "verification_statusDate", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "courseId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '09f6fc49a2f7a494d27f3290d7bae350')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 138692348..6c29cdf12 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -2,6 +2,7 @@ package org.openedx.app import org.openedx.auth.presentation.AuthAnalytics import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics import org.openedx.dashboard.presentation.DashboardAnalytics @@ -21,7 +22,8 @@ class AnalyticsManager : DiscoveryAnalytics, DiscussionAnalytics, ProfileAnalytics, - WhatsNewAnalytics { + WhatsNewAnalytics, + DownloadsAnalytics { private val analytics: MutableList = mutableListOf() diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 19c096338..b904bf6a1 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.content.res.Configuration import android.graphics.Color import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.View import android.view.WindowManager @@ -27,11 +28,11 @@ import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.ApiConstants import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.global.InsetHolder import org.openedx.core.presentation.global.WindowSizeHolder import org.openedx.core.utils.Logger import org.openedx.core.worker.CalendarSyncScheduler -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.extension.requestApplyInsetsWhenAttached import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType @@ -157,10 +158,14 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { window.apply { addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) WindowCompat.setDecorFitsSystemWindows(this, false) - val insetsController = WindowInsetsControllerCompat(this, binding.root) insetsController.isAppearanceLightStatusBars = !isUsingNightModeResources() - statusBarColor = Color.TRANSPARENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } else { + window.statusBarColor = Color.TRANSPARENT + } } } @@ -214,7 +219,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { } } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) this.intent = intent @@ -222,13 +227,13 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { addFragment(SignInFragment.newInstance(null, null, authCode = authCode)) } - val extras = intent?.extras + val extras = intent.extras if (extras?.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) == true) { handlePushNotification(extras) } if (viewModel.isBranchEnabled) { - if (intent?.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false) == true) { + if (intent.getBooleanExtra(BRANCH_FORCE_NEW_SESSION, false)) { Branch.sessionBuilder(this) .withCallback(branchCallback) .reInit() diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 0fe3ed4be..55b26b492 100644 --- a/app/src/main/java/org/openedx/app/AppAnalytics.kt +++ b/app/src/main/java/org/openedx/app/AppAnalytics.kt @@ -20,6 +20,10 @@ enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { "MainDashboard:Discover", "edx.bi.app.main_dashboard.discover" ), + DOWNLOADS( + "MainDashboard:Downloads", + "edx.bi.app.main_dashboard.downloads" + ), PROFILE( "MainDashboard:Profile", "edx.bi.app.main_dashboard.profile" diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 0130d6b31..4678344ee 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -11,7 +11,6 @@ import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.auth.presentation.signup.SignUpFragment import org.openedx.core.CalendarRouter import org.openedx.core.FragmentViewType -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.WebContentFragment @@ -24,6 +23,7 @@ import org.openedx.course.presentation.handouts.HandoutsType import org.openedx.course.presentation.handouts.HandoutsWebViewFragment import org.openedx.course.presentation.section.CourseSectionFragment import org.openedx.course.presentation.unit.container.CourseUnitContainerFragment +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.course.presentation.unit.video.VideoFullScreenFragment import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.course.settings.download.DownloadQueueFragment @@ -44,6 +44,7 @@ import org.openedx.discussion.presentation.responses.DiscussionResponsesFragment import org.openedx.discussion.presentation.search.DiscussionSearchThreadFragment import org.openedx.discussion.presentation.threads.DiscussionAddThreadFragment import org.openedx.discussion.presentation.threads.DiscussionThreadsFragment +import org.openedx.downloads.presentation.DownloadsRouter import org.openedx.profile.domain.model.Account import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.presentation.anothersaccount.AnothersProfileFragment @@ -67,7 +68,8 @@ class AppRouter : ProfileRouter, AppUpgradeRouter, WhatsNewRouter, - CalendarRouter { + CalendarRouter, + DownloadsRouter { // region AuthRouter override fun navigateToMain( diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 3ab735d27..82092e439 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -1,7 +1,13 @@ package org.openedx.app import android.os.Bundle +import android.view.Menu import android.view.View +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.core.os.bundleOf import androidx.core.view.forEach import androidx.fragment.app.Fragment @@ -13,10 +19,16 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.FragmentMainBinding import org.openedx.app.deeplink.HomeTab +import org.openedx.core.AppUpdateState +import org.openedx.core.AppUpdateState.wasUpgradeDialogClosed import org.openedx.core.adapter.NavigationFragmentAdapter +import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment +import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.downloads.presentation.download.DownloadsFragment import org.openedx.learn.presentation.LearnFragment import org.openedx.learn.presentation.LearnTab import org.openedx.profile.presentation.profile.ProfileFragment @@ -27,8 +39,6 @@ class MainFragment : Fragment(R.layout.fragment_main) { private val viewModel by viewModel() private val router by inject() - private lateinit var adapter: NavigationFragmentAdapter - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) @@ -40,29 +50,107 @@ class MainFragment : Fragment(R.layout.fragment_main) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + handleArguments() + setupBottomNavigation() + setupViewPager() + setupBottomPopup() + observeViewModel() + } - initViewPager() - - binding.bottomNavView.setOnItemSelectedListener { - when (it.itemId) { - R.id.fragmentLearn -> { - viewModel.logLearnTabClickedEvent() - binding.viewPager.setCurrentItem(0, false) + private fun handleArguments() { + requireArguments().apply { + getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> + val infoType = getString(ARG_INFO_TYPE) + if (viewModel.isDiscoveryTypeWebView && infoType != null) { + router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) + } else { + router.navigateToCourseDetail(parentFragmentManager, courseId) } + putString(ARG_COURSE_ID, "") + putString(ARG_INFO_TYPE, "") + } + } + } - R.id.fragmentDiscover -> { - viewModel.logDiscoveryTabClickedEvent() - binding.viewPager.setCurrentItem(1, false) - } + private fun setupBottomNavigation() { + val openTabArg = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) + val initialMenuId = getInitialMenuId(openTabArg) + binding.bottomNavView.selectedItemId = initialMenuId + + val menu = binding.bottomNavView.menu + menu.clear() + + val tabList = createTabList(openTabArg) + addMenuItems(menu, tabList) + setupBottomNavListener(tabList) - R.id.fragmentProfile -> { - viewModel.logProfileTabClickedEvent() - binding.viewPager.setCurrentItem(2, false) + requireArguments().remove(ARG_OPEN_TAB) + } + + private fun createTabList(openTabArg: String): List Fragment>> { + val learnFragmentFactory = { + LearnFragment.newInstance( + openTab = if (openTabArg == HomeTab.PROGRAMS.name) { + LearnTab.PROGRAMS.name + } else { + LearnTab.COURSES.name } + ) + } + + return mutableListOf Fragment>>().apply { + add(R.id.fragmentLearn to learnFragmentFactory) + add(R.id.fragmentDiscover to { viewModel.getDiscoveryFragment }) + if (viewModel.isDownloadsFragmentEnabled) { + add(R.id.fragmentDownloads to { DownloadsFragment() }) + } + add(R.id.fragmentProfile to { ProfileFragment() }) + } + } + + private fun addMenuItems(menu: Menu, tabList: List Fragment>>) { + val tabTitles = mapOf( + R.id.fragmentLearn to resources.getString(R.string.app_navigation_learn), + R.id.fragmentDiscover to resources.getString(R.string.app_navigation_discovery), + R.id.fragmentDownloads to resources.getString(R.string.app_navigation_downloads), + R.id.fragmentProfile to resources.getString(R.string.app_navigation_profile), + ) + val tabIconSelectors = mapOf( + R.id.fragmentLearn to R.drawable.app_ic_learn_selector, + R.id.fragmentDiscover to R.drawable.app_ic_discover_selector, + R.id.fragmentDownloads to R.drawable.app_ic_downloads_selector, + R.id.fragmentProfile to R.drawable.app_ic_profile_selector + ) + + for ((id, _) in tabList) { + val menuItem = menu.add(Menu.NONE, id, Menu.NONE, tabTitles[id] ?: "") + tabIconSelectors[id]?.let { menuItem.setIcon(it) } + } + } + + private fun setupBottomNavListener(tabList: List Fragment>>) { + val menuIdToIndex = tabList.mapIndexed { index, pair -> pair.first to index }.toMap() + + binding.bottomNavView.setOnItemSelectedListener { menuItem -> + when (menuItem.itemId) { + R.id.fragmentLearn -> viewModel.logLearnTabClickedEvent() + R.id.fragmentDiscover -> viewModel.logDiscoveryTabClickedEvent() + R.id.fragmentDownloads -> viewModel.logDownloadsTabClickedEvent() + R.id.fragmentProfile -> viewModel.logProfileTabClickedEvent() + } + menuIdToIndex[menuItem.itemId]?.let { index -> + binding.viewPager.setCurrentItem(index, false) } true } + } + private fun setupViewPager() { + val tabList = createTabList(requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name)) + initViewPager(tabList) + } + + private fun observeViewModel() { viewModel.isBottomBarEnabled.observe(viewLifecycleOwner) { isBottomBarEnabled -> enableBottomBar(isBottomBarEnabled) } @@ -74,57 +162,32 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } } + } - requireArguments().apply { - getString(ARG_COURSE_ID).takeIf { it.isNullOrBlank().not() }?.let { courseId -> - val infoType = getString(ARG_INFO_TYPE) - - if (viewModel.isDiscoveryTypeWebView && infoType != null) { - router.navigateToCourseInfo(parentFragmentManager, courseId, infoType) - } else { - router.navigateToCourseDetail(parentFragmentManager, courseId) - } - - // Clear arguments after navigation - putString(ARG_COURSE_ID, "") - putString(ARG_INFO_TYPE, "") + private fun getInitialMenuId(openTabArg: String): Int { + return when (openTabArg) { + HomeTab.LEARN.name, HomeTab.PROGRAMS.name -> R.id.fragmentLearn + HomeTab.DISCOVER.name -> R.id.fragmentDiscover + HomeTab.DOWNLOADS.name -> if (viewModel.isDownloadsFragmentEnabled) { + R.id.fragmentDownloads + } else { + R.id.fragmentLearn } - when (requireArguments().getString(ARG_OPEN_TAB, "")) { - HomeTab.LEARN.name, - HomeTab.PROGRAMS.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentLearn - } - - HomeTab.DISCOVER.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentDiscover - } - - HomeTab.PROFILE.name -> { - binding.bottomNavView.selectedItemId = R.id.fragmentProfile - } - } - requireArguments().remove(ARG_OPEN_TAB) + HomeTab.PROFILE.name -> R.id.fragmentProfile + else -> R.id.fragmentLearn } } - @Suppress("MagicNumber") - private fun initViewPager() { + private fun initViewPager(tabList: List Fragment>>) { binding.viewPager.orientation = ViewPager2.ORIENTATION_HORIZONTAL - binding.viewPager.offscreenPageLimit = 4 - - val openTab = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) - val learnTab = if (openTab == HomeTab.PROGRAMS.name) { - LearnTab.PROGRAMS - } else { - LearnTab.COURSES - } - adapter = NavigationFragmentAdapter(this).apply { - addFragment(LearnFragment.newInstance(openTab = learnTab.name)) - addFragment(viewModel.getDiscoveryFragment) - addFragment(ProfileFragment()) + binding.viewPager.offscreenPageLimit = tabList.size + binding.viewPager.adapter = NavigationFragmentAdapter(this).apply { + tabList.forEach { (_, fragmentFactory) -> + // Use fragment factory to prevent memory leaks + addFragment { fragmentFactory() } + } } - binding.viewPager.adapter = adapter binding.viewPager.isUserInputEnabled = false } @@ -134,6 +197,56 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } + private fun setupBottomPopup() { + binding.composeBottomPopup.setContent { + val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() + val wasUpgradeDialogClosed by remember { wasUpgradeDialogClosed } + val appUpgradeParameters = AppUpdateState.AppUpgradeParameters( + appUpgradeEvent = appUpgradeEvent, + wasUpgradeDialogClosed = wasUpgradeDialogClosed, + appUpgradeRecommendedDialog = { + val dialog = AppUpgradeDialogFragment.newInstance() + dialog.show( + requireActivity().supportFragmentManager, + AppUpgradeDialogFragment::class.simpleName + ) + }, + onAppUpgradeRecommendedBoxClick = { + AppUpdateState.openPlayMarket(requireContext()) + }, + onAppUpgradeRequired = { + router.navigateToUpgradeRequired( + requireActivity().supportFragmentManager + ) + } + ) + when (appUpgradeParameters.appUpgradeEvent) { + is AppUpgradeEvent.UpgradeRecommendedEvent -> { + if (appUpgradeParameters.wasUpgradeDialogClosed) { + AppUpgradeRecommendedBox( + modifier = Modifier.fillMaxWidth(), + onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick + ) + } else { + if (!AppUpdateState.wasUpdateDialogDisplayed) { + AppUpdateState.wasUpdateDialogDisplayed = true + appUpgradeParameters.appUpgradeRecommendedDialog() + } + } + } + + is AppUpgradeEvent.UpgradeRequiredEvent -> { + if (!AppUpdateState.wasUpdateDialogDisplayed) { + AppUpdateState.wasUpdateDialogDisplayed = true + appUpgradeParameters.onAppUpgradeRequired() + } + } + + else -> {} + } + } + } + companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_INFO_TYPE = "info_type" diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 69c809b5c..8723d6dbe 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -10,9 +10,12 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import org.openedx.core.config.Config import org.openedx.core.system.notifier.DiscoveryNotifier import org.openedx.core.system.notifier.NavigationToDiscovery +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.presentation.DiscoveryNavigator import org.openedx.foundation.presentation.BaseViewModel @@ -20,6 +23,7 @@ class MainViewModel( private val config: Config, private val notifier: DiscoveryNotifier, private val analytics: AppAnalytics, + private val appNotifier: AppNotifier, ) : BaseViewModel() { private val _isBottomBarEnabled = MutableLiveData(true) @@ -30,19 +34,19 @@ class MainViewModel( val navigateToDiscovery: SharedFlow get() = _navigateToDiscovery.asSharedFlow() + private val _appUpgradeEvent = MutableLiveData() + val appUpgradeEvent: LiveData + get() = _appUpgradeEvent + val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() + val isDownloadsFragmentEnabled get() = config.getDownloadsConfig().isEnabled + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) - notifier.notifier - .onEach { - if (it is NavigationToDiscovery) { - _navigateToDiscovery.emit(true) - } - } - .distinctUntilChanged() - .launchIn(viewModelScope) + collectDiscoveryEvents() + collectAppUpgradeEvent() } fun enableBottomBar(enable: Boolean) { @@ -57,6 +61,10 @@ class MainViewModel( logScreenEvent(AppAnalyticsEvent.DISCOVER) } + fun logDownloadsTabClickedEvent() { + logScreenEvent(AppAnalyticsEvent.DOWNLOADS) + } + fun logProfileTabClickedEvent() { logScreenEvent(AppAnalyticsEvent.PROFILE) } @@ -69,4 +77,28 @@ class MainViewModel( } ) } + + private fun collectDiscoveryEvents() { + notifier.notifier + .onEach { + if (it is NavigationToDiscovery) { + _navigateToDiscovery.emit(true) + } + } + .distinctUntilChanged() + .launchIn(viewModelScope) + } + + private fun collectAppUpgradeEvent() { + viewModelScope.launch { + appNotifier.notifier + .onEach { event -> + if (event is AppUpgradeEvent) { + _appUpgradeEvent.value = event + } + } + .distinctUntilChanged() + .launchIn(viewModelScope) + } + } } diff --git a/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt index e789ed52b..e3add144d 100644 --- a/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/AppUpgradeInterceptor.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Response import org.openedx.app.BuildConfig +import org.openedx.core.AppUpdateState import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.utils.TimeUtils @@ -17,23 +18,30 @@ class AppUpgradeInterceptor( val responseCode = response.code val latestAppVersion = response.header(HEADER_APP_LATEST_VERSION) ?: "" val lastSupportedDateString = response.header(HEADER_APP_VERSION_LAST_SUPPORTED_DATE) ?: "" - val lastSupportedDateTime = TimeUtils.iso8601WithTimeZoneToDate(lastSupportedDateString)?.time ?: 0L + val lastSupportedDateTime = + TimeUtils.iso8601WithTimeZoneToDate(lastSupportedDateString)?.time ?: 0L runBlocking { - when { + val appUpgradeEvent = when { responseCode == 426 -> { - appNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + AppUpgradeEvent.UpgradeRequiredEvent } BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime > Date().time -> { - appNotifier.send(AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion)) + AppUpgradeEvent.UpgradeRecommendedEvent(latestAppVersion) } latestAppVersion.isNotEmpty() && BuildConfig.VERSION_NAME != latestAppVersion && lastSupportedDateTime < Date().time -> { - appNotifier.send(AppUpgradeEvent.UpgradeRequiredEvent) + AppUpgradeEvent.UpgradeRequiredEvent + } + + else -> { + return@runBlocking } } + AppUpdateState.lastAppUpgradeEvent = appUpgradeEvent + appNotifier.send(appUpgradeEvent) } return response } 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 bdc7c6284..a4daf0809 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 @@ -1,14 +1,13 @@ package org.openedx.app.data.networking -import android.content.Context import okhttp3.Interceptor import okhttp3.Response -import org.openedx.app.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.presentation.global.AppData class HeadersInterceptor( - private val context: Context, + private val appData: AppData, private val config: Config, private val preferencesManager: CorePreferences, ) : Interceptor { @@ -26,13 +25,7 @@ class HeadersInterceptor( addHeader("Accept", "application/json") val httpAgent = System.getProperty("http.agent") ?: "" - addHeader( - "User-Agent", - httpAgent + " " + - context.getString(org.openedx.core.R.string.app_name) + "/" + - BuildConfig.APPLICATION_ID + "/" + - BuildConfig.VERSION_NAME - ) + addHeader("User-Agent", "$httpAgent ${appData.versionName}") }.build() ) } diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt index 6061eb6b1..2192a6b89 100644 --- a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -11,9 +11,9 @@ import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.FragmentViewType import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.handouts.HandoutsType +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.discovery.presentation.catalog.WebViewLink diff --git a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt index c020cf636..ce72703ad 100644 --- a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -4,5 +4,6 @@ enum class HomeTab { LEARN, PROGRAMS, DISCOVER, + DOWNLOADS, PROFILE } 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 ce6e20cd9..cdb240387 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -35,13 +35,16 @@ import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewManager +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.global.AppData import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter @@ -58,7 +61,6 @@ import org.openedx.core.worker.CalendarSyncScheduler import org.openedx.course.data.storage.CoursePreferences import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.course.utils.ImageProcessor import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.dashboard.presentation.DashboardAnalytics @@ -68,6 +70,7 @@ import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.system.notifier.DiscussionNotifier +import org.openedx.downloads.presentation.DownloadsRouter import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil import org.openedx.profile.data.storage.ProfilePreferences @@ -127,6 +130,7 @@ val appModule = module { single { get() } single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } single { get() } + single { get() } single { NetworkConnection(get()) } @@ -205,6 +209,7 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } factory { AgreementProvider(get(), get()) } factory { FacebookAuthHelper() } @@ -212,6 +217,7 @@ val appModule = module { factory { MicrosoftAuthHelper() } factory { BrowserAuthHelper(get()) } factory { OAuthHelper(get(), get(), get()) } + factory { VideoPreviewHelper(get(), get()) } factory { FileUtil(get(), get().getString(R.string.app_name)) } single { DownloadHelper(get(), 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 6b7692f99..1d3604050 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -18,13 +18,18 @@ import org.openedx.core.presentation.settings.video.VideoQualityViewModel import org.openedx.core.repository.CalendarRepository import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.assignments.CourseAssignmentViewModel import org.openedx.course.presentation.container.CourseContainerViewModel +import org.openedx.course.presentation.contenttab.ContentTabViewModel import org.openedx.course.presentation.dates.CourseDatesViewModel import org.openedx.course.presentation.handouts.HandoutsViewModel +import org.openedx.course.presentation.home.CourseHomeViewModel import org.openedx.course.presentation.offline.CourseOfflineViewModel -import org.openedx.course.presentation.outline.CourseOutlineViewModel +import org.openedx.course.presentation.outline.CourseContentAllViewModel +import org.openedx.course.presentation.progress.CourseProgressViewModel import org.openedx.course.presentation.section.CourseSectionViewModel import org.openedx.course.presentation.unit.container.CourseUnitContainerViewModel +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.course.presentation.unit.html.HtmlUnitViewModel import org.openedx.course.presentation.unit.video.BaseVideoViewModel import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel @@ -54,6 +59,9 @@ import org.openedx.discussion.presentation.search.DiscussionSearchThreadViewMode import org.openedx.discussion.presentation.threads.DiscussionAddThreadViewModel import org.openedx.discussion.presentation.threads.DiscussionThreadsViewModel import org.openedx.discussion.presentation.topics.DiscussionTopicsViewModel +import org.openedx.downloads.data.repository.DownloadRepository +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.download.DownloadsViewModel import org.openedx.foundation.presentation.WindowSize import org.openedx.learn.presentation.LearnViewModel import org.openedx.profile.data.repository.ProfileRepository @@ -88,7 +96,7 @@ val screenModule = module { get(), ) } - viewModel { MainViewModel(get(), get(), get()) } + viewModel { MainViewModel(get(), get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } @@ -145,7 +153,7 @@ val screenModule = module { factory { DashboardRepository(get(), get(), get(), get()) } factory { DashboardInteractor(get()) } - viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get()) } viewModel { (windowSize: WindowSize) -> DashboardGalleryViewModel( get(), @@ -166,7 +174,7 @@ val screenModule = module { factory { DiscoveryRepository(get(), get(), get()) } factory { DiscoveryInteractor(get()) } - viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get(), get()) } + viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get()) } viewModel { (querySearch: String) -> WebViewDiscoveryViewModel( querySearch, @@ -190,7 +198,16 @@ val screenModule = module { profileRouter = get(), ) } - viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), get(), account) } + viewModel { (account: Account) -> + EditProfileViewModel( + get(), + get(), + get(), + get(), + get(), + account + ) + } viewModel { VideoSettingsViewModel(get(), get(), get(), get()) } viewModel { (qualityType: String) -> VideoQualityViewModel(qualityType, get(), get(), get()) } viewModel { DeleteProfileViewModel(get(), get(), get(), get(), get()) } @@ -220,6 +237,7 @@ val screenModule = module { single { CourseRepository(get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } + single { get() } viewModel { (pathId: String, infoType: String) -> CourseInfoViewModel( @@ -267,7 +285,7 @@ val screenModule = module { ) } viewModel { (courseId: String, courseTitle: String) -> - CourseOutlineViewModel( + CourseContentAllViewModel( courseId, courseTitle, get(), @@ -286,6 +304,34 @@ val screenModule = module { get(), ) } + viewModel { (courseId: String, courseTitle: String) -> + ContentTabViewModel( + courseId, + courseTitle, + get(), + ) + } + viewModel { (courseId: String, courseTitle: String) -> + CourseHomeViewModel( + courseId, + courseTitle, + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get() + ) + } viewModel { (courseId: String) -> CourseSectionViewModel( courseId, @@ -295,10 +341,12 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, unitId: String) -> + viewModel { (courseId: String, unitId: String, mode: CourseViewMode) -> CourseUnitContainerViewModel( courseId, unitId, + mode, + get(), get(), get(), get(), @@ -306,10 +354,9 @@ val screenModule = module { get(), ) } - viewModel { (courseId: String, courseTitle: String) -> + viewModel { (courseId: String) -> CourseVideoViewModel( courseId, - courseTitle, get(), get(), get(), @@ -329,9 +376,11 @@ val screenModule = module { } viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get(), get()) } - viewModel { (courseId: String) -> + viewModel { (courseId: String, videoUrl: String, blockId: String) -> VideoUnitViewModel( courseId, + videoUrl, + blockId, get(), get(), get(), @@ -339,9 +388,10 @@ val screenModule = module { get() ) } - viewModel { (courseId: String, blockId: String) -> + viewModel { (courseId: String, videoUrl: String, blockId: String) -> EncodedVideoUnitViewModel( courseId, + videoUrl, blockId, get(), get(), @@ -480,6 +530,57 @@ val screenModule = module { get(), get(), get(), + get() + ) + } + viewModel { (courseId: String) -> + CourseProgressViewModel( + courseId, + get(), + get() + ) + } + + single { + DownloadRepository( + api = get(), + corePreferences = get(), + dao = get(), + courseDao = get() + ) + } + single { + DownloadInteractor( + repository = get() + ) + } + viewModel { + DownloadsViewModel( + downloadsRouter = get(), + networkConnection = get(), + interactor = get(), + resourceManager = get(), + config = get(), + preferencesManager = get(), + coreAnalytics = get(), + downloadDao = get(), + workerController = get(), + downloadHelper = get(), + downloadDialogManager = get(), + fileUtil = get(), + analytics = get(), + discoveryNotifier = get(), + courseNotifier = get(), + router = get() + ) + } + viewModel { (courseId: String) -> + CourseAssignmentViewModel( + courseId = courseId, + interactor = get(), + courseRouter = get(), + courseNotifier = get(), + analytics = 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 6aa46ed1f..b2f275bb3 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -1,26 +1,32 @@ package org.openedx.app.room +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.DownloadCoursePreview import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.data.storage.CourseDao import org.openedx.core.module.db.CalendarDao import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModelEntity 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 +const val DATABASE_VERSION = 5 const val DATABASE_NAME = "OpenEdX_db" +@Suppress("MagicNumber") @Database( entities = [ CourseEntity::class, @@ -29,10 +35,19 @@ const val DATABASE_NAME = "OpenEdX_db" DownloadModelEntity::class, OfflineXBlockProgress::class, CourseCalendarEventEntity::class, - CourseCalendarStateEntity::class + CourseCalendarStateEntity::class, + DownloadCoursePreview::class, + CourseEnrollmentDetailsEntity::class, + VideoProgressEntity::class, + CourseProgressEntity::class, ], - version = DATABASE_VERSION, - exportSchema = false + autoMigrations = [ + AutoMigration(1, 2), + AutoMigration(2, 3), + AutoMigration(3, 4), + AutoMigration(4, DATABASE_VERSION), + ], + version = DATABASE_VERSION ) @TypeConverters(DiscoveryConverter::class, CourseConverter::class) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index 5d5415854..0c3087abf 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -4,8 +4,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.openedx.core.DatabaseManager +import org.openedx.core.data.storage.CourseDao import org.openedx.core.module.db.DownloadDao -import org.openedx.course.data.storage.CourseDao import org.openedx.dashboard.data.DashboardDao import org.openedx.discovery.data.storage.DiscoveryDao diff --git a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt index 2d5b47410..52caf4de7 100644 --- a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt +++ b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt @@ -3,7 +3,6 @@ package org.openedx.app.system.push import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent -import android.content.Context import android.content.Intent import android.media.RingtoneManager import android.os.Build @@ -75,7 +74,7 @@ class OpenEdXFirebaseMessagingService : FirebaseMessagingService() { .setContentIntent(pendingIntent) val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + getSystemService(NOTIFICATION_SERVICE) as NotificationManager // Since android Oreo notification channel is needed. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { diff --git a/app/src/main/res/drawable/app_ic_book.xml b/app/src/main/res/drawable/app_ic_book.xml deleted file mode 100644 index 4245846af..000000000 --- a/app/src/main/res/drawable/app_ic_book.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_rows.xml b/app/src/main/res/drawable/app_ic_book_fill.xml similarity index 100% rename from app/src/main/res/drawable/app_ic_rows.xml rename to app/src/main/res/drawable/app_ic_book_fill.xml diff --git a/app/src/main/res/drawable/app_ic_book_outline.xml b/app/src/main/res/drawable/app_ic_book_outline.xml new file mode 100644 index 000000000..58021d21f --- /dev/null +++ b/app/src/main/res/drawable/app_ic_book_outline.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/drawable/app_ic_discover_selector.xml b/app/src/main/res/drawable/app_ic_discover_selector.xml new file mode 100644 index 000000000..9d2d2a951 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_discover_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_download_cloud_fill.xml b/app/src/main/res/drawable/app_ic_download_cloud_fill.xml new file mode 100644 index 000000000..8e623dc60 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_download_cloud_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_download_cloud_outline.xml b/app/src/main/res/drawable/app_ic_download_cloud_outline.xml new file mode 100644 index 000000000..193cc1a6a --- /dev/null +++ b/app/src/main/res/drawable/app_ic_download_cloud_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_downloads_selector.xml b/app/src/main/res/drawable/app_ic_downloads_selector.xml new file mode 100644 index 000000000..a24c486d5 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_downloads_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_home.xml b/app/src/main/res/drawable/app_ic_home.xml deleted file mode 100644 index b703f9f28..000000000 --- a/app/src/main/res/drawable/app_ic_home.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_learn_selector.xml b/app/src/main/res/drawable/app_ic_learn_selector.xml new file mode 100644 index 000000000..d3077a298 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_learn_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_profile.xml b/app/src/main/res/drawable/app_ic_profile.xml deleted file mode 100644 index 1b241a689..000000000 --- a/app/src/main/res/drawable/app_ic_profile.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/app_ic_profile_fill.xml b/app/src/main/res/drawable/app_ic_profile_fill.xml new file mode 100644 index 000000000..c4ed432a2 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_profile_outline.xml b/app/src/main/res/drawable/app_ic_profile_outline.xml new file mode 100644 index 000000000..07226fc2b --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_outline.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/app_ic_profile_selector.xml b/app/src/main/res/drawable/app_ic_profile_selector.xml new file mode 100644 index 000000000..83708d080 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_profile_selector.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/app_ic_search_fill.xml b/app/src/main/res/drawable/app_ic_search_fill.xml new file mode 100644 index 000000000..6635fc8b1 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_search_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_search_outline.xml b/app/src/main/res/drawable/app_ic_search_outline.xml new file mode 100644 index 000000000..4372bd085 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_search_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/splash_inset.xml b/app/src/main/res/drawable/app_splash_inset.xml similarity index 100% rename from app/src/main/res/drawable/splash_inset.xml rename to app/src/main/res/drawable/app_splash_inset.xml diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 9794b7bd7..362793686 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -25,7 +25,13 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.5" - app:layout_constraintStart_toStartOf="parent" - app:menu="@menu/bottom_view_menu" /> + app:layout_constraintStart_toStartOf="parent" /> + + diff --git a/app/src/main/res/menu/bottom_view_menu.xml b/app/src/main/res/menu/bottom_view_menu.xml deleted file mode 100644 index f97e849f7..000000000 --- a/app/src/main/res/menu/bottom_view_menu.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index d34811e00..000000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml deleted file mode 100644 index 22d7f0043..000000000 --- a/app/src/main/res/values-land/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 48dp - \ No newline at end of file diff --git a/app/src/main/res/values-w1240dp/dimens.xml b/app/src/main/res/values-w1240dp/dimens.xml deleted file mode 100644 index d73f4a359..000000000 --- a/app/src/main/res/values-w1240dp/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 200dp - \ No newline at end of file diff --git a/app/src/main/res/values-w600dp/dimens.xml b/app/src/main/res/values-w600dp/dimens.xml deleted file mode 100644 index 22d7f0043..000000000 --- a/app/src/main/res/values-w600dp/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 48dp - \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index 125df8711..000000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 16dp - \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index 7e567b52f..000000000 --- a/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #72BB25 - \ No newline at end of file diff --git a/app/src/main/res/values/main_manu_tab_ids.xml b/app/src/main/res/values/main_manu_tab_ids.xml new file mode 100644 index 000000000..f769b5bde --- /dev/null +++ b/app/src/main/res/values/main_manu_tab_ids.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/values/splash.xml b/app/src/main/res/values/splash.xml index e206c4bc1..7865c7d37 100644 --- a/app/src/main/res/values/splash.xml +++ b/app/src/main/res/values/splash.xml @@ -7,7 +7,7 @@ - @drawable/splash_inset + @drawable/app_splash_inset 300 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index baa1c2a89..801ce0c80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,10 +1,6 @@ - Settings - Next - Previous - Discover Learn - Programs Profile + Downloads diff --git a/auth/build.gradle b/auth/build.gradle index 6b11037a2..3bd660c15 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -6,17 +6,17 @@ plugins { } android { - compileSdk 34 + namespace 'org.openedx.auth' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.auth' flavorDimensions += "env" productFlavors { @@ -33,17 +33,19 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { viewBinding true @@ -54,22 +56,28 @@ android { dependencies { implementation project(path: ':core') - implementation 'androidx.browser:browser:1.7.0' - implementation "androidx.credentials:credentials:1.3.0" - implementation "androidx.credentials:credentials-play-services-auth:1.3.0" - implementation "com.facebook.android:facebook-login:16.2.0" - implementation "com.google.android.gms:play-services-auth:21.2.0" - implementation "com.google.android.libraries.identity.googleid:googleid:1.1.1" - 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") + // AndroidX + implementation "androidx.browser:browser:$browser_version" + implementation "androidx.credentials:credentials:$credentials_version" + implementation "androidx.credentials:credentials-play-services-auth:$credentials_version" + + // Social Login + implementation "com.facebook.android:facebook-login:$facebook_login_version" + implementation "com.google.android.gms:play-services-auth:$play_services_auth_version" + implementation "com.google.android.libraries.identity.googleid:googleid:$googleid_version" + implementation("com.microsoft.identity.client:msal:$msal_version") { + exclude group: 'com.microsoft.identity.client', module: 'msal-browser' + exclude group: 'io.opentelemetry', module: 'opentelemetry-bom' } - implementation("io.opentelemetry:opentelemetry-api:1.18.0") - implementation("io.opentelemetry:opentelemetry-context:1.18.0") - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + + // OpenTelemetry + implementation("io.opentelemetry:opentelemetry-api:$opentelemetry_version") + implementation("io.opentelemetry:opentelemetry-context:$opentelemetry_version") + testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } 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 332aa6faa..81d216c39 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 @@ -186,7 +186,7 @@ private fun RestorePasswordScreen( modifier = Modifier .fillMaxWidth() .height(200.dp), - painter = painterResource(id = org.openedx.core.R.drawable.core_top_header), + painter = painterResource(id = R.drawable.core_top_header), contentScale = ContentScale.FillBounds, contentDescription = null ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt index b2dee1919..2045297a5 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt @@ -40,7 +40,7 @@ internal fun SocialSignedView(authType: AuthType) { modifier = Modifier .padding(end = 8.dp) .size(20.dp), - painter = painterResource(id = coreR.drawable.ic_core_check), + painter = painterResource(id = coreR.drawable.core_ic_check), tint = MaterialTheme.appColors.successBackground, contentDescription = "" ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt index 1022da676..cd3233b39 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt @@ -25,7 +25,7 @@ class BrowserAuthHelper(private val config: Config) { .appendQueryParameter("response_type", ApiConstants.BrowserLogin.RESPONSE_TYPE).build() val intent = CustomTabsIntent.Builder().setUrlBarHidingEnabled(true).setShowTitle(true).build() - intent.intent.setFlags(FLAG_ACTIVITY_NEW_TASK) + intent.intent.flags = FLAG_ACTIVITY_NEW_TASK intent.launchUrl(activityContext, uri) } 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 ccd790512..61d8f7450 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 @@ -23,7 +23,7 @@ import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -524,7 +524,7 @@ fun ExpandableText( } else { stringResource(id = R.string.auth_show_optional_fields) } - val icon = Icons.Filled.ChevronRight + val icon = Icons.AutoMirrored.Filled.KeyboardArrowRight Row( modifier = modifier 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 12b707033..e4962d072 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 @@ -54,7 +54,7 @@ internal fun SocialAuthView( ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - painter = painterResource(id = R.drawable.ic_auth_google), + painter = painterResource(id = R.drawable.auth_ic_google), contentDescription = null, tint = Color.Unspecified, ) @@ -86,7 +86,7 @@ internal fun SocialAuthView( ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - painter = painterResource(id = R.drawable.ic_auth_facebook), + painter = painterResource(id = R.drawable.auth_ic_facebook), contentDescription = null, tint = MaterialTheme.appColors.primaryButtonText, ) @@ -118,7 +118,7 @@ internal fun SocialAuthView( ) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - painter = painterResource(id = R.drawable.ic_auth_microsoft), + painter = painterResource(id = R.drawable.auth_ic_microsoft), contentDescription = null, tint = Color.Unspecified, ) diff --git a/auth/src/main/res/drawable/ic_auth_facebook.xml b/auth/src/main/res/drawable/auth_ic_facebook.xml similarity index 100% rename from auth/src/main/res/drawable/ic_auth_facebook.xml rename to auth/src/main/res/drawable/auth_ic_facebook.xml diff --git a/auth/src/main/res/drawable/ic_auth_google.xml b/auth/src/main/res/drawable/auth_ic_google.xml similarity index 100% rename from auth/src/main/res/drawable/ic_auth_google.xml rename to auth/src/main/res/drawable/auth_ic_google.xml diff --git a/auth/src/main/res/drawable/auth_ic_microsoft.xml b/auth/src/main/res/drawable/auth_ic_microsoft.xml new file mode 100644 index 000000000..30170272a --- /dev/null +++ b/auth/src/main/res/drawable/auth_ic_microsoft.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/auth/src/main/res/drawable/ic_auth_microsoft.xml b/auth/src/main/res/drawable/ic_auth_microsoft.xml deleted file mode 100644 index ce31faab7..000000000 --- a/auth/src/main/res/drawable/ic_auth_microsoft.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 49a8fb68e..77401c27f 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ What do you want to learn? Search our 3000+ courses Explore all courses - Sign up Forgot password? Email Invalid email diff --git a/build.gradle b/build.gradle index 390d02699..674a1057f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,25 +1,76 @@ import io.gitlab.arturbosch.detekt.Detekt import org.edx.builder.ConfigHelper +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.regex.Matcher import java.util.regex.Pattern buildscript { ext { + // Plugin versions + android_gradle_plugin_version = '8.12.2' + google_services_version = '4.4.3' + firebase_crashlytics_version = '3.0.6' + ksp_version = '2.2.10-2.0.2' + //Depends on versions in OEXFoundation - kotlin_version = '2.0.0' - room_version = '2.6.1' - detekt_version = '1.23.7' + kotlin_version = '2.2.10' + room_version = '2.7.2' + detekt_version = '1.23.8' + + // Library versions + media3_version = "1.8.0" + youtubeplayer_version = "13.0.0" + firebase_version = "33.0.0" + jsoup_version = '1.21.2' + in_app_review = '2.0.2' + extented_spans_version = "1.4.0" + zip_version = '2.11.5' + + // Third-party library versions + branch_sdk_version = '5.20.0' + play_services_ads_identifier_version = '18.2.0' + install_referrer_version = '2.2' + snakeyaml_version = '2.4' + openedx_foundation_version = '1.0.2' + openedx_firebase_analytics_version = '1.0.1' + braze_sdk_version = '37.0.0' + + // AndroidX library versions + core_splashscreen_version = '1.0.1' + activity_compose_version = '1.10.1' + browser_version = '1.9.0' + credentials_version = '1.5.0' + + // Social login versions + facebook_login_version = '18.1.3' + play_services_auth_version = '21.4.0' + googleid_version = '1.1.1' + msal_version = '7.0.0' + + // OpenTelemetry versions + opentelemetry_version = '1.53.0' + + // Testing versions + compose_ui_tooling = '1.7.8' + mockk_version = '1.14.5' + android_arch_version = '2.2.0' + junit_version = '4.13.2' + test_ext_version = '1.3.0' + espresso_version = '3.7.0' + kotlinx_coroutines_test_version = '1.10.2' } } plugins { - id 'com.android.application' version '8.5.2' apply false - id 'com.android.library' version '8.5.2' apply false + //noinspection GradlePluginVersion + id 'com.android.application' version "$android_gradle_plugin_version" apply false + //noinspection GradlePluginVersion + id 'com.android.library' version "$android_gradle_plugin_version" apply false id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false - id 'com.google.gms.google-services' version '4.4.2' apply false - id "com.google.firebase.crashlytics" version "3.0.2" apply false - id "com.google.devtools.ksp" version "2.0.0-1.0.24" apply false + id 'com.google.gms.google-services' version "$google_services_version" apply false + id "com.google.firebase.crashlytics" version "$firebase_crashlytics_version" apply false + id "com.google.devtools.ksp" version "$ksp_version" apply false id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" apply false id 'io.gitlab.arturbosch.detekt' version "$detekt_version" apply false } @@ -29,24 +80,14 @@ tasks.register('clean', Delete) { } ext { - media3_version = "1.4.1" - youtubeplayer_version = "11.1.0" - - firebase_version = "33.0.0" - - jsoup_version = '1.13.1' - - in_app_review = '2.0.1' - - extented_spans_version = "1.3.0" + // Android SDK versions + compile_sdk_version = 36 + target_sdk_version = 36 + min_sdk_version = 24 + java_version = JavaVersion.VERSION_17 + jvm_target_version = JvmTarget.JVM_17 configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) - - zip_version = '2.6.3' - //testing - mockk_version = '1.13.12' - android_arch_version = '2.2.0' - junit_version = '4.13.2' } def getCurrentFlavor() { diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index f1d8de5cb..4532d0758 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -14,5 +14,5 @@ java { dependencies { implementation localGroovy() implementation gradleApi() - implementation 'org.yaml:snakeyaml:1.33' + implementation "org.yaml:snakeyaml:2.4" } diff --git a/core/build.gradle b/core/build.gradle index f1ae6be5e..19be1f57a 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -4,7 +4,7 @@ buildscript { } dependencies { - classpath 'org.yaml:snakeyaml:2.0' + classpath "org.yaml:snakeyaml:$snakeyaml_version" } } @@ -21,17 +21,17 @@ def config = configHelper.fetchConfig() def themeDirectory = config.getOrDefault("THEME_DIRECTORY", "openedx") android { - compileSdk 34 + namespace 'org.openedx.core' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.core' flavorDimensions += "env" productFlavors { @@ -76,14 +76,15 @@ android { } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } - buildFeatures { viewBinding true compose true @@ -109,19 +110,22 @@ dependencies { api "com.google.android.play:review-ktx:$in_app_review" // Branch SDK Integration - api "io.branch.sdk.android:library:5.9.0" - api "com.google.android.gms:play-services-ads-identifier:18.1.0" - api "com.android.installreferrer:installreferrer:2.2" + api "io.branch.sdk.android:library:$branch_sdk_version" + api "com.google.android.gms:play-services-ads-identifier:$play_services_ads_identifier_version" + api "com.android.installreferrer:installreferrer:$install_referrer_version" // Zip api "net.lingala.zip4j:zip4j:$zip_version" // OpenEdx libs - api("com.github.openedx:openedx-app-foundation-android:1.0.0") + api("com.github.openedx:openedx-app-foundation-android:$openedx_foundation_version") + + // Preview + debugApi "androidx.compose.ui:ui-tooling:$compose_ui_tooling" - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' + testImplementation "junit:junit:$junit_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } def insertBuildConfigFields(currentFlavour, buildType) { diff --git a/core/src/main/java/org/openedx/core/AppDataConstants.kt b/core/src/main/java/org/openedx/core/AppDataConstants.kt index eb2580e99..cf6766ac1 100644 --- a/core/src/main/java/org/openedx/core/AppDataConstants.kt +++ b/core/src/main/java/org/openedx/core/AppDataConstants.kt @@ -1,12 +1,12 @@ package org.openedx.core -import java.util.* +import java.util.Locale object AppDataConstants { const val USER_MIN_YEAR = 13 const val USER_MAX_YEAR = 77 const val DEFAULT_MIME_TYPE = "image/jpeg" - val defaultLocale = Locale("en") + val defaultLocale: Locale = Locale.Builder().setLanguage("en").build() const val VIDEO_FORMAT_M3U8 = ".m3u8" const val VIDEO_FORMAT_MP4 = ".mp4" diff --git a/core/src/main/java/org/openedx/core/AppUpdateState.kt b/core/src/main/java/org/openedx/core/AppUpdateState.kt index 0f92d145b..9c016581d 100644 --- a/core/src/main/java/org/openedx/core/AppUpdateState.kt +++ b/core/src/main/java/org/openedx/core/AppUpdateState.kt @@ -3,23 +3,29 @@ package org.openedx.core import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent -import android.net.Uri import androidx.compose.runtime.mutableStateOf +import androidx.core.net.toUri import org.openedx.core.system.notifier.app.AppUpgradeEvent object AppUpdateState { var wasUpdateDialogDisplayed = false - var wasUpdateDialogClosed = mutableStateOf(false) + var wasUpgradeDialogClosed = mutableStateOf(false) + var lastAppUpgradeEvent: AppUpgradeEvent? = null fun openPlayMarket(context: Context) { try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=${context.packageName}"))) + context.startActivity( + Intent( + Intent.ACTION_VIEW, + "market://details?id=${context.packageName}".toUri() + ) + ) } catch (e: ActivityNotFoundException) { e.printStackTrace() context.startActivity( Intent( Intent.ACTION_VIEW, - Uri.parse("https://play.google.com/store/apps/details?id=${context.packageName}") + "https://play.google.com/store/apps/details?id=${context.packageName}".toUri() ) ) } @@ -27,7 +33,7 @@ object AppUpdateState { data class AppUpgradeParameters( val appUpgradeEvent: AppUpgradeEvent? = null, - val wasUpdateDialogClosed: Boolean = AppUpdateState.wasUpdateDialogClosed.value, + val wasUpgradeDialogClosed: Boolean = AppUpdateState.wasUpgradeDialogClosed.value, val appUpgradeRecommendedDialog: () -> Unit = {}, val onAppUpgradeRecommendedBoxClick: () -> Unit = {}, val onAppUpgradeRequired: () -> Unit = {}, diff --git a/core/src/main/java/org/openedx/core/Mock.kt b/core/src/main/java/org/openedx/core/Mock.kt new file mode 100644 index 000000000..445fc7a05 --- /dev/null +++ b/core/src/main/java/org/openedx/core/Mock.kt @@ -0,0 +1,263 @@ +package org.openedx.core + +import org.openedx.core.data.model.room.VideoProgressEntity +import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseComponentStatus +import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.EncodedVideos +import org.openedx.core.domain.model.OfflineDownload +import org.openedx.core.domain.model.Progress +import org.openedx.core.domain.model.ResetCourseDates +import org.openedx.core.domain.model.StudentViewData +import org.openedx.core.domain.model.VideoInfo +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import java.util.Date + +object Mock { + private val mockAssignmentProgress = AssignmentProgress( + assignmentType = "Home", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HM1" + ) + val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = null + ) + private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = mockAssignmentProgress, + due = Date(), + offlineDownload = OfflineDownload("fileUrl", "", 1), + ) + + val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockSequentialBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = Progress(1, 3), + ) + + val mockCourseComponentStatus = CourseComponentStatus( + lastVisitedBlockId = "video1" + ) + + val mockCourseDatesBannerInfo = CourseDatesBannerInfo( + missedDeadlines = false, + missedGatedContent = false, + contentTypeGatingEnabled = false, + verifiedUpgradeLink = "", + hasEnded = false + ) + + val mockCourseDatesResult = CourseDatesResult( + datesSection = linkedMapOf(), + courseBanner = mockCourseDatesBannerInfo + ) + + val mockCourseProgress = CourseProgress( + verifiedMode = "audit", + accessExpiration = "", + certificateData = null, + completionSummary = null, + courseGrade = null, + creditCourseRequirements = "", + end = "", + enrollmentMode = "audit", + gradingPolicy = null, + hasScheduledContent = false, + sectionScores = emptyList(), + studioUrl = "", + username = "testuser", + userHasPassingGrade = false, + verificationData = null, + disableProgressGraph = false + ) + + val mockVideoProgress = VideoProgressEntity( + blockId = "video1", + videoUrl = "test-video-url", + videoTime = 1000L, + duration = 5000L + ) + + val mockResetCourseDates = ResetCourseDates( + message = "Dates reset successfully", + body = "Your course dates have been reset", + header = "Success", + link = "", + linkText = "" + ) + + val mockDownloadModel = DownloadModel( + id = "video1", + title = "Video 1", + courseId = "test-course-id", + size = 1000L, + path = "/test/path/video1", + url = "test-url", + type = FileType.VIDEO, + downloadedState = DownloadedState.NOT_DOWNLOADED, + lastModified = null + ) + + val mockVideoBlock = Block( + id = "video1", + blockId = "video1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.VIDEO, + displayName = "Video 1", + graded = false, + studentViewData = StudentViewData( + onlyOnWeb = false, + duration = "", + transcripts = null, + encodedVideos = EncodedVideos( + youtube = null, + hls = null, + fallback = null, + desktopMp4 = null, + mobileHigh = null, + mobileLow = VideoInfo( + url = "test-url", + fileSize = 1000L + ) + ), + topicId = "" + ), + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.VIDEO, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockSequentialBlockForDownload = Block( + id = "sequential1", + blockId = "sequential1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential 1", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("vertical1"), + descendantsType = BlockType.VERTICAL, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockVerticalBlock = Block( + id = "vertical1", + blockId = "vertical1", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.VERTICAL, + displayName = "Vertical 1", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("video1"), + descendantsType = BlockType.VIDEO, + completion = 0.0, + containsGatedContent = false, + assignmentProgress = null, + due = null, + offlineDownload = null, + ) + + val mockCourseStructureForDownload = CourseStructure( + root = "sequential1", + blockData = listOf(mockSequentialBlockForDownload, mockVerticalBlock, mockVideoBlock), + id = "test-course-id", + name = "Test Course", + number = "CS101", + org = "TestOrg", + start = Date(), + startDisplay = "2024-01-01", + startType = "timestamped", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = null + ) +} diff --git a/core/src/main/java/org/openedx/core/NoContentScreenType.kt b/core/src/main/java/org/openedx/core/NoContentScreenType.kt index 88e8ad94b..559cf05d1 100644 --- a/core/src/main/java/org/openedx/core/NoContentScreenType.kt +++ b/core/src/main/java/org/openedx/core/NoContentScreenType.kt @@ -16,6 +16,10 @@ enum class NoContentScreenType( iconResId = R.drawable.core_ic_no_content, messageResId = R.string.core_no_dates ), + COURSE_ASSIGNMENT( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_assignments + ), COURSE_DISCUSSIONS( iconResId = R.drawable.core_ic_no_content, messageResId = R.string.core_no_discussion @@ -27,5 +31,9 @@ enum class NoContentScreenType( COURSE_ANNOUNCEMENTS( iconResId = R.drawable.core_ic_no_announcements, messageResId = R.string.core_no_announcements - ) + ), + COURSE_PROGRESS( + iconResId = R.drawable.core_ic_no_content, + messageResId = R.string.core_no_progress + ), } diff --git a/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt index 708b43829..f3d210449 100644 --- a/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt +++ b/core/src/main/java/org/openedx/core/adapter/NavigationFragmentAdapter.kt @@ -5,13 +5,13 @@ import androidx.viewpager2.adapter.FragmentStateAdapter class NavigationFragmentAdapter(fragment: Fragment) : FragmentStateAdapter(fragment) { - private val fragments = ArrayList() + private val fragmentFactories = ArrayList<() -> Fragment>() - override fun getItemCount(): Int = fragments.size + override fun getItemCount(): Int = fragmentFactories.size - override fun createFragment(position: Int): Fragment = fragments[position] + override fun createFragment(position: Int): Fragment = fragmentFactories[position].invoke() - fun addFragment(fragment: Fragment) { - fragments.add(fragment) + fun addFragment(fragmentFactory: () -> Fragment) { + fragmentFactories.add(fragmentFactory) } } diff --git a/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt b/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt new file mode 100644 index 000000000..577f297c6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/AppLevelDownloadsConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class AppLevelDownloadsConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = true, +) 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 f240b9531..d26741699 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -92,6 +92,10 @@ class Config(context: Context) { return getObjectOrNewInstance(DASHBOARD, DashboardConfig::class.java) } + fun getDownloadsConfig(): AppLevelDownloadsConfig { + return getExperimentalFeaturesConfig().appLevelDownloadsConfig + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } @@ -120,6 +124,10 @@ class Config(context: Context) { return getBoolean(BROWSER_REGISTRATION, false) } + private fun getExperimentalFeaturesConfig(): ExperimentalFeaturesConfig { + return getObjectOrNewInstance(EXPERIMENTAL_FEATURES, ExperimentalFeaturesConfig::class.java) + } + private fun getString(key: String, defaultValue: String = ""): String { val element = getObject(key) return if (element != null) { @@ -179,6 +187,7 @@ class Config(context: Context) { private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val DASHBOARD = "DASHBOARD" + private const val EXPERIMENTAL_FEATURES = "EXPERIMENTAL_FEATURES" private const val BRANCH = "BRANCH" private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" diff --git a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt new file mode 100644 index 000000000..03dd43150 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class ExperimentalFeaturesConfig( + @SerializedName("APP_LEVEL_DOWNLOADS") + val appLevelDownloadsConfig: AppLevelDownloadsConfig = AppLevelDownloadsConfig(), +) 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 8b5f0913a..d6e44cfe2 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 @@ -8,7 +8,9 @@ import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments +import org.openedx.core.data.model.CourseProgressResponse import org.openedx.core.data.model.CourseStructureModel +import org.openedx.core.data.model.DownloadCoursePreview import org.openedx.core.data.model.EnrollmentStatus import org.openedx.core.data.model.HandoutsModel import org.openedx.core.data.model.ResetCourseDates @@ -60,7 +62,10 @@ interface CourseApi { ) @GET("/api/course_home/v1/dates/{course_id}") - suspend fun getCourseDates(@Path("course_id") courseId: String): CourseDates + suspend fun getCourseDates( + @Path("course_id") courseId: String, + @Query("allow_not_started_courses") allowNotStartedCourses: Boolean = true + ): CourseDates @POST("/api/course_experience/v1/reset_course_deadlines") suspend fun resetCourseDates(@Body courseBody: Map): ResetCourseDates @@ -100,4 +105,14 @@ interface CourseApi { suspend fun getEnrollmentDetails( @Path("course_id") courseId: String, ): CourseEnrollmentDetails + + @GET("/api/mobile/v1/download_courses/{username}") + suspend fun getDownloadCoursesPreview( + @Path("username") username: String + ): List + + @GET("/api/course_home/progress/{course_id}") + suspend fun getCourseProgress( + @Path("course_id") courseId: String, + ): CourseProgressResponse } diff --git a/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt index 2ac10cb18..8c4d20e35 100644 --- a/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt +++ b/core/src/main/java/org/openedx/core/data/model/AssignmentProgress.kt @@ -4,6 +4,8 @@ import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.room.AssignmentProgressDb import org.openedx.core.domain.model.AssignmentProgress +private const val DEFAULT_LABEL_LENGTH = 5 + data class AssignmentProgress( @SerializedName("assignment_type") val assignmentType: String?, @@ -11,16 +13,20 @@ data class AssignmentProgress( val numPointsEarned: Float?, @SerializedName("num_points_possible") val numPointsPossible: Float?, + @SerializedName("short_label") + val shortLabel: String? ) { - fun mapToDomain() = AssignmentProgress( - assignmentType = assignmentType ?: "", + fun mapToDomain(displayName: String) = AssignmentProgress( + assignmentType = assignmentType, numPointsEarned = numPointsEarned ?: 0f, - numPointsPossible = numPointsPossible ?: 0f + numPointsPossible = numPointsPossible ?: 0f, + shortLabel = shortLabel ?: displayName.take(DEFAULT_LABEL_LENGTH) ) fun mapToRoomEntity() = AssignmentProgressDb( assignmentType = assignmentType, numPointsEarned = numPointsEarned, - numPointsPossible = numPointsPossible + numPointsPossible = numPointsPossible, + shortLabel = shortLabel ) } diff --git a/core/src/main/java/org/openedx/core/data/model/Block.kt b/core/src/main/java/org/openedx/core/data/model/Block.kt index 8ac8a8378..c85a4c1b5 100644 --- a/core/src/main/java/org/openedx/core/data/model/Block.kt +++ b/core/src/main/java/org/openedx/core/data/model/Block.kt @@ -65,7 +65,7 @@ data class Block( blockCounts = blockCounts?.mapToDomain()!!, completion = completion ?: 0.0, containsGatedContent = containsGatedContent ?: false, - assignmentProgress = assignmentProgress?.mapToDomain(), + assignmentProgress = assignmentProgress?.mapToDomain(displayName.orEmpty()), due = TimeUtils.iso8601ToDate(due.orEmpty()), offlineDownload = offlineDownload?.mapToDomain() ) @@ -136,7 +136,9 @@ data class VideoInfo( var fileSize: Long? ) { fun mapToDomain() = DomainVideoInfo( - url = url.orEmpty(), + url = url + .orEmpty() + .trim(), fileSize = fileSize ?: 0 ) } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt new file mode 100644 index 000000000..00d55a9b5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -0,0 +1,285 @@ +package org.openedx.core.data.model + +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.CertificateDataDb +import org.openedx.core.data.model.room.CompletionSummaryDb +import org.openedx.core.data.model.room.CourseGradeDb +import org.openedx.core.data.model.room.CourseProgressEntity +import org.openedx.core.data.model.room.GradingPolicyDb +import org.openedx.core.data.model.room.SectionScoreDb +import org.openedx.core.data.model.room.VerificationDataDb +import org.openedx.core.domain.model.CourseProgress + +data class CourseProgressResponse( + @SerializedName("verified_mode") val verifiedMode: String?, + @SerializedName("access_expiration") val accessExpiration: String?, + @SerializedName("certificate_data") val certificateData: CertificateData?, + @SerializedName("completion_summary") val completionSummary: CompletionSummary?, + @SerializedName("course_grade") val courseGrade: CourseGrade?, + @SerializedName("credit_course_requirements") val creditCourseRequirements: String?, + @SerializedName("end") val end: String?, + @SerializedName("enrollment_mode") val enrollmentMode: String?, + @SerializedName("grading_policy") val gradingPolicy: GradingPolicy?, + @SerializedName("has_scheduled_content") val hasScheduledContent: Boolean?, + @SerializedName("section_scores") val sectionScores: List?, + @SerializedName("studio_url") val studioUrl: String?, + @SerializedName("username") val username: String?, + @SerializedName("user_has_passing_grade") val userHasPassingGrade: Boolean?, + @SerializedName("verification_data") val verificationData: VerificationData?, + @SerializedName("disable_progress_graph") val disableProgressGraph: Boolean?, +) { + data class CertificateData( + @SerializedName("cert_status") val certStatus: String?, + @SerializedName("cert_web_view_url") val certWebViewUrl: String?, + @SerializedName("download_url") val downloadUrl: String?, + @SerializedName("certificate_available_date") val certificateAvailableDate: String? + ) { + fun mapToRoomEntity() = CertificateDataDb( + certStatus = certStatus.orEmpty(), + certWebViewUrl = certWebViewUrl.orEmpty(), + downloadUrl = downloadUrl.orEmpty(), + certificateAvailableDate = certificateAvailableDate.orEmpty() + ) + + fun mapToDomain() = CourseProgress.CertificateData( + certStatus = certStatus ?: "", + certWebViewUrl = certWebViewUrl ?: "", + downloadUrl = downloadUrl ?: "", + certificateAvailableDate = certificateAvailableDate ?: "" + ) + } + + data class CompletionSummary( + @SerializedName("complete_count") val completeCount: Int?, + @SerializedName("incomplete_count") val incompleteCount: Int?, + @SerializedName("locked_count") val lockedCount: Int? + ) { + fun mapToRoomEntity() = CompletionSummaryDb( + completeCount = completeCount ?: 0, + incompleteCount = incompleteCount ?: 0, + lockedCount = lockedCount ?: 0 + ) + + fun mapToDomain() = CourseProgress.CompletionSummary( + completeCount = completeCount ?: 0, + incompleteCount = incompleteCount ?: 0, + lockedCount = lockedCount ?: 0 + ) + } + + data class CourseGrade( + @SerializedName("letter_grade") val letterGrade: String?, + @SerializedName("percent") val percent: Double?, + @SerializedName("is_passing") val isPassing: Boolean? + ) { + fun mapToRoomEntity() = CourseGradeDb( + letterGrade = letterGrade.orEmpty(), + percent = percent ?: 0.0, + isPassing = isPassing ?: false + ) + + fun mapToDomain() = CourseProgress.CourseGrade( + letterGrade = letterGrade ?: "", + percent = percent ?: 0.0, + isPassing = isPassing ?: false + ) + } + + data class GradingPolicy( + @SerializedName("assignment_policies") val assignmentPolicies: List?, + @SerializedName("grade_range") val gradeRange: Map?, + @SerializedName("assignment_colors") val assignmentColors: List? + ) { + // TODO Temporary solution. Backend will returns color list later + val defaultColors = listOf( + "#D24242", + "#7B9645", + "#5A5AD8", + "#B0842C", + "#2E90C2", + "#D13F88", + "#36A17D", + "#AE5AD8", + "#3BA03B" + ) + + fun mapToRoomEntity() = GradingPolicyDb( + assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(), + gradeRange = gradeRange ?: emptyMap(), + assignmentColors = assignmentColors ?: defaultColors + ) + + fun mapToDomain() = CourseProgress.GradingPolicy( + assignmentPolicies = assignmentPolicies?.map { it.mapToDomain() } ?: emptyList(), + gradeRange = gradeRange ?: emptyMap(), + assignmentColors = assignmentColors?.map { colorString -> + Color(colorString.toColorInt()) + } ?: defaultColors.map { Color(it.toColorInt()) } + ) + + data class AssignmentPolicy( + @SerializedName("num_droppable") val numDroppable: Int?, + @SerializedName("num_total") val numTotal: Int?, + @SerializedName("short_label") val shortLabel: String?, + @SerializedName("type") val type: String?, + @SerializedName("weight") val weight: Double? + ) { + fun mapToRoomEntity() = GradingPolicyDb.AssignmentPolicyDb( + numDroppable = numDroppable ?: 0, + numTotal = numTotal ?: 0, + shortLabel = shortLabel.orEmpty(), + type = type.orEmpty(), + weight = weight ?: 0.0 + ) + + fun mapToDomain() = CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = numDroppable ?: 0, + numTotal = numTotal ?: 0, + shortLabel = shortLabel ?: "", + type = type ?: "", + weight = weight ?: 0.0 + ) + } + } + + data class SectionScore( + @SerializedName("display_name") val displayName: String?, + @SerializedName("subsections") val subsections: List? + ) { + fun mapToRoomEntity() = SectionScoreDb( + displayName = displayName.orEmpty(), + subsections = subsections?.map { it.mapToRoomEntity() } ?: emptyList() + ) + + fun mapToDomain() = CourseProgress.SectionScore( + displayName = displayName ?: "", + subsections = subsections?.map { it.mapToDomain() } ?: emptyList() + ) + + data class Subsection( + @SerializedName("assignment_type") val assignmentType: String?, + @SerializedName("block_key") val blockKey: String?, + @SerializedName("display_name") val displayName: String?, + @SerializedName("has_graded_assignment") val hasGradedAssignment: Boolean?, + @SerializedName("override") val override: String?, + @SerializedName("learner_has_access") val learnerHasAccess: Boolean?, + @SerializedName("num_points_earned") val numPointsEarned: Float?, + @SerializedName("num_points_possible") val numPointsPossible: Float?, + @SerializedName("percent_graded") val percentGraded: Double?, + @SerializedName("problem_scores") val problemScores: List?, + @SerializedName("show_correctness") val showCorrectness: String?, + @SerializedName("show_grades") val showGrades: Boolean?, + @SerializedName("url") val url: String? + ) { + fun mapToRoomEntity() = SectionScoreDb.SubsectionDb( + assignmentType = assignmentType.orEmpty(), + blockKey = blockKey.orEmpty(), + displayName = displayName.orEmpty(), + hasGradedAssignment = hasGradedAssignment ?: false, + override = override.orEmpty(), + learnerHasAccess = learnerHasAccess ?: false, + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f, + percentGraded = percentGraded ?: 0.0, + problemScores = problemScores?.map { it.mapToRoomEntity() } ?: emptyList(), + showCorrectness = showCorrectness.orEmpty(), + showGrades = showGrades ?: false, + url = url.orEmpty() + ) + + fun mapToDomain() = CourseProgress.SectionScore.Subsection( + assignmentType = assignmentType ?: "", + blockKey = blockKey ?: "", + displayName = displayName ?: "", + hasGradedAssignment = hasGradedAssignment ?: false, + override = override ?: "", + learnerHasAccess = learnerHasAccess ?: false, + numPointsEarned = numPointsEarned ?: 0f, + numPointsPossible = numPointsPossible ?: 0f, + percentGraded = percentGraded ?: 0.0, + problemScores = problemScores?.map { it.mapToDomain() } ?: emptyList(), + showCorrectness = showCorrectness ?: "", + showGrades = showGrades ?: false, + url = url ?: "" + ) + + data class ProblemScore( + @SerializedName("earned") val earned: Double?, + @SerializedName("possible") val possible: Double? + ) { + fun mapToRoomEntity() = SectionScoreDb.SubsectionDb.ProblemScoreDb( + earned = earned ?: 0.0, + possible = possible ?: 0.0 + ) + + fun mapToDomain() = CourseProgress.SectionScore.Subsection.ProblemScore( + earned = earned ?: 0.0, + possible = possible ?: 0.0 + ) + } + } + } + + data class VerificationData( + @SerializedName("link") val link: String?, + @SerializedName("status") val status: String?, + @SerializedName("status_date") val statusDate: String? + ) { + fun mapToRoomEntity() = VerificationDataDb( + link = link.orEmpty(), + status = status.orEmpty(), + statusDate = statusDate.orEmpty() + ) + + fun mapToDomain() = CourseProgress.VerificationData( + link = link ?: "", + status = status ?: "", + statusDate = statusDate ?: "" + ) + } + + fun mapToDomain(): CourseProgress { + return CourseProgress( + verifiedMode = verifiedMode ?: "", + accessExpiration = accessExpiration ?: "", + certificateData = certificateData?.mapToDomain(), + completionSummary = completionSummary?.mapToDomain(), + courseGrade = courseGrade?.mapToDomain(), + creditCourseRequirements = creditCourseRequirements ?: "", + end = end ?: "", + enrollmentMode = enrollmentMode ?: "", + gradingPolicy = gradingPolicy?.mapToDomain(), + hasScheduledContent = hasScheduledContent ?: false, + sectionScores = sectionScores?.map { it.mapToDomain() } ?: emptyList(), + studioUrl = studioUrl ?: "", + username = username ?: "", + userHasPassingGrade = userHasPassingGrade ?: false, + verificationData = verificationData?.mapToDomain(), + disableProgressGraph = disableProgressGraph ?: false, + ) + } + + fun mapToRoomEntity(courseId: String): CourseProgressEntity { + return CourseProgressEntity( + courseId = courseId, + verifiedMode = verifiedMode.orEmpty(), + accessExpiration = accessExpiration.orEmpty(), + certificateData = certificateData?.mapToRoomEntity(), + completionSummary = completionSummary?.mapToRoomEntity(), + courseGrade = courseGrade?.mapToRoomEntity(), + creditCourseRequirements = creditCourseRequirements.orEmpty(), + end = end.orEmpty(), + enrollmentMode = enrollmentMode.orEmpty(), + gradingPolicy = gradingPolicy?.mapToRoomEntity(), + hasScheduledContent = hasScheduledContent ?: false, + sectionScores = sectionScores?.map { it.mapToRoomEntity() } ?: emptyList(), + studioUrl = studioUrl.orEmpty(), + username = username.orEmpty(), + userHasPassingGrade = userHasPassingGrade ?: false, + verificationData = verificationData?.mapToRoomEntity(), + disableProgressGraph = disableProgressGraph ?: false, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt new file mode 100644 index 000000000..2731b8b5d --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/DownloadCoursePreview.kt @@ -0,0 +1,34 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.data.model.room.DownloadCoursePreview as EntityDownloadCoursePreview +import org.openedx.core.domain.model.DownloadCoursePreview as DomainDownloadCoursePreview + +data class DownloadCoursePreview( + @SerializedName("course_id") + val id: String, + @SerializedName("course_name") + val name: String?, + @SerializedName("course_image") + val image: String?, + @SerializedName("total_size") + val totalSize: Long?, +) { + fun mapToDomain(): DomainDownloadCoursePreview { + return DomainDownloadCoursePreview( + id = id, + name = name ?: "", + image = image ?: "", + totalSize = totalSize ?: 0, + ) + } + + fun mapToRoomEntity(): EntityDownloadCoursePreview { + return EntityDownloadCoursePreview( + id = id, + name = name, + image = image, + totalSize = totalSize, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/Media.kt b/core/src/main/java/org/openedx/core/data/model/Media.kt index 7b4998175..96ffafb4c 100644 --- a/core/src/main/java/org/openedx/core/data/model/Media.kt +++ b/core/src/main/java/org/openedx/core/data/model/Media.kt @@ -14,7 +14,7 @@ data class Media( val image: Image?, ) { - fun mapToDomain(): org.openedx.core.domain.model.Media { + fun mapToDomain(): Media { return Media( bannerImage = bannerImage?.mapToDomain(), courseImage = courseImage?.mapToDomain(), diff --git a/core/src/main/java/org/openedx/core/data/model/Progress.kt b/core/src/main/java/org/openedx/core/data/model/Progress.kt index d4813c14c..469be14b9 100644 --- a/core/src/main/java/org/openedx/core/data/model/Progress.kt +++ b/core/src/main/java/org/openedx/core/data/model/Progress.kt @@ -11,8 +11,8 @@ data class Progress( val totalAssignmentsCount: Int?, ) { fun mapToDomain() = Progress( - assignmentsCompleted = assignmentsCompleted ?: 0, - totalAssignmentsCount = totalAssignmentsCount ?: 0 + completed = assignmentsCompleted ?: 0, + total = totalAssignmentsCount ?: 0 ) fun mapToRoomEntity() = ProgressDb( diff --git a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt index a60d9e68c..4ec631f30 100644 --- a/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt +++ b/core/src/main/java/org/openedx/core/data/model/room/BlockDb.kt @@ -203,7 +203,9 @@ data class VideoInfoDb( fun createFrom(videoInfo: VideoInfo?): VideoInfoDb? { if (videoInfo == null) return null return VideoInfoDb( - videoInfo.url ?: "", + videoInfo.url + .orEmpty() + .trim(), videoInfo.fileSize ?: 0, ) } @@ -230,11 +232,13 @@ data class AssignmentProgressDb( val numPointsEarned: Float?, @ColumnInfo("num_points_possible") val numPointsPossible: Float?, + val shortLabel: String? ) { fun mapToDomain() = DomainAssignmentProgress( - assignmentType = assignmentType ?: "", + assignmentType = assignmentType, numPointsEarned = numPointsEarned ?: 0f, - numPointsPossible = numPointsPossible ?: 0f + numPointsPossible = numPointsPossible ?: 0f, + shortLabel = shortLabel ?: "" ) } diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt new file mode 100644 index 000000000..cc80a0438 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseEnrollmentDetailsEntity.kt @@ -0,0 +1,84 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.data.model.room.discovery.CertificateDb +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb +import org.openedx.core.data.model.room.discovery.CourseSharingUtmParametersDb +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB +import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseInfoOverview +import java.util.Date + +@Entity(tableName = "course_enrollment_details_table") +data class CourseEnrollmentDetailsEntity( + @PrimaryKey + @ColumnInfo("id") + val id: String, + @ColumnInfo("courseUpdates") + val courseUpdates: String, + @ColumnInfo("courseHandouts") + val courseHandouts: String, + @ColumnInfo("discussionUrl") + val discussionUrl: String, + @Embedded + val courseAccessDetails: CourseAccessDetailsDb, + @Embedded + val certificate: CertificateDb?, + @Embedded + val enrollmentDetails: EnrollmentDetailsDB, + @Embedded + val courseInfoOverview: CourseInfoOverviewDb +) { + fun mapToDomain() = CourseEnrollmentDetails( + id = id, + courseUpdates = courseUpdates, + courseHandouts = courseHandouts, + discussionUrl = discussionUrl, + courseAccessDetails = courseAccessDetails.mapToDomain(), + certificate = certificate?.mapToDomain(), + enrollmentDetails = enrollmentDetails.mapToDomain(), + courseInfoOverview = courseInfoOverview.mapToDomain() + ) +} + +data class CourseInfoOverviewDb( + @ColumnInfo("name") + val name: String, + @ColumnInfo("number") + val number: String, + @ColumnInfo("org") + val org: String, + @ColumnInfo("start") + val start: Date?, + @ColumnInfo("startDisplay") + val startDisplay: String, + @ColumnInfo("startType") + val startType: String, + @ColumnInfo("end") + val end: Date?, + @ColumnInfo("isSelfPaced") + val isSelfPaced: Boolean, + @Embedded + var media: MediaDb?, + @Embedded + val courseSharingUtmParameters: CourseSharingUtmParametersDb, + @ColumnInfo("courseAbout") + val courseAbout: String, +) { + fun mapToDomain() = CourseInfoOverview( + name = name, + number = number, + org = org, + start = start, + startDisplay = startDisplay, + startType = startType, + end = end, + isSelfPaced = isSelfPaced, + media = media?.mapToDomain(), + courseSharingUtmParameters = courseSharingUtmParameters.mapToDomain(), + courseAbout = courseAbout + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt new file mode 100644 index 000000000..19ad78590 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseProgressEntity.kt @@ -0,0 +1,239 @@ +package org.openedx.core.data.model.room + +import androidx.compose.ui.graphics.Color +import androidx.core.graphics.toColorInt +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.CourseProgress + +@Entity(tableName = "course_progress_table") +data class CourseProgressEntity( + @PrimaryKey + @ColumnInfo("courseId") + val courseId: String, + @ColumnInfo("verifiedMode") + val verifiedMode: String, + @ColumnInfo("accessExpiration") + val accessExpiration: String, + @Embedded(prefix = "certificate_") + val certificateData: CertificateDataDb?, + @Embedded(prefix = "completion_") + val completionSummary: CompletionSummaryDb?, + @Embedded(prefix = "grade_") + val courseGrade: CourseGradeDb?, + @ColumnInfo("creditCourseRequirements") + val creditCourseRequirements: String, + @ColumnInfo("end") + val end: String, + @ColumnInfo("enrollmentMode") + val enrollmentMode: String, + @Embedded(prefix = "grading_") + val gradingPolicy: GradingPolicyDb?, + @ColumnInfo("hasScheduledContent") + val hasScheduledContent: Boolean, + @ColumnInfo("sectionScores") + val sectionScores: List, + @ColumnInfo("studioUrl") + val studioUrl: String, + @ColumnInfo("username") + val username: String, + @ColumnInfo("userHasPassingGrade") + val userHasPassingGrade: Boolean, + @Embedded(prefix = "verification_") + val verificationData: VerificationDataDb?, + @ColumnInfo("disableProgressGraph") + val disableProgressGraph: Boolean, +) { + fun mapToDomain(): CourseProgress { + return CourseProgress( + verifiedMode = verifiedMode, + accessExpiration = accessExpiration, + certificateData = certificateData?.mapToDomain(), + completionSummary = completionSummary?.mapToDomain(), + courseGrade = courseGrade?.mapToDomain(), + creditCourseRequirements = creditCourseRequirements, + end = end, + enrollmentMode = enrollmentMode, + gradingPolicy = gradingPolicy?.mapToDomain(), + hasScheduledContent = hasScheduledContent, + sectionScores = sectionScores.map { it.mapToDomain() }, + studioUrl = studioUrl, + username = username, + userHasPassingGrade = userHasPassingGrade, + verificationData = verificationData?.mapToDomain(), + disableProgressGraph = disableProgressGraph, + ) + } +} + +data class CertificateDataDb( + @ColumnInfo("certStatus") + val certStatus: String, + @ColumnInfo("certWebViewUrl") + val certWebViewUrl: String, + @ColumnInfo("downloadUrl") + val downloadUrl: String, + @ColumnInfo("certificateAvailableDate") + val certificateAvailableDate: String +) { + fun mapToDomain() = CourseProgress.CertificateData( + certStatus = certStatus, + certWebViewUrl = certWebViewUrl, + downloadUrl = downloadUrl, + certificateAvailableDate = certificateAvailableDate + ) +} + +data class CompletionSummaryDb( + @ColumnInfo("completeCount") + val completeCount: Int, + @ColumnInfo("incompleteCount") + val incompleteCount: Int, + @ColumnInfo("lockedCount") + val lockedCount: Int +) { + fun mapToDomain() = CourseProgress.CompletionSummary( + completeCount = completeCount, + incompleteCount = incompleteCount, + lockedCount = lockedCount + ) +} + +data class CourseGradeDb( + @ColumnInfo("letterGrade") + val letterGrade: String, + @ColumnInfo("percent") + val percent: Double, + @ColumnInfo("isPassing") + val isPassing: Boolean +) { + fun mapToDomain() = CourseProgress.CourseGrade( + letterGrade = letterGrade, + percent = percent, + isPassing = isPassing + ) +} + +data class GradingPolicyDb( + @ColumnInfo("assignmentPolicies") + val assignmentPolicies: List, + @ColumnInfo("gradeRange") + val gradeRange: Map, + @ColumnInfo("assignmentColors") + val assignmentColors: List +) { + fun mapToDomain() = CourseProgress.GradingPolicy( + assignmentPolicies = assignmentPolicies.map { it.mapToDomain() }, + gradeRange = gradeRange, + assignmentColors = assignmentColors.map { colorString -> + Color(colorString.toColorInt()) + } + ) + + data class AssignmentPolicyDb( + @ColumnInfo("numDroppable") + val numDroppable: Int, + @ColumnInfo("numTotal") + val numTotal: Int, + @ColumnInfo("shortLabel") + val shortLabel: String, + @ColumnInfo("type") + val type: String, + @ColumnInfo("weight") + val weight: Double + ) { + fun mapToDomain() = CourseProgress.GradingPolicy.AssignmentPolicy( + numDroppable = numDroppable, + numTotal = numTotal, + shortLabel = shortLabel, + type = type, + weight = weight + ) + } +} + +data class SectionScoreDb( + @ColumnInfo("displayName") + val displayName: String, + @ColumnInfo("subsections") + val subsections: List +) { + fun mapToDomain() = CourseProgress.SectionScore( + displayName = displayName, + subsections = subsections.map { it.mapToDomain() } + ) + + data class SubsectionDb( + @ColumnInfo("assignmentType") + val assignmentType: String, + @ColumnInfo("blockKey") + val blockKey: String, + @ColumnInfo("displayName") + val displayName: String, + @ColumnInfo("hasGradedAssignment") + val hasGradedAssignment: Boolean, + @ColumnInfo("override") + val override: String, + @ColumnInfo("learnerHasAccess") + val learnerHasAccess: Boolean, + @ColumnInfo("numPointsEarned") + val numPointsEarned: Float, + @ColumnInfo("numPointsPossible") + val numPointsPossible: Float, + @ColumnInfo("percentGraded") + val percentGraded: Double, + @ColumnInfo("problemScores") + val problemScores: List, + @ColumnInfo("showCorrectness") + val showCorrectness: String, + @ColumnInfo("showGrades") + val showGrades: Boolean, + @ColumnInfo("url") + val url: String + ) { + fun mapToDomain() = CourseProgress.SectionScore.Subsection( + assignmentType = assignmentType, + blockKey = blockKey, + displayName = displayName, + hasGradedAssignment = hasGradedAssignment, + override = override, + learnerHasAccess = learnerHasAccess, + numPointsEarned = numPointsEarned, + numPointsPossible = numPointsPossible, + percentGraded = percentGraded, + problemScores = problemScores.map { it.mapToDomain() }, + showCorrectness = showCorrectness, + showGrades = showGrades, + url = url + ) + + data class ProblemScoreDb( + @ColumnInfo("earned") + val earned: Double, + @ColumnInfo("possible") + val possible: Double + ) { + fun mapToDomain() = CourseProgress.SectionScore.Subsection.ProblemScore( + earned = earned, + possible = possible + ) + } + } +} + +data class VerificationDataDb( + @ColumnInfo("link") + val link: String, + @ColumnInfo("status") + val status: String, + @ColumnInfo("statusDate") + val statusDate: String +) { + fun mapToDomain() = CourseProgress.VerificationData( + link = link, + status = status, + statusDate = statusDate + ) +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt new file mode 100644 index 000000000..b4806f0f3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/DownloadCoursePreview.kt @@ -0,0 +1,28 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.domain.model.DownloadCoursePreview as DomainDownloadCoursePreview + +@Entity(tableName = "download_course_preview_table") +data class DownloadCoursePreview( + @PrimaryKey + @ColumnInfo("course_id") + val id: String, + @ColumnInfo("course_name") + val name: String?, + @ColumnInfo("course_image") + val image: String?, + @ColumnInfo("total_size") + val totalSize: Long?, +) { + fun mapToDomain(): DomainDownloadCoursePreview { + return DomainDownloadCoursePreview( + id = id, + name = name ?: "", + image = image ?: "", + totalSize = totalSize ?: 0, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt new file mode 100644 index 000000000..2ee58d802 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/VideoProgressEntity.kt @@ -0,0 +1,18 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "video_progress_table") +data class VideoProgressEntity( + @PrimaryKey + @ColumnInfo("block_id") + val blockId: String, + @ColumnInfo("video_url") + val videoUrl: String, + @ColumnInfo("video_time") + val videoTime: Long?, + @ColumnInfo("duration") + val duration: Long?, +) diff --git a/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt new file mode 100644 index 000000000..4ca7db3a6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/storage/CourseDao.kt @@ -0,0 +1,59 @@ +package org.openedx.core.data.storage + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity +import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.VideoProgressEntity + +@Dao +interface CourseDao { + + @Query("SELECT * FROM course_structure_table WHERE id=:id") + suspend fun getCourseStructureById(id: String): CourseStructureEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) + + @Transaction + suspend fun clearCachedData() { + clearCourseStructure() + clearVideoProgress() + clearEnrollmentCachedData() + clearCourseProgressData() + } + + @Query("DELETE FROM course_structure_table") + suspend fun clearCourseStructure() + + @Query("DELETE FROM video_progress_table") + suspend fun clearVideoProgress() + + @Query("DELETE FROM course_enrollment_details_table") + suspend fun clearEnrollmentCachedData() + + @Query("DELETE FROM course_progress_table") + suspend fun clearCourseProgressData() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseEnrollmentDetailsEntity(vararg courseEnrollmentDetailsEntity: CourseEnrollmentDetailsEntity) + + @Query("SELECT * FROM course_enrollment_details_table WHERE id=:id") + suspend fun getCourseEnrollmentDetailsById(id: String): CourseEnrollmentDetailsEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertVideoProgressEntity(vararg videoProgressEntity: VideoProgressEntity) + + @Query("SELECT * FROM video_progress_table WHERE block_id=:blockId") + suspend fun getVideoProgressByBlockId(blockId: String): VideoProgressEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertCourseProgressEntity(vararg courseProgressEntity: CourseProgressEntity) + + @Query("SELECT * FROM course_progress_table WHERE courseId=:id") + suspend fun getCourseProgressById(id: String): CourseProgressEntity? +} diff --git a/core/src/main/java/org/openedx/core/domain/helper/VideoPreviewHelper.kt b/core/src/main/java/org/openedx/core/domain/helper/VideoPreviewHelper.kt new file mode 100644 index 000000000..6914cb78c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/helper/VideoPreviewHelper.kt @@ -0,0 +1,62 @@ +package org.openedx.core.domain.helper + +import android.content.Context +import org.openedx.core.domain.model.Block +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.utils.VideoPreview + +/** + * Helper class for handling video preview generation. + * This class encapsulates the logic for getting video previews from blocks, + * avoiding the need to inject Context directly into ViewModels. + */ +class VideoPreviewHelper( + private val context: Context, + private val networkConnection: NetworkConnection +) { + + /** + * Gets video preview for a single block + * @param block The block to get video preview for + * @param offlineUrl Optional offline URL for the video + * @return VideoPreview object or null if no preview available + */ + fun getVideoPreview(block: Block, offlineUrl: String? = null): VideoPreview? { + return block.getVideoPreview( + context = context, + isOnline = networkConnection.isOnline(), + offlineUrl = offlineUrl + ) + } + + /** + * Gets video previews for multiple blocks + * @param blocks List of blocks to get video previews for + * @param offlineUrls Optional map of block IDs to offline URLs + * @return Map of block IDs to VideoPreview objects + */ + fun getVideoPreviews( + blocks: List, + offlineUrls: Map? = null + ): Map { + return blocks.associate { block -> + val offlineUrl = offlineUrls?.get(block.id) + block.id to getVideoPreview(block, offlineUrl) + } + } + + /** + * Gets video preview for a single block with a specific offline URL + * @param blockId The ID of the block + * @param block The block to get video preview for + * @param offlineUrl Optional offline URL for the video + * @return Pair of block ID and VideoPreview object or null + */ + fun getVideoPreviewWithId( + blockId: String, + block: Block, + offlineUrl: String? = null + ): Pair { + return blockId to getVideoPreview(block, offlineUrl) + } +} diff --git a/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt new file mode 100644 index 000000000..ef5a8b7c5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/interactor/CourseInteractor.kt @@ -0,0 +1,15 @@ +package org.openedx.core.domain.interactor + +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.module.db.DownloadModel + +interface CourseInteractor { + suspend fun getCourseStructure( + courseId: String, + isNeedRefresh: Boolean = false + ): CourseStructure + + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure + + suspend fun getAllDownloadModels(): List +} diff --git a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt index 730bfbfba..6c51810fb 100644 --- a/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/AssignmentProgress.kt @@ -1,11 +1,27 @@ package org.openedx.core.domain.model import android.os.Parcelable +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import org.openedx.core.extension.safeDivBy @Parcelize data class AssignmentProgress( - val assignmentType: String, + val assignmentType: String?, val numPointsEarned: Float, - val numPointsPossible: Float -) : Parcelable + val numPointsPossible: Float, + val shortLabel: String +) : Parcelable { + + @IgnoredOnParcel + val value: Float = numPointsEarned.safeDivBy(numPointsPossible) + + fun toPointString(separator: String = ""): String { + return "${numPointsEarned.toInt()}$separator/$separator${numPointsPossible.toInt()}" + } + + @IgnoredOnParcel + val label = shortLabel + .replace(" ", "") + .replaceFirst(Regex("^(\\D+)(0*)(\\d+)$"), "$1$3") +} 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 ba7b91a41..4b27c87fd 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 @@ -1,5 +1,6 @@ package org.openedx.core.domain.model +import android.content.Context import android.os.Parcelable import android.webkit.URLUtil import kotlinx.parcelize.Parcelize @@ -7,8 +8,9 @@ import kotlinx.parcelize.RawValue import org.openedx.core.AppDataConstants import org.openedx.core.BlockType import org.openedx.core.module.db.DownloadModel -import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.utils.PreviewHelper +import org.openedx.core.utils.VideoPreview import org.openedx.core.utils.VideoUtil import java.util.Date @@ -51,13 +53,6 @@ data class Block( null } - fun isDownloading(): Boolean { - return downloadModel?.downloadedState == DownloadedState.DOWNLOADING || - downloadModel?.downloadedState == DownloadedState.WAITING - } - - fun isDownloaded() = downloadModel?.downloadedState == DownloadedState.DOWNLOADED - fun isGated() = containsGatedContent fun isCompleted() = completion == 1.0 @@ -81,6 +76,44 @@ data class Block( return count } + fun getFileSize(): Long { + return when { + type == BlockType.VIDEO -> downloadModel?.size ?: 0L + isxBlock -> offlineDownload?.fileSize ?: 0L + else -> 0L + } + } + + fun getVideoPreview(context: Context, isOnline: Boolean, offlineUrl: String?): VideoPreview? { + return if (studentViewData?.encodedVideos?.hasYoutubeUrl == true) { + val youtubeUrl = studentViewData.encodedVideos.youtube?.url ?: "" + VideoPreview.createYoutubePreview( + PreviewHelper.getYouTubeThumbnailUrl(youtubeUrl) + ) + } else if (studentViewData?.encodedVideos?.hasVideoUrl == true) { + val videoUrl = if (studentViewData.encodedVideos.videoUrl.isNotEmpty() && isOnline) { + studentViewData.encodedVideos.videoUrl + } else { + offlineUrl ?: "" + } + val bitmap = PreviewHelper.getVideoFrameBitmap( + context = context, + isOnline = isOnline, + videoUrl = videoUrl + ) + bitmap?.let { VideoPreview.createEncodedVideoPreview(it) } + } else { + null + } + } + + val videoUrl: String? + get() = if (studentViewData?.encodedVideos?.hasVideoUrl == true) { + studentViewData.encodedVideos.videoUrl + } else { + studentViewData?.encodedVideos?.youtube?.url + } + val isVideoBlock get() = type == BlockType.VIDEO val isDiscussionBlock get() = type == BlockType.DISCUSSION val isHTMLBlock get() = type == BlockType.HTML @@ -161,7 +194,10 @@ data class EncodedVideos( isPreferredVideoInfo(mobileHigh) -> mobileHigh isPreferredVideoInfo(desktopMp4) -> desktopMp4 fallback != null && isPreferredVideoInfo(fallback) && - !VideoUtil.videoHasFormat(fallback!!.url, AppDataConstants.VIDEO_FORMAT_M3U8) -> fallback + !VideoUtil.videoHasFormat( + fallback!!.url, + AppDataConstants.VIDEO_FORMAT_M3U8 + ) -> fallback hls != null && isPreferredVideoInfo(hls) -> hls else -> null diff --git a/core/src/main/java/org/openedx/core/domain/model/Certificate.kt b/core/src/main/java/org/openedx/core/domain/model/Certificate.kt index 054b75511..62fb51b50 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Certificate.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Certificate.kt @@ -2,10 +2,13 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CertificateDb @Parcelize data class Certificate( val certificateURL: String? ) : Parcelable { fun isCertificateEarned() = certificateURL?.isNotEmpty() == true + + fun mapToRoomEntity() = CertificateDb(certificateURL) } diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt index fac674e66..2c95865e9 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseAccessDetails.kt @@ -1,7 +1,9 @@ package org.openedx.core.domain.model import android.os.Parcelable +import com.google.gson.internal.bind.util.ISO8601Utils import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CourseAccessDetailsDb import java.util.Date @Parcelize @@ -11,4 +13,14 @@ data class CourseAccessDetails( val isStaff: Boolean, val auditAccessExpires: Date?, val coursewareAccess: CoursewareAccess?, -) : Parcelable +) : Parcelable { + + fun mapToRoomEntity(): CourseAccessDetailsDb = + CourseAccessDetailsDb( + hasUnmetPrerequisites = hasUnmetPrerequisites, + isTooEarly = isTooEarly, + isStaff = isStaff, + auditAccessExpires = auditAccessExpires?.let { ISO8601Utils.format(it) }, + coursewareAccess = coursewareAccess?.mapToEntity() + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt index 5c61fee60..ec961dfcd 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseEnrollmentDetails.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity import org.openedx.core.extension.isNotNull import java.util.Date @@ -23,6 +24,17 @@ data class CourseEnrollmentDetails( val isAuditAccessExpired: Boolean get() = courseAccessDetails.auditAccessExpires.isNotNull() && Date().after(courseAccessDetails.auditAccessExpires) + + fun mapToEntity() = CourseEnrollmentDetailsEntity( + id = id, + courseUpdates = courseUpdates, + courseHandouts = courseHandouts, + discussionUrl = discussionUrl, + courseAccessDetails = courseAccessDetails.mapToRoomEntity(), + certificate = certificate?.mapToRoomEntity(), + enrollmentDetails = enrollmentDetails.mapToEntity(), + courseInfoOverview = courseInfoOverview.mapToEntity() + ) } enum class CourseAccessError { diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt index 4d02f10b9..6895522f5 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseInfoOverview.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.CourseInfoOverviewDb import java.util.Date @Parcelize @@ -10,7 +11,7 @@ data class CourseInfoOverview( val number: String, val org: String, val start: Date?, - val startDisplay: String, + val startDisplay: String?, val startType: String, val end: Date?, val isSelfPaced: Boolean, @@ -20,4 +21,18 @@ data class CourseInfoOverview( ) : Parcelable { val isStarted: Boolean get() = start?.before(Date()) ?: false + + fun mapToEntity() = CourseInfoOverviewDb( + name = name, + number = number, + org = org, + start = start, + startDisplay = startDisplay ?: "", + startType = startType, + end = end, + isSelfPaced = isSelfPaced, + media = media?.mapToEntity(), + courseSharingUtmParameters = courseSharingUtmParameters.mapToEntity(), + courseAbout = courseAbout + ) } diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt new file mode 100644 index 000000000..77ae5f65a --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseProgress.kt @@ -0,0 +1,138 @@ +package org.openedx.core.domain.model + +import androidx.compose.ui.graphics.Color + +data class CourseProgress( + val verifiedMode: String, + val accessExpiration: String, + val certificateData: CertificateData?, + val completionSummary: CompletionSummary?, + val courseGrade: CourseGrade?, + val creditCourseRequirements: String, + val end: String, + val enrollmentMode: String, + val gradingPolicy: GradingPolicy?, + val hasScheduledContent: Boolean, + val sectionScores: List, + val studioUrl: String, + val username: String, + val userHasPassingGrade: Boolean, + val verificationData: VerificationData?, + val disableProgressGraph: Boolean, +) { + val completion = with(completionSummary) { + val total = (this?.completeCount ?: 0) + (this?.incompleteCount ?: 0) + if (total > 0f) (this?.completeCount ?: 0).toFloat() / total else 0f + } + val completionPercent = (completion * 100f).toInt() + val requiredGrade = gradingPolicy?.gradeRange?.values?.firstOrNull() ?: 0f + val requiredGradePercent = (requiredGrade * 100f).toInt() + + fun getAssignmentGradedPercent(type: String): Float { + val assignmentSections = getAssignmentSections(type) + if (assignmentSections.isEmpty()) return 0f + return assignmentSections.sumOf { it.percentGraded }.toFloat() / assignmentSections.size + } + + fun getAssignmentSections(type: String) = sectionScores + .flatMap { it.subsections } + .filter { it.assignmentType == type } + + fun getAssignmentWeightedGradedPercent(assignmentPolicy: GradingPolicy.AssignmentPolicy): Float { + return (assignmentPolicy.weight * getAssignmentGradedPercent(assignmentPolicy.type) * 100f).toFloat() + } + + fun getTotalWeightPercent() = + gradingPolicy?.assignmentPolicies?.sumOf { getAssignmentWeightedGradedPercent(it).toDouble() } + ?.toFloat() ?: 0f + + fun getNotCompletedWeightedGradePercent(): Float { + val totalWeightedPercent = getTotalWeightPercent() + val notCompletedPercent = 100.0 - totalWeightedPercent + return if (notCompletedPercent < 0.0) 0f else notCompletedPercent.toFloat() + } + + fun getNotEmptyGradingPolicies() = gradingPolicy?.assignmentPolicies?.mapNotNull { + if (getAssignmentSections(it.type).isNotEmpty()) { + it + } else { + null + } + } + + fun getCompletedAssignmentCount( + policy: GradingPolicy.AssignmentPolicy, + courseStructure: CourseStructure? = null + ): Int { + val assignments = getAssignmentSections(policy.type) + return courseStructure?.blockData + ?.filter { it.id in assignments.map { assignment -> assignment.blockKey } } + ?.filter { it.isCompleted() } + ?.size ?: 0 + } + + data class CertificateData( + val certStatus: String, + val certWebViewUrl: String, + val downloadUrl: String, + val certificateAvailableDate: String + ) + + data class CompletionSummary( + val completeCount: Int, + val incompleteCount: Int, + val lockedCount: Int + ) + + data class CourseGrade( + val letterGrade: String, + val percent: Double, + val isPassing: Boolean + ) + + data class GradingPolicy( + val assignmentPolicies: List, + val gradeRange: Map, + val assignmentColors: List, + ) { + data class AssignmentPolicy( + val numDroppable: Int, + val numTotal: Int, + val shortLabel: String, + val type: String, + val weight: Double + ) + } + + data class SectionScore( + val displayName: String, + val subsections: List + ) { + data class Subsection( + val assignmentType: String, + val blockKey: String, + val displayName: String, + val hasGradedAssignment: Boolean, + val override: String, + val learnerHasAccess: Boolean, + val numPointsEarned: Float, + val numPointsPossible: Float, + val percentGraded: Double, + val problemScores: List, + val showCorrectness: String, + val showGrades: Boolean, + val url: String + ) { + data class ProblemScore( + val earned: Double, + val possible: Double + ) + } + } + + data class VerificationData( + val link: String, + val status: String, + val statusDate: String + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt b/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt index 186ef85fd..1d27361a3 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CourseSharingUtmParameters.kt @@ -2,9 +2,16 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CourseSharingUtmParametersDb @Parcelize data class CourseSharingUtmParameters( val facebook: String, val twitter: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = CourseSharingUtmParametersDb( + facebook = facebook, + twitter = twitter + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt b/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt index 5dd48d94e..9f0fd60e6 100644 --- a/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt +++ b/core/src/main/java/org/openedx/core/domain/model/CoursewareAccess.kt @@ -2,6 +2,7 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.CoursewareAccessDb @Parcelize data class CoursewareAccess( @@ -11,4 +12,14 @@ data class CoursewareAccess( val userMessage: String, val additionalContextUserMessage: String, val userFragment: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = CoursewareAccessDb( + hasAccess = hasAccess, + errorCode = errorCode, + developerMessage = developerMessage, + userMessage = userMessage, + additionalContextUserMessage = additionalContextUserMessage, + userFragment = userFragment + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt b/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt new file mode 100644 index 000000000..d4fccf4e0 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/DownloadCoursePreview.kt @@ -0,0 +1,8 @@ +package org.openedx.core.domain.model + +data class DownloadCoursePreview( + val id: String, + val name: String, + val image: String, + val totalSize: Long, +) diff --git a/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt b/core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt similarity index 81% rename from course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt rename to core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt index cded4944a..a0666f2b1 100644 --- a/course/src/main/java/org/openedx/course/domain/model/DownloadDialogResource.kt +++ b/core/src/main/java/org/openedx/core/domain/model/DownloadDialogResource.kt @@ -1,4 +1,4 @@ -package org.openedx.course.domain.model +package org.openedx.core.domain.model import androidx.compose.ui.graphics.painter.Painter diff --git a/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt index c9d39ec35..b880f3948 100644 --- a/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt +++ b/core/src/main/java/org/openedx/core/domain/model/EnrollmentDetails.kt @@ -1,7 +1,9 @@ package org.openedx.core.domain.model import android.os.Parcelable +import com.google.gson.internal.bind.util.ISO8601Utils import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.discovery.EnrollmentDetailsDB import java.util.Date @Parcelize @@ -10,4 +12,12 @@ data class EnrollmentDetails( val mode: String?, val isActive: Boolean, val upgradeDeadline: Date?, -) : Parcelable +) : Parcelable { + + fun mapToEntity() = EnrollmentDetailsDB( + created = created?.let { ISO8601Utils.format(it) }, + mode = mode, + isActive = isActive, + upgradeDeadline = upgradeDeadline?.let { ISO8601Utils.format(it) } + ) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/Media.kt b/core/src/main/java/org/openedx/core/domain/model/Media.kt index 51fa6dda5..572fcbdae 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Media.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Media.kt @@ -2,6 +2,11 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize +import org.openedx.core.data.model.room.BannerImageDb +import org.openedx.core.data.model.room.CourseImageDb +import org.openedx.core.data.model.room.CourseVideoDb +import org.openedx.core.data.model.room.ImageDb +import org.openedx.core.data.model.room.MediaDb @Parcelize data class Media( @@ -9,28 +14,48 @@ data class Media( val courseImage: CourseImage? = null, val courseVideo: CourseVideo? = null, val image: Image? = null -) : Parcelable +) : Parcelable { + + fun mapToEntity() = MediaDb( + bannerImage = bannerImage?.mapToEntity(), + courseImage = courseImage?.mapToEntity(), + courseVideo = courseVideo?.mapToEntity(), + image = image?.mapToEntity() + ) +} @Parcelize data class Image( val large: String, val raw: String, val small: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = ImageDb(large, raw, small) +} @Parcelize data class CourseVideo( val uri: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = CourseVideoDb(uri) +} @Parcelize data class CourseImage( val uri: String, val name: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = CourseImageDb(uri, name) +} @Parcelize data class BannerImage( val uri: String, val uriAbsolute: String -) : Parcelable +) : Parcelable { + + fun mapToEntity() = BannerImageDb(uri, uriAbsolute) +} diff --git a/core/src/main/java/org/openedx/core/domain/model/Progress.kt b/core/src/main/java/org/openedx/core/domain/model/Progress.kt index 800a9c292..fbe82d5cc 100644 --- a/core/src/main/java/org/openedx/core/domain/model/Progress.kt +++ b/core/src/main/java/org/openedx/core/domain/model/Progress.kt @@ -3,19 +3,16 @@ package org.openedx.core.domain.model import android.os.Parcelable import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize +import org.openedx.core.extension.safeDivBy @Parcelize data class Progress( - val assignmentsCompleted: Int, - val totalAssignmentsCount: Int, + val completed: Int, + val total: Int, ) : Parcelable { @IgnoredOnParcel - val value: Float = try { - assignmentsCompleted.toFloat() / totalAssignmentsCount.toFloat() - } catch (_: ArithmeticException) { - 0f - } + val value: Float = completed.toFloat().safeDivBy(total.toFloat()) companion object { val DEFAULT_PROGRESS = Progress(0, 0) diff --git a/core/src/main/java/org/openedx/core/extension/CoroutineExt.kt b/core/src/main/java/org/openedx/core/extension/CoroutineExt.kt new file mode 100644 index 000000000..5a29ef9f5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/CoroutineExt.kt @@ -0,0 +1,14 @@ +package org.openedx.core.extension + +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.channelFlow +import kotlin.experimental.ExperimentalTypeInference + +@OptIn(ExperimentalTypeInference::class) +inline fun channelFlowWithAwait( + @BuilderInference crossinline block: suspend ProducerScope.() -> Unit +) = channelFlow { + block(this) + awaitClose() +} diff --git a/core/src/main/java/org/openedx/core/extension/FloatExt.kt b/core/src/main/java/org/openedx/core/extension/FloatExt.kt new file mode 100644 index 000000000..77a022736 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/FloatExt.kt @@ -0,0 +1,19 @@ +package org.openedx.core.extension + +/** + * Safely divides this Float by [divisor], returning 0f if: + * - [divisor] is zero, + * - the result is NaN. + * + * Workaround for accessibility issue: + * https://github.com/openedx/openedx-app-android/issues/442 + */ +fun Float.safeDivBy(divisor: Float): Float = try { + var result = this / divisor + if (result.isNaN()) { + result = 0f + } + result +} catch (_: ArithmeticException) { + 0f +} diff --git a/core/src/main/java/org/openedx/core/extension/ListExt.kt b/core/src/main/java/org/openedx/core/extension/ListExt.kt index 6d97816ae..f5cc21279 100644 --- a/core/src/main/java/org/openedx/core/extension/ListExt.kt +++ b/core/src/main/java/org/openedx/core/extension/ListExt.kt @@ -10,3 +10,25 @@ fun List.getVerticalBlocks(): List { fun List.getSequentialBlocks(): List { return this.filter { it.type == BlockType.SEQUENTIAL } } + +fun List.getChapterBlocks(): List { + return this.filter { it.type == BlockType.CHAPTER } +} + +fun List.getUnitChapter(blockId: String): Block? { + val verticalBlock = this.firstOrNull { + it.type == BlockType.VERTICAL && it.descendants.contains(blockId) + } + + val sequentialBlock = verticalBlock?.let { vertical -> + this.firstOrNull { + it.type == BlockType.SEQUENTIAL && it.descendants.contains(vertical.id) + } + } + + return sequentialBlock?.let { sequential -> + this.firstOrNull { + it.type == BlockType.CHAPTER && it.descendants.contains(sequential.id) + } + } +} diff --git a/core/src/main/java/org/openedx/core/extension/TextConverter.kt b/core/src/main/java/org/openedx/core/extension/TextConverter.kt index 22879220e..f01d33aa3 100644 --- a/core/src/main/java/org/openedx/core/extension/TextConverter.kt +++ b/core/src/main/java/org/openedx/core/extension/TextConverter.kt @@ -1,8 +1,6 @@ package org.openedx.core.extension -import android.os.Parcelable import android.util.Patterns -import kotlinx.parcelize.Parcelize import org.jsoup.Jsoup import org.jsoup.nodes.Document import org.jsoup.select.Elements @@ -36,84 +34,10 @@ object TextConverter : KoinComponent { return LinkedText(text, linksMap.toMap()) } - fun textToLinkedImageText(html: String): LinkedImageText { - val doc: Document = - Jsoup.parse(html) - val links: Elements = doc.select("a[href]") - var text = doc.text() - val headers = getHeaders(doc) - val linksMap = mutableMapOf() - for (link in links) { - if (isLinkValid(link.attr("href"))) { - val linkText = if (link.hasText()) link.text() else link.attr("href") - linksMap[linkText] = link.attr("href") - } else { - val resultLink = - if (link.attr("href").isNotEmpty() && link.attr("href")[0] == '/') { - link.attr("href").substring(1) - } else { - link.attr("href") - } - if (resultLink.isNotEmpty() && isLinkValid(config.getApiHostURL() + resultLink)) { - linksMap[link.text()] = config.getApiHostURL() + resultLink - } - } - } - text = setSpacesForHeaders(text, headers) - return LinkedImageText( - text, - linksMap.toMap(), - getImageLinks(doc), - headers - ) - } - fun isLinkValid(link: String) = Patterns.WEB_URL.matcher(link.lowercase()).matches() - - @Suppress("MagicNumber") - private fun getHeaders(document: Document): List { - val headersList = mutableListOf() - for (index in 1..6) { - if (document.select("h$index").hasText()) { - headersList.add(document.select("h$index").text()) - } - } - return headersList.toList() - } - - private fun setSpacesForHeaders(text: String, headers: List): String { - var result = text - headers.forEach { - val startIndex = text.indexOf(it) - val endIndex = startIndex + it.length + 1 - result = text.replaceRange(startIndex, endIndex, it + "\n") - } - return result - } - - private fun getImageLinks(document: Document): Map { - val imageLinks = mutableMapOf() - val elements = document.getElementsByTag("img") - for (element in elements) { - if (element.hasAttr("alt")) { - imageLinks[element.attr("alt")] = element.attr("src") - } else { - imageLinks[element.attr("src")] = element.attr("src") - } - } - return imageLinks.toMap() - } } data class LinkedText( val text: String, val links: Map ) - -@Parcelize -data class LinkedImageText( - val text: String, - val links: Map, - val imageLinks: Map, - val headers: List -) : Parcelable diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt index afb2f6383..f91a19c6a 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -23,6 +23,9 @@ import org.openedx.core.module.download.AbstractDownloader.DownloadResult import org.openedx.core.module.download.CurrentProgress import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader +import org.openedx.core.presentation.DownloadsAnalytics +import org.openedx.core.presentation.DownloadsAnalyticsEvent +import org.openedx.core.presentation.DownloadsAnalyticsKey import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged @@ -33,12 +36,14 @@ class DownloadWorker( parameters: WorkerParameters, ) : CoroutineWorker(context, parameters), CoroutineScope { - private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + private val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) private val notifier by inject(DownloadNotifier::class.java) private val downloadDao: DownloadDao by inject(DownloadDao::class.java) private val downloadHelper: DownloadHelper by inject(DownloadHelper::class.java) + private val analytics: DownloadsAnalytics by inject(DownloadsAnalytics::class.java) private var downloadEnqueue = listOf() private var downloadError = mutableListOf() @@ -134,9 +139,11 @@ class DownloadWorker( ) ) ) + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_STARTED) val downloadResult = fileDownloader.download(downloadTask.url, downloadTask.path) when (downloadResult) { DownloadResult.SUCCESS -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_COMPLETED) val updatedModel = downloadHelper.updateDownloadStatus(downloadTask) if (updatedModel == null) { downloadDao.removeDownloadModel(downloadTask.id) @@ -149,10 +156,12 @@ class DownloadWorker( } DownloadResult.CANCELED -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) downloadDao.removeDownloadModel(downloadTask.id) } DownloadResult.ERROR -> { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) downloadDao.removeDownloadModel(downloadTask.id) downloadError.add(downloadTask) } @@ -173,6 +182,15 @@ class DownloadWorker( notificationManager.createNotificationChannel(notificationChannel) } + fun logEvent(event: DownloadsAnalyticsEvent) { + analytics.logEvent( + event = event.eventName, + params = buildMap { + put(DownloadsAnalyticsKey.NAME.key, event.biValue) + } + ) + } + companion object { const val WORKER_TAG = "downloadWorker" diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index a07329e4d..377a8a2d9 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow +import org.openedx.core.data.model.room.DownloadCoursePreview import org.openedx.core.data.model.room.OfflineXBlockProgress @Dao @@ -32,6 +33,9 @@ interface DownloadDao { @Query("DELETE FROM download_model WHERE id in (:ids)") suspend fun removeAllDownloadModels(ids: List) + @Query("SELECT * FROM download_model WHERE courseId = :courseId") + suspend fun getDownloadModelsByCourseIds(courseId: String): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOfflineXBlockProgress(offlineXBlockProgress: OfflineXBlockProgress) @@ -46,4 +50,10 @@ interface DownloadDao { @Query("DELETE FROM offline_x_block_progress_table") suspend fun clearOfflineProgress() + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertDownloadCoursePreview(downloadCoursePreview: List) + + @Query("SELECT * FROM download_course_preview_table") + fun getDownloadCoursesPreview(): List } diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt index da736ba28..9f5abd3f4 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadModel.kt @@ -17,11 +17,11 @@ data class DownloadModel( ) : Parcelable enum class DownloadedState { - WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED; + WAITING, DOWNLOADING, DOWNLOADED, NOT_DOWNLOADED, LOADING_COURSE_STRUCTURE; val isWaitingOrDownloading: Boolean get() { - return this == WAITING || this == DOWNLOADING + return this == WAITING || this == DOWNLOADING || this == LOADING_COURSE_STRUCTURE } val isDownloaded: Boolean diff --git a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt index d2c6d8c74..86fac4271 100644 --- a/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt +++ b/core/src/main/java/org/openedx/core/module/download/AbstractDownloader.kt @@ -73,6 +73,7 @@ abstract class AbstractDownloader : KoinComponent { private fun closeResources() { fos?.close() input?.close() + currentDownloadingFilePath = null } suspend fun cancelDownloading() { diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index 0fcf962a3..ba87e6ab0 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -19,7 +19,6 @@ import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.foundation.presentation.BaseViewModel abstract class BaseDownloadViewModel( - private val courseId: String, private val downloadDao: DownloadDao, private val preferencesManager: CorePreferences, private val workerController: DownloadWorkerController, @@ -35,7 +34,6 @@ abstract class BaseDownloadViewModel( private val _downloadModelsStatusFlow = MutableSharedFlow>() protected val downloadModelsStatusFlow = _downloadModelsStatusFlow.asSharedFlow() - private var downloadingModelsList = listOf() private val _downloadingModelsFlow = MutableSharedFlow>() protected val downloadingModelsFlow = _downloadingModelsFlow.asSharedFlow() @@ -54,7 +52,7 @@ abstract class BaseDownloadViewModel( _downloadModelsStatusFlow.emit(downloadModelsStatus) } - private suspend fun getDownloadModelList(): List { + suspend fun getDownloadModelList(): List { return downloadDao.getAllDataFlow().first().map { it.mapToDomain() } } @@ -66,8 +64,7 @@ abstract class BaseDownloadViewModel( updateParentStatus(parentId, children.size, downloadingCount, downloadedCount) } - downloadingModelsList = models.filter { it.downloadedState.isWaitingOrDownloading } - _downloadingModelsFlow.emit(downloadingModelsList) + _downloadingModelsFlow.emit(models) } private fun updateChildrenStatus( @@ -116,6 +113,10 @@ abstract class BaseDownloadViewModel( allBlocks.putAll(list.map { it.id to it }) } + protected fun addBlocks(list: List) { + allBlocks.putAll(list.map { it.id to it }) + } + fun isBlockDownloading(id: String): Boolean { val blockDownloadingState = downloadModelsStatus[id] return blockDownloadingState?.isWaitingOrDownloading == true @@ -126,22 +127,22 @@ abstract class BaseDownloadViewModel( return blockDownloadingState == DownloadedState.DOWNLOADED } - open fun saveDownloadModels(folder: String, id: String) { + open fun saveDownloadModels(folder: String, courseId: String, id: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap[id] ?: listOf() - logSubsectionDownloadEvent(id, saveBlocksIds.size) - saveDownloadModels(folder, saveBlocksIds) + logSubsectionDownloadEvent(id, saveBlocksIds.size, courseId) + saveDownloadModels(folder, courseId, saveBlocksIds) } } - open fun saveAllDownloadModels(folder: String) { + open fun saveAllDownloadModels(folder: String, courseId: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap.values.flatten() - saveDownloadModels(folder, saveBlocksIds) + saveDownloadModels(folder, courseId, saveBlocksIds) } } - suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { + suspend fun saveDownloadModels(folder: String, courseId: String, saveBlocksIds: List) { val downloadModels = mutableListOf() val downloadModelList = getDownloadModelList() for (blockId in saveBlocksIds) { @@ -196,21 +197,12 @@ abstract class BaseDownloadViewModel( ) } - fun hasDownloadModelsInQueue() = downloadingModelsList.isNotEmpty() - fun getDownloadableChildren(id: String) = downloadableChildrenMap[id] - open fun removeDownloadModels(blockId: String) { + open fun removeDownloadModels(blockId: String, courseId: String) { viewModelScope.launch { val downloadableChildren = downloadableChildrenMap[blockId] ?: listOf() - logSubsectionDeleteEvent(blockId, downloadableChildren.size) - workerController.removeModels(downloadableChildren) - } - } - - fun removeAllDownloadModels() { - viewModelScope.launch { - val downloadableChildren = downloadableChildrenMap.values.flatten() + logSubsectionDeleteEvent(blockId, downloadableChildren.size, courseId) workerController.removeModels(downloadableChildren) } } @@ -242,36 +234,41 @@ abstract class BaseDownloadViewModel( downloadableChildrenMap[parentId] = children + childId } - fun logBulkDownloadToggleEvent(toggle: Boolean) { - logEvent( - CoreAnalyticsEvent.VIDEO_BULK_DOWNLOAD_TOGGLE, - buildMap { - put(CoreAnalyticsKey.ACTION.key, toggle) - } - ) - } - - private fun logSubsectionDownloadEvent(subsectionId: String, numberOfVideos: Int) { + private fun logSubsectionDownloadEvent( + subsectionId: String, + numberOfVideos: Int, + courseId: String + ) { logEvent( CoreAnalyticsEvent.VIDEO_DOWNLOAD_SUBSECTION, buildMap { put(CoreAnalyticsKey.BLOCK_ID.key, subsectionId) put(CoreAnalyticsKey.NUMBER_OF_VIDEOS.key, numberOfVideos) - } + }, + courseId ) } - private fun logSubsectionDeleteEvent(subsectionId: String, numberOfVideos: Int) { + private fun logSubsectionDeleteEvent( + subsectionId: String, + numberOfVideos: Int, + courseId: String + ) { logEvent( CoreAnalyticsEvent.VIDEO_DELETE_SUBSECTION, buildMap { put(CoreAnalyticsKey.BLOCK_ID.key, subsectionId) put(CoreAnalyticsKey.NUMBER_OF_VIDEOS.key, numberOfVideos) - } + }, + courseId ) } - private fun logEvent(event: CoreAnalyticsEvent, param: Map = emptyMap()) { + private fun logEvent( + event: CoreAnalyticsEvent, + param: Map = emptyMap(), + courseId: String + ) { analytics.logEvent( event.eventName, buildMap { diff --git a/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt b/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt new file mode 100644 index 000000000..625140d4f --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/DownloadsAnalytics.kt @@ -0,0 +1,49 @@ +package org.openedx.core.presentation + +interface DownloadsAnalytics { + fun logEvent(event: String, params: Map) + fun logScreenEvent(screenName: String, params: Map) +} + +enum class DownloadsAnalyticsEvent(val eventName: String, val biValue: String) { + DOWNLOAD_COURSE_CLICKED( + "Downloads:Download Course Clicked", + "edx.bi.app.downloads.downloadCourseClicked" + ), + CANCEL_DOWNLOAD_CLICKED( + "Downloads:Cancel Download Clicked", + "edx.bi.app.downloads.cancelDownloadClicked" + ), + REMOVE_DOWNLOAD_CLICKED( + "Downloads:Remove Download Clicked", + "edx.bi.app.downloads.removeDownloadClicked" + ), + DOWNLOAD_CONFIRMED( + "Downloads:Download Confirmed", + "edx.bi.app.downloads.downloadConfirmed" + ), + DOWNLOAD_CANCELLED( + "Downloads:Download Cancelled", + "edx.bi.app.downloads.downloadCancelled" + ), + DOWNLOAD_REMOVED( + "Downloads:Download Removed", + "edx.bi.app.downloads.downloadRemoved" + ), + DOWNLOAD_ERROR( + "Downloads:Download Error", + "edx.bi.app.downloads.downloadError" + ), + DOWNLOAD_COMPLETED( + "Downloads:Download Completed", + "edx.bi.app.downloads.downloadCompleted" + ), + DOWNLOAD_STARTED( + "Downloads:Download Started", + "edx.bi.app.downloads.downloadStarted" + ), +} + +enum class DownloadsAnalyticsKey(val key: String) { + NAME("name"), +} diff --git a/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt b/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt deleted file mode 100644 index 8a73475ed..000000000 --- a/core/src/main/java/org/openedx/core/presentation/course/CourseViewMode.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.openedx.core.presentation.course - -enum class CourseViewMode { - FULL, - VIDEOS -} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt index 6e7a4c301..a558e8b40 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appupgrade/AppUpgradeDialogFragment.kt @@ -33,12 +33,12 @@ class AppUpgradeDialogFragment : DialogFragment() { } private fun onNotNowClick() { - AppUpdateState.wasUpdateDialogClosed.value = true + AppUpdateState.wasUpgradeDialogClosed.value = true dismiss() } private fun onUpdateClick() { - AppUpdateState.wasUpdateDialogClosed.value = true + AppUpdateState.wasUpgradeDialogClosed.value = true dismiss() AppUpdateState.openPlayMarket(requireContext()) } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt similarity index 85% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt index c591966f4..5ab8db529 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt @@ -1,7 +1,6 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -11,6 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -31,8 +31,11 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.IconText @@ -41,51 +44,52 @@ import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R -import org.openedx.course.domain.model.DownloadDialogResource import org.openedx.foundation.extension.parcelable import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.system.PreviewFragmentManager import androidx.compose.ui.graphics.Color as ComposeColor -import org.openedx.core.R as coreR -class DownloadConfirmDialogFragment : DialogFragment() { +class DownloadConfirmDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { val dialogType = - requireArguments().parcelable(ARG_DIALOG_TYPE) ?: return@OpenEdXTheme - val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + requireArguments().parcelable(ARG_DIALOG_TYPE) + ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) + ?: return@OpenEdXTheme val sizeSumString = uiState.sizeSum.toFileSize(1, false) val dialogData = when (dialogType) { DownloadConfirmDialogType.CONFIRM -> DownloadDialogResource( - title = stringResource(id = coreR.string.course_confirm_download), + title = stringResource(id = R.string.course_confirm_download), description = stringResource( - id = R.string.course_download_confirm_dialog_description, + id = R.string.core_download_confirm_dialog_description, sizeSumString ), ) DownloadConfirmDialogType.DOWNLOAD_ON_CELLULAR -> DownloadDialogResource( - title = stringResource(id = R.string.course_download_on_cellural), + title = stringResource(id = R.string.core_download_on_cellural), description = stringResource( - id = R.string.course_download_on_cellural_dialog_description, + id = R.string.core_download_on_cellural_dialog_description, sizeSumString ), - icon = painterResource(id = coreR.drawable.core_ic_warning), + icon = painterResource(id = R.drawable.core_ic_warning), ) DownloadConfirmDialogType.REMOVE -> DownloadDialogResource( - title = stringResource(id = R.string.course_download_remove_offline_content), + title = stringResource(id = R.string.core_download_remove_offline_content), description = stringResource( - id = R.string.course_download_remove_dialog_description, + id = R.string.core_download_remove_dialog_description, sizeSumString ) ) @@ -98,6 +102,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { onConfirmClick = { uiState.saveDownloadModels() dismiss() + listener?.onConfirmClick() }, onRemoveClick = { uiState.removeDownloadModels() @@ -105,6 +110,7 @@ class DownloadConfirmDialogFragment : DialogFragment() { }, onCancelClick = { dismiss() + listener?.onCancelClick() } ) } @@ -112,7 +118,6 @@ class DownloadConfirmDialogFragment : DialogFragment() { } companion object { - const val DIALOG_TAG = "DownloadConfirmDialogFragment" const val ARG_DIALOG_TYPE = "dialogType" const val ARG_UI_STATE = "uiState" @@ -148,7 +153,6 @@ private fun DownloadConfirmDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -171,7 +175,11 @@ private fun DownloadConfirmDialogView( minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } - Column { + Column( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { uiState.downloadDialogItems.forEach { DownloadDialogItem(downloadDialogItem = it) } @@ -188,14 +196,14 @@ private fun DownloadConfirmDialogView( val onClick: () -> Unit when (dialogType) { DownloadConfirmDialogType.REMOVE -> { - buttonText = stringResource(id = R.string.course_remove) + buttonText = stringResource(id = R.string.core_remove) buttonIcon = Icons.Rounded.Delete buttonColor = MaterialTheme.appColors.error onClick = onRemoveClick } else -> { - buttonText = stringResource(id = R.string.course_download) + buttonText = stringResource(id = R.string.core_download) buttonIcon = Icons.Outlined.CloudDownload buttonColor = MaterialTheme.appColors.secondaryButtonBackground onClick = onConfirmClick @@ -216,7 +224,7 @@ private fun DownloadConfirmDialogView( ) OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = coreR.string.core_cancel), + text = stringResource(id = R.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt similarity index 74% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt index 9c0833ff3..a14a1033c 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadConfirmDialogType.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogType.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import kotlinx.parcelize.Parcelize diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt similarity index 83% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt index 9f3cfc4d4..2e29ccec4 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogItem.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogItem.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import androidx.compose.ui.graphics.vector.ImageVector diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt similarity index 57% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt index 434f74c67..cc9959c79 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogManager.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogManager.kt @@ -1,5 +1,12 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog +import android.content.res.Configuration +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.School +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -7,12 +14,23 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.interactor.CourseInteractor import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.DownloadCoursePreview import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadModel import org.openedx.core.system.StorageManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.foundation.presentation.rememberWindowSize + +interface DownloadDialogListener { + fun onCancelClick() + fun onConfirmClick() +} + +interface DownloadDialog { + var listener: DownloadDialogListener? +} class DownloadDialogManager( private val networkConnection: NetworkConnection, @@ -24,6 +42,22 @@ class DownloadDialogManager( companion object { const val MAX_CELLULAR_SIZE = 104857600 // 100MB const val DOWNLOAD_SIZE_FACTOR = 2 // Multiplier to match required disk size + + val listMaxSize: Dp + @Composable + get() { + val configuration = LocalConfiguration.current + val windowSize = rememberWindowSize() + return when { + configuration.orientation == Configuration.ORIENTATION_PORTRAIT || windowSize.isTablet -> { + 200.dp + } + + else -> { + 88.dp + } + } + } } private val uiState = MutableSharedFlow() @@ -76,7 +110,22 @@ class DownloadDialogManager( else -> null } - dialog?.show(state.fragmentManager, dialog::class.java.simpleName) ?: state.saveDownloadModels() + val dialogListener = object : DownloadDialogListener { + override fun onCancelClick() { + state.onDismissClick() + } + + override fun onConfirmClick() { + state.onConfirmClick() + } + } + if (dialog != null) { + dialog.listener = dialogListener + dialog.show(state.fragmentManager, dialog::class.java.simpleName) + } else { + state.onConfirmClick() + state.saveDownloadModels() + } } } } @@ -87,8 +136,10 @@ class DownloadDialogManager( isBlocksDownloaded: Boolean, onlyVideoBlocks: Boolean = false, fragmentManager: FragmentManager, - removeDownloadModels: (blockId: String) -> Unit, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, ) { createDownloadItems( subSectionsBlocks = subSectionsBlocks, @@ -97,7 +148,29 @@ class DownloadDialogManager( isBlocksDownloaded = isBlocksDownloaded, onlyVideoBlocks = onlyVideoBlocks, removeDownloadModels = removeDownloadModels, - saveDownloadModels = saveDownloadModels + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick + ) + } + + fun showPopup( + coursePreview: DownloadCoursePreview, + isBlocksDownloaded: Boolean, + fragmentManager: FragmentManager, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, + saveDownloadModels: () -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, + ) { + createCourseDownloadItems( + coursePreview = coursePreview, + fragmentManager = fragmentManager, + isBlocksDownloaded = isBlocksDownloaded, + removeDownloadModels = removeDownloadModels, + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick ) } @@ -143,14 +216,16 @@ class DownloadDialogManager( courseIds.forEach { courseId -> val courseStructure = interactor.getCourseStructureFromCache(courseId) - val allSubSectionBlocks = courseStructure.blockData.filter { it.type == BlockType.SEQUENTIAL } + val allSubSectionBlocks = + courseStructure.blockData.filter { it.type == BlockType.SEQUENTIAL } allSubSectionBlocks.forEach { subSectionBlock -> - val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionBlock.descendants } + val verticalBlocks = + courseStructure.blockData.filter { it.id in subSectionBlock.descendants } val blocks = courseStructure.blockData.filter { it.id in verticalBlocks.flatMap { it.descendants } && it.id in blockIds } - val totalSize = blocks.sumOf { getFileSize(it) } + val totalSize = blocks.sumOf { it.getFileSize() } if (blocks.isNotEmpty()) notDownloadedSubSections.add(subSectionBlock) if (totalSize > 0) { @@ -188,15 +263,18 @@ class DownloadDialogManager( fragmentManager: FragmentManager, isBlocksDownloaded: Boolean, onlyVideoBlocks: Boolean, - removeDownloadModels: (blockId: String) -> Unit, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, saveDownloadModels: (blockId: String) -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, ) { coroutineScope.launch { val courseStructure = interactor.getCourseStructure(courseId, false) val downloadModelIds = interactor.getAllDownloadModels().map { it.id } val downloadDialogItems = subSectionsBlocks.mapNotNull { subSectionBlock -> - val verticalBlocks = courseStructure.blockData.filter { it.id in subSectionBlock.descendants } + val verticalBlocks = + courseStructure.blockData.filter { it.id in subSectionBlock.descendants } val blocks = verticalBlocks.flatMap { verticalBlock -> courseStructure.blockData.filter { it.id in verticalBlock.descendants && @@ -204,8 +282,15 @@ class DownloadDialogManager( (!onlyVideoBlocks || it.type == BlockType.VIDEO) } } - val size = blocks.sumOf { getFileSize(it) } - if (size > 0) DownloadDialogItem(title = subSectionBlock.displayName, size = size) else null + val size = blocks.sumOf { it.getFileSize() } + if (size > 0) { + DownloadDialogItem( + title = subSectionBlock.displayName, + size = size + ) + } else { + null + } } uiState.emit( @@ -215,18 +300,65 @@ class DownloadDialogManager( isDownloadFailed = false, sizeSum = downloadDialogItems.sumOf { it.size }, fragmentManager = fragmentManager, - removeDownloadModels = { subSectionsBlocks.forEach { removeDownloadModels(it.id) } }, - saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } } + removeDownloadModels = { + subSectionsBlocks.forEach { + removeDownloadModels( + it.id, + courseId + ) + } + }, + saveDownloadModels = { subSectionsBlocks.forEach { saveDownloadModels(it.id) } }, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick, ) ) } } - private fun getFileSize(block: Block): Long { - return when { - block.type == BlockType.VIDEO -> block.downloadModel?.size ?: 0L - block.isxBlock -> block.offlineDownload?.fileSize ?: 0L - else -> 0L + private fun createCourseDownloadItems( + coursePreview: DownloadCoursePreview, + fragmentManager: FragmentManager, + isBlocksDownloaded: Boolean, + removeDownloadModels: (blockId: String, courseId: String) -> Unit, + saveDownloadModels: () -> Unit, + onDismissClick: () -> Unit = {}, + onConfirmClick: () -> Unit = {}, + ) { + coroutineScope.launch { + val downloadDialogItems = listOf( + DownloadDialogItem( + title = coursePreview.name, + size = coursePreview.totalSize, + icon = Icons.Default.School + ) + ) + + uiState.emit( + DownloadDialogUIState( + downloadDialogItems = downloadDialogItems, + isAllBlocksDownloaded = isBlocksDownloaded, + isDownloadFailed = false, + sizeSum = downloadDialogItems.sumOf { it.size }, + fragmentManager = fragmentManager, + removeDownloadModels = { + coroutineScope.launch { + val downloadModels = interactor.getAllDownloadModels().filter { + it.courseId == coursePreview.id + } + downloadModels.forEach { + removeDownloadModels( + it.id, + coursePreview.id + ) + } + } + }, + saveDownloadModels = saveDownloadModels, + onDismissClick = onDismissClick, + onConfirmClick = onConfirmClick, + ) + ) } } } diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt similarity index 71% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt index b58e856bd..72288449b 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadDialogUIState.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadDialogUIState.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import androidx.fragment.app.FragmentManager @@ -13,5 +13,7 @@ data class DownloadDialogUIState( val isDownloadFailed: Boolean, val fragmentManager: @RawValue FragmentManager, val removeDownloadModels: () -> Unit, - val saveDownloadModels: () -> Unit + val saveDownloadModels: () -> Unit, + val onDismissClick: () -> Unit = {}, + val onConfirmClick: () -> Unit = {}, ) : Parcelable diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt similarity index 85% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt index 96cdf3d40..f7bbe6ea5 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt @@ -1,7 +1,6 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -11,6 +10,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -27,8 +27,11 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource import org.openedx.core.presentation.dialog.DefaultDialogBox import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXButton @@ -36,20 +39,19 @@ import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R -import org.openedx.course.domain.model.DownloadDialogResource import org.openedx.foundation.extension.parcelable import org.openedx.foundation.system.PreviewFragmentManager -import org.openedx.core.R as coreR -class DownloadErrorDialogFragment : DialogFragment() { +class DownloadErrorDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { @@ -58,21 +60,21 @@ class DownloadErrorDialogFragment : DialogFragment() { val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme val downloadDialogResource = when (dialogType) { DownloadErrorDialogType.NO_CONNECTION -> DownloadDialogResource( - title = stringResource(id = coreR.string.core_no_internet_connection), - description = stringResource(id = R.string.course_download_no_internet_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + title = stringResource(id = R.string.core_no_internet_connection), + description = stringResource(id = R.string.core_download_no_internet_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), ) DownloadErrorDialogType.WIFI_REQUIRED -> DownloadDialogResource( - title = stringResource(id = R.string.course_wifi_required), - description = stringResource(id = R.string.course_download_wifi_required_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + title = stringResource(id = R.string.core_wifi_required), + description = stringResource(id = R.string.core_download_wifi_required_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), ) DownloadErrorDialogType.DOWNLOAD_FAILED -> DownloadDialogResource( - title = stringResource(id = R.string.course_download_failed), - description = stringResource(id = R.string.course_download_failed_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + title = stringResource(id = R.string.core_download_failed), + description = stringResource(id = R.string.core_download_failed_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), ) } @@ -86,6 +88,7 @@ class DownloadErrorDialogFragment : DialogFragment() { }, onCancelClick = { dismiss() + listener?.onCancelClick() } ) } @@ -93,7 +96,6 @@ class DownloadErrorDialogFragment : DialogFragment() { } companion object { - const val DIALOG_TAG = "DownloadErrorDialogFragment" const val ARG_DIALOG_TYPE = "dialogType" const val ARG_UI_STATE = "uiState" @@ -122,8 +124,8 @@ private fun DownloadErrorDialogView( ) { val scrollState = rememberScrollState() val dismissButtonText = when (dialogType) { - DownloadErrorDialogType.DOWNLOAD_FAILED -> stringResource(id = coreR.string.core_cancel) - else -> stringResource(id = coreR.string.core_close) + DownloadErrorDialogType.DOWNLOAD_FAILED -> stringResource(id = R.string.core_cancel) + else -> stringResource(id = R.string.core_close) } DefaultDialogBox( modifier = modifier, @@ -132,7 +134,6 @@ private fun DownloadErrorDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -155,7 +156,11 @@ private fun DownloadErrorDialogView( minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } - Column { + Column( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { uiState.downloadDialogItems.forEach { DownloadDialogItem(downloadDialogItem = it) } @@ -167,7 +172,7 @@ private fun DownloadErrorDialogView( ) if (dialogType == DownloadErrorDialogType.DOWNLOAD_FAILED) { OpenEdXButton( - text = stringResource(id = coreR.string.core_error_try_again), + text = stringResource(id = R.string.core_error_try_again), backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, onClick = onTryAgainClick, ) @@ -194,7 +199,7 @@ private fun DownloadErrorDialogViewPreview() { downloadDialogResource = DownloadDialogResource( title = "Title", description = "Description Description Description Description Description Description Description ", - icon = painterResource(id = R.drawable.course_ic_error) + icon = painterResource(id = R.drawable.core_ic_error) ), uiState = DownloadDialogUIState( downloadDialogItems = listOf( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt similarity index 74% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt index 85f01cf1a..5bb035f07 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadErrorDialogType.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogType.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.os.Parcelable import kotlinx.parcelize.Parcelize diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt similarity index 83% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt index 5b99e6123..8c026bdf2 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadStorageErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt @@ -1,7 +1,6 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import android.graphics.Color -import android.graphics.drawable.ColorDrawable import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -19,6 +18,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -39,40 +39,43 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment +import org.openedx.core.R +import org.openedx.core.domain.model.DownloadDialogResource import org.openedx.core.presentation.dialog.DefaultDialogBox +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR +import org.openedx.core.presentation.dialog.downloaddialog.DownloadStorageErrorDialogFragment.Companion.STORAGE_BAR_MIN_SIZE import org.openedx.core.system.StorageManager import org.openedx.core.ui.AutoSizeText import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography -import org.openedx.course.R -import org.openedx.course.domain.model.DownloadDialogResource -import org.openedx.course.presentation.download.DownloadDialogManager.Companion.DOWNLOAD_SIZE_FACTOR -import org.openedx.course.presentation.download.DownloadStorageErrorDialogFragment.Companion.STORAGE_BAR_MIN_SIZE import org.openedx.foundation.extension.parcelable import org.openedx.foundation.extension.toFileSize import org.openedx.foundation.system.PreviewFragmentManager -import org.openedx.core.R as coreR -class DownloadStorageErrorDialogFragment : DialogFragment() { +class DownloadStorageErrorDialogFragment : DialogFragment(), DownloadDialog { + + override var listener: DownloadDialogListener? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { - dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog?.window?.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { - val uiState = requireArguments().parcelable(ARG_UI_STATE) ?: return@OpenEdXTheme + val uiState = requireArguments().parcelable(ARG_UI_STATE) + ?: return@OpenEdXTheme val downloadDialogResource = DownloadDialogResource( - title = stringResource(id = R.string.course_device_storage_full), - description = stringResource(id = R.string.course_download_device_storage_full_dialog_description), - icon = painterResource(id = R.drawable.course_ic_error), + title = stringResource(id = R.string.core_device_storage_full), + description = stringResource(id = R.string.core_download_device_storage_full_dialog_description), + icon = painterResource(id = R.drawable.core_ic_error), ) DownloadStorageErrorDialogView( @@ -80,6 +83,7 @@ class DownloadStorageErrorDialogFragment : DialogFragment() { downloadDialogResource = downloadDialogResource, onCancelClick = { dismiss() + listener?.onCancelClick() } ) } @@ -87,7 +91,6 @@ class DownloadStorageErrorDialogFragment : DialogFragment() { } companion object { - const val DIALOG_TAG = "DownloadStorageErrorDialogFragment" const val ARG_UI_STATE = "uiState" const val STORAGE_BAR_MIN_SIZE = 0.1f @@ -118,7 +121,6 @@ private fun DownloadStorageErrorDialogView( Column( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState) .padding(20.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -141,7 +143,11 @@ private fun DownloadStorageErrorDialogView( minSize = MaterialTheme.appTypography.titleLarge.fontSize.value - 1 ) } - Column { + Column( + modifier = Modifier + .heightIn(max = DownloadDialogManager.listMaxSize) + .verticalScroll(scrollState) + ) { uiState.downloadDialogItems.forEach { DownloadDialogItem(downloadDialogItem = it.copy(size = it.size * DOWNLOAD_SIZE_FACTOR)) } @@ -158,7 +164,7 @@ private fun DownloadStorageErrorDialogView( ) OpenEdXOutlinedButton( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = coreR.string.core_cancel), + text = stringResource(id = R.string.core_cancel), backgroundColor = MaterialTheme.appColors.background, borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.primaryButtonBackground, @@ -214,7 +220,12 @@ private fun StorageBar( modifier = Modifier .weight(freePercentage) .fillMaxHeight() - .padding(top = boxPadding, bottom = boxPadding, start = boxPadding, end = boxPadding / 2) + .padding( + top = boxPadding, + bottom = boxPadding, + start = boxPadding, + end = boxPadding / 2 + ) .clip(RoundedCornerShape(topStart = cornerRadius, bottomStart = cornerRadius)) .background(MaterialTheme.appColors.cardViewBorder) ) @@ -222,7 +233,12 @@ private fun StorageBar( modifier = Modifier .weight(animReqPercentage.value) .fillMaxHeight() - .padding(top = boxPadding, bottom = boxPadding, end = boxPadding, start = boxPadding / 2) + .padding( + top = boxPadding, + bottom = boxPadding, + end = boxPadding, + start = boxPadding / 2 + ) .clip(RoundedCornerShape(topEnd = cornerRadius, bottomEnd = cornerRadius)) .background(MaterialTheme.appColors.error) ) @@ -233,7 +249,7 @@ private fun StorageBar( ) { Text( text = stringResource( - R.string.course_used_free_storage, + R.string.core_used_free_storage, usedSpace.toFileSize(1, false), freeSpace.toFileSize(1, false) ), @@ -258,7 +274,7 @@ private fun DownloadStorageErrorDialogViewPreview() { downloadDialogResource = DownloadDialogResource( title = "Title", description = "Description Description Description Description Description Description Description ", - icon = painterResource(id = R.drawable.course_ic_error) + icon = painterResource(id = R.drawable.core_ic_error) ), uiState = DownloadDialogUIState( downloadDialogItems = listOf( diff --git a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt similarity index 94% rename from course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt rename to core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt index fd70dd723..58a5f9d22 100644 --- a/course/src/main/java/org/openedx/course/presentation/download/DownloadView.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt @@ -1,4 +1,4 @@ -package org.openedx.course.presentation.download +package org.openedx.core.presentation.dialog.downloaddialog import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -27,7 +27,7 @@ fun DownloadDialogItem( val icon = if (downloadDialogItem.icon != null) { rememberVectorPainter(downloadDialogItem.icon) } else { - painterResource(id = R.drawable.ic_core_chapter_icon) + painterResource(id = R.drawable.core_ic_chapter_icon) } Row( modifier = modifier.padding(vertical = 6.dp), diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt index 6b7f5ffcf..3890aa360 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import androidx.fragment.app.DialogFragment import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment @@ -47,7 +46,7 @@ class SelectBottomDialogFragment : BottomSheetDialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.values = requireArguments().parcelableArrayList(ARG_LIST_VALUES)!! - setStyle(DialogFragment.STYLE_NORMAL, R.style.BottomSheetDialog) + setStyle(STYLE_NORMAL, R.style.BottomSheetDialog) } override fun onCreateView( diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt index 527a7ce51..6272ded50 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseNotifier.kt @@ -12,6 +12,7 @@ class CourseNotifier { suspend fun send(event: CourseVideoPositionChanged) = channel.emit(event) suspend fun send(event: CourseStructureUpdated) = channel.emit(event) + suspend fun send(event: CourseStructureGot) = channel.emit(event) suspend fun send(event: CourseSubtitleLanguageChanged) = channel.emit(event) suspend fun send(event: CourseSectionChanged) = channel.emit(event) suspend fun send(event: CourseCompletionSet) = channel.emit(event) @@ -21,4 +22,6 @@ class CourseNotifier { suspend fun send(event: CourseOpenBlock) = channel.emit(event) suspend fun send(event: RefreshDates) = channel.emit(event) suspend fun send(event: RefreshDiscussions) = channel.emit(event) + suspend fun send(event: RefreshProgress) = channel.emit(event) + suspend fun send(event: CourseProgressLoaded) = channel.emit(event) } diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt new file mode 100644 index 000000000..482d9271e --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseProgressLoaded.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object CourseProgressLoaded : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt new file mode 100644 index 000000000..d685519e3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseStructureGot.kt @@ -0,0 +1,5 @@ +package org.openedx.core.system.notifier + +class CourseStructureGot( + val courseId: String +) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt b/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt index bdeba1114..a289abe91 100644 --- a/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt +++ b/core/src/main/java/org/openedx/core/system/notifier/CourseVideoPositionChanged.kt @@ -3,5 +3,6 @@ package org.openedx.core.system.notifier data class CourseVideoPositionChanged( val videoUrl: String, val videoTime: Long, + val duration: Long, val isPlaying: Boolean ) : CourseEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt b/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt new file mode 100644 index 000000000..c0835f787 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/RefreshProgress.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +object RefreshProgress : CourseEvent diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index fbbead83e..eed214567 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -1,7 +1,5 @@ package org.openedx.core.ui -import android.os.Build -import android.os.Build.VERSION.SDK_INT import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background @@ -19,9 +17,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -52,6 +50,7 @@ import androidx.compose.material.ScaffoldState import androidx.compose.material.Text import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Info @@ -80,7 +79,6 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -105,15 +103,10 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import coil.ImageLoader -import coil.compose.AsyncImage -import coil.decode.GifDecoder -import coil.decode.ImageDecoderDecoder import kotlinx.coroutines.launch import org.openedx.core.NoContentScreenType import org.openedx.core.R import org.openedx.core.domain.model.RegistrationField -import org.openedx.core.extension.LinkedImageText import org.openedx.core.presentation.global.ErrorType import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -221,6 +214,40 @@ fun Toolbar( } } +@Composable +fun MainToolbar( + modifier: Modifier = Modifier, + label: String, + onSettingsClick: () -> Unit, +) { + Box( + modifier = modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp), + text = label, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.headlineBold + ) + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 12.dp), + onClick = { + onSettingsClick() + } + ) { + Icon( + imageVector = Icons.Default.ManageAccounts, + tint = MaterialTheme.appColors.textAccent, + contentDescription = stringResource(id = R.string.core_accessibility_settings) + ) + } + } +} + @Composable fun SearchBar( modifier: Modifier, @@ -507,131 +534,6 @@ fun HyperlinkText( ) } -@Composable -fun HyperlinkImageText( - modifier: Modifier = Modifier, - title: String = "", - imageText: LinkedImageText, - textStyle: TextStyle = TextStyle.Default, - linkTextColor: Color = MaterialTheme.appColors.primary, - linkTextFontWeight: FontWeight = FontWeight.Normal, - linkTextDecoration: TextDecoration = TextDecoration.None, - fontSize: TextUnit = TextUnit.Unspecified, -) { - val fullText = imageText.text - val hyperLinks = imageText.links - val annotatedString = buildAnnotatedString { - if (title.isNotEmpty()) { - append(title) - append("\n\n") - } - append(fullText) - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, - fontSize = fontSize - ), - start = 0, - end = this.length - ) - - for ((key, value) in hyperLinks) { - val startIndex = this.toString().indexOf(key) - if (startIndex == -1) continue - val endIndex = startIndex + key.length - addStyle( - style = SpanStyle( - color = linkTextColor, - fontSize = fontSize, - fontWeight = linkTextFontWeight, - textDecoration = linkTextDecoration - ), - start = startIndex, - end = endIndex - ) - addStringAnnotation( - tag = "URL", - annotation = value, - start = startIndex, - end = endIndex - ) - } - if (title.isNotEmpty()) { - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, - fontSize = MaterialTheme.appTypography.titleLarge.fontSize, - fontWeight = MaterialTheme.appTypography.titleLarge.fontWeight - ), - start = 0, - end = title.length - ) - } - for (item in imageText.headers) { - val startIndex = this.toString().indexOf(item) - if (startIndex == -1) continue - val endIndex = startIndex + item.length - addStyle( - style = SpanStyle( - color = MaterialTheme.appColors.textPrimary, - fontSize = MaterialTheme.appTypography.titleLarge.fontSize, - fontWeight = MaterialTheme.appTypography.titleLarge.fontWeight - ), - start = startIndex, - end = endIndex - ) - } - addStyle( - style = SpanStyle( - fontSize = fontSize - ), - start = 0, - end = this.length - ) - } - - val uriHandler = LocalUriHandler.current - val context = LocalContext.current - val imageLoader = ImageLoader.Builder(context) - .components { - if (SDK_INT >= Build.VERSION_CODES.P) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - } - .build() - - Column(Modifier.fillMaxWidth()) { - BasicText( - text = annotatedString, - modifier = modifier.pointerInput(Unit) { - detectTapGestures { offset -> - val position = offset.x.toInt() - annotatedString.getStringAnnotations("URL", position, position) - .firstOrNull()?.let { stringAnnotation -> - uriHandler.openUri(stringAnnotation.item) - } - } - }, - style = textStyle - ) - imageText.imageLinks.values.forEach { - Spacer(Modifier.height(8.dp)) - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .heightIn(0.dp, 360.dp), - contentScale = ContentScale.Fit, - model = it, - contentDescription = null, - imageLoader = imageLoader - ) - } - Spacer(Modifier.height(16.dp)) - } -} - @Composable fun SheetContent( searchValue: TextFieldValue, @@ -1082,7 +984,9 @@ fun OfflineModeDialog( @Composable fun OpenEdXButton( - modifier: Modifier = Modifier.fillMaxWidth(), + modifier: Modifier = Modifier + .fillMaxWidth() + .height(42.dp), text: String = "", onClick: () -> Unit, enabled: Boolean = true, @@ -1093,8 +997,7 @@ fun OpenEdXButton( Button( modifier = Modifier .testTag("btn_${text.tagId()}") - .then(modifier) - .height(42.dp), + .then(modifier), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.buttonColors( backgroundColor = backgroundColor @@ -1163,7 +1066,7 @@ fun BackBtn( } ) { Icon( - painter = painterResource(id = R.drawable.core_ic_back), + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.core_accessibility_btn_back), tint = tint ) @@ -1240,7 +1143,11 @@ fun NoContentScreen(message: String, icon: Painter) { horizontalAlignment = Alignment.CenterHorizontally ) { Icon( - modifier = Modifier.size(80.dp), + modifier = Modifier + .sizeIn( + maxWidth = 80.dp, + maxHeight = 80.dp + ), painter = icon, contentDescription = null, tint = MaterialTheme.appColors.progressBarBackgroundColor, @@ -1264,19 +1171,6 @@ fun AuthButtonsPanel( showRegisterButton: Boolean, ) { Row { - if (showRegisterButton) { - OpenEdXButton( - modifier = Modifier - .testTag("btn_register") - .width(0.dp) - .weight(1f), - text = stringResource(id = R.string.core_register), - textColor = MaterialTheme.appColors.primaryButtonText, - backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, - onClick = { onRegisterClick() } - ) - } - OpenEdXOutlinedButton( modifier = Modifier .testTag("btn_sign_in") @@ -1284,7 +1178,7 @@ fun AuthButtonsPanel( if (showRegisterButton) { Modifier .width(100.dp) - .padding(start = 16.dp) + .padding(end = 16.dp) } else { Modifier.weight(1f) } @@ -1295,6 +1189,18 @@ fun AuthButtonsPanel( backgroundColor = MaterialTheme.appColors.secondaryButtonBorderedBackground, borderColor = MaterialTheme.appColors.secondaryButtonBorder, ) + if (showRegisterButton) { + OpenEdXButton( + modifier = Modifier + .testTag("btn_register") + .width(0.dp) + .weight(1f), + text = stringResource(id = R.string.core_register), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = { onRegisterClick() } + ) + } } } @@ -1404,6 +1310,23 @@ private fun RoundTab( } } +@Composable +fun OpenEdXDropdownMenuItem( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit +) { + Text( + modifier = modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(16.dp), + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textDark, + ) +} + @Preview @Composable private fun StaticSearchBarPreview() { diff --git a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt index b30746fe3..1351662eb 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -1,16 +1,16 @@ package org.openedx.core.ui import android.content.res.Configuration -import android.graphics.Rect -import android.view.ViewTreeObserver import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable @@ -34,7 +34,6 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -156,25 +155,16 @@ fun rememberSaveableMap(init: () -> MutableMap): MutableMa } @Composable -fun isImeVisibleState(): State { - val keyboardState = remember { mutableStateOf(false) } - val view = LocalView.current - DisposableEffect(view) { - val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener { - val rect = Rect() - view.getWindowVisibleDisplayFrame(rect) - val screenHeight = view.rootView.height - val keypadHeight = screenHeight - rect.bottom - keyboardState.value = keypadHeight > screenHeight * KEYBOARD_VISIBILITY_THRESHOLD - } - view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener) - - onDispose { - view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener) - } +fun isImeVisibleState(threshold: Int = 0): State { + val imeInsets = WindowInsets.ime + val imeBottom = imeInsets.getBottom(LocalDensity.current) + val isOpen = remember(imeBottom) { mutableStateOf(false) } + + LaunchedEffect(imeBottom) { + isOpen.value = imeBottom > threshold } - return keyboardState + return isOpen } fun PagerState.calculateCurrentOffsetForPage(page: Int): Float { diff --git a/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt b/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt new file mode 100644 index 000000000..0105e2cff --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt @@ -0,0 +1,295 @@ +package org.openedx.core.ui + +import android.content.ActivityNotFoundException +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.core.net.toUri +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.jsoup.Jsoup +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode +import org.openedx.core.R +import org.openedx.core.ui.theme.appColors + +@Composable +fun RenderHtmlContent(html: String) { + val document = remember(html) { Jsoup.parse(html) } + val bodyElements = document.body().children() + Column { + bodyElements.forEach { element -> + RenderBlockElement(element) + } + } +} + +@Composable +private fun RenderClickableText(annotated: AnnotatedString) { + val context = LocalContext.current + val hasLink = annotated.getStringAnnotations("URL", 0, annotated.length).isNotEmpty() + var textLayoutResult by remember { mutableStateOf(null) } + val modifier = if (hasLink) { + Modifier.pointerInput(annotated) { + detectTapGestures { offset -> + textLayoutResult?.let { layoutResult -> + val position = layoutResult.getOffsetForPosition(offset) + annotated.getStringAnnotations("URL", position, position) + .firstOrNull()?.let { annotation -> + try { + val intent = Intent(Intent.ACTION_VIEW, annotation.item.toUri()) + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + e.printStackTrace() + } + } + } + } + } + } else { + Modifier + } + Text( + text = annotated, + modifier = modifier, + color = MaterialTheme.appColors.textPrimary, + onTextLayout = { textLayoutResult = it } + ) +} + +@Composable +private fun RenderParagraph(element: Element) { + val segments = extractSegmentsFromNodes(element.childNodes()) + Column(modifier = Modifier.padding(vertical = 4.dp)) { + segments.forEach { segment -> + when (segment) { + is List<*> -> { + val nodes = segment.filterIsInstance() + val annotated = buildAnnotatedStringFromNodes(nodes) + RenderClickableText(annotated) + } + + is Element -> { + RenderBlockElement(segment) + } + } + } + } +} + +private fun extractSegmentsFromNodes(nodes: List): List { + val segments = mutableListOf() + val currentSegment = mutableListOf() + + for (node in nodes) { + if (node is Element) { + val tagName = node.tagName() + if (tagName == "img" || tagName == "ul" || tagName == "ol" || tagName == "blockquote") { + flush(currentSegment, segments) + segments.add(node) + } else if (node.select("img").isNotEmpty()) { + flush(currentSegment, segments) + segments.addAll(extractSegmentsFromNodes(node.childNodes())) + } else { + currentSegment.add(node) + } + } else { + currentSegment.add(node) + } + } + flush(currentSegment, segments) + return segments +} + +@Composable +private fun RenderBlockElement(element: Element, indent: Int = 0) { + when (element.tagName()) { + "p" -> { + RenderParagraph(element) + } + + "ul" -> { + Column(modifier = Modifier.padding(start = (indent + 1) * 16.dp)) { + element.children().forEach { child -> + if (child.tagName() == "li") { + Row( + modifier = Modifier.padding(vertical = 2.dp), + ) { + Text( + modifier = Modifier.padding(top = 4.dp), + text = AnnotatedString("• "), + style = TextStyle(fontWeight = FontWeight.Bold), + color = MaterialTheme.appColors.textPrimary + ) + RenderBlockElement(child, indent + 1) + } + } + } + } + } + + "ol" -> { + Column(modifier = Modifier.padding(start = (indent + 1) * 16.dp)) { + element.children().forEachIndexed { index, child -> + if (child.tagName() == "li") { + Row( + modifier = Modifier.padding(vertical = 2.dp), + ) { + Text( + modifier = Modifier.padding(top = 4.dp), + text = AnnotatedString("${index + 1}. "), + color = MaterialTheme.appColors.textPrimary + ) + RenderBlockElement(child, indent + 1) + } + } + } + } + } + + "li" -> { + RenderParagraph(element) + } + + "blockquote" -> { + Row( + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Box( + modifier = Modifier + .width(2.dp) + .fillMaxHeight() + .background(MaterialTheme.appColors.cardViewBorder) + ) + Column { + element.children().forEach { child -> + RenderBlockElement(child) + } + } + } + } + + "img" -> { + val src = element.attr("src") + AsyncImage( + modifier = Modifier.fillMaxWidth(), + model = ImageRequest.Builder(LocalContext.current) + .data(src) + .error(R.drawable.core_no_image_course) + .placeholder(R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.FillWidth + ) + } + + else -> { + RenderParagraph(element) + } + } +} + +@Composable +private fun AnnotatedString.Builder.AppendNodes(nodes: List) { + nodes.forEach { node -> + when (node) { + is TextNode -> append(node.text()) + is Element -> AppendElement(node) + } + } +} + +@Composable +private fun AnnotatedString.Builder.AppendElement(element: Element) { + when (element.tagName()) { + "br" -> append("\n") + "strong" -> withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + AppendNodes(element.childNodes()) + } + + "em" -> withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + AppendNodes(element.childNodes()) + } + + "code" -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + AppendNodes(element.childNodes()) + } + + "span" -> { + val styleAttr = element.attr("style") + if (styleAttr.contains("text-decoration: underline", ignoreCase = true)) { + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + AppendNodes(element.childNodes()) + } + } else { + AppendNodes(element.childNodes()) + } + } + + "a" -> { + val href = element.attr("href") + val start = this.length + AppendNodes(element.childNodes()) + val end = this.length + addStyle( + SpanStyle( + color = MaterialTheme.appColors.primary, + textDecoration = TextDecoration.Underline + ), + start, + end + ) + addStringAnnotation(tag = "URL", annotation = href, start = start, end = end) + } + + else -> AppendNodes(element.childNodes()) + } +} + +@Composable +private fun buildAnnotatedStringFromNodes(nodes: List): AnnotatedString { + return AnnotatedString.Builder().apply { + AppendNodes(nodes) + }.toAnnotatedString() +} + +private fun flush(currentSegment: MutableList, segments: MutableList) { + if (currentSegment.isNotEmpty()) { + segments.add(currentSegment.toList()) + currentSegment.clear() + } +} diff --git a/core/src/main/java/org/openedx/core/ui/PageIndicator.kt b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt new file mode 100644 index 000000000..8e9f4f40b --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt @@ -0,0 +1,123 @@ +package org.openedx.core.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors + +@Composable +fun PageIndicator( + numberOfPages: Int, + modifier: Modifier = Modifier, + selectedPage: Int = 0, + selectedColor: Color = MaterialTheme.appColors.info, + previousUnselectedColor: Color = MaterialTheme.appColors.cardViewBorder, + nextUnselectedColor: Color = MaterialTheme.appColors.textFieldBorder, + defaultRadius: Dp = 20.dp, + selectedLength: Dp = 60.dp, + space: Dp = 30.dp, + animationDurationInMillis: Int = 300, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(space), + modifier = modifier, + ) { + for (i in 0 until numberOfPages) { + val isSelected = i == selectedPage + val unselectedColor = + if (i < selectedPage) previousUnselectedColor else nextUnselectedColor + PageIndicatorView( + isSelected = isSelected, + selectedColor = selectedColor, + defaultColor = unselectedColor, + defaultRadius = defaultRadius, + selectedLength = selectedLength, + animationDurationInMillis = animationDurationInMillis, + ) + } + } +} + +@Composable +fun PageIndicatorView( + isSelected: Boolean, + selectedColor: Color, + defaultColor: Color, + defaultRadius: Dp, + selectedLength: Dp, + animationDurationInMillis: Int, + modifier: Modifier = Modifier, +) { + val color: Color by animateColorAsState( + targetValue = if (isSelected) { + selectedColor + } else { + defaultColor + }, + animationSpec = tween( + durationMillis = animationDurationInMillis, + ), + label = "" + ) + val width: Dp by animateDpAsState( + targetValue = if (isSelected) { + selectedLength + } else { + defaultRadius + }, + animationSpec = tween( + durationMillis = animationDurationInMillis, + ), + label = "" + ) + + Canvas( + modifier = modifier + .size( + width = width, + height = defaultRadius, + ), + ) { + drawRoundRect( + color = color, + topLeft = Offset.Zero, + size = Size( + width = width.toPx(), + height = defaultRadius.toPx(), + ), + cornerRadius = CornerRadius( + x = defaultRadius.toPx(), + y = defaultRadius.toPx(), + ), + ) + } +} + +@Preview +@Composable +private fun PageIndicatorViewPreview() { + OpenEdXTheme { + PageIndicator( + numberOfPages = 4, + selectedPage = 2 + ) + } +} diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index 12da2cfce..bf20366d9 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -78,7 +78,10 @@ data class AppColors( val settingsTitleContent: Color, val progressBarColor: Color, - val progressBarBackgroundColor: Color + val progressBarBackgroundColor: Color, + val gradeProgressBarBorder: Color, + val gradeProgressBarBackground: Color, + val assignmentCardBorder: Color, ) { val primary: Color get() = material.primary val primaryVariant: Color get() = material.primaryVariant diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt index eed4d481d..1a45681f9 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt @@ -13,9 +13,11 @@ data class AppShapes( val textFieldShape: CornerBasedShape, val screenBackgroundShape: CornerBasedShape, val cardShape: CornerBasedShape, + val sectionCardShape: CornerBasedShape, val screenBackgroundShapeFull: CornerBasedShape, val courseImageShape: CornerBasedShape, val dialogShape: CornerBasedShape, + val videoPreviewShape: CornerBasedShape, ) val MaterialTheme.appShapes: AppShapes diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 2ad2a4eae..9b42c90ac 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -1,7 +1,7 @@ package org.openedx.core.ui.theme import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material.MaterialTheme import androidx.compose.material.darkColors @@ -96,7 +96,10 @@ private val DarkColorPalette = AppColors( settingsTitleContent = dark_settings_title_content, progressBarColor = dark_progress_bar_color, - progressBarBackgroundColor = dark_progress_bar_background_color + progressBarBackgroundColor = dark_progress_bar_background_color, + gradeProgressBarBorder = dark_grade_progress_bar_color, + gradeProgressBarBackground = dark_grade_progress_bar_background, + assignmentCardBorder = dark_assignment_card_border, ) private val LightColorPalette = AppColors( @@ -185,7 +188,10 @@ private val LightColorPalette = AppColors( settingsTitleContent = light_settings_title_content, progressBarColor = light_progress_bar_color, - progressBarBackgroundColor = light_progress_bar_background_color + progressBarBackgroundColor = light_progress_bar_background_color, + gradeProgressBarBorder = light_grade_progress_bar_color, + gradeProgressBarBackground = light_grade_progress_bar_background, + assignmentCardBorder = light_assignment_card_border, ) val MaterialTheme.appColors: AppColors @@ -208,7 +214,7 @@ fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl shapes = LocalShapes.current.material, ) { CompositionLocalProvider( - LocalOverscrollConfiguration provides null, + LocalOverscrollFactory provides null, content = content ) } diff --git a/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt b/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt index b6ae624f5..2b22a00a5 100644 --- a/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/LocaleUtils.kt @@ -37,21 +37,25 @@ object LocaleUtils { fun getCountryByCountryCode(code: String): String? { val countryISO = Locale.getISOCountries().firstOrNull { it == code } return countryISO?.let { - Locale("", it).getDisplayCountry(defaultLocale) + Locale.Builder().setRegion(it).build().getDisplayCountry(defaultLocale) } } fun getLanguageByLanguageCode(code: String): String? { val countryISO = Locale.getISOLanguages().firstOrNull { it == code } return countryISO?.let { - Locale(it, "").getDisplayLanguage(defaultLocale) + Locale.Builder().setLanguage(it).build().getDisplayLanguage(defaultLocale) } } private fun getAvailableCountries() = Locale.getISOCountries() .asSequence() .map { - RegistrationField.Option(it, Locale("", it).getDisplayCountry(defaultLocale), "") + RegistrationField.Option( + it, + Locale.Builder().setRegion(it).build().getDisplayCountry(defaultLocale), + "" + ) } .sortedBy { it.name } .toList() @@ -60,12 +64,16 @@ object LocaleUtils { .asSequence() .filter { it.length == 2 } .map { - RegistrationField.Option(it, Locale(it, "").getDisplayLanguage(defaultLocale), "") + RegistrationField.Option( + it, + Locale.Builder().setLanguage(it).build().getDisplayLanguage(defaultLocale), + "" + ) } .sortedBy { it.name } .toList() fun getDisplayLanguage(languageCode: String): String { - return Locale(languageCode, "").getDisplayLanguage(defaultLocale) + return Locale.Builder().setLanguage(languageCode).build().getDisplayLanguage(defaultLocale) } } diff --git a/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt new file mode 100644 index 000000000..dd3d65fdf --- /dev/null +++ b/core/src/main/java/org/openedx/core/utils/PreviewHelper.kt @@ -0,0 +1,172 @@ +package org.openedx.core.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import java.io.File +import java.io.FileOutputStream +import java.security.MessageDigest +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +data class VideoPreview( + val link: String? = null, + val bitmap: Bitmap? = null +) { + companion object { + fun createYoutubePreview(link: String): VideoPreview { + return VideoPreview(link = link) + } + + fun createEncodedVideoPreview(bitmap: Bitmap): VideoPreview { + return VideoPreview(bitmap = bitmap) + } + } +} + +object PreviewHelper { + + private const val TIMEOUT_MS = 5000L // 5 seconds + private val executor = Executors.newSingleThreadExecutor() + + fun getYouTubeThumbnailUrl(url: String): String { + val videoId = extractYouTubeVideoId(url) + return "https://img.youtube.com/vi/$videoId/0.jpg" + } + + private fun extractYouTubeVideoId(url: String): String { + val regex = Regex( + "^(?:https?://)?(?:www\\.)?(?:youtube\\.com/(?:[^/]+/.+/|(?:v|e(?:mbed)?)|.*[?&]v=)|youtu\\.be/)" + + "([^\"&?/\\s]{11})", + RegexOption.IGNORE_CASE + ) + val matchResult = regex.find(url) + return matchResult?.groups?.get(1)?.value ?: "" + } + + fun getVideoFrameBitmap(context: Context, isOnline: Boolean, videoUrl: String): Bitmap? { + var result: Bitmap? = null + if (isOnline || isLocalFile(videoUrl)) { + // Check cache first + val cacheFile = getCacheFile(context, videoUrl) + result = if (cacheFile.exists()) { + try { + BitmapFactory.decodeFile(cacheFile.absolutePath) + } catch (_: Exception) { + // If cache file is corrupted, try to extract from video with timeout + extractBitmapFromVideoWithTimeout(videoUrl, context) + } + } else { + // Extract from video with timeout + extractBitmapFromVideoWithTimeout(videoUrl, context) + } + } + return result + } + + private fun extractBitmapFromVideoWithTimeout(videoUrl: String, context: Context): Bitmap? { + return try { + val future = executor.submit { + extractBitmapFromVideo(videoUrl, context) + } + future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS) + } catch (e: TimeoutException) { + // Server didn't respond within timeout, return null immediately + e.printStackTrace() + null + } catch (e: Exception) { + // Any other exception, return null immediately + e.printStackTrace() + null + } + } + + private fun extractBitmapFromVideo(videoUrl: String, context: Context): Bitmap? { + val retriever = MediaMetadataRetriever() + try { + if (isLocalFile(videoUrl)) { + retriever.setDataSource(videoUrl) + } else { + retriever.setDataSource(videoUrl, HashMap()) + } + val bitmap = retriever.getFrameAtTime(0) + + // Save bitmap to cache if it was successfully retrieved + bitmap?.let { + saveBitmapToCache(context, videoUrl, it) + } + + return bitmap + } catch (e: Exception) { + // Log the exception for debugging but don't crash + e.printStackTrace() + return null + } finally { + try { + retriever.release() + } catch (e: Exception) { + // Ignore release exceptions + e.printStackTrace() + } + } + } + + private fun isLocalFile(url: String): Boolean { + return url.startsWith("/") || url.startsWith("file://") + } + + private fun getCacheFile(context: Context, videoUrl: String): File { + val cacheDir = context.cacheDir + val fileName = generateFileName(videoUrl) + return File(cacheDir, "video_thumbnails/$fileName") + } + + private fun generateFileName(videoUrl: String): String { + val md = MessageDigest.getInstance("MD5") + val digest = md.digest(videoUrl.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + ".jpg" + } + + private fun saveBitmapToCache(context: Context, videoUrl: String, bitmap: Bitmap) { + try { + val cacheFile = getCacheFile(context, videoUrl) + cacheFile.parentFile?.mkdirs() // Create directories if they don't exist + + FileOutputStream(cacheFile).use { out -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, out) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Clear the bitmap cache to free storage + */ + fun clearCache(context: Context) { + try { + val cacheDir = File(context.cacheDir, "video_thumbnails") + if (cacheDir.exists()) { + cacheDir.deleteRecursively() + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + /** + * Remove a specific bitmap from cache + */ + fun removeFromCache(context: Context, videoUrl: String) { + try { + val cacheFile = getCacheFile(context, videoUrl) + if (cacheFile.exists()) { + cacheFile.delete() + } + } catch (e: Exception) { + e.printStackTrace() + } + } +} diff --git a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt index d9fe2f853..cba6a5e8e 100644 --- a/core/src/main/java/org/openedx/core/utils/TimeUtils.kt +++ b/core/src/main/java/org/openedx/core/utils/TimeUtils.kt @@ -20,11 +20,12 @@ object TimeUtils { private const val FORMAT_ISO_8601 = "yyyy-MM-dd'T'HH:mm:ss'Z'" private const val FORMAT_ISO_8601_WITH_TIME_ZONE = "yyyy-MM-dd'T'HH:mm:ssXXX" + private const val FORMAT_MONTH_DAY = "MMM dd" private const val SEVEN_DAYS_IN_MILLIS = 604800000L fun formatToString(context: Context, date: Date, useRelativeDates: Boolean): String { if (!useRelativeDates) { - val locale = Locale(Locale.getDefault().language) + val locale = Locale.Builder().setLanguage(Locale.getDefault().language).build() val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, locale) return dateFormat.format(date) } @@ -77,6 +78,30 @@ object TimeUtils { } } } + fun formatToDueInString(context: Context, date: Date): String { + val now = Calendar.getInstance() + val dueDate = Calendar.getInstance().apply { time = date } + now.set(Calendar.HOUR_OF_DAY, 0) + now.set(Calendar.MINUTE, 0) + now.set(Calendar.SECOND, 0) + now.set(Calendar.MILLISECOND, 0) + dueDate.set(Calendar.HOUR_OF_DAY, 0) + dueDate.set(Calendar.MINUTE, 0) + dueDate.set(Calendar.SECOND, 0) + dueDate.set(Calendar.MILLISECOND, 0) + val daysDifference = + ((dueDate.timeInMillis - now.timeInMillis) / (24 * 60 * 60 * 1000)).toInt() + return when { + daysDifference < 0 -> context.getString(R.string.core_date_type_past_due) + daysDifference == 0 -> context.getString(R.string.core_date_type_today) + else -> context.getString(R.string.core_date_format_due_in_days, daysDifference) + } + } + + fun formatToMonthDay(date: Date): String { + val sdf = SimpleDateFormat(FORMAT_MONTH_DAY, Locale.getDefault()) + return sdf.format(date) + } fun getCurrentTime(): Long { return Calendar.getInstance().timeInMillis diff --git a/course/src/main/res/drawable/course_download_waiting.png b/core/src/main/res/drawable/core_download_waiting.png similarity index 100% rename from course/src/main/res/drawable/course_download_waiting.png rename to core/src/main/res/drawable/core_download_waiting.png diff --git a/core/src/main/res/drawable/core_ic_back.xml b/core/src/main/res/drawable/core_ic_back.xml deleted file mode 100644 index 912dc1200..000000000 --- a/core/src/main/res/drawable/core_ic_back.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/core/src/main/res/drawable/ic_core_chapter_icon.xml b/core/src/main/res/drawable/core_ic_chapter_icon.xml similarity index 100% rename from core/src/main/res/drawable/ic_core_chapter_icon.xml rename to core/src/main/res/drawable/core_ic_chapter_icon.xml diff --git a/core/src/main/res/drawable/core_ic_check.xml b/core/src/main/res/drawable/core_ic_check.xml index 81badcbcd..381b4712a 100644 --- a/core/src/main/res/drawable/core_ic_check.xml +++ b/core/src/main/res/drawable/core_ic_check.xml @@ -1,13 +1,9 @@ - + android:width="16dp" + android:height="16dp" + android:viewportWidth="16" + android:viewportHeight="16"> + diff --git a/core/src/main/res/drawable/core_ic_edit.xml b/core/src/main/res/drawable/core_ic_edit.xml deleted file mode 100644 index 62f035a78..000000000 --- a/core/src/main/res/drawable/core_ic_edit.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/course/src/main/res/drawable/course_ic_error.xml b/core/src/main/res/drawable/core_ic_error.xml similarity index 100% rename from course/src/main/res/drawable/course_ic_error.xml rename to core/src/main/res/drawable/core_ic_error.xml diff --git a/core/src/main/res/drawable/core_ic_forward.xml b/core/src/main/res/drawable/core_ic_forward.xml deleted file mode 100644 index 8c47ce201..000000000 --- a/core/src/main/res/drawable/core_ic_forward.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - diff --git a/core/src/main/res/drawable/core_ic_mountains.xml b/core/src/main/res/drawable/core_ic_mountains.xml new file mode 100644 index 000000000..eea9a0e6b --- /dev/null +++ b/core/src/main/res/drawable/core_ic_mountains.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/core/src/main/res/drawable/core_ic_screen_rotation.xml b/core/src/main/res/drawable/core_ic_screen_rotation.xml deleted file mode 100644 index 0d842b791..000000000 --- a/core/src/main/res/drawable/core_ic_screen_rotation.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/core/src/main/res/drawable/ic_core_check.xml b/core/src/main/res/drawable/ic_core_check.xml index 10551dea9..e636ca1d8 100644 --- a/core/src/main/res/drawable/ic_core_check.xml +++ b/core/src/main/res/drawable/ic_core_check.xml @@ -1,9 +1,12 @@ + android:width="15dp" + android:height="15dp" + android:viewportWidth="15" + android:viewportHeight="15"> + android:pathData="M7.5,7.5m-6.5,0a6.5,6.5 0,1 1,13 0a6.5,6.5 0,1 1,-13 0" + android:fillColor="#ffffff" /> + diff --git a/core/src/main/res/drawable/ic_core_pointer.xml b/core/src/main/res/drawable/ic_core_pointer.xml new file mode 100644 index 000000000..cc777cf3e --- /dev/null +++ b/core/src/main/res/drawable/ic_core_pointer.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/src/main/res/drawable/ic_core_watch_later.xml b/core/src/main/res/drawable/ic_core_watch_later.xml new file mode 100644 index 000000000..4dd7cedf0 --- /dev/null +++ b/core/src/main/res/drawable/ic_core_watch_later.xml @@ -0,0 +1,14 @@ + + + + diff --git a/core/src/main/res/font/font.xml b/core/src/main/res/font/font.xml deleted file mode 100644 index 4cdad3af5..000000000 --- a/core/src/main/res/font/font.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index c8d529afa..f4fabd553 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -2,7 +2,6 @@ @string/platform_name - Results Invalid credentials Slow or no internet connection Something went wrong @@ -57,7 +56,6 @@ Settings App Update Required This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version. - Why do I need to update? Version: %1$s Up-to-date Tap to update to version %1$s @@ -89,11 +87,13 @@ Completed Past Due Today + Due Tomorrow This Week Next Week Upcoming None Due %1$s + Due in %1$d days %d Item Hidden %d Items Hidden @@ -130,12 +130,10 @@ Video download quality Manage Account - Assignment Due Syncing calendar… Sync to calendar - Automatically sync all deadlines and due dates for this course to your calendar. \“%s\” Would Like to Access Your Calendar %s would like to use your calendar list to subscribe to your personalized %s calendar for this course. @@ -156,22 +154,13 @@ Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. Update Now Remove Course Calendar - - Your course calendar has been added. - Your course calendar has been removed. - Your course calendar has been updated. - Error Adding Calendar, Please try later - - Home - Videos - Discussions - More - Dates No course content is currently available. - There are currently no videos for this course. + No videos available for this course. Course dates are currently not available. + This course does not contain exams or graded assignments. + No assignments available for this course. Unable to load discussions.\n Please try again later. There are currently no handouts for this course. There are currently no announcements for this course. @@ -187,4 +176,45 @@ Not Synced Syncing to calendar… Next + Previous + + Downloads + (Untitled) + Download + The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? + Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? + Are you sure you want to delete all video(s) for \"%s\"? + Are you sure you want to delete video(s) for \"%s\"? + Downloading this content requires an active internet connection. Please connect to the internet and try again. + Wi-Fi Required + Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. + Download Failed + Unfortunately, this content failed to download. Please try again later or report this issue. + Downloading this %1$s of content will save available blocks offline. + Download on Cellular? + Downloading this content will use %1$s of cellular data. + Remove Offline Content? + Removing this content will free up %1$s. + Download + Remove + Device Storage Full + Your device does not have enough free space to download this content. Please free up some space and try again. + %1$s used, %2$s free + 0MB + Available to download + None of this course’s content is currently available to download offline. + Download all + Downloaded + Ready to Download + You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. + Downloading + Largest Downloads + Remove all downloads + Cancel Course Download + This component is not yet available offline + Explore other parts of this course or view this when you reconnect. + This component is not downloaded + Explore other parts of this course or download this when you reconnect. + Authorization + Please enter the system to continue with course enrollment. diff --git a/core/src/main/res/values/themes.xml b/core/src/main/res/values/themes.xml index e6859e022..e43010475 100644 --- a/core/src/main/res/values/themes.xml +++ b/core/src/main/res/values/themes.xml @@ -1,4 +1,4 @@ - + - - \ No newline at end of file + \ No newline at end of file diff --git a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt index 531bef58f..f9b17792c 100644 --- a/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/container/CourseContainerViewModelTest.kt @@ -11,6 +11,8 @@ import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -215,6 +217,7 @@ class CourseContainerViewModelTest { Dispatchers.resetMain() } + @Suppress("TooGenericExceptionThrown") @Test fun `getCourseEnrollmentDetails unknown exception`() = runTest { val viewModel = CourseContainerViewModel( @@ -233,8 +236,12 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any(), any()) } throws Exception() - coEvery { interactor.getEnrollmentDetails(any()) } throws Exception() + coEvery { + interactor.getCourseStructureFlow(any(), any()) + } returns flowOf(null) + coEvery { + interactor.getEnrollmentDetailsFlow(any()) + } returns flow { throw Exception() } every { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, @@ -250,7 +257,7 @@ class CourseContainerViewModelTest { viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + coVerify(exactly = 1) { interactor.getEnrollmentDetailsFlow(any()) } verify(exactly = 1) { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, @@ -285,8 +292,8 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStructure(any(), any()) } returns courseStructure - coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { interactor.getEnrollmentDetailsFlow(any()) } returns flowOf(enrollmentDetails) every { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, @@ -302,7 +309,7 @@ class CourseContainerViewModelTest { viewModel.fetchCourseDetails() advanceUntilIdle() - coVerify(exactly = 1) { interactor.getEnrollmentDetails(any()) } + coVerify(exactly = 1) { interactor.getEnrollmentDetailsFlow(any()) } verify(exactly = 1) { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, @@ -338,7 +345,8 @@ class CourseContainerViewModelTest { courseRouter ) every { networkConnection.isOnline() } returns false - coEvery { interactor.getEnrollmentDetails(any()) } returns enrollmentDetails + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { interactor.getEnrollmentDetailsFlow(any()) } returns flowOf(enrollmentDetails) every { analytics.logScreenEvent( CourseAnalyticsEvent.DASHBOARD.eventName, diff --git a/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt new file mode 100644 index 000000000..7196d7df3 --- /dev/null +++ b/course/src/test/java/org/openedx/course/presentation/home/CourseHomeViewModelTest.kt @@ -0,0 +1,865 @@ +package org.openedx.course.presentation.home + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flow +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.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.Mock +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.helper.VideoPreviewHelper +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDatesShifted +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseOpenBlock +import org.openedx.core.system.notifier.CourseProgressLoaded +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.course.domain.interactor.CourseInteractor +import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.CourseAnalyticsEvent +import org.openedx.course.presentation.CourseAnalyticsKey +import org.openedx.course.presentation.CourseRouter +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import java.net.UnknownHostException +import org.openedx.course.R as courseR + +@Suppress("LargeClass") +@OptIn(ExperimentalCoroutinesApi::class) +class CourseHomeViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + private val dispatcher = StandardTestDispatcher() + private val courseId = "test-course-id" + private val courseTitle = "Test Course" + private val config = mockk() + private val interactor = mockk() + private val resourceManager = mockk() + private val courseNotifier = mockk() + private val networkConnection = mockk() + private val preferencesManager = mockk() + private val analytics = mockk() + private val downloadDialogManager = mockk() + private val fileUtil = mockk() + private val courseRouter = mockk() + private val coreAnalytics = mockk() + private val downloadDao = mockk() + private val workerController = mockk() + private val downloadHelper = mockk() + private val videoPreviewHelper = mockk() + + private val noInternet = "Slow or no internet connection" + private val somethingWrong = "Something went wrong" + private val cantDownload = "You can download content only from Wi-fi" + + private val courseStructure = Mock.mockCourseStructure.copy( + id = courseId, + name = courseTitle + ) + private val courseComponentStatus = Mock.mockCourseComponentStatus + private val courseDatesResult = Mock.mockCourseDatesResult + private val courseProgress = Mock.mockCourseProgress + private val videoProgress = Mock.mockVideoProgress + private val resetCourseDates = Mock.mockResetCourseDates + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { + resourceManager.getString(courseR.string.course_can_download_only_with_wifi) + } returns cantDownload + every { + resourceManager.getString(R.string.core_dates_shift_dates_unsuccessful_msg) + } returns "Failed to shift dates" + + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns true + every { config.getCourseUIConfig().isCourseDownloadQueueEnabled } returns true + + every { preferencesManager.isRelativeDatesEnabled } returns true + every { preferencesManager.videoSettings.wifiDownloadOnly } returns false + + every { networkConnection.isWifiConnected() } returns true + every { networkConnection.isOnline() } returns true + + every { fileUtil.getExternalAppDir().path } returns "/test/path" + + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + + every { courseNotifier.notifier } returns flow { } + coEvery { courseNotifier.send(any()) } returns Unit + + every { analytics.logEvent(any(), any()) } returns Unit + every { coreAnalytics.logEvent(any(), any()) } returns Unit + + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns Unit + + coEvery { workerController.saveModels(any()) } returns Unit + + every { videoPreviewHelper.getVideoPreview(any(), any()) } returns null + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getCourseData success`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + coEvery { interactor.getVideoProgress("video1") } returns videoProgress + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify { interactor.getCourseStructureFlow(courseId, false) } + coVerify { interactor.getCourseStatusFlow(courseId) } + coVerify { interactor.getCourseDatesFlow(courseId) } + coVerify { interactor.getCourseProgress(courseId, false, true) } + + assertTrue(viewModel.uiState.value is CourseHomeUIState.CourseData) + val courseData = viewModel.uiState.value as CourseHomeUIState.CourseData + assertEquals(courseId, courseData.courseStructure.id) + assertEquals(courseTitle, courseData.courseStructure.name) + assertEquals(courseProgress, courseData.courseProgress) + } + + @Test + fun `getCourseData no internet connection error`() = runTest { + coEvery { + interactor.getCourseStructureFlow( + courseId, + false + ) + } returns flow { throw UnknownHostException() } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.value !is CourseHomeUIState.CourseData) + } + + @Suppress("TooGenericExceptionThrown") + @Test + fun `getCourseData unknown error`() = runTest { + coEvery { + interactor.getCourseStructureFlow( + courseId, + false + ) + } returns flow { throw Exception() } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + assertTrue(viewModel.uiState.value !is CourseHomeUIState.CourseData) + } + + @Test + fun `saveDownloadModels with wifi only enabled but no wifi connection`() = runTest { + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns false + + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.saveDownloadModels("/test/path", courseId, "test-block-id") + + coVerify(exactly = 0) { workerController.saveModels(any()) } + } + + @Test + fun `resetCourseDatesBanner success`() = runTest { + coEvery { interactor.resetCourseDates(courseId) } returns resetCourseDates + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + var resetResult: Boolean? = null + + viewModel.resetCourseDatesBanner { success -> + resetResult = success + } + + advanceUntilIdle() + + coVerify { interactor.resetCourseDates(courseId) } + coVerify { courseNotifier.send(CourseDatesShifted) } + assertEquals(true, resetResult) + } + + @Test + fun `resetCourseDatesBanner with internet error`() = runTest { + coEvery { interactor.resetCourseDates(courseId) } throws UnknownHostException() + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + var resetResult: Boolean? = null + + viewModel.resetCourseDatesBanner { success -> + resetResult = success + } + + advanceUntilIdle() + + coVerify { interactor.resetCourseDates(courseId) } + coVerify(exactly = 0) { courseNotifier.send(CourseDatesShifted) } + assertEquals(false, resetResult) + } + + @Test + fun `logVideoClick analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.logVideoClick("video1") + + verify { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.COURSE_HOME_VIDEO_CLICK.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId && + it[CourseAnalyticsKey.COURSE_NAME.key] == courseTitle && + it[CourseAnalyticsKey.BLOCK_ID.key] == "video1" + } + ) + } + } + + @Test + fun `logAssignmentClick analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.logAssignmentClick("assignment1") + + verify { + analytics.logEvent( + CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.COURSE_HOME_ASSIGNMENT_CLICK.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId && + it[CourseAnalyticsKey.COURSE_NAME.key] == courseTitle && + it[CourseAnalyticsKey.BLOCK_ID.key] == "assignment1" + } + ) + } + } + + @Test + fun `viewCertificateTappedEvent analytics event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.viewCertificateTappedEvent() + + verify { + analytics.logEvent( + CourseAnalyticsEvent.VIEW_CERTIFICATE.eventName, + match { + it[CourseAnalyticsKey.NAME.key] == CourseAnalyticsEvent.VIEW_CERTIFICATE.biValue && + it[CourseAnalyticsKey.COURSE_ID.key] == courseId + } + ) + } + } + + @Test + fun `getCourseProgress success`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + viewModel.getCourseProgress() + + coVerify { interactor.getCourseProgress(courseId, false, true) } + } + + @Test + fun `CourseStructureUpdated notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated(courseId)) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify(atLeast = 2) { interactor.getCourseStructureFlow(courseId, false) } + } + + @Test + fun `CourseOpenBlock notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseOpenBlock("test-block-id")) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + } + + @Test + fun `CourseProgressLoaded notifier event`() = runTest { + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + every { courseNotifier.notifier } returns flow { emit(CourseProgressLoaded) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + advanceUntilIdle() + + coVerify(atLeast = 2) { interactor.getCourseProgress(courseId, false, true) } + } + + @Test + fun `isCourseDropdownNavigationEnabled property`() = runTest { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns true + + coEvery { interactor.getCourseStructureFlow(courseId, false) } returns flow { + emit( + courseStructure + ) + } + coEvery { interactor.getCourseStatusFlow(courseId) } returns flow { + emit( + courseComponentStatus + ) + } + coEvery { interactor.getCourseDatesFlow(courseId) } returns flow { emit(courseDatesResult) } + coEvery { + interactor.getCourseProgress( + courseId, + false, + true + ) + } returns flow { emit(courseProgress) } + + val viewModel = CourseHomeViewModel( + courseId = courseId, + courseTitle = courseTitle, + config = config, + interactor = interactor, + resourceManager = resourceManager, + courseNotifier = courseNotifier, + networkConnection = networkConnection, + preferencesManager = preferencesManager, + analytics = analytics, + downloadDialogManager = downloadDialogManager, + fileUtil = fileUtil, + courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, + coreAnalytics = coreAnalytics, + downloadDao = downloadDao, + workerController = workerController, + downloadHelper = downloadHelper + ) + + assertTrue(viewModel.isCourseDropdownNavigationEnabled) + } +} diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index 663409188..62fc097b7 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -51,13 +52,13 @@ import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -94,7 +95,8 @@ class CourseOutlineViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( @@ -239,6 +241,7 @@ class CourseOutlineViewModelTest { every { preferencesManager.isRelativeDatesEnabled } returns true coEvery { interactor.getCourseDates(any()) } returns mockedCourseDatesResult + coEvery { interactor.getCourseDatesFlow(any()) } returns flowOf(mockedCourseDatesResult) } @After @@ -247,52 +250,68 @@ class CourseOutlineViewModelTest { } @Test - fun `getCourseDataInternal no internet connection exception`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { networkConnection.isOnline() } returns true - every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } - every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit - coEvery { interactor.getCourseStatus(any()) } throws UnknownHostException() - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) + fun `getCourseDataInternal no internet connection exception`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns true + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns Unit + coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw UnknownHostException() } + + val viewModel = CourseContentAllViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) - val message = async { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage - } - viewModel.getCourseData() - advanceUntilIdle() + val message = async { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } + viewModel.getCourseData() + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assertEquals(noInternet, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Error) - } + assertEquals(noInternet, message.await()?.message) + assert(viewModel.uiState.value is CourseContentAllUIState.Error) + } + @Suppress("TooGenericExceptionThrown") @Test fun `getCourseDataInternal unknown exception`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) every { networkConnection.isOnline() } returns true every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } - coEvery { interactor.getCourseStatus(any()) } throws Exception() - val viewModel = CourseOutlineViewModel( + coEvery { interactor.getCourseStatusFlow(any()) } returns flow { throw Exception() } + val viewModel = CourseContentAllViewModel( "", "", config, @@ -317,168 +336,182 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } assertEquals(somethingWrong, message.await()?.message) - assert(viewModel.uiState.value is CourseOutlineUIState.Error) + assert(viewModel.uiState.value is CourseContentAllUIState.Error) } @Test - fun `getCourseDataInternal success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { networkConnection.isOnline() } returns true - coEvery { downloadDao.getAllDataFlow() } returns flow { - emit( - listOf( - DownloadModelEntity.createFrom( - downloadModel + fun `getCourseDataInternal success with internet connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns true + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) ) ) + } + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + + val viewModel = CourseContentAllViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, ) - } - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.getCourseData() - advanceUntilIdle() + viewModel.getCourseData() + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) - } + assert(message.await() == null) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) + } @Test - fun `getCourseDataInternal success without internet connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { networkConnection.isOnline() } returns false - coEvery { downloadDao.getAllDataFlow() } returns flow { - emit( - listOf( - DownloadModelEntity.createFrom( - downloadModel + fun `getCourseDataInternal success without internet connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns false + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) ) ) + } + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + + val viewModel = CourseContentAllViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, ) - } - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.getCourseData() - advanceUntilIdle() + viewModel.getCourseData() + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 0) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) - } + assert(message.await() == null) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) + } @Test - fun `updateCourseData success with internet connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { networkConnection.isOnline() } returns true - coEvery { downloadDao.getAllDataFlow() } returns flow { - emit( - listOf( - DownloadModelEntity.createFrom( - downloadModel + fun `updateCourseData success with internet connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + every { networkConnection.isOnline() } returns true + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) ) ) + } + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + + val viewModel = CourseContentAllViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, ) - } - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.getCourseData() - advanceUntilIdle() + viewModel.getCourseData() + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 2) { interactor.getCourseStatus(any()) } + coVerify(exactly = 2) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 2) { interactor.getCourseStatusFlow(any()) } - assert(message.await() == null) - assert(viewModel.uiState.value is CourseOutlineUIState.CourseData) - } + assert(message.await() == null) + assert(viewModel.uiState.value is CourseContentAllUIState.CourseData) + } @Test fun `CourseStructureUpdated notifier test`() = runTest(UnconfinedTestDispatcher()) { coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } - val viewModel = CourseOutlineViewModel( + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) + coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } + every { networkConnection.isOnline() } returns true + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + + val viewModel = CourseContentAllViewModel( "", "", config, @@ -496,10 +529,6 @@ class CourseOutlineViewModelTest { workerController, downloadHelper, ) - coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("")) } - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - every { networkConnection.isOnline() } returns true - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) @@ -509,14 +538,15 @@ class CourseOutlineViewModelTest { viewModel.getCourseData() advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructure(any()) } - coVerify(exactly = 1) { interactor.getCourseStatus(any()) } + coVerify(exactly = 3) { interactor.getCourseStructureFlow(any(), any()) } + coVerify(exactly = 3) { interactor.getCourseStatusFlow(any()) } } @Test fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { preferencesManager.videoSettings.wifiDownloadOnly } returns false coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf(courseStructure) every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true every { @@ -527,10 +557,11 @@ class CourseOutlineViewModelTest { } returns Unit coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - val viewModel = CourseOutlineViewModel( + val viewModel = CourseContentAllViewModel( "", "", config, @@ -553,7 +584,7 @@ class CourseOutlineViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() verify(exactly = 1) { coreAnalytics.logEvent( @@ -566,43 +597,48 @@ class CourseOutlineViewModelTest { } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - coEvery { interactor.getCourseStructure(any()) } returns courseStructure - coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns true - every { networkConnection.isOnline() } returns true - coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - every { coreAnalytics.logEvent(any(), any()) } returns Unit - - val viewModel = CourseOutlineViewModel( - "", - "", - config, - interactor, - resourceManager, - notifier, - networkConnection, - preferencesManager, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, with connection`() = + runTest(UnconfinedTestDispatcher()) { + coEvery { interactor.getCourseStructure(any()) } returns courseStructure + coEvery { interactor.getCourseStructureFlow(any(), any()) } returns flowOf( + courseStructure + ) + coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") + coEvery { interactor.getCourseStatusFlow(any()) } returns flowOf(CourseComponentStatus("id")) + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns true + every { networkConnection.isOnline() } returns true + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { coreAnalytics.logEvent(any(), any()) } returns Unit + + val viewModel = CourseContentAllViewModel( + "", + "", + config, + interactor, + resourceManager, + notifier, + networkConnection, + preferencesManager, + analytics, + downloadDialogManager, + fileUtil, + courseRouter, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "") - advanceUntilIdle() + viewModel.saveDownloadModels("", "", "") + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } } diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index 02eda9622..685311e9e 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -38,11 +38,11 @@ import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.presentation.CoreAnalytics -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics +import org.openedx.course.presentation.unit.container.CourseViewMode import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException @@ -73,7 +73,8 @@ class CourseSectionViewModelTest { private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( diff --git a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt index 9d0f0c7c1..fb8ac2920 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModelTest.kt @@ -20,12 +20,12 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.config.Config +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess -import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor @@ -46,11 +46,13 @@ class CourseUnitContainerViewModelTest { private val notifier = mockk() private val analytics = mockk() private val networkConnection = mockk() + private val videoPreviewHelper = mockk() private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( @@ -161,6 +163,7 @@ class CourseUnitContainerViewModelTest { @Before fun setUp() { Dispatchers.setMain(dispatcher) + every { videoPreviewHelper.getVideoPreviews(any(), any()) } returns emptyMap() } @After @@ -171,13 +174,22 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks no internet connection exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.FULL, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() - viewModel.loadBlocks(CourseViewMode.FULL) + viewModel.loadBlocks() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseStructure(any()) } @@ -186,13 +198,22 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks unknown exception`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.FULL, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } throws UnknownHostException() coEvery { interactor.getCourseStructureForVideos(any()) } throws UnknownHostException() - viewModel.loadBlocks(CourseViewMode.FULL) + viewModel.loadBlocks() advanceUntilIdle() coVerify(exactly = 1) { interactor.getCourseStructure(any()) } @@ -201,13 +222,22 @@ class CourseUnitContainerViewModelTest { @Test fun `getBlocks unknown success`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS) + viewModel.loadBlocks() advanceUntilIdle() @@ -218,12 +248,21 @@ class CourseUnitContainerViewModelTest { @Test fun setupCurrentIndex() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") + viewModel.loadBlocks("id") advanceUntilIdle() coVerify(exactly = 0) { interactor.getCourseStructure(any()) } @@ -233,12 +272,21 @@ class CourseUnitContainerViewModelTest { @Test fun `getCurrentBlock test`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") + viewModel.loadBlocks("id") advanceUntilIdle() @@ -250,12 +298,21 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToPrevBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") + viewModel.loadBlocks("id3") advanceUntilIdle() @@ -267,12 +324,21 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToPrevBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "id", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id1") + viewModel.loadBlocks("id1") advanceUntilIdle() @@ -284,12 +350,21 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToNextBlock null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id3") + viewModel.loadBlocks("id3") advanceUntilIdle() @@ -301,12 +376,21 @@ class CourseUnitContainerViewModelTest { @Test fun `moveToNextBlock not null`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "id", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "id", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure("") } returns courseStructure coEvery { interactor.getCourseStructureForVideos("") } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id") + viewModel.loadBlocks("id") advanceUntilIdle() @@ -318,12 +402,21 @@ class CourseUnitContainerViewModelTest { @Test fun `currentIndex isLastIndex`() = runTest { every { notifier.notifier } returns MutableSharedFlow() - val viewModel = - CourseUnitContainerViewModel("", "", config, interactor, notifier, analytics, networkConnection) + val viewModel = CourseUnitContainerViewModel( + "", + "", + CourseViewMode.VIDEOS, + config, + interactor, + notifier, + analytics, + networkConnection, + videoPreviewHelper + ) coEvery { interactor.getCourseStructure(any()) } returns courseStructure coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - viewModel.loadBlocks(CourseViewMode.VIDEOS, "id3") + viewModel.loadBlocks("id3") advanceUntilIdle() diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt index effd426a0..1d8524a7b 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoUnitViewModelTest.kt @@ -57,6 +57,8 @@ class VideoUnitViewModelTest { @Test fun `markBlockCompleted exception`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -96,6 +98,8 @@ class VideoUnitViewModelTest { @Test fun `markBlockCompleted success`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -135,6 +139,8 @@ class VideoUnitViewModelTest { @Test fun `CourseVideoPositionChanged notifier test`() = runTest { val viewModel = VideoUnitViewModel( + "", + "", "", courseRepository, notifier, @@ -147,10 +153,12 @@ class VideoUnitViewModelTest { CourseVideoPositionChanged( "", 10, - false + 10000L, + false, ) ) } + coEvery { courseRepository.saveVideoProgress(any(), any(), any(), any()) } returns Unit val mockLifeCycleOwner: LifecycleOwner = mockk() val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) lifecycleRegistry.addObserver(viewModel) diff --git a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt index ad04283d7..ae954c5f7 100644 --- a/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/unit/video/VideoViewModelTest.kt @@ -52,11 +52,11 @@ class VideoViewModelTest { fun `sendTime test`() = runTest { val viewModel = VideoViewModel("", courseRepository, notifier, preferenceManager, courseAnalytics) - coEvery { notifier.send(CourseVideoPositionChanged("", 0, false)) } returns Unit + coEvery { notifier.send(CourseVideoPositionChanged("", 0, 0L, false)) } returns Unit viewModel.sendTime() advanceUntilIdle() - coVerify(exactly = 1) { notifier.send(CourseVideoPositionChanged("", 0, false)) } + coVerify(exactly = 1) { notifier.send(CourseVideoPositionChanged("", 0, 0L, false)) } } @Test diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index b84bb61eb..e8a16c151 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -16,7 +16,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -30,7 +29,9 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.BlockType import org.openedx.core.config.Config +import org.openedx.core.data.model.room.VideoProgressEntity import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts @@ -45,16 +46,14 @@ import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType import org.openedx.core.module.download.DownloadHelper import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.CourseLoading import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated -import org.openedx.core.system.notifier.VideoNotifier import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter -import org.openedx.course.presentation.download.DownloadDialogManager import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @@ -65,15 +64,14 @@ class CourseVideoViewModelTest { @get:Rule val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() - private val dispatcher = StandardTestDispatcher() + private val dispatcher = UnconfinedTestDispatcher() private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() private val courseNotifier = spyk() - private val videoNotifier = spyk() - private val analytics = mockk() private val coreAnalytics = mockk() + private val courseAnalytics = mockk() private val preferencesManager = mockk() private val networkConnection = mockk() private val downloadDao = mockk() @@ -82,13 +80,15 @@ class CourseVideoViewModelTest { private val downloadHelper = mockk() private val downloadDialogManager = mockk() private val fileUtil = mockk() + private val videoPreviewHelper = mockk() private val cantDownload = "You can download content only from Wi-fi" private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", numPointsEarned = 1f, - numPointsPossible = 3f + numPointsPossible = 3f, + shortLabel = "HW1", ) private val blocks = listOf( @@ -196,9 +196,26 @@ class CourseVideoViewModelTest { every { resourceManager.getString(R.string.course_can_download_only_with_wifi) } returns cantDownload Dispatchers.setMain(dispatcher) every { config.getApiHostURL() } returns "http://localhost:8000" - every { courseNotifier.notifier } returns flowOf(CourseLoading(false)) + every { courseNotifier.notifier } returns flowOf() every { preferencesManager.isRelativeDatesEnabled } returns true - every { downloadDialogManager.showPopup(any(), any(), any(), any(), any(), any(), any()) } returns Unit + every { + downloadDialogManager.showPopup( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } returns Unit + + every { videoPreviewHelper.getVideoPreviewWithId(any(), any(), any()) } returns Pair( + "test", + null + ) } @After @@ -207,7 +224,7 @@ class CourseVideoViewModelTest { } @Test - fun `getVideos empty list`() = runTest { + fun `getVideos empty list`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) @@ -215,7 +232,6 @@ class CourseVideoViewModelTest { every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( - "", "", config, interactor, @@ -223,11 +239,11 @@ class CourseVideoViewModelTest { networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -239,18 +255,21 @@ class CourseVideoViewModelTest { coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.Empty) + assert(viewModel.uiState.value is CourseVideoUIState.Empty) } @Test - fun `getVideos success`() = runTest { + fun `getVideos success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + every { downloadDao.getAllDataFlow() } returns flow { + repeat(5) { + delay(10000) + emit(emptyList()) + } + } every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", "", config, interactor, @@ -258,41 +277,43 @@ class CourseVideoViewModelTest { networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, downloadHelper, ) - viewModel.getVideos() + val mockLifeCycleOwner: LifecycleOwner = mockk() + val lifecycleRegistry = LifecycleRegistry(mockLifeCycleOwner) + lifecycleRegistry.addObserver(viewModel) + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) + advanceUntilIdle() - coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } + coVerify(exactly = 1) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.CourseData) + assert(viewModel.uiState.value is CourseVideoUIState.CourseData) } @Test - fun `updateVideos success`() = runTest { + fun `updateVideos success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("")) } every { downloadDao.getAllDataFlow() } returns flow { - repeat(5) { - delay(10000) - emit(emptyList()) - } + emit(emptyList()) } every { preferencesManager.videoSettings } returns VideoSettings.default + every { networkConnection.isOnline() } returns true + coEvery { interactor.getVideoProgress(any()) } returns VideoProgressEntity("", "", 0L, 0L) val viewModel = CourseVideoViewModel( - "", "", config, interactor, @@ -300,11 +321,11 @@ class CourseVideoViewModelTest { networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -320,11 +341,11 @@ class CourseVideoViewModelTest { coVerify(exactly = 2) { interactor.getCourseStructureForVideos(any()) } - assert(viewModel.uiState.value is CourseVideosUIState.CourseData) + assert(viewModel.uiState.value is CourseVideoUIState.CourseData) } @Test - fun `setIsUpdating success`() = runTest { + fun `setIsUpdating success`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure @@ -336,8 +357,9 @@ class CourseVideoViewModelTest { fun `saveDownloadModels test`() = runTest(UnconfinedTestDispatcher()) { every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false every { preferencesManager.videoSettings } returns VideoSettings.default + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( - "", "", config, interactor, @@ -345,11 +367,11 @@ class CourseVideoViewModelTest { networkConnection, preferencesManager, courseNotifier, - videoNotifier, - analytics, downloadDialogManager, fileUtil, courseRouter, + courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -366,95 +388,99 @@ class CourseVideoViewModelTest { viewModel.uiMessage.first() as? UIMessage.SnackBarMessage } } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") advanceUntilIdle() assert(message.await()?.message.isNullOrEmpty()) } @Test - fun `saveDownloadModels only wifi download, with connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(any()) } returns Unit - coEvery { downloadDao.getAllDataFlow() } returns flow { - emit(listOf(DownloadModelEntity.createFrom(downloadModel))) - } - every { coreAnalytics.logEvent(any(), any()) } returns Unit - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, with connection`() = + runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + val viewModel = CourseVideoViewModel( + "", + config, + interactor, + resourceManager, + networkConnection, + preferencesManager, + courseNotifier, + downloadDialogManager, + fileUtil, + courseRouter, + courseAnalytics, + videoPreviewHelper, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns true + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.getAllDataFlow() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } + every { coreAnalytics.logEvent(any(), any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "") - advanceUntilIdle() + viewModel.saveDownloadModels("", "", "") + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } @Test - fun `saveDownloadModels only wifi download, without connection`() = runTest(UnconfinedTestDispatcher()) { - every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false - every { preferencesManager.videoSettings } returns VideoSettings.default - val viewModel = CourseVideoViewModel( - "", - "", - config, - interactor, - resourceManager, - networkConnection, - preferencesManager, - courseNotifier, - videoNotifier, - analytics, - downloadDialogManager, - fileUtil, - courseRouter, - coreAnalytics, - downloadDao, - workerController, - downloadHelper, - ) - every { preferencesManager.videoSettings.wifiDownloadOnly } returns true - every { networkConnection.isWifiConnected() } returns false - every { networkConnection.isOnline() } returns false - coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure - coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } - coEvery { workerController.saveModels(any()) } returns Unit - val message = async { - withTimeoutOrNull(5000) { - viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + fun `saveDownloadModels only wifi download, without connection`() = + runTest(UnconfinedTestDispatcher()) { + every { config.getCourseUIConfig().isCourseDropdownNavigationEnabled } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default + every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + val viewModel = CourseVideoViewModel( + "", + config, + interactor, + resourceManager, + networkConnection, + preferencesManager, + courseNotifier, + downloadDialogManager, + fileUtil, + courseRouter, + courseAnalytics, + videoPreviewHelper, + coreAnalytics, + downloadDao, + workerController, + downloadHelper, + ) + every { preferencesManager.videoSettings.wifiDownloadOnly } returns true + every { networkConnection.isWifiConnected() } returns false + every { networkConnection.isOnline() } returns false + coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure + coEvery { downloadDao.getAllDataFlow() } returns flow { emit(listOf(downloadModelEntity)) } + coEvery { workerController.saveModels(any()) } returns Unit + val message = async { + withTimeoutOrNull(5000) { + viewModel.uiMessage.first() as? UIMessage.SnackBarMessage + } } - } - viewModel.saveDownloadModels("", "") + viewModel.saveDownloadModels("", "", "") - advanceUntilIdle() + advanceUntilIdle() - assert(message.await()?.message.isNullOrEmpty()) - } + assert(message.await()?.message.isNullOrEmpty()) + } } diff --git a/dashboard/build.gradle b/dashboard/build.gradle index 13119287f..f8272cc82 100644 --- a/dashboard/build.gradle +++ b/dashboard/build.gradle @@ -5,32 +5,33 @@ plugins { } android { - compileSdk 34 + namespace 'org.openedx.dashboard' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.dashboard' buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { @@ -55,10 +56,10 @@ android { dependencies { implementation project(path: ':core') - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } \ No newline at end of file diff --git a/dashboard/proguard-rules.pro b/dashboard/proguard-rules.pro index cdb308aa0..4d3a6c1df 100644 --- a/dashboard/proguard-rules.pro +++ b/dashboard/proguard-rules.pro @@ -5,3 +5,5 @@ -dontshrink -dontoptimize -dontobfuscate + +-dontwarn java.lang.invoke.StringConcatFactory diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt index 9d26e39df..c0967b5d0 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesView.kt @@ -230,7 +230,7 @@ private fun AllEnrolledCoursesView( val contentWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 650.dp), + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), compact = Modifier.fillMaxWidth(), ) ) @@ -274,7 +274,9 @@ private fun AllEnrolledCoursesView( Header( modifier = Modifier .padding( - start = contentPaddings.calculateStartPadding(layoutDirection), + start = contentPaddings.calculateStartPadding( + layoutDirection + ), end = contentPaddings.calculateEndPadding(layoutDirection) ), onSearchClick = { @@ -305,50 +307,52 @@ private fun AllEnrolledCoursesView( !state.courses.isNullOrEmpty() -> { Box( - modifier = Modifier - .fillMaxSize() - .padding(contentPaddings), - contentAlignment = Alignment.Center + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - LazyVerticalGrid( - modifier = Modifier - .fillMaxHeight(), - state = scrollState, - columns = GridCells.Fixed(columns), - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - content = { - items(state.courses) { course -> - CourseItem( - course = course, - apiHostUrl = apiHostUrl, - onClick = { - onAction(AllEnrolledCoursesAction.OpenCourse(it)) - } - ) - } - item(span = { GridItemSpan(columns) }) { - if (state.canLoadMore) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(180.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - color = MaterialTheme.appColors.primary + LazyVerticalGrid( + modifier = Modifier + .fillMaxHeight(), + state = scrollState, + columns = GridCells.Fixed(columns), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = contentPaddings, + content = { + items(state.courses) { course -> + CourseItem( + course = course, + apiHostUrl = apiHostUrl, + onClick = { + onAction( + AllEnrolledCoursesAction.OpenCourse( + it ) - } + ) + } + ) + } + item(span = { GridItemSpan(columns) }) { + if (state.canLoadMore) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + color = MaterialTheme.appColors.primary + ) } } } + } + ) + if (scrollState.shouldLoadMore( + firstVisibleIndex, + LOAD_MORE_THRESHOLD ) - } - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + ) { onAction(AllEnrolledCoursesAction.EndOfPage) } } @@ -431,16 +435,11 @@ fun CourseItem( .fillMaxWidth() .height(90.dp) ) - val progress: Float = try { - course.progress.assignmentsCompleted.toFloat() / course.progress.totalAssignmentsCount.toFloat() - } catch (_: ArithmeticException) { - 0f - } LinearProgressIndicator( modifier = Modifier .fillMaxWidth() .height(8.dp), - progress = progress, + progress = course.progress.value, color = MaterialTheme.appColors.primary, backgroundColor = MaterialTheme.appColors.divider ) diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt index c8363e24d..80c0d5fce 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/AllEnrolledCoursesViewModel.kt @@ -58,11 +58,24 @@ class AllEnrolledCoursesViewModel( init { collectDiscoveryNotifier() - getCourses(currentFilter.value) + loadInitialCourses() } - fun getCourses(courseStatusFilter: CourseStatusFilter? = null) { - _uiState.update { it.copy(showProgress = true) } + private fun loadInitialCourses() { + viewModelScope.launch { + _uiState.update { it.copy(showProgress = true) } + val cachedList = interactor.getEnrolledCoursesFromCache() + if (cachedList.isNotEmpty()) { + _uiState.update { it.copy(courses = cachedList.toList(), showProgress = false) } + } + getCourses(showLoadingProgress = false) + } + } + + fun getCourses(courseStatusFilter: CourseStatusFilter? = null, showLoadingProgress: Boolean = true) { + if (showLoadingProgress) { + _uiState.update { it.copy(showProgress = true) } + } coursesList.clear() internalLoadingCourses(courseStatusFilter ?: currentFilter.value) } diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt index 2c44c2c61..c7108405a 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryView.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -17,6 +18,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.items @@ -33,8 +35,7 @@ import androidx.compose.material.Scaffold import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.School import androidx.compose.material.icons.filled.Warning import androidx.compose.material.pullrefresh.PullRefreshIndicator @@ -46,6 +47,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -53,6 +55,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -62,6 +65,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentManager import androidx.lifecycle.Lifecycle @@ -89,6 +93,7 @@ import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.TextIcon +import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -100,6 +105,7 @@ import org.openedx.dashboard.R import org.openedx.foundation.extension.toImageLink import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue import java.util.Date import org.openedx.core.R as CoreR @@ -181,6 +187,7 @@ private fun DashboardGalleryView( onAction: (DashboardGalleryScreenAction) -> Unit, hasInternetConnection: Boolean ) { + val windowSize = rememberWindowSize() val scaffoldState = rememberScaffoldState() val pullRefreshState = rememberPullRefreshState( refreshing = updating, @@ -190,6 +197,24 @@ private fun DashboardGalleryView( mutableStateOf(false) } + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + + val contentPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(0.dp), + compact = PaddingValues(horizontal = 16.dp) + ) + ) + } + Scaffold( scaffoldState = scaffoldState, modifier = Modifier.fillMaxSize(), @@ -201,68 +226,71 @@ private fun DashboardGalleryView( Surface( modifier = Modifier .fillMaxSize() + .displayCutoutForLandscape() .padding(paddingValues), color = MaterialTheme.appColors.background ) { Box( - Modifier.fillMaxSize() + Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + .verticalScroll(rememberScrollState()), ) { - Box( - Modifier - .fillMaxSize() - .pullRefresh(pullRefreshState) - .verticalScroll(rememberScrollState()), - ) { - when (uiState) { - is DashboardGalleryUIState.Loading -> { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = MaterialTheme.appColors.primary - ) - } - - is DashboardGalleryUIState.Courses -> { - UserCourses( - modifier = Modifier.fillMaxSize(), - userCourses = uiState.userCourses, - useRelativeDates = uiState.useRelativeDates, - apiHostUrl = apiHostUrl, - openCourse = { - onAction(DashboardGalleryScreenAction.OpenCourse(it)) - }, - onViewAllClick = { - onAction(DashboardGalleryScreenAction.ViewAll) - }, - navigateToDates = { - onAction(DashboardGalleryScreenAction.NavigateToDates(it)) - }, - resumeBlockId = { course, blockId -> - onAction(DashboardGalleryScreenAction.OpenBlock(course, blockId)) - } - ) - } + when (uiState) { + is DashboardGalleryUIState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.appColors.primary + ) + } - is DashboardGalleryUIState.Empty -> { - NoCoursesInfo( - modifier = Modifier - .align(Alignment.Center) - ) - FindACourseButton( - modifier = Modifier - .align(Alignment.BottomCenter), - findACourseClick = { - onAction(DashboardGalleryScreenAction.NavigateToDiscovery) - } - ) - } + is DashboardGalleryUIState.Courses -> { + UserCourses( + modifier = contentWidth + .fillMaxHeight() + .padding(vertical = 12.dp) + .displayCutoutForLandscape() + .align(Alignment.TopCenter), + contentPadding = contentPadding, + userCourses = uiState.userCourses, + useRelativeDates = uiState.useRelativeDates, + apiHostUrl = apiHostUrl, + openCourse = { + onAction(DashboardGalleryScreenAction.OpenCourse(it)) + }, + onViewAllClick = { + onAction(DashboardGalleryScreenAction.ViewAll) + }, + navigateToDates = { + onAction(DashboardGalleryScreenAction.NavigateToDates(it)) + }, + resumeBlockId = { course, blockId -> + onAction(DashboardGalleryScreenAction.OpenBlock(course, blockId)) + } + ) } - PullRefreshIndicator( - updating, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) + is DashboardGalleryUIState.Empty -> { + NoCoursesInfo( + modifier = Modifier + .align(Alignment.Center) + ) + FindACourseButton( + modifier = Modifier + .align(Alignment.BottomCenter), + findACourseClick = { + onAction(DashboardGalleryScreenAction.NavigateToDiscovery) + } + ) + } } + + PullRefreshIndicator( + updating, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + if (!isInternetConnectionShown && !hasInternetConnection) { OfflineModeDialog( Modifier @@ -286,6 +314,7 @@ private fun DashboardGalleryView( private fun UserCourses( modifier: Modifier = Modifier, userCourses: CourseEnrollments, + contentPadding: PaddingValues, apiHostUrl: String, useRelativeDates: Boolean, openCourse: (EnrolledCourse) -> Unit, @@ -295,11 +324,11 @@ private fun UserCourses( ) { Column( modifier = modifier - .padding(vertical = 12.dp) ) { val primaryCourse = userCourses.primary if (primaryCourse != null) { PrimaryCourseCard( + modifier = Modifier.padding(contentPadding), primaryCourse = primaryCourse, apiHostUrl = apiHostUrl, navigateToDates = navigateToDates, @@ -313,6 +342,7 @@ private fun UserCourses( courses = userCourses.enrollments.courses, hasNextPage = userCourses.enrollments.pagination.next.isNotEmpty(), apiHostUrl = apiHostUrl, + contentPadding = contentPadding, onCourseClick = openCourse, onViewAllClick = onViewAllClick ) @@ -325,6 +355,7 @@ private fun SecondaryCourses( courses: List, hasNextPage: Boolean, apiHostUrl: String, + contentPadding: PaddingValues, onCourseClick: (EnrolledCourse) -> Unit, onViewAllClick: () -> Unit ) { @@ -344,10 +375,10 @@ private fun SecondaryCourses( verticalArrangement = Arrangement.spacedBy(8.dp) ) { TextIcon( - modifier = Modifier.padding(horizontal = 18.dp), + modifier = Modifier.padding(contentPadding), text = stringResource(R.string.dashboard_view_all_with_count, courses.size + 1), textStyle = MaterialTheme.appTypography.titleSmall, - icon = Icons.Default.ChevronRight, + icon = Icons.AutoMirrored.Filled.KeyboardArrowRight, color = MaterialTheme.appColors.textDark, iconModifier = Modifier.size(22.dp), onClick = onViewAllClick @@ -357,7 +388,7 @@ private fun SecondaryCourses( .fillMaxSize() .height(height), rows = GridCells.Fixed(rows), - contentPadding = PaddingValues(horizontal = 18.dp), + contentPadding = contentPadding, content = { items(items) { CourseListItem( @@ -512,8 +543,8 @@ private fun AssignmentItem( } } Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + modifier = Modifier.size(22.dp), + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.textDark, contentDescription = null ) @@ -522,6 +553,7 @@ private fun AssignmentItem( @Composable private fun PrimaryCourseCard( + modifier: Modifier = Modifier, primaryCourse: EnrolledCourse, apiHostUrl: String, useRelativeDates: Boolean, @@ -529,113 +561,187 @@ private fun PrimaryCourseCard( resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, openCourse: (EnrolledCourse) -> Unit, ) { - val context = LocalContext.current + val orientation = LocalConfiguration.current.orientation Card( - modifier = Modifier - .padding(horizontal = 16.dp) + modifier = modifier .fillMaxWidth() .padding(2.dp), backgroundColor = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.courseImageShape, elevation = 4.dp ) { - Column( - modifier = Modifier - .clickable { - openCourse(primaryCourse) - } - ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(primaryCourse.course.courseImage.toImageLink(apiHostUrl)) - .error(CoreR.drawable.core_no_image_course) - .placeholder(CoreR.drawable.core_no_image_course) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .fillMaxWidth() - .height(140.dp) - ) - val progress: Float = try { - primaryCourse.progress.assignmentsCompleted.toFloat() / - primaryCourse.progress.totalAssignmentsCount.toFloat() - } catch (_: ArithmeticException) { - 0f - } - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(8.dp), - progress = progress, - color = MaterialTheme.appColors.primary, - backgroundColor = MaterialTheme.appColors.divider - ) - PrimaryCourseTitle( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp) - .padding(top = 8.dp, bottom = 16.dp), - primaryCourse = primaryCourse - ) - val pastAssignments = primaryCourse.courseAssignments?.pastAssignments - if (!pastAssignments.isNullOrEmpty()) { - val nearestAssignment = pastAssignments.maxBy { it.date } - val title = if (pastAssignments.size == 1) nearestAssignment.title else null - Divider() - AssignmentItem( - modifier = Modifier.clickable { - if (pastAssignments.size == 1) { - resumeBlockId(primaryCourse, nearestAssignment.blockId) - } else { - navigateToDates(primaryCourse) + when (orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + Row( + modifier = Modifier + .clickable { + openCourse(primaryCourse) } - }, - painter = rememberVectorPainter(Icons.Default.Warning), - title = title, - info = pluralStringResource( - R.plurals.dashboard_past_due_assignment, - pastAssignments.size, - pastAssignments.size + .height(IntrinsicSize.Min) + ) { + PrimaryCourseCaption( + modifier = Modifier.weight(1f), + primaryCourse = primaryCourse, + apiHostUrl = apiHostUrl, + imageHeight = null, ) - ) + PrimaryCourseButtons( + modifier = Modifier.weight(1f), + primaryCourse = primaryCourse, + navigateToDates = navigateToDates, + resumeBlockId = resumeBlockId, + openCourse = openCourse, + adjustHeight = true, + useRelativeDates = useRelativeDates, + ) + } } - val futureAssignments = primaryCourse.courseAssignments?.futureAssignments - if (!futureAssignments.isNullOrEmpty()) { - val nearestAssignment = futureAssignments.minBy { it.date } - val title = if (futureAssignments.size == 1) nearestAssignment.title else null - Divider() - AssignmentItem( + + else -> { + Column( modifier = Modifier.clickable { - if (futureAssignments.size == 1) { - resumeBlockId(primaryCourse, nearestAssignment.blockId) - } else { - navigateToDates(primaryCourse) - } - }, - painter = painterResource(id = CoreR.drawable.ic_core_chapter_icon), - title = title, - info = stringResource( - R.string.dashboard_assignment_due, - nearestAssignment.assignmentType ?: "", - stringResource( - id = CoreR.string.core_date_format_assignment_due, - TimeUtils.formatToString(context, nearestAssignment.date, useRelativeDates) - ) + openCourse(primaryCourse) + } + ) { + PrimaryCourseCaption( + primaryCourse = primaryCourse, + apiHostUrl = apiHostUrl, ) - ) + PrimaryCourseButtons( + primaryCourse = primaryCourse, + navigateToDates = navigateToDates, + resumeBlockId = resumeBlockId, + openCourse = openCourse, + useRelativeDates = useRelativeDates, + ) + } } - ResumeButton( - primaryCourse = primaryCourse, - onClick = { - if (primaryCourse.courseStatus == null) { - openCourse(primaryCourse) + } + } +} + +@Composable +private fun PrimaryCourseButtons( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse, + useRelativeDates: Boolean, + adjustHeight: Boolean = false, + navigateToDates: (EnrolledCourse) -> Unit, + resumeBlockId: (enrolledCourse: EnrolledCourse, blockId: String) -> Unit, + openCourse: (EnrolledCourse) -> Unit, +) { + val context = LocalContext.current + val pastAssignments = primaryCourse.courseAssignments?.pastAssignments + Column(modifier = modifier) { + var titleModifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 16.dp) + if (adjustHeight) { + titleModifier = titleModifier.weight(1f) + } + PrimaryCourseTitle( + modifier = titleModifier, + primaryCourse = primaryCourse, + ) + Divider() + if (!pastAssignments.isNullOrEmpty()) { + val nearestAssignment = pastAssignments.maxBy { it.date } + val title = if (pastAssignments.size == 1) nearestAssignment.title else null + AssignmentItem( + modifier = Modifier.clickable { + if (pastAssignments.size == 1) { + resumeBlockId(primaryCourse, nearestAssignment.blockId) } else { - resumeBlockId(primaryCourse, primaryCourse.courseStatus?.lastVisitedBlockId ?: "") + navigateToDates(primaryCourse) } - } + }, + painter = rememberVectorPainter(Icons.Default.Warning), + title = title, + info = pluralStringResource( + R.plurals.dashboard_past_due_assignment, + pastAssignments.size, + pastAssignments.size + ) ) } + val futureAssignments = primaryCourse.courseAssignments?.futureAssignments + if (!futureAssignments.isNullOrEmpty()) { + val nearestAssignment = futureAssignments.minBy { it.date } + val title = if (futureAssignments.size == 1) nearestAssignment.title else null + Divider() + AssignmentItem( + modifier = Modifier.clickable { + if (futureAssignments.size == 1) { + resumeBlockId(primaryCourse, nearestAssignment.blockId) + } else { + navigateToDates(primaryCourse) + } + }, + painter = painterResource(id = CoreR.drawable.core_ic_chapter_icon), + title = title, + info = stringResource( + R.string.dashboard_assignment_due, + nearestAssignment.assignmentType ?: "", + stringResource( + id = CoreR.string.core_date_format_assignment_due, + TimeUtils.formatToString(context, nearestAssignment.date, useRelativeDates), + ) + ) + ) + } + ResumeButton( + primaryCourse = primaryCourse, + onClick = { + if (primaryCourse.courseStatus == null) { + openCourse(primaryCourse) + } else { + resumeBlockId( + primaryCourse, + primaryCourse.courseStatus?.lastVisitedBlockId ?: "" + ) + } + } + ) + } +} + +@Composable +private fun PrimaryCourseCaption( + modifier: Modifier = Modifier, + primaryCourse: EnrolledCourse, + imageHeight: Dp? = 140.dp, + apiHostUrl: String, +) { + val context = LocalContext.current + Column(modifier = modifier) { + val imageModifier = imageHeight?.let { + Modifier + .height(it) + .fillMaxWidth() + } ?: Modifier + .height(IntrinsicSize.Max) + .fillMaxWidth() + .weight(1f) + + AsyncImage( + model = ImageRequest.Builder(context) + .data(primaryCourse.course.courseImage.toImageLink(apiHostUrl)) + .error(CoreR.drawable.core_no_image_course) + .placeholder(CoreR.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = imageModifier, + ) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp), + progress = primaryCourse.progress.value, + color = MaterialTheme.appColors.primary, + backgroundColor = MaterialTheme.appColors.divider + ) } } @@ -690,8 +796,8 @@ private fun ResumeButton( } } Icon( - modifier = Modifier.size(16.dp), - imageVector = Icons.AutoMirrored.Filled.ArrowForwardIos, + modifier = Modifier.size(22.dp), + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.primaryButtonText, contentDescription = null ) @@ -705,7 +811,7 @@ private fun PrimaryCourseTitle( ) { Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.Center ) { Text( modifier = Modifier.fillMaxWidth(), @@ -714,7 +820,9 @@ private fun PrimaryCourseTitle( color = MaterialTheme.appColors.textFieldHint ) Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), text = primaryCourse.course.name, style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textDark, @@ -722,7 +830,9 @@ private fun PrimaryCourseTitle( maxLines = 3 ) Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textFieldHint, text = TimeUtils.getCourseFormattedDate( diff --git a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt index aacb85719..0ca8f4a6e 100644 --- a/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt +++ b/dashboard/src/main/java/org/openedx/courses/presentation/DashboardGalleryViewModel.kt @@ -67,6 +67,20 @@ class DashboardGalleryViewModel( fun getCourses() { viewModelScope.launch { try { + val cachedCourseEnrollments = fileUtil.getObjectFromFile() + if (cachedCourseEnrollments == null) { + if (networkConnection.isOnline()) { + _uiState.value = DashboardGalleryUIState.Loading + } else { + _uiState.value = DashboardGalleryUIState.Empty + } + } else { + _uiState.value = + DashboardGalleryUIState.Courses( + cachedCourseEnrollments.mapToDomain(), + corePreferences.isRelativeDatesEnabled + ) + } if (networkConnection.isOnline()) { isLoading = true val pageSize = if (windowSize.isTablet) { @@ -83,17 +97,6 @@ class DashboardGalleryViewModel( corePreferences.isRelativeDatesEnabled ) } - } else { - val courseEnrollments = fileUtil.getObjectFromFile() - if (courseEnrollments == null) { - _uiState.value = DashboardGalleryUIState.Empty - } else { - _uiState.value = - DashboardGalleryUIState.Courses( - courseEnrollments.mapToDomain(), - corePreferences.isRelativeDatesEnabled - ) - } } } catch (e: Exception) { if (e.isInternetError()) { diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt index 642f6257a..55f995a01 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListFragment.kt @@ -73,7 +73,6 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.AppUpdateState import org.openedx.core.domain.model.Certificate import org.openedx.core.domain.model.CourseAssignments import org.openedx.core.domain.model.CourseSharingUtmParameters @@ -82,8 +81,6 @@ import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.domain.model.Progress -import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox -import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OfflineModeDialog import org.openedx.core.ui.displayCutoutForLandscape @@ -127,7 +124,6 @@ class DashboardListFragment : Fragment() { val uiMessage by viewModel.uiMessage.observeAsState() val refreshing by viewModel.updating.observeAsState(false) val canLoadMore by viewModel.canLoadMore.observeAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() DashboardListView( windowSize = windowSize, @@ -154,12 +150,6 @@ class DashboardListFragment : Fragment() { paginationCallback = { viewModel.fetchMore() }, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters( - appUpgradeEvent = appUpgradeEvent, - onAppUpgradeRecommendedBoxClick = { - AppUpdateState.openPlayMarket(requireContext()) - }, - ), ) } } @@ -184,7 +174,6 @@ internal fun DashboardListView( onSwipeRefresh: () -> Unit, paginationCallback: () -> Unit, onItemClick: (EnrolledCourse) -> Unit, - appUpgradeParameters: AppUpdateState.AppUpgradeParameters, ) { val scaffoldState = rememberScaffoldState() val pullRefreshState = @@ -306,7 +295,11 @@ internal fun DashboardListView( } } ) - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + if (scrollState.shouldLoadMore( + firstVisibleIndex, + LOAD_MORE_THRESHOLD + ) + ) { paginationCallback() } } @@ -338,17 +331,6 @@ internal fun DashboardListView( .fillMaxWidth() .align(Alignment.BottomCenter) ) { - when (appUpgradeParameters.appUpgradeEvent) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - AppUpgradeRecommendedBox( - modifier = Modifier.fillMaxWidth(), - onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick - ) - } - - else -> {} - } - if (!isInternetConnectionShown && !hasInternetConnection) { OfflineModeDialog( Modifier @@ -564,7 +546,6 @@ private fun DashboardListViewPreview() { refreshing = false, canLoadMore = false, paginationCallback = {}, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } } @@ -595,7 +576,6 @@ private fun DashboardListViewTabletPreview() { refreshing = false, canLoadMore = false, paginationCallback = {}, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } } @@ -617,7 +597,6 @@ private fun EmptyStatePreview() { refreshing = false, canLoadMore = false, paginationCallback = {}, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters() ) } } diff --git a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt index e9945f18e..58f83b8f2 100644 --- a/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt +++ b/dashboard/src/main/java/org/openedx/dashboard/presentation/DashboardListViewModel.kt @@ -11,8 +11,6 @@ import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier -import org.openedx.core.system.notifier.app.AppNotifier -import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel @@ -27,7 +25,6 @@ class DashboardListViewModel( private val resourceManager: ResourceManager, private val discoveryNotifier: DiscoveryNotifier, private val analytics: DashboardAnalytics, - private val appNotifier: AppNotifier ) : BaseViewModel() { private val coursesList = mutableListOf() @@ -55,10 +52,6 @@ class DashboardListViewModel( val canLoadMore: LiveData get() = _canLoadMore - private val _appUpgradeEvent = MutableLiveData() - val appUpgradeEvent: LiveData - get() = _appUpgradeEvent - override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { @@ -72,7 +65,6 @@ class DashboardListViewModel( init { getCourses() - collectAppUpgradeEvent() } fun getCourses() { @@ -168,16 +160,6 @@ class DashboardListViewModel( } } - private fun collectAppUpgradeEvent() { - viewModelScope.launch { - appNotifier.notifier.collect { event -> - if (event is AppUpgradeEvent) { - _appUpgradeEvent.value = event - } - } - } - } - fun dashboardCourseClickedEvent(courseId: String, courseName: String) { analytics.dashboardCourseClickedEvent(courseId, courseName) } diff --git a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt index c6843a5f8..b7fe74fd0 100644 --- a/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt +++ b/dashboard/src/main/java/org/openedx/learn/presentation/LearnFragment.kt @@ -5,7 +5,6 @@ import android.view.View import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -15,12 +14,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.DropdownMenu import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Icon -import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -44,6 +41,7 @@ import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.adapter.NavigationFragmentAdapter import org.openedx.core.presentation.global.viewBinding +import org.openedx.core.ui.MainToolbar import org.openedx.core.ui.crop import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset @@ -55,7 +53,6 @@ import org.openedx.dashboard.databinding.FragmentLearnBinding import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue import org.openedx.learn.LearnType -import org.openedx.core.R as CoreR class LearnFragment : Fragment(R.layout.fragment_learn) { @@ -94,8 +91,8 @@ class LearnFragment : Fragment(R.layout.fragment_learn) { binding.viewPager.offscreenPageLimit = 2 adapter = NavigationFragmentAdapter(this).apply { - addFragment(viewModel.getDashboardFragment) - addFragment(viewModel.getProgramFragment) + addFragment { viewModel.getDashboardFragment } + addFragment { viewModel.getProgramFragment } } binding.viewPager.adapter = adapter binding.viewPager.setUserInputEnabled(false) @@ -140,7 +137,7 @@ private fun Header( .then(contentWidth), horizontalAlignment = Alignment.CenterHorizontally ) { - Title( + MainToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = { viewModel.onSettingsClick(fragmentManager) @@ -158,40 +155,6 @@ private fun Header( } } -@Composable -private fun Title( - modifier: Modifier = Modifier, - label: String, - onSettingsClick: () -> Unit, -) { - Box( - modifier = modifier.fillMaxWidth() - ) { - Text( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp), - text = label, - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.headlineBold - ) - IconButton( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 12.dp), - onClick = { - onSettingsClick() - } - ) { - Icon( - imageVector = Icons.Default.ManageAccounts, - tint = MaterialTheme.appColors.textAccent, - contentDescription = stringResource(id = CoreR.string.core_accessibility_settings) - ) - } - } -} - @Composable private fun LearnDropdownMenu( modifier: Modifier = Modifier, @@ -277,7 +240,7 @@ private fun LearnDropdownMenu( @Composable private fun HeaderPreview() { OpenEdXTheme { - Title( + MainToolbar( label = stringResource(id = R.string.dashboard_learn), onSettingsClick = {} ) diff --git a/dashboard/src/main/res/values/strings.xml b/dashboard/src/main/res/values/strings.xml index 01979f21d..74909ac48 100644 --- a/dashboard/src/main/res/values/strings.xml +++ b/dashboard/src/main/res/values/strings.xml @@ -4,7 +4,6 @@ You are not enrolled in any courses yet. Learn Programs - Course %1$s Start Course Resume Course View All Courses (%1$d) diff --git a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt index 4a54b8f36..fae8a9455 100644 --- a/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt +++ b/dashboard/src/test/java/org/openedx/dashboard/presentation/DashboardListViewModelTest.kt @@ -8,10 +8,8 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -31,7 +29,6 @@ import org.openedx.core.domain.model.Pagination import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseDashboardUpdate import org.openedx.core.system.notifier.DiscoveryNotifier -import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -51,7 +48,6 @@ class DashboardListViewModelTest { private val networkConnection = mockk() private val discoveryNotifier = mockk() private val analytics = mockk() - private val appNotifier = mockk() private val noInternet = "Slow or no internet connection" private val somethingWrong = "Something went wrong" @@ -66,7 +62,6 @@ class DashboardListViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appNotifier.notifier } returns emptyFlow() every { config.getApiHostURL() } returns "http://localhost:8000" } @@ -84,7 +79,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -92,7 +86,6 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -108,7 +101,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -116,7 +108,6 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -132,7 +123,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList @@ -141,7 +131,6 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -156,7 +145,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) every { networkConnection.isOnline() } returns true coEvery { interactor.getEnrolledCourses(any()) } returns dashboardCourseList.copy( @@ -173,7 +161,6 @@ class DashboardListViewModelTest { coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -190,14 +177,12 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) advanceUntilIdle() coVerify(exactly = 0) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 1) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } assert(viewModel.uiMessage.value == null) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -214,7 +199,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) coEvery { interactor.getEnrolledCourses(any()) } throws UnknownHostException() @@ -223,7 +207,6 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -242,7 +225,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) coEvery { interactor.getEnrolledCourses(any()) } throws Exception() @@ -251,7 +233,6 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(somethingWrong, message?.message) @@ -270,7 +251,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) viewModel.updateCourses() @@ -278,8 +258,6 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } - assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -303,7 +281,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) viewModel.updateCourses() @@ -311,8 +288,6 @@ class DashboardListViewModelTest { coVerify(exactly = 2) { interactor.getEnrolledCourses(any()) } coVerify(exactly = 0) { interactor.getEnrolledCoursesFromCache() } - verify(exactly = 1) { appNotifier.notifier } - assert(viewModel.uiMessage.value == null) assert(viewModel.updating.value == false) assert(viewModel.uiState.value is DashboardUIState.Courses) @@ -328,7 +303,6 @@ class DashboardListViewModelTest { resourceManager, discoveryNotifier, analytics, - appNotifier ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -339,6 +313,5 @@ class DashboardListViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { interactor.getEnrolledCourses(any()) } - verify(exactly = 1) { appNotifier.notifier } } } diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 4d1d694ec..952e041de 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -64,6 +64,12 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + APP_LEVEL_DATES: + ENABLED: false + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 4d1d694ec..a7f265a45 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -64,6 +64,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 4d1d694ec..a7f265a45 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -64,6 +64,10 @@ BRANCH: HOST: '' ALTERNATE_HOST: '' +EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + #Platform names PLATFORM_NAME: "OpenEdX" PLATFORM_FULL_NAME: "OpenEdX" diff --git a/discovery/build.gradle b/discovery/build.gradle index d9c4419fc..efb02b6f4 100644 --- a/discovery/build.gradle +++ b/discovery/build.gradle @@ -7,32 +7,33 @@ plugins { } android { - compileSdk 34 + namespace 'org.openedx.discovery' + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" } - namespace 'org.openedx.discovery' buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { @@ -57,14 +58,12 @@ android { dependencies { implementation project(path: ':core') - ksp "androidx.room:room-compiler:$room_version" - implementation 'androidx.activity:activity-compose:1.8.1' + implementation "androidx.activity:activity-compose:$activity_compose_version" - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" - + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt index 28976b4a7..2212849b5 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryFragment.kt @@ -57,12 +57,7 @@ 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.openedx.core.AppUpdateState -import org.openedx.core.AppUpdateState.wasUpdateDialogClosed import org.openedx.core.domain.model.Media -import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment -import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox -import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage @@ -104,8 +99,6 @@ class NativeDiscoveryFragment : Fragment() { val uiMessage by viewModel.uiMessage.observeAsState() val canLoadMore by viewModel.canLoadMore.observeAsState(false) val refreshing by viewModel.isUpdating.observeAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState() - val wasUpdateDialogClosed by remember { wasUpdateDialogClosed } val querySearch = arguments?.getString(ARG_SEARCH_QUERY, "") ?: "" DiscoveryScreen( @@ -119,25 +112,6 @@ class NativeDiscoveryFragment : Fragment() { canShowBackButton = viewModel.canShowBackButton, isUserLoggedIn = viewModel.isUserLoggedIn, isRegistrationEnabled = viewModel.isRegistrationEnabled, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters( - appUpgradeEvent = appUpgradeEvent, - wasUpdateDialogClosed = wasUpdateDialogClosed, - appUpgradeRecommendedDialog = { - val dialog = AppUpgradeDialogFragment.newInstance() - dialog.show( - requireActivity().supportFragmentManager, - AppUpgradeDialogFragment::class.simpleName - ) - }, - onAppUpgradeRecommendedBoxClick = { - AppUpdateState.openPlayMarket(requireContext()) - }, - onAppUpgradeRequired = { - router.navigateToUpgradeRequired( - requireActivity().supportFragmentManager - ) - } - ), onSearchClick = { viewModel.discoverySearchBarClickedEvent() router.navigateToCourseSearch( @@ -214,7 +188,6 @@ internal fun DiscoveryScreen( canShowBackButton: Boolean, isUserLoggedIn: Boolean, isRegistrationEnabled: Boolean, - appUpgradeParameters: AppUpdateState.AppUpgradeParameters, onSearchClick: () -> Unit, onSwipeRefresh: () -> Unit, onReloadClick: () -> Unit, @@ -419,7 +392,11 @@ internal fun DiscoveryScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + if (scrollState.shouldLoadMore( + firstVisibleIndex, + LOAD_MORE_THRESHOLD + ) + ) { paginationCallback() } } @@ -436,30 +413,6 @@ internal fun DiscoveryScreen( .fillMaxWidth() .align(Alignment.BottomCenter) ) { - when (appUpgradeParameters.appUpgradeEvent) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - if (appUpgradeParameters.wasUpdateDialogClosed) { - AppUpgradeRecommendedBox( - modifier = Modifier.fillMaxWidth(), - onClick = appUpgradeParameters.onAppUpgradeRecommendedBoxClick - ) - } else { - if (!AppUpdateState.wasUpdateDialogDisplayed) { - AppUpdateState.wasUpdateDialogDisplayed = true - appUpgradeParameters.appUpgradeRecommendedDialog() - } - } - } - - is AppUpgradeEvent.UpgradeRequiredEvent -> { - if (!AppUpdateState.wasUpdateDialogDisplayed) { - AppUpdateState.wasUpdateDialogDisplayed = true - appUpgradeParameters.onAppUpgradeRequired() - } - } - - else -> {} - } if (!isInternetConnectionShown && !hasInternetConnection) { OfflineModeDialog( Modifier @@ -526,7 +479,6 @@ private fun DiscoveryScreenPreview() { hasInternetConnection = true, isUserLoggedIn = false, isRegistrationEnabled = true, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSignInClick = {}, onRegisterClick = {}, onBackClick = {}, @@ -568,7 +520,6 @@ private fun DiscoveryScreenTabletPreview() { hasInternetConnection = true, isUserLoggedIn = true, isRegistrationEnabled = true, - appUpgradeParameters = AppUpdateState.AppUpgradeParameters(), onSignInClick = {}, onRegisterClick = {}, onBackClick = {}, diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt index 0d4673e23..70acffbd8 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/NativeDiscoveryViewModel.kt @@ -3,15 +3,11 @@ package org.openedx.discovery.presentation import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.app.AppNotifier -import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.Course import org.openedx.foundation.extension.isInternetError @@ -26,7 +22,6 @@ class NativeDiscoveryViewModel( private val interactor: DiscoveryInteractor, private val resourceManager: ResourceManager, private val analytics: DiscoveryAnalytics, - private val appNotifier: AppNotifier, private val corePreferences: CorePreferences, ) : BaseViewModel() { @@ -51,10 +46,6 @@ class NativeDiscoveryViewModel( val isUpdating: LiveData get() = _isUpdating - private val _appUpgradeEvent = MutableLiveData() - val appUpgradeEvent: LiveData - get() = _appUpgradeEvent - val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -64,7 +55,6 @@ class NativeDiscoveryViewModel( init { getCoursesList() - collectAppUpgradeEvent() } private fun loadCoursesInternal( @@ -159,24 +149,6 @@ class NativeDiscoveryViewModel( } } - @OptIn(FlowPreview::class) - private fun collectAppUpgradeEvent() { - viewModelScope.launch { - appNotifier.notifier - .debounce(100) - .collect { event -> - when (event) { - is AppUpgradeEvent.UpgradeRecommendedEvent -> { - _appUpgradeEvent.value = event - } - is AppUpgradeEvent.UpgradeRequiredEvent -> { - _appUpgradeEvent.value = AppUpgradeEvent.UpgradeRequiredEvent - } - } - } - } - } - fun discoverySearchBarClickedEvent() { analytics.discoverySearchBarClickedEvent() } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/AuthorizationDialogFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/AuthorizationDialogFragment.kt new file mode 100644 index 000000000..b6c7e18a8 --- /dev/null +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/AuthorizationDialogFragment.kt @@ -0,0 +1,318 @@ +package org.openedx.discovery.presentation.detail + +import android.content.res.Configuration +import android.content.res.Configuration.UI_MODE_NIGHT_NO +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import org.koin.android.ext.android.inject +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.discovery.presentation.DiscoveryRouter +import org.openedx.foundation.extension.setWidthPercent +import org.openedx.core.R as coreR + +class AuthorizationDialogFragment : DialogFragment() { + + private val router: DiscoveryRouter by inject() + + override fun onResume() { + super.onResume() + if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { + setWidthPercent(percentage = LANDSCAPE_WIDTH_PERCENT) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val courseId = requireArguments().getString(ARG_COURSE_ID) ?: "" + AuthorizationDialogView( + onRegisterButtonClick = { + router.navigateToSignUp(requireActivity().supportFragmentManager, courseId) + dismiss() + }, + onSignInButtonClick = { + router.navigateToSignIn( + requireActivity().supportFragmentManager, + courseId, + null + ) + dismiss() + }, + onCancelButtonClick = { + dismiss() + } + ) + } + } + } + + companion object { + private const val ARG_COURSE_ID = "arg_course_id" + private const val LANDSCAPE_WIDTH_PERCENT = 66 + fun newInstance( + courseId: String, + ): AuthorizationDialogFragment { + val dialog = AuthorizationDialogFragment() + dialog.arguments = bundleOf( + ARG_COURSE_ID to courseId, + ) + return dialog + } + } +} + +@Composable +private fun AuthorizationDialogView( + onRegisterButtonClick: () -> Unit, + onSignInButtonClick: () -> Unit, + onCancelButtonClick: () -> Unit +) { + val configuration = LocalConfiguration.current + if (configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + AuthorizationDialogPortraitView( + onRegisterButtonClick = onRegisterButtonClick, + onSignInButtonClick = onSignInButtonClick, + onCancelButtonClick = onCancelButtonClick + ) + } else { + AuthorizationDialogLandscapeView( + onRegisterButtonClick = onRegisterButtonClick, + onSignInButtonClick = onSignInButtonClick, + onCancelButtonClick = onCancelButtonClick + ) + } +} + +@Composable +private fun AuthorizationDialogPortraitView( + onRegisterButtonClick: () -> Unit, + onSignInButtonClick: () -> Unit, + onCancelButtonClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth(fraction = 0.95f) + .clip(MaterialTheme.appShapes.courseImageShape), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape + ) { + Column( + modifier = Modifier.padding(30.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier.fillMaxWidth() + ) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = onCancelButtonClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = coreR.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + Icon( + modifier = Modifier + .width(76.dp) + .height(72.dp), + imageVector = Icons.AutoMirrored.Filled.Login, + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.height(36.dp)) + Text( + text = stringResource(id = coreR.string.core_authorization), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge + ) + Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_authorization_request), + color = MaterialTheme.appColors.textFieldText, + style = MaterialTheme.appTypography.titleSmall, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(42.dp)) + Row { + OpenEdXOutlinedButton( + modifier = Modifier.weight(1f), + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + text = stringResource(id = coreR.string.core_sign_in), + onClick = onSignInButtonClick + ) + Spacer(Modifier.width(16.dp)) + OpenEdXButton( + modifier = Modifier.weight(1f), + text = stringResource(id = coreR.string.core_register), + onClick = onRegisterButtonClick + ) + } + } + } +} + +@Composable +private fun AuthorizationDialogLandscapeView( + onRegisterButtonClick: () -> Unit, + onSignInButtonClick: () -> Unit, + onCancelButtonClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.appShapes.courseImageShape), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape + ) { + Column( + modifier = Modifier.padding(38.dp) + ) { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + IconButton( + modifier = Modifier.size(24.dp), + onClick = onCancelButtonClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = coreR.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + modifier = Modifier + .width(76.dp) + .height(72.dp), + imageVector = Icons.AutoMirrored.Filled.Login, + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.height(36.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_authorization), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(8.dp)) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = coreR.string.core_authorization_request), + color = MaterialTheme.appColors.textFieldText, + style = MaterialTheme.appTypography.titleSmall, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.width(42.dp)) + Column( + Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OpenEdXOutlinedButton( + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonBackground, + text = stringResource(id = coreR.string.core_sign_in), + onClick = onSignInButtonClick, + ) + Spacer(Modifier.height(16.dp)) + OpenEdXButton( + text = stringResource(id = coreR.string.core_register), + onClick = onRegisterButtonClick + ) + } + } + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AuthorizationDialogPortraitViewPreview() { + OpenEdXTheme { + AuthorizationDialogPortraitView( + onSignInButtonClick = {}, + onRegisterButtonClick = {}, + onCancelButtonClick = {} + ) + } +} + +@Preview(uiMode = UI_MODE_NIGHT_NO) +@Preview(uiMode = UI_MODE_NIGHT_YES) +@Composable +private fun AuthorizationDialogLandscapeViewPreview() { + OpenEdXTheme { + AuthorizationDialogLandscapeView( + onSignInButtonClick = {}, + onRegisterButtonClick = {}, + onCancelButtonClick = {} + ) + } +} diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt index 556f61459..d49f9e1c4 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/detail/CourseDetailsFragment.kt @@ -154,9 +154,12 @@ class CourseDetailsFragment : Fragment() { if (currentState is CourseDetailsUIState.CourseData) { when { (!currentState.isUserLoggedIn) -> { - router.navigateToLogistration( - parentFragmentManager, - currentState.course.courseId + val dialog = AuthorizationDialogFragment.newInstance( + viewModel.courseId + ) + dialog.show( + requireActivity().supportFragmentManager, + AuthorizationDialogFragment::class.simpleName ) } diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt index 21098a55f..7c397f206 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/info/CourseInfoFragment.kt @@ -57,7 +57,6 @@ import org.openedx.core.ui.theme.appColors import org.openedx.discovery.R import org.openedx.discovery.presentation.DiscoveryAnalyticsScreen import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen -import org.openedx.discovery.presentation.catalog.WebViewLink import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.WindowType @@ -251,7 +250,7 @@ private fun CourseInfoScreen( onRegisterClick: () -> Unit, onSignInClick: () -> Unit, onBackClick: () -> Unit, - onUriClick: (String, WebViewLink.Authority) -> Unit, + onUriClick: (String, linkAuthority) -> Unit, ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt index 308cdd52d..1c78faf23 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/program/ProgramFragment.kt @@ -60,7 +60,6 @@ import org.openedx.core.ui.theme.appColors import org.openedx.discovery.R import org.openedx.discovery.presentation.DiscoveryAnalyticsScreen import org.openedx.discovery.presentation.catalog.CatalogWebViewScreen -import org.openedx.discovery.presentation.catalog.WebViewLink import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.extension.toastMessage import org.openedx.foundation.presentation.WindowSize @@ -170,8 +169,7 @@ class ProgramFragment : Fragment() { } linkAuthority.PROGRAM_INFO, - linkAuthority.COURSE_INFO, - -> { + linkAuthority.COURSE_INFO -> { viewModel.onViewCourseClick( fragmentManager = requireActivity().supportFragmentManager, courseId = param, @@ -251,7 +249,7 @@ private fun ProgramInfoScreen( onWebViewUIAction: (WebViewUIAction) -> Unit, onSettingsClick: () -> Unit, onBackClick: () -> Unit, - onUriClick: (String, WebViewLink.Authority) -> Unit, + onUriClick: (String, linkAuthority) -> Unit, ) { val scaffoldState = rememberScaffoldState() val configuration = LocalConfiguration.current diff --git a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt index e4c7687a6..eeb497f56 100644 --- a/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt +++ b/discovery/src/main/java/org/openedx/discovery/presentation/ui/DiscoveryUI.kt @@ -49,7 +49,7 @@ import org.openedx.foundation.extension.toImageLink import org.openedx.foundation.presentation.WindowSize import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.foundation.presentation.windowSizeValue -import org.openedx.core.R as CoreR +import org.openedx.core.R as сoreR @Composable fun ImageHeader( @@ -70,11 +70,11 @@ fun ImageHeader( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(courseImage?.toImageLink(apiHostUrl)) - .error(CoreR.drawable.core_no_image_course) - .placeholder(CoreR.drawable.core_no_image_course) + .error(сoreR.drawable.core_no_image_course) + .placeholder(сoreR.drawable.core_no_image_course) .build(), contentDescription = stringResource( - id = CoreR.string.core_accessibility_header_image_for, + id = сoreR.string.core_accessibility_header_image_for, courseName ), contentScale = contentScale, @@ -119,8 +119,8 @@ fun DiscoveryCourseItem( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(course.media.courseImage?.uri?.toImageLink(apiHostUrl) ?: "") - .error(org.openedx.core.R.drawable.core_no_image_course) - .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .error(сoreR.drawable.core_no_image_course) + .placeholder(сoreR.drawable.core_no_image_course) .build(), contentDescription = null, contentScale = ContentScale.Crop, @@ -216,7 +216,7 @@ fun WarningLabel( private fun WarningLabelPreview() { OpenEdXTheme { WarningLabel( - painter = painterResource(id = CoreR.drawable.core_ic_offline), + painter = painterResource(id = сoreR.drawable.core_ic_offline), text = stringResource(id = R.string.discovery_no_internet_label) ) } diff --git a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt index 9a88b445a..d6270fe7b 100644 --- a/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt +++ b/discovery/src/test/java/org/openedx/discovery/presentation/NativeDiscoveryViewModelTest.kt @@ -5,10 +5,8 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain @@ -25,7 +23,6 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination import org.openedx.core.system.connection.NetworkConnection -import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.domain.model.CourseList import org.openedx.foundation.presentation.UIMessage @@ -45,7 +42,6 @@ class NativeDiscoveryViewModelTest { private val interactor = mockk() private val networkConnection = mockk() private val analytics = mockk() - private val appNotifier = mockk() private val corePreferences = mockk() private val noInternet = "Slow or no internet connection" @@ -56,7 +52,6 @@ class NativeDiscoveryViewModelTest { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong - every { appNotifier.notifier } returns emptyFlow() every { corePreferences.user } returns null every { config.getApiHostURL() } returns "http://localhost:8000" every { config.isPreLoginExperienceEnabled() } returns false @@ -75,7 +70,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -84,7 +78,6 @@ class NativeDiscoveryViewModelTest { coVerify(exactly = 1) { interactor.getCoursesList(any(), any(), any()) } coVerify(exactly = 0) { interactor.getCoursesListFromCache() } - verify(exactly = 1) { appNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage assertEquals(noInternet, message?.message) @@ -100,7 +93,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -124,7 +116,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns false @@ -147,7 +138,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -178,7 +168,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -209,7 +198,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -234,7 +222,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -259,7 +246,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true @@ -291,7 +277,6 @@ class NativeDiscoveryViewModelTest { interactor, resourceManager, analytics, - appNotifier, corePreferences ) every { networkConnection.isOnline() } returns true diff --git a/discussion/build.gradle b/discussion/build.gradle index 5442a57b2..213571427 100644 --- a/discussion/build.gradle +++ b/discussion/build.gradle @@ -7,11 +7,11 @@ plugins { android { namespace 'org.openedx.discussion' - compileSdk 34 + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -19,17 +19,19 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { @@ -54,10 +56,10 @@ android { dependencies { implementation project(path: ':core') - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } \ No newline at end of file diff --git a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt index 37f84aa3a..4f1eee74a 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/api/DiscussionApi.kt @@ -80,28 +80,28 @@ interface DiscussionApi { suspend fun setThreadRead( @Path("thread_id") threadId: String, @Body body: ReadBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/threads/{thread_id}/") suspend fun setThreadVoted( @Path("thread_id") threadId: String, @Body body: VoteBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/threads/{thread_id}/") suspend fun setThreadFlagged( @Path("thread_id") threadId: String, @Body reportBody: ReportBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/threads/{thread_id}/") suspend fun setThreadFollowed( @Path("thread_id") threadId: String, @Body followBody: FollowBody - ): ThreadsResponse.Thread + ): Thread @Headers("Cache-Control: no-cache", "Content-type: application/merge-patch+json") @PATCH("/api/discussion/v1/comments/{comment_id}/") diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt b/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt index 77b50e504..08bf03a0f 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/response/CommentsResponse.kt @@ -3,7 +3,6 @@ package org.openedx.discussion.data.model.response import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.Pagination import org.openedx.core.data.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment @@ -78,7 +77,6 @@ data class CommentResult( updatedAt, rawBody, renderedBody, - TextConverter.textToLinkedImageText(renderedBody), abuseFlagged, voted, voteCount, diff --git a/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt b/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt index b34005c04..c8f56ff8e 100644 --- a/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt +++ b/discussion/src/main/java/org/openedx/discussion/data/model/response/ThreadsResponse.kt @@ -3,7 +3,6 @@ package org.openedx.discussion.data.model.response import com.google.gson.annotations.SerializedName import org.openedx.core.data.model.Pagination import org.openedx.core.data.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData @@ -104,7 +103,6 @@ data class ThreadsResponse( updatedAt, rawBody, renderedBody, - TextConverter.textToLinkedImageText(renderedBody), abuseFlagged, voted, voteCount, diff --git a/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt b/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt index 13a2fba9c..6ffcc3d64 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/model/DiscussionComment.kt @@ -3,7 +3,6 @@ package org.openedx.discussion.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.LinkedImageText @Parcelize data class DiscussionComment( @@ -14,7 +13,6 @@ data class DiscussionComment( val updatedAt: String, val rawBody: String, val renderedBody: String, - val parsedRenderedBody: LinkedImageText, val abuseFlagged: Boolean, val voted: Boolean, val voteCount: Int, diff --git a/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt b/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt index 9b7f2498c..c87cbc368 100644 --- a/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt +++ b/discussion/src/main/java/org/openedx/discussion/domain/model/Thread.kt @@ -3,7 +3,6 @@ package org.openedx.discussion.domain.model import android.os.Parcelable import kotlinx.parcelize.Parcelize import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.LinkedImageText import org.openedx.discussion.R @Parcelize @@ -15,7 +14,6 @@ data class Thread( val updatedAt: String, val rawBody: String, val renderedBody: String, - val parsedRenderedBody: LinkedImageText, val abuseFlagged: Boolean, val voted: Boolean, val voteCount: Int, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt index b33646b9a..5bbee6ff9 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/comments/DiscussionCommentsFragment.kt @@ -72,7 +72,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -550,7 +549,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -585,7 +583,6 @@ private val mockComment = DiscussionComment( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt index 736455a7e..863cc89ef 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/responses/DiscussionResponsesFragment.kt @@ -12,11 +12,9 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -47,6 +45,7 @@ import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -56,7 +55,9 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy @@ -75,7 +76,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.displayCutoutForLandscape @@ -85,6 +85,7 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography +import org.openedx.discussion.R import org.openedx.discussion.domain.model.DiscussionComment import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.presentation.comments.DiscussionCommentsFragment @@ -217,8 +218,9 @@ private fun DiscussionResponsesScreen( val focusManager = LocalFocusManager.current val firstVisibleIndex = remember { - mutableStateOf(scrollState.firstVisibleItemIndex) + mutableIntStateOf(scrollState.firstVisibleItemIndex) } + val isShouldLoadMore = scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD) val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { onSwipeRefresh() }) @@ -362,7 +364,7 @@ private fun DiscussionResponsesScreen( .padding(horizontal = paddingContent) .padding(top = 24.dp, bottom = 8.dp), text = pluralStringResource( - id = org.openedx.discussion.R.plurals.discussion_comments, + id = R.plurals.discussion_comments, uiState.mainComment.childCount, uiState.mainComment.childCount ), @@ -374,23 +376,31 @@ private fun DiscussionResponsesScreen( } items(uiState.childComments) { comment -> + var itemHeight by remember { mutableIntStateOf(0) } + val boxHeight = if (itemHeight > 0) { + Modifier.height(with(LocalDensity.current) { itemHeight.toDp() }) + } else { + Modifier + } Row( Modifier .fillMaxWidth() - .height(IntrinsicSize.Min) .padding(start = paddingContent), verticalAlignment = Alignment.CenterVertically ) { Box( modifier = Modifier - .fillMaxHeight() .width(1.dp) + .then(boxHeight) .background(MaterialTheme.appColors.cardViewBorder) ) CommentMainItem( modifier = Modifier .padding(4.dp) - .fillMaxWidth(), + .fillMaxWidth() + .onGloballyPositioned { coordinates -> + itemHeight = coordinates.size.height + }, comment = comment, onClick = { action, commentId, bool -> onItemClick(action, commentId, bool) @@ -412,7 +422,7 @@ private fun DiscussionResponsesScreen( } } } - if (scrollState.shouldLoadMore(firstVisibleIndex, LOAD_MORE_THRESHOLD)) { + if (isShouldLoadMore) { paginationCallBack() } } @@ -449,7 +459,7 @@ private fun DiscussionResponsesScreen( placeholder = { Text( text = stringResource( - id = org.openedx.discussion.R.string.discussion_add_comment + id = R.string.discussion_add_comment ), color = MaterialTheme.appColors.textFieldHint, style = MaterialTheme.appTypography.labelLarge, @@ -480,7 +490,7 @@ private fun DiscussionResponsesScreen( Icon( modifier = Modifier.padding(7.dp), painter = painterResource( - id = org.openedx.discussion.R.drawable.discussion_ic_send + id = R.drawable.discussion_ic_send ), contentDescription = null, tint = iconButtonColor @@ -578,7 +588,6 @@ private val mockComment = DiscussionComment( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt index a8a835603..e67fe40b3 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadFragment.kt @@ -60,7 +60,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.R -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.SearchBar @@ -414,7 +413,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt index c66838fb0..bda4e3730 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadFragment.kt @@ -389,7 +389,7 @@ private fun DiscussionAddThreadScreen( .fillMaxWidth() .height(150.dp), title = if (currentPage == 0) { - stringResource(id = org.openedx.discussion.R.string.discussion_discussion) + stringResource(id = discussionR.string.discussion_discussion) } else { stringResource( id = discussionR.string.discussion_question diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt index b68379afe..f610dfa9d 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/threads/DiscussionThreadsFragment.kt @@ -74,7 +74,6 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.core.FragmentViewType -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.IconText @@ -742,7 +741,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt index 376e3118e..1a544e40a 100644 --- a/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt +++ b/discussion/src/main/java/org/openedx/discussion/presentation/ui/DiscussionUI.kt @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalComposeUiApi::class) - package org.openedx.discussion.presentation.ui import android.content.res.Configuration.UI_MODE_NIGHT_NO @@ -27,11 +25,10 @@ import androidx.compose.material.MaterialTheme import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.outlined.HelpOutline -import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape @@ -48,10 +45,9 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import coil.request.ImageRequest import org.openedx.core.domain.model.ProfileImage -import org.openedx.core.extension.TextConverter import org.openedx.core.ui.AutoSizeText -import org.openedx.core.ui.HyperlinkImageText import org.openedx.core.ui.IconText +import org.openedx.core.ui.RenderHtmlContent import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appShapes @@ -162,10 +158,8 @@ fun ThreadMainItem( ) } Spacer(modifier = Modifier.height(24.dp)) - HyperlinkImageText( - title = thread.title, - imageText = thread.parsedRenderedBody, - linkTextColor = MaterialTheme.appColors.primary + RenderHtmlContent( + html = thread.rawBody, ) Spacer(modifier = Modifier.height(24.dp)) Row( @@ -316,9 +310,8 @@ fun CommentItem( ) } Spacer(modifier = Modifier.height(14.dp)) - HyperlinkImageText( - imageText = comment.parsedRenderedBody, - linkTextColor = MaterialTheme.appColors.primary + RenderHtmlContent( + html = comment.rawBody, ) Spacer(modifier = Modifier.height(16.dp)) Row( @@ -455,9 +448,8 @@ fun CommentMainItem( } } Spacer(modifier = Modifier.height(14.dp)) - HyperlinkImageText( - imageText = comment.parsedRenderedBody, - linkTextColor = MaterialTheme.appColors.primary + RenderHtmlContent( + html = comment.rawBody, ) Spacer(modifier = Modifier.height(16.dp)) Row( @@ -661,7 +653,7 @@ fun TopicItem( color = MaterialTheme.appColors.textPrimary ) Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.primary, contentDescription = "Expandable Arrow" ) @@ -723,7 +715,6 @@ private val mockComment = DiscussionComment( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -749,7 +740,6 @@ private val mockThread = org.openedx.discussion.domain.model.Thread( "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/main/res/values/strings.xml b/discussion/src/main/res/values/strings.xml index a9b11d04d..02bd2bcba 100644 --- a/discussion/src/main/res/values/strings.xml +++ b/discussion/src/main/res/values/strings.xml @@ -1,10 +1,8 @@ - Discussions All Posts Unread Unanswered Posts I\'m following - Refine: Recent activity Most activity Most votes @@ -23,9 +21,6 @@ Title Follow this discussion Follow this question - Post discussion - Post question - General Search all posts Main categories Select post type diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt index e9323270e..f3a9704f5 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/comments/DiscussionCommentsViewModelTest.kt @@ -27,7 +27,6 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment @@ -68,7 +67,6 @@ class DiscussionCommentsViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, @@ -107,7 +105,6 @@ class DiscussionCommentsViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt index ac57556bc..bb3579eda 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/responses/DiscussionResponsesViewModelTest.kt @@ -22,11 +22,9 @@ import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Pagination -import org.openedx.core.extension.LinkedImageText import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.CommentsData import org.openedx.discussion.domain.model.DiscussionComment -import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.system.notifier.DiscussionNotifier import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -49,45 +47,6 @@ class DiscussionResponsesViewModelTest { private val somethingWrong = "Something went wrong" private val commentAddedSuccessfully = "Comment Successfully added" - //region mockThread - - val mockThread = org.openedx.discussion.domain.model.Thread( - "", - "", - "", - "", - "", - "", - "", - LinkedImageText("", emptyMap(), emptyMap(), emptyList()), - false, - true, - 20, - emptyList(), - false, - "", - "", - "", - "", - DiscussionType.DISCUSSION, - "", - "", - "Discussion title long Discussion title long good item", - true, - false, - true, - 21, - 4, - false, - false, - mapOf(), - 0, - false, - false - ) - - //endregion - //region mockComment private val mockComment = DiscussionComment( @@ -98,7 +57,6 @@ class DiscussionResponsesViewModelTest { "", "", "", - LinkedImageText("", emptyMap(), emptyMap(), emptyList()), false, true, 20, diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt index 39e01c194..14eb3f062 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/search/DiscussionSearchThreadViewModelTest.kt @@ -24,7 +24,6 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.domain.model.Pagination -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData @@ -59,7 +58,6 @@ class DiscussionSearchThreadViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt index 9dc8ba339..65b4a1ae8 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionAddThreadViewModelTest.kt @@ -19,7 +19,6 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.Topic @@ -53,7 +52,6 @@ class DiscussionAddThreadViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt index ae4f966ba..15e49570d 100644 --- a/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt +++ b/discussion/src/test/java/org/openedx/discussion/presentation/threads/DiscussionThreadsViewModelTest.kt @@ -26,7 +26,6 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.R import org.openedx.core.domain.model.Pagination -import org.openedx.core.extension.TextConverter import org.openedx.discussion.domain.interactor.DiscussionInteractor import org.openedx.discussion.domain.model.DiscussionType import org.openedx.discussion.domain.model.ThreadsData @@ -63,7 +62,6 @@ class DiscussionThreadsViewModelTest { "", "", "", - TextConverter.textToLinkedImageText(""), false, true, 20, diff --git a/downloads/.gitignore b/downloads/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/downloads/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/downloads/build.gradle b/downloads/build.gradle new file mode 100644 index 000000000..cf1c95f77 --- /dev/null +++ b/downloads/build.gradle @@ -0,0 +1,67 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id "org.jetbrains.kotlin.plugin.compose" + id 'kotlin-parcelize' +} + +android { + namespace 'org.openedx.downloads' + compileSdkVersion compile_sdk_version + + defaultConfig { + minSdk min_sdk_version + targetSdk target_sdk_version + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility java_version + targetCompatibility java_version + } + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } + } + + buildFeatures { + viewBinding true + compose true + } + + flavorDimensions += "env" + productFlavors { + prod { + dimension 'env' + } + develop { + dimension 'env' + } + stage { + dimension 'env' + } + } +} + +dependencies { + implementation project(path: ':core') + + testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" + testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" +} diff --git a/downloads/consumer-rules.pro b/downloads/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/downloads/proguard-rules.pro b/downloads/proguard-rules.pro new file mode 100644 index 000000000..cdb308aa0 --- /dev/null +++ b/downloads/proguard-rules.pro @@ -0,0 +1,7 @@ +# Prevent shrinking, optimization, and obfuscation of the library when consumed by other modules. +# This ensures that all classes and methods remain available for use by the consumer of the library. +# Disabling these steps at the library level is important because the main app module will handle +# shrinking, optimization, and obfuscation for the entire application, including this library. +-dontshrink +-dontoptimize +-dontobfuscate diff --git a/downloads/src/main/AndroidManifest.xml b/downloads/src/main/AndroidManifest.xml new file mode 100644 index 000000000..e10007615 --- /dev/null +++ b/downloads/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt new file mode 100644 index 000000000..3a23f8118 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/data/repository/DownloadRepository.kt @@ -0,0 +1,56 @@ +package org.openedx.downloads.data.repository + +import kotlinx.coroutines.flow.flow +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.CourseDao +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.exception.NoCachedDataException +import org.openedx.core.module.db.DownloadDao + +class DownloadRepository( + private val api: CourseApi, + private val dao: DownloadDao, + private val courseDao: CourseDao, + private val corePreferences: CorePreferences, +) { + fun getDownloadCoursesPreview(refresh: Boolean) = flow { + if (!refresh) { + val cachedDownloadCoursesPreview = dao.getDownloadCoursesPreview() + emit(cachedDownloadCoursesPreview.map { it.mapToDomain() }) + } + val username = corePreferences.user?.username ?: "" + val response = api.getDownloadCoursesPreview(username) + val downloadCoursesPreview = response.map { it.mapToDomain() } + emit(downloadCoursesPreview) + val downloadCoursesPreviewEntity = response.map { it.mapToRoomEntity() } + dao.insertDownloadCoursePreview(downloadCoursesPreviewEntity) + } + + suspend fun getCourseStructureFromCache(courseId: String): CourseStructure { + val cachedCourseStructure = courseDao.getCourseStructureById(courseId) + if (cachedCourseStructure != null) { + return cachedCourseStructure.mapToDomain() + } else { + throw NoCachedDataException() + } + } + + suspend fun getCourseStructure(courseId: String): CourseStructure { + try { + val response = api.getCourseStructure( + cacheControlHeaderParam = "stale-if-error=0", + blocksApiVersion = "v4", + username = corePreferences.user?.username, + courseId = courseId + ) + courseDao.insertCourseStructureEntity(response.mapToRoomEntity()) + return response.mapToDomain() + } catch (_: Exception) { + return getCourseStructureFromCache(courseId) + } + } + + suspend fun getDownloadModelsByCourseIds(courseId: String) = + dao.getDownloadModelsByCourseIds(courseId).map { it.mapToDomain() } +} diff --git a/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt new file mode 100644 index 000000000..6082e7751 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/domain/interactor/DownloadInteractor.kt @@ -0,0 +1,17 @@ +package org.openedx.downloads.domain.interactor + +import org.openedx.downloads.data.repository.DownloadRepository + +class DownloadInteractor( + private val repository: DownloadRepository +) { + fun getDownloadCoursesPreview(refresh: Boolean) = repository.getDownloadCoursesPreview(refresh) + + suspend fun getDownloadModelsByCourseIds(courseId: String) = + repository.getDownloadModelsByCourseIds(courseId) + + suspend fun getCourseStructureFromCache(courseId: String) = + repository.getCourseStructureFromCache(courseId) + + suspend fun getCourseStructure(courseId: String) = repository.getCourseStructure(courseId) +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt new file mode 100644 index 000000000..0b6445f19 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/DownloadsRouter.kt @@ -0,0 +1,14 @@ +package org.openedx.downloads.presentation + +import androidx.fragment.app.FragmentManager + +interface DownloadsRouter { + + fun navigateToSettings(fm: FragmentManager) + + fun navigateToCourseOutline( + fm: FragmentManager, + courseId: String, + courseTitle: String, + ) +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt new file mode 100644 index 000000000..1dc4d1be9 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsFragment.kt @@ -0,0 +1,78 @@ +package org.openedx.downloads.presentation.download + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.ui.theme.OpenEdXTheme + +class DownloadsFragment : Fragment() { + + private val viewModel by viewModel() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val uiState by viewModel.uiState.collectAsState() + val uiMessage by viewModel.uiMessage.collectAsState(null) + DownloadsScreen( + uiState = uiState, + uiMessage = uiMessage, + apiHostUrl = viewModel.apiHostUrl, + hasInternetConnection = viewModel.hasInternetConnection, + onAction = { action -> + when (action) { + DownloadsViewActions.OpenSettings -> { + viewModel.onSettingsClick(requireActivity().supportFragmentManager) + } + + DownloadsViewActions.SwipeRefresh -> { + viewModel.refreshData() + } + + is DownloadsViewActions.OpenCourse -> { + viewModel.navigateToCourseOutline( + fm = requireActivity().supportFragmentManager, + courseId = action.courseId + ) + } + + is DownloadsViewActions.DownloadCourse -> { + viewModel.downloadCourse( + requireActivity().supportFragmentManager, + action.courseId + ) + } + + is DownloadsViewActions.CancelDownloading -> { + viewModel.cancelDownloading(action.courseId) + } + + is DownloadsViewActions.RemoveDownloads -> { + viewModel.removeDownloads( + requireActivity().supportFragmentManager, + action.courseId + ) + } + } + } + ) + } + } + } +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt new file mode 100644 index 000000000..e633368b3 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsScreen.kt @@ -0,0 +1,567 @@ +package org.openedx.downloads.presentation.download + +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Card +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.DropdownMenu +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.extension.safeDivBy +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.DownloadedState.LOADING_COURSE_STRUCTURE +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.IconText +import org.openedx.core.ui.MainToolbar +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXDropdownMenuItem +import org.openedx.core.ui.crop +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.downloads.R +import org.openedx.foundation.extension.toFileSize +import org.openedx.foundation.extension.toImageLink +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.rememberWindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun DownloadsScreen( + uiState: DownloadsUIState, + uiMessage: UIMessage?, + apiHostUrl: String, + hasInternetConnection: Boolean, + onAction: (DownloadsViewActions) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + val pullRefreshState = rememberPullRefreshState( + refreshing = uiState.isRefreshing, + onRefresh = { onAction(DownloadsViewActions.SwipeRefresh) } + ) + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize(), + backgroundColor = MaterialTheme.appColors.background, + topBar = { + MainToolbar( + modifier = Modifier + .statusBarsInset() + .displayCutoutForLandscape(), + label = stringResource(id = R.string.downloads), + onSettingsClick = { + onAction(DownloadsViewActions.OpenSettings) + } + ) + }, + content = { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) + ) { + if (uiState.isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } else if (uiState.downloadCoursePreviews.isEmpty()) { + EmptyState( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) + } else { + Box( + modifier = Modifier + .fillMaxSize() + .displayCutoutForLandscape() + .padding(paddingValues) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.TopCenter + ) { + if (configuration.orientation == ORIENTATION_LANDSCAPE || windowSize.isTablet) { + LazyVerticalGrid( + modifier = contentWidth.fillMaxHeight(), + state = rememberLazyGridState(), + columns = GridCells.Fixed(2), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + contentPadding = PaddingValues(bottom = 46.dp, top = 12.dp), + content = { + items(uiState.downloadCoursePreviews) { item -> + val downloadModels = + uiState.downloadModels.filter { it.courseId == item.id } + val downloadState = uiState.courseDownloadState[item.id] + ?: DownloadedState.NOT_DOWNLOADED + CourseItem( + modifier = Modifier.height(314.dp), + downloadCoursePreview = item, + downloadModels = downloadModels, + downloadedState = downloadState, + apiHostUrl = apiHostUrl, + onCourseClick = { + onAction(DownloadsViewActions.OpenCourse(item.id)) + }, + onDownloadClick = { + onAction(DownloadsViewActions.DownloadCourse(item.id)) + }, + onCancelClick = { + onAction(DownloadsViewActions.CancelDownloading(item.id)) + }, + onRemoveClick = { + onAction(DownloadsViewActions.RemoveDownloads(item.id)) + } + ) + } + } + ) + } else { + LazyColumn( + modifier = contentWidth, + contentPadding = PaddingValues(bottom = 46.dp, top = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + items(uiState.downloadCoursePreviews) { item -> + val downloadModels = + uiState.downloadModels.filter { it.courseId == item.id } + val downloadState = uiState.courseDownloadState[item.id] + ?: DownloadedState.NOT_DOWNLOADED + CourseItem( + downloadCoursePreview = item, + downloadModels = downloadModels, + downloadedState = downloadState, + apiHostUrl = apiHostUrl, + onCourseClick = { + onAction(DownloadsViewActions.OpenCourse(item.id)) + }, + onDownloadClick = { + onAction(DownloadsViewActions.DownloadCourse(item.id)) + }, + onCancelClick = { + onAction(DownloadsViewActions.CancelDownloading(item.id)) + }, + onRemoveClick = { + onAction(DownloadsViewActions.RemoveDownloads(item.id)) + } + ) + } + } + } + } + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + PullRefreshIndicator( + uiState.isRefreshing, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onAction(DownloadsViewActions.SwipeRefresh) + } + ) + } + } + } + ) +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun CourseItem( + modifier: Modifier = Modifier, + downloadCoursePreview: DownloadCoursePreview, + downloadModels: List, + downloadedState: DownloadedState, + apiHostUrl: String, + onCourseClick: () -> Unit, + onDownloadClick: () -> Unit, + onRemoveClick: () -> Unit, + onCancelClick: () -> Unit +) { + val windowSize = rememberWindowSize() + val configuration = LocalConfiguration.current + var isDropdownExpanded by remember { mutableStateOf(false) } + val downloadedSize = downloadModels + .filter { it.downloadedState == DownloadedState.DOWNLOADED } + .sumOf { it.size } + val availableSize = downloadCoursePreview.totalSize - downloadedSize + val availableSizeString = availableSize.toFileSize(space = false, round = 1) + val progress = downloadedSize.toFloat().safeDivBy(downloadCoursePreview.totalSize.toFloat()) + Card( + modifier = modifier + .fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.courseImageShape, + elevation = 4.dp, + onClick = onCourseClick + ) { + Box { + Column( + modifier = Modifier.animateContentSize() + ) { + val imageModifier = + if (configuration.orientation == ORIENTATION_LANDSCAPE || windowSize.isTablet) { + Modifier.weight(1f) + } else { + Modifier.height(120.dp) + } + AsyncImage( + modifier = imageModifier.fillMaxWidth(), + model = ImageRequest.Builder(LocalContext.current) + .data(downloadCoursePreview.image.toImageLink(apiHostUrl)) + .error(org.openedx.core.R.drawable.core_no_image_course) + .placeholder(org.openedx.core.R.drawable.core_no_image_course) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + ) + Column( + modifier = Modifier + .padding(horizontal = 12.dp) + .padding(top = 8.dp, bottom = 12.dp), + ) { + Text( + text = downloadCoursePreview.name, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textDark, + overflow = TextOverflow.Ellipsis, + minLines = 1, + maxLines = 2 + ) + Spacer(modifier = Modifier.height(8.dp)) + if (downloadedState != DownloadedState.DOWNLOADED && downloadedSize != 0L) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(CircleShape), + progress = progress, + color = MaterialTheme.appColors.successGreen, + backgroundColor = MaterialTheme.appColors.divider + ) + } + if (downloadedSize != 0L) { + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Filled.CloudDone, + color = MaterialTheme.appColors.successGreen, + text = stringResource( + R.string.downloaded_downloaded_size, + downloadedSize.toFileSize(space = false, round = 1) + ) + ) + } + if (downloadedState != DownloadedState.DOWNLOADED) { + Spacer(modifier = Modifier.height(4.dp)) + IconText( + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.textPrimaryVariant, + text = stringResource( + R.string.downloaded_available_size, + availableSizeString + ) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + if (downloadedState.isWaitingOrDownloading) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + IconButton( + modifier = Modifier + .size(28.dp) + .padding(2.dp), + onClick = onCancelClick + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource( + id = R.string.downloads_accessibility_stop_downloading_course + ), + tint = MaterialTheme.appColors.error + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + val text = if (downloadedState == LOADING_COURSE_STRUCTURE) { + stringResource(R.string.downloads_loading_course_structure) + } else { + stringResource(org.openedx.core.R.string.core_downloading) + } + Text( + text = text, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary + ) + } + } else if (downloadedState == DownloadedState.NOT_DOWNLOADED) { + OpenEdXButton( + onClick = { + onDownloadClick() + }, + content = { + IconText( + text = stringResource(R.string.downloads_download_course), + icon = Icons.Outlined.CloudDownload, + color = MaterialTheme.appColors.primaryButtonText, + textStyle = MaterialTheme.appTypography.labelLarge + ) + } + ) + } + } + } + + Column( + modifier = Modifier + .align(Alignment.TopEnd), + ) { + if (downloadedSize != 0L || downloadedState.isWaitingOrDownloading) { + MoreButton( + onClick = { + isDropdownExpanded = true + } + ) + } + DropdownMenu( + modifier = Modifier + .crop(vertical = 8.dp) + .defaultMinSize(minWidth = 269.dp) + .background(MaterialTheme.appColors.background), + expanded = isDropdownExpanded, + onDismissRequest = { isDropdownExpanded = false }, + ) { + Column { + if (downloadedSize != 0L) { + OpenEdXDropdownMenuItem( + text = stringResource(R.string.downloads_remove_course_downloads), + onClick = { + isDropdownExpanded = false + onRemoveClick() + } + ) + Divider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.appColors.divider + ) + } + if (downloadedState.isWaitingOrDownloading) { + OpenEdXDropdownMenuItem( + text = stringResource(R.string.downloads_cancel_download), + onClick = { + isDropdownExpanded = false + onCancelClick() + } + ) + } + } + } + } + } + } +} + +@Composable +private fun MoreButton( + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + IconButton( + modifier = modifier, + onClick = onClick + ) { + Icon( + modifier = Modifier + .size(30.dp) + .background( + color = MaterialTheme.appColors.onPrimary.copy(alpha = 0.5f), + shape = CircleShape + ) + .padding(4.dp), + imageVector = Icons.Default.MoreHoriz, + contentDescription = null, + tint = MaterialTheme.appColors.onSurface + ) + } +} + +@Composable +private fun EmptyState( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.width(200.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_book), + tint = MaterialTheme.appColors.textFieldBorder, + contentDescription = null + ) + Spacer(Modifier.height(4.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_title") + .fillMaxWidth(), + text = stringResource(id = R.string.downloads_empty_state_title), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + textAlign = TextAlign.Center + ) + Spacer(Modifier.height(12.dp)) + Text( + modifier = Modifier + .testTag("txt_empty_state_description") + .fillMaxWidth(), + text = stringResource(id = R.string.downloads_empty_state_description), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.labelMedium, + textAlign = TextAlign.Center + ) + } + } +} + +@Preview +@Composable +private fun DownloadsScreenPreview() { + OpenEdXTheme { + DownloadsScreen( + uiState = DownloadsUIState(isLoading = false), + uiMessage = null, + apiHostUrl = "", + hasInternetConnection = true, + onAction = {} + ) + } +} + +@Preview +@Composable +private fun CourseItemPreview() { + OpenEdXTheme { + CourseItem( + downloadCoursePreview = DownloadCoursePreview("", "name", "", 100), + downloadModels = emptyList(), + apiHostUrl = "", + downloadedState = DownloadedState.NOT_DOWNLOADED, + onCourseClick = {}, + onDownloadClick = {}, + onCancelClick = {}, + onRemoveClick = {}, + ) + } +} diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt new file mode 100644 index 000000000..e3f24b666 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsUIState.kt @@ -0,0 +1,13 @@ +package org.openedx.downloads.presentation.download + +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState + +data class DownloadsUIState( + val isLoading: Boolean = true, + val isRefreshing: Boolean = false, + val downloadCoursePreviews: List = emptyList(), + val downloadModels: List = emptyList(), + val courseDownloadState: Map = emptyMap(), +) diff --git a/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt new file mode 100644 index 000000000..24381a2a5 --- /dev/null +++ b/downloads/src/main/java/org/openedx/downloads/presentation/download/DownloadsViewModel.kt @@ -0,0 +1,385 @@ +package org.openedx.downloads.presentation.download + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.School +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics +import org.openedx.core.presentation.DownloadsAnalyticsEvent +import org.openedx.core.presentation.DownloadsAnalyticsKey +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogItem +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseDashboardUpdate +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.CourseStructureGot +import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.DownloadsRouter +import org.openedx.foundation.extension.isInternetError +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil + +class DownloadsViewModel( + private val downloadsRouter: DownloadsRouter, + private val networkConnection: NetworkConnection, + private val interactor: DownloadInteractor, + private val downloadDialogManager: DownloadDialogManager, + private val resourceManager: ResourceManager, + private val fileUtil: FileUtil, + private val config: Config, + private val analytics: DownloadsAnalytics, + private val discoveryNotifier: DiscoveryNotifier, + private val courseNotifier: CourseNotifier, + private val router: DownloadsRouter, + preferencesManager: CorePreferences, + coreAnalytics: CoreAnalytics, + downloadDao: DownloadDao, + workerController: DownloadWorkerController, + downloadHelper: DownloadHelper, +) : BaseDownloadViewModel( + downloadDao, + preferencesManager, + workerController, + coreAnalytics, + downloadHelper, +) { + val apiHostUrl get() = config.getApiHostURL() + + private val _uiState = MutableStateFlow(DownloadsUIState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiMessage = MutableSharedFlow() + val uiMessage: SharedFlow = _uiMessage.asSharedFlow() + + private val courseBlockIds = mutableMapOf>() + + val hasInternetConnection: Boolean get() = networkConnection.isOnline() + + private var downloadJobs = mutableMapOf() + + init { + fetchDownloads(refresh = false) + observeCourseDashboardUpdates() + observeDownloadingModels() + observeDownloadModelsStatus() + observeCourseStructureUpdates() + } + + private fun observeCourseDashboardUpdates() { + viewModelScope.launch { + discoveryNotifier.notifier.collect { notifier -> + if (notifier is CourseDashboardUpdate) { + fetchDownloads(refresh = true) + } + } + } + } + + private fun observeCourseStructureUpdates() { + viewModelScope.launch { + courseNotifier.notifier.collect { notifier -> + when (notifier) { + is CourseStructureGot, is CourseStructureUpdated -> { + fetchDownloads(refresh = true) + } + } + } + } + } + + private fun observeDownloadingModels() { + viewModelScope.launch { + downloadingModelsFlow.collect { downloadModels -> + _uiState.update { state -> + state.copy(downloadModels = downloadModels) + } + } + } + } + + private fun observeDownloadModelsStatus() { + viewModelScope.launch { + downloadModelsStatusFlow.collect { statusMap -> + val updatedCourseStates = courseBlockIds.mapValues { (courseId, blockIds) -> + val currentCourseState = uiState.value.courseDownloadState[courseId] + val blockStates = blockIds.mapNotNull { statusMap[it] } + val computedState = if (blockStates.isEmpty()) { + DownloadedState.NOT_DOWNLOADED + } else { + val downloadedSize = _uiState.value.downloadModels + .filter { it.courseId == courseId } + .sumOf { it.size } + val courseSize = _uiState.value.downloadCoursePreviews + .find { it.id == courseId }?.totalSize ?: 0 + val isSizeMatch: Boolean = + downloadedSize.toDouble() / courseSize >= SIZE_MATCH_THRESHOLD + determineCourseState(blockStates, isSizeMatch) + } + if (currentCourseState == DownloadedState.LOADING_COURSE_STRUCTURE && + computedState == DownloadedState.NOT_DOWNLOADED + ) { + DownloadedState.LOADING_COURSE_STRUCTURE + } else { + computedState + } + } + + _uiState.update { state -> + state.copy(courseDownloadState = updatedCourseStates) + } + } + } + } + + private fun determineCourseState( + blockStates: List, + isSizeMatch: Boolean + ): DownloadedState { + return when { + blockStates.all { it == DownloadedState.DOWNLOADED } && isSizeMatch -> DownloadedState.DOWNLOADED + blockStates.all { it == DownloadedState.WAITING } -> DownloadedState.WAITING + blockStates.any { it == DownloadedState.DOWNLOADING } -> DownloadedState.DOWNLOADING + else -> DownloadedState.NOT_DOWNLOADED + } + } + + private fun fetchDownloads(refresh: Boolean) { + viewModelScope.launch { + updateLoadingState(isLoading = !refresh, isRefreshing = refresh) + interactor.getDownloadCoursesPreview(refresh) + .onCompletion { + resetLoadingState() + } + .catch { e -> + emitErrorMessage(e) + } + .collect { downloadCoursePreviews -> + downloadCoursePreviews.forEach { preview -> + runCatching { initializeCourseBlocks(preview.id, useCache = true) } + .onFailure { it.printStackTrace() } + } + allBlocks.values + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { addDownloadableChildrenForSequentialBlock(it) } + initDownloadModelsStatus() + _uiState.update { state -> + state.copy( + downloadCoursePreviews = downloadCoursePreviews, + isLoading = false, + isRefreshing = false + ) + } + } + } + } + + private fun updateLoadingState(isLoading: Boolean, isRefreshing: Boolean) { + _uiState.update { state -> + state.copy(isLoading = isLoading, isRefreshing = isRefreshing) + } + } + + private fun emitErrorMessage(e: Throwable) { + viewModelScope.launch { + val text = if (e.isInternetError()) { + R.string.core_error_no_connection + } else { + R.string.core_error_unknown_error + } + _uiMessage.emit( + UIMessage.SnackBarMessage(resourceManager.getString(text)) + ) + } + } + + fun refreshData() { + fetchDownloads(refresh = true) + } + + fun onSettingsClick(fragmentManager: FragmentManager) { + downloadsRouter.navigateToSettings(fragmentManager) + } + + fun downloadCourse(fragmentManager: FragmentManager, courseId: String) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_COURSE_CLICKED) + try { + showDownloadPopup(fragmentManager, courseId) + } catch (e: Exception) { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_ERROR) + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + emitErrorMessage(e) + } + } + + fun cancelDownloading(courseId: String) { + logEvent(DownloadsAnalyticsEvent.CANCEL_DOWNLOAD_CLICKED) + viewModelScope.launch { + downloadJobs[courseId]?.cancel() + interactor.getDownloadModelsByCourseIds(courseId) + .filter { it.downloadedState.isWaitingOrDownloading } + .forEach { removeBlockDownloadModel(it.id) } + } + } + + fun removeDownloads(fragmentManager: FragmentManager, courseId: String) { + logEvent(DownloadsAnalyticsEvent.REMOVE_DOWNLOAD_CLICKED) + viewModelScope.launch { + val downloadModels = interactor.getDownloadModelsByCourseIds(courseId) + val downloadedModels = downloadModels.filter { + it.downloadedState == DownloadedState.DOWNLOADED + } + val totalSize = downloadedModels.sumOf { it.size } + val title = getCoursePreview(courseId)?.name.orEmpty() + val downloadDialogItem = DownloadDialogItem( + title = title, + size = totalSize, + icon = Icons.Default.School + ) + downloadDialogManager.showRemoveDownloadModelPopup( + downloadDialogItem = downloadDialogItem, + fragmentManager = fragmentManager, + removeDownloadModels = { + downloadModels.forEach { super.removeBlockDownloadModel(it.id) } + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_REMOVED) + } + ) + } + } + + private suspend fun initializeCourseBlocks( + courseId: String, + useCache: Boolean + ): CourseStructure { + val courseStructure = if (useCache) { + interactor.getCourseStructureFromCache(courseId) + } else { + interactor.getCourseStructure(courseId) + } + courseBlockIds[courseStructure.id] = courseStructure.blockData.map { it.id } + addBlocks(courseStructure.blockData) + return courseStructure + } + + private fun showDownloadPopup(fragmentManager: FragmentManager, courseId: String) { + viewModelScope.launch { + val coursePreview = getCoursePreview(courseId) ?: return@launch + val downloadModels = interactor.getDownloadModelsByCourseIds(courseId) + val downloadedModelsSize = downloadModels + .filter { it.downloadedState == DownloadedState.DOWNLOADED } + .sumOf { it.size } + downloadDialogManager.showPopup( + coursePreview = coursePreview.copy(totalSize = coursePreview.totalSize - downloadedModelsSize), + isBlocksDownloaded = false, + fragmentManager = fragmentManager, + removeDownloadModels = ::removeDownloadModels, + saveDownloadModels = { + initiateSaveDownloadModels(courseId) + }, + onDismissClick = { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CANCELLED) + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + }, + onConfirmClick = { + logEvent(DownloadsAnalyticsEvent.DOWNLOAD_CONFIRMED) + } + ) + } + } + + private fun initiateSaveDownloadModels(courseId: String) { + downloadJobs[courseId] = viewModelScope.launch { + try { + updateCourseState(courseId, DownloadedState.LOADING_COURSE_STRUCTURE) + val courseStructure = initializeCourseBlocks(courseId, useCache = false) + courseStructure.blockData + .filter { it.type == BlockType.SEQUENTIAL } + .forEach { sequentialBlock -> + addDownloadableChildrenForSequentialBlock(sequentialBlock) + super.saveDownloadModels( + fileUtil.getExternalAppDir().path, + courseId, + sequentialBlock.id + ) + } + } catch (e: Exception) { + updateCourseState(courseId, DownloadedState.NOT_DOWNLOADED) + emitErrorMessage(e) + } + } + } + + fun navigateToCourseOutline(fm: FragmentManager, courseId: String) { + val coursePreview = getCoursePreview(courseId) ?: return + router.navigateToCourseOutline( + fm = fm, + courseId = coursePreview.id, + courseTitle = coursePreview.name, + ) + } + + private fun logEvent(event: DownloadsAnalyticsEvent) { + analytics.logEvent( + event = event.eventName, + params = mapOf(DownloadsAnalyticsKey.NAME.key to event.biValue) + ) + } + + private fun resetLoadingState() { + _uiState.update { state -> + state.copy(isLoading = false, isRefreshing = false) + } + } + + private fun updateCourseState(courseId: String, state: DownloadedState) { + _uiState.update { currentState -> + currentState.copy( + courseDownloadState = currentState.courseDownloadState.toMutableMap().apply { + put(courseId, state) + } + ) + } + } + + private fun getCoursePreview(courseId: String): DownloadCoursePreview? { + return _uiState.value.downloadCoursePreviews.find { it.id == courseId } + } + + companion object { + const val SIZE_MATCH_THRESHOLD = 0.95 + } +} + +interface DownloadsViewActions { + object OpenSettings : DownloadsViewActions + object SwipeRefresh : DownloadsViewActions + data class OpenCourse(val courseId: String) : DownloadsViewActions + data class DownloadCourse(val courseId: String) : DownloadsViewActions + data class CancelDownloading(val courseId: String) : DownloadsViewActions + data class RemoveDownloads(val courseId: String) : DownloadsViewActions +} diff --git a/downloads/src/main/res/values/strings.xml b/downloads/src/main/res/values/strings.xml new file mode 100644 index 000000000..5a0503db1 --- /dev/null +++ b/downloads/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Downloads + Download course + Remove course downloads + Cancel download + No Courses with Downloadable Content + You currently have no courses with downloadable content. + %1$s downloaded + %1$s available + Stop downloading course + Loading course structure… + \ No newline at end of file diff --git a/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt new file mode 100644 index 000000000..c57445b42 --- /dev/null +++ b/downloads/src/test/java/org/openedx/downloads/DownloadsViewModelTest.kt @@ -0,0 +1,396 @@ +package org.openedx.downloads + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.fragment.app.FragmentManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.openedx.core.BlockType +import org.openedx.core.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AssignmentProgress +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DownloadCoursePreview +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadModelEntity +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadHelper +import org.openedx.core.presentation.CoreAnalytics +import org.openedx.core.presentation.DownloadsAnalytics +import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager +import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.CourseNotifier +import org.openedx.core.system.notifier.DiscoveryNotifier +import org.openedx.downloads.domain.interactor.DownloadInteractor +import org.openedx.downloads.presentation.DownloadsRouter +import org.openedx.downloads.presentation.download.DownloadsViewModel +import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.system.ResourceManager +import org.openedx.foundation.utils.FileUtil +import java.net.UnknownHostException +import java.util.Date + +class DownloadsViewModelTest { + + @get:Rule + val testInstantTaskExecutorRule: TestRule = InstantTaskExecutorRule() + + private val dispatcher = StandardTestDispatcher() + + // Mocks for all dependencies + private val downloadsRouter = mockk(relaxed = true) + private val networkConnection = mockk(relaxed = true) + private val interactor = mockk(relaxed = true) + private val downloadDialogManager = mockk(relaxed = true) + private val resourceManager = mockk(relaxed = true) + private val fileUtil = mockk(relaxed = true) + private val config = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val preferencesManager = mockk(relaxed = true) + private val coreAnalytics = mockk(relaxed = true) + private val downloadDao = mockk(relaxed = true) + private val workerController = mockk(relaxed = true) + private val downloadHelper = mockk(relaxed = true) + private val router = mockk(relaxed = true) + private val discoveryNotifier = mockk(relaxed = true) + private val courseNotifier = mockk(relaxed = true) + + private val noInternet = "No connection" + private val unknownError = "Unknown error" + + private val downloadCoursePreview = + DownloadCoursePreview( + id = "course1", + name = "", + image = "", + totalSize = DownloadDialogManager.MAX_CELLULAR_SIZE.toLong() + ) + private val assignmentProgress = AssignmentProgress( + assignmentType = "Homework", + numPointsEarned = 1f, + numPointsPossible = 3f, + shortLabel = "HW1", + ) + private val blocks = listOf( + Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("1", "id1"), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ), + Block( + id = "id1", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.HTML, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = listOf("id2"), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ), + Block( + id = "id2", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.HTML, + displayName = "Block", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.HTML, + completion = 0.0, + assignmentProgress = assignmentProgress, + due = Date(), + offlineDownload = null, + ) + ) + + private val downloadModel = DownloadModel( + "id", + "title", + "", + 0, + "", + "url", + FileType.VIDEO, + DownloadedState.NOT_DOWNLOADED, + null + ) + + private val courseStructure = CourseStructure( + root = "", + blockData = blocks, + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false, + progress = null + ) + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { config.getApiHostURL() } returns "http://localhost:8000" + every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet + every { resourceManager.getString(R.string.core_error_unknown_error) } returns unknownError + every { networkConnection.isOnline() } returns true + + coEvery { interactor.getDownloadCoursesPreview(any()) } returns flow { + emit(listOf(downloadCoursePreview)) + } + coEvery { interactor.getCourseStructureFromCache("course1") } returns courseStructure + coEvery { interactor.getCourseStructure("course1") } returns courseStructure + coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns emptyList() + coEvery { downloadDao.getAllDataFlow() } returns flowOf( + listOf( + DownloadModelEntity.createFrom( + downloadModel + ) + ) + ) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `onSettingsClick should navigate to settings`() = runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.onSettingsClick(fragmentManager) + verify(exactly = 1) { downloadsRouter.navigateToSettings(fragmentManager) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `downloadCourse should show download dialog`() = runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + val fragmentManager = mockk(relaxed = true) + viewModel.downloadCourse(fragmentManager, "course1") + advanceUntilIdle() + + verify(exactly = 1) { analytics.logEvent(any(), any()) } + + coVerify(exactly = 1) { + downloadDialogManager.showPopup( + coursePreview = any(), + isBlocksDownloaded = any(), + fragmentManager = any(), + removeDownloadModels = any(), + saveDownloadModels = any(), + onDismissClick = any(), + onConfirmClick = any() + ) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `cancelDownloading should update courseDownloadState to NOT_DOWNLOADED and cancel download job`() = + runTest { + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.downloadCourse(fragmentManager, "course1") + advanceUntilIdle() + + viewModel.cancelDownloading("course1") + advanceUntilIdle() + + coVerify { interactor.getDownloadModelsByCourseIds(any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `removeDownloads should show remove popup with correct parameters`() = runTest { + coEvery { interactor.getDownloadModelsByCourseIds(any()) } returns listOf(downloadModel) + + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + advanceUntilIdle() + + val fragmentManager = mockk(relaxed = true) + viewModel.removeDownloads(fragmentManager, "course1") + advanceUntilIdle() + + coVerify { + downloadDialogManager.showRemoveDownloadModelPopup( + any(), + any(), + any() + ) + } + + verify(exactly = 1) { analytics.logEvent(any(), any()) } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `refreshData no internet error should emit snack bar message`() = runTest { + every { networkConnection.isOnline() } returns true + coEvery { interactor.getDownloadCoursesPreview(any()) } returns flow { throw UnknownHostException() } + + val viewModel = DownloadsViewModel( + downloadsRouter, + networkConnection, + interactor, + downloadDialogManager, + resourceManager, + fileUtil, + config, + analytics, + discoveryNotifier, + courseNotifier, + router, + preferencesManager, + coreAnalytics, + downloadDao, + workerController, + downloadHelper + ) + val deferred = async { viewModel.uiMessage.first() } + advanceUntilIdle() + + viewModel.refreshData() + advanceUntilIdle() + + assertEquals(noInternet, (deferred.await() as? UIMessage.SnackBarMessage)?.message) + assertFalse(viewModel.uiState.value.isRefreshing) + } +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ec34fd6a7..0f37100ea 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Fri May 03 13:24:00 EEST 2024 +#Mon Aug 11 14:17:42 EEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/profile/build.gradle b/profile/build.gradle index a1b894421..8f4ed5783 100644 --- a/profile/build.gradle +++ b/profile/build.gradle @@ -7,11 +7,11 @@ plugins { android { namespace 'org.openedx.profile' - compileSdk 34 + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -19,18 +19,19 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { @@ -55,10 +56,10 @@ android { dependencies { implementation project(path: ":core") - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } \ No newline at end of file diff --git a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt index e7bbecae5..857af17d0 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/calendar/NewCalendarDialogFragment.kt @@ -265,7 +265,8 @@ private fun CalendarTitleTextField( onValueChanged(it.text.trim()) }, colors = TextFieldDefaults.outlinedTextFieldColors( - unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + textColor = MaterialTheme.appColors.textPrimary ), shape = MaterialTheme.appShapes.textFieldShape, placeholder = { diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt index 62727f822..8f9a3fd14 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileFragment.kt @@ -6,8 +6,6 @@ import android.content.res.Configuration import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.graphics.Bitmap import android.graphics.ImageDecoder -import android.graphics.Matrix -import android.media.ExifInterface import android.net.Uri import android.os.Build import android.os.Bundle @@ -236,8 +234,6 @@ class EditProfileFragment : Fragment() { @Suppress("DEPRECATION") private fun cropImage(uri: Uri): Uri { - val matrix = Matrix() - matrix.postRotate(getImageOrientation(uri).toFloat()) val originalBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { ImageDecoder.decodeBitmap( ImageDecoder.createSource( @@ -248,26 +244,17 @@ class EditProfileFragment : Fragment() { } else { MediaStore.Images.Media.getBitmap(requireContext().contentResolver, uri) } - val rotatedBitmap = Bitmap.createBitmap( - originalBitmap, - 0, - 0, - originalBitmap.width, - originalBitmap.height, - matrix, - true - ) val newFile = File.createTempFile( "Image_${System.currentTimeMillis()}", ".jpg", requireContext().getExternalFilesDir(Environment.DIRECTORY_PICTURES) ) - val ratio: Float = rotatedBitmap.width.toFloat() / TARGET_IMAGE_WIDTH + val ratio: Float = originalBitmap.width.toFloat() / TARGET_IMAGE_WIDTH val newBitmap = Bitmap.createScaledBitmap( - rotatedBitmap, + originalBitmap, TARGET_IMAGE_WIDTH, - (rotatedBitmap.height.toFloat() / ratio).toInt(), + (originalBitmap.height.toFloat() / ratio).toInt(), false ) val bos = ByteArrayOutputStream() @@ -285,28 +272,9 @@ class EditProfileFragment : Fragment() { )!! } - private fun getImageOrientation(uri: Uri): Int { - var rotation = 0 - val exif = ExifInterface(requireActivity().contentResolver.openInputStream(uri)!!) - when ( - exif.getAttributeInt( - ExifInterface.TAG_ORIENTATION, - ExifInterface.ORIENTATION_NORMAL - ) - ) { - ExifInterface.ORIENTATION_ROTATE_270 -> rotation = ORIENTATION_ROTATE_270 - ExifInterface.ORIENTATION_ROTATE_180 -> rotation = ORIENTATION_ROTATE_180 - ExifInterface.ORIENTATION_ROTATE_90 -> rotation = ORIENTATION_ROTATE_90 - } - return rotation - } - companion object { private const val ARG_ACCOUNT = "argAccount" const val LEAVE_PROFILE_WIDTH_FACTOR = 0.7f - private const val ORIENTATION_ROTATE_270 = 270 - private const val ORIENTATION_ROTATE_180 = 180 - private const val ORIENTATION_ROTATE_90 = 90 private const val IMAGE_QUALITY = 90 private const val TARGET_IMAGE_WIDTH = 500 diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt index 217a35258..f1eaf0aeb 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt @@ -28,12 +28,10 @@ class SettingsFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.collectAsState() val logoutSuccess by viewModel.successLogout.collectAsState(false) - val appUpgradeEvent by viewModel.appUpgradeEvent.collectAsState(null) SettingsScreen( windowSize = windowSize, uiState = uiState, - appUpgradeEvent = appUpgradeEvent, onBackClick = { requireActivity().supportFragmentManager.popBackStack() }, diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index 68c773745..6122775bf 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import org.openedx.core.AppUpdateState import org.openedx.core.R import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.presentation.global.AppData @@ -75,7 +76,6 @@ import org.openedx.profile.R as profileR internal fun SettingsScreen( windowSize: WindowSize, uiState: SettingsUIState, - appUpgradeEvent: AppUpgradeEvent?, onBackClick: () -> Unit, onAction: (SettingsScreenAction) -> Unit, ) { @@ -189,7 +189,6 @@ internal fun SettingsScreen( SupportInfoSection( uiState = uiState, onAction = onAction, - appUpgradeEvent = appUpgradeEvent, ) Spacer(modifier = Modifier.height(24.dp)) @@ -264,7 +263,6 @@ private fun ManageAccountSection(onManageAccountClick: () -> Unit) { @Composable private fun SupportInfoSection( uiState: SettingsUIState.Data, - appUpgradeEvent: AppUpgradeEvent?, onAction: (SettingsScreenAction) -> Unit ) { Column { @@ -325,7 +323,7 @@ private fun SupportInfoSection( } AppVersionItem( versionName = uiState.configuration.versionName, - appUpgradeEvent = appUpgradeEvent, + appUpgradeEvent = AppUpdateState.lastAppUpgradeEvent, ) { onAction(SettingsScreenAction.AppVersionClick) } @@ -692,7 +690,6 @@ private fun SettingsScreenPreview() { windowSize = WindowSize(WindowType.Medium, WindowType.Medium), uiState = mockUiState, onAction = {}, - appUpgradeEvent = null, ) } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 59548d1c9..c21f72df3 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -21,7 +21,6 @@ import org.openedx.core.module.DownloadWorkerController import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager import org.openedx.core.system.notifier.app.AppNotifier -import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.utils.EmailUtil import org.openedx.foundation.extension.isInternetError @@ -62,10 +61,6 @@ class SettingsViewModel( val uiMessage: SharedFlow get() = _uiMessage.asSharedFlow() - private val _appUpgradeEvent = MutableStateFlow(null) - val appUpgradeEvent: StateFlow - get() = _appUpgradeEvent.asStateFlow() - val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() private val configuration @@ -77,7 +72,6 @@ class SettingsViewModel( ) init { - collectAppUpgradeEvent() collectProfileEvent() } @@ -117,16 +111,6 @@ class SettingsViewModel( } } - private fun collectAppUpgradeEvent() { - viewModelScope.launch { - appNotifier.notifier.collect { event -> - if (event is AppUpgradeEvent) { - _appUpgradeEvent.value = event - } - } - } - } - private fun collectProfileEvent() { viewModelScope.launch { profileNotifier.notifier.collect { diff --git a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt index f4811135a..7a41a916e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ui/SettingsUI.kt @@ -11,7 +11,7 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -32,7 +32,7 @@ fun SettingsItem( val icon = if (external) { Icons.AutoMirrored.Filled.OpenInNew } else { - Icons.AutoMirrored.Filled.ArrowForwardIos + Icons.AutoMirrored.Filled.KeyboardArrowRight } Row( Modifier @@ -57,7 +57,7 @@ fun SettingsItem( color = MaterialTheme.appColors.textPrimary ) Icon( - modifier = Modifier.size(16.dp), + modifier = Modifier.size(22.dp), imageVector = icon, contentDescription = null ) diff --git a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt index d9b434130..5cbfc0635 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/video/VideoSettingsFragment.kt @@ -25,7 +25,7 @@ import androidx.compose.material.Switch import androidx.compose.material.SwitchDefaults import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -253,7 +253,7 @@ private fun VideoSettingsScreen( ) } Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, contentDescription = stringResource(CoreR.string.core_accessibility_expandable_arrow) ) @@ -284,7 +284,7 @@ private fun VideoSettingsScreen( ) } Icon( - imageVector = Icons.Filled.ChevronRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, tint = MaterialTheme.appColors.onSurface, contentDescription = stringResource(CoreR.string.core_accessibility_expandable_arrow) ) diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 1adf22c97..1de55c683 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -1,13 +1,8 @@ - Profile info - Bio: %1$s - Year of Birth: %1$s Full profile Limited profile Edit Profile - Edit - Save Delete Account You must be over 13 years old to have a profile with full access to information. Year of Birth @@ -27,7 +22,6 @@ Change profile image Select from gallery Remove photo - Settings Leave without saving? Leave Keep editing diff --git a/settings.gradle b/settings.gradle index 40beee473..a58940420 100644 --- a/settings.gradle +++ b/settings.gradle @@ -12,7 +12,7 @@ pluginManagement { } } dependencies { - classpath("com.android.tools:r8:8.5.35") + classpath("com.android.tools:r8:8.12.14") } } } @@ -46,3 +46,4 @@ include ':discovery' include ':profile' include ':discussion' include ':whatsnew' +include ':downloads' diff --git a/whatsnew/build.gradle b/whatsnew/build.gradle index 59a5e14cc..679843623 100644 --- a/whatsnew/build.gradle +++ b/whatsnew/build.gradle @@ -7,11 +7,11 @@ plugins { android { namespace 'org.openedx.whatsnew' - compileSdk 34 + compileSdkVersion compile_sdk_version defaultConfig { - minSdk 24 - targetSdk 34 + minSdk min_sdk_version + targetSdk target_sdk_version testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -19,19 +19,19 @@ android { buildTypes { release { - minifyEnabled true + minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } - compileOptions { - sourceCompatibility JavaVersion.VERSION_17 - targetCompatibility JavaVersion.VERSION_17 + sourceCompatibility java_version + targetCompatibility java_version } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - freeCompilerArgs = List.of("-Xstring-concat=inline") + kotlin { + compilerOptions { + jvmTarget = jvm_target_version + freeCompilerArgs = ['-XXLanguage:+PropertyParamAnnotationDefaultTargetMode'] + } } buildFeatures { @@ -51,15 +51,15 @@ android { dimension 'env' } } -} -dependencies { - implementation project(path: ":core") + dependencies { + implementation project(path: ":core") - androidTestImplementation 'androidx.test.ext:junit:1.2.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' - testImplementation "junit:junit:$junit_version" - testImplementation "io.mockk:mockk:$mockk_version" - testImplementation "io.mockk:mockk-android:$mockk_version" - testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" + testImplementation "androidx.arch.core:core-testing:$android_arch_version" + testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_version" + androidTestImplementation "androidx.test.ext:junit:$test_ext_version" + androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" + } } \ No newline at end of file diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt index 9c34603f1..2c2765619 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/ui/WhatsNewUI.kt @@ -1,21 +1,17 @@ package org.openedx.whatsnew.presentation.ui import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults @@ -23,20 +19,18 @@ import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors @@ -44,95 +38,6 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.whatsnew.R -@Composable -fun PageIndicator( - numberOfPages: Int, - modifier: Modifier = Modifier, - selectedPage: Int = 0, - selectedColor: Color = MaterialTheme.appColors.info, - previousUnselectedColor: Color = MaterialTheme.appColors.cardViewBorder, - nextUnselectedColor: Color = MaterialTheme.appColors.textFieldBorder, - defaultRadius: Dp = 20.dp, - selectedLength: Dp = 60.dp, - space: Dp = 30.dp, - animationDurationInMillis: Int = 300, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(space), - modifier = modifier, - ) { - for (i in 0 until numberOfPages) { - val isSelected = i == selectedPage - val unselectedColor = - if (i < selectedPage) previousUnselectedColor else nextUnselectedColor - PageIndicatorView( - isSelected = isSelected, - selectedColor = selectedColor, - defaultColor = unselectedColor, - defaultRadius = defaultRadius, - selectedLength = selectedLength, - animationDurationInMillis = animationDurationInMillis, - ) - } - } -} - -@Composable -fun PageIndicatorView( - isSelected: Boolean, - selectedColor: Color, - defaultColor: Color, - defaultRadius: Dp, - selectedLength: Dp, - animationDurationInMillis: Int, - modifier: Modifier = Modifier, -) { - val color: Color by animateColorAsState( - targetValue = if (isSelected) { - selectedColor - } else { - defaultColor - }, - animationSpec = tween( - durationMillis = animationDurationInMillis, - ), - label = "" - ) - val width: Dp by animateDpAsState( - targetValue = if (isSelected) { - selectedLength - } else { - defaultRadius - }, - animationSpec = tween( - durationMillis = animationDurationInMillis, - ), - label = "" - ) - - Canvas( - modifier = modifier - .size( - width = width, - height = defaultRadius, - ), - ) { - drawRoundRect( - color = color, - topLeft = Offset.Zero, - size = Size( - width = width.toPx(), - height = defaultRadius.toPx(), - ), - cornerRadius = CornerRadius( - x = defaultRadius.toPx(), - y = defaultRadius.toPx(), - ), - ) - } -} - @Composable fun NavigationUnitsButtons( hasPrevPage: Boolean, @@ -185,7 +90,7 @@ fun PrevButton( horizontalArrangement = Arrangement.Center ) { Icon( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_back), + imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null, tint = MaterialTheme.appColors.primary ) @@ -235,7 +140,7 @@ fun NextFinishButton( ) Spacer(Modifier.width(8.dp)) Icon( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_forward), + imageVector = Icons.AutoMirrored.Filled.ArrowForward, contentDescription = null, tint = MaterialTheme.appColors.primaryButtonText ) @@ -301,14 +206,3 @@ private fun NavigationUnitsButtonsPrevInTheEnd() { ) } } - -@Preview -@Composable -private fun PageIndicatorViewPreview() { - OpenEdXTheme { - PageIndicator( - numberOfPages = 4, - selectedPage = 2 - ) - } -} diff --git a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt index 0cab35466..22dc96737 100644 --- a/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt +++ b/whatsnew/src/main/java/org/openedx/whatsnew/presentation/whatsnew/WhatsNewFragment.kt @@ -56,6 +56,7 @@ import androidx.fragment.app.Fragment import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf +import org.openedx.core.ui.PageIndicator import org.openedx.core.ui.calculateCurrentOffsetForPage import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -67,7 +68,6 @@ import org.openedx.foundation.presentation.windowSizeValue import org.openedx.whatsnew.domain.model.WhatsNewItem import org.openedx.whatsnew.domain.model.WhatsNewMessage import org.openedx.whatsnew.presentation.ui.NavigationUnitsButtons -import org.openedx.whatsnew.presentation.ui.PageIndicator import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment.Companion.BASE_ALPHA_VALUE class WhatsNewFragment : Fragment() {