diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 0c1f551c0..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 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/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index b4633cc27..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,6 +35,7 @@ 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 @@ -216,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 07caf8037..1d3604050 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -29,6 +29,7 @@ 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 @@ -340,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(), 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 fd0b0069f..b2f275bb3 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -23,7 +23,7 @@ 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 = 4 +const val DATABASE_VERSION = 5 const val DATABASE_NAME = "OpenEdX_db" @Suppress("MagicNumber") @@ -44,7 +44,8 @@ const val DATABASE_NAME = "OpenEdX_db" autoMigrations = [ AutoMigration(1, 2), AutoMigration(2, 3), - AutoMigration(3, DATABASE_VERSION), + AutoMigration(3, 4), + AutoMigration(4, DATABASE_VERSION), ], version = DATABASE_VERSION ) diff --git a/build.gradle b/build.gradle index b59516fd2..674a1057f 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,7 @@ buildscript { // Library versions media3_version = "1.8.0" - youtubeplayer_version = "11.1.0" + youtubeplayer_version = "13.0.0" firebase_version = "33.0.0" jsoup_version = '1.21.2' in_app_review = '2.0.2' 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 index cc5d55278..cc80a0438 100644 --- 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 @@ -51,13 +51,13 @@ data class CourseInfoOverviewDb( val number: String, @ColumnInfo("org") val org: String, - @Embedded + @ColumnInfo("start") val start: Date?, @ColumnInfo("startDisplay") val startDisplay: String, @ColumnInfo("startType") val startType: String, - @Embedded + @ColumnInfo("end") val end: Date?, @ColumnInfo("isSelfPaced") val isSelfPaced: Boolean, 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/openedx/org/openedx/core/ui/theme/Colors.kt b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt index 089acc04f..f6e39aef3 100644 --- a/core/src/openedx/org/openedx/core/ui/theme/Colors.kt +++ b/core/src/openedx/org/openedx/core/ui/theme/Colors.kt @@ -16,25 +16,25 @@ val light_onSurface = Color.Black val light_onError = Color.White val light_onWarning = Color.White val light_onInfo = Color.White -val light_info_variant = Color(0xFF3C68FF) +val light_info_variant = light_primary val light_text_primary = Color(0xFF212121) val light_text_primary_variant = Color(0xFF3D4964) val light_text_primary_light = light_text_primary val light_text_secondary = Color(0xFFB3B3B3) val light_text_dark = Color(0xFF19212F) -val light_text_accent = Color(0xFF3C68FF) +val light_text_accent = light_primary val light_text_warning = Color(0xFF19212F) val light_text_field_background = Color(0xFFF7F7F8) val light_text_field_background_variant = Color.White val light_text_field_border = Color(0xFF97A5BB) val light_text_field_text = Color(0xFF3D4964) val light_text_field_hint = Color(0xFF97A5BB) -val light_text_hyper_link = Color(0xFF3C68FF) +val light_text_hyper_link = light_primary -val light_primary_button_background = Color(0xFF3C68FF) +val light_primary_button_background = light_primary val light_primary_button_border = Color(0xFF97A5BB) val light_primary_button_text = Color.White -val light_primary_button_bordered_text = Color(0xFF3C68FF) +val light_primary_button_bordered_text = light_primary val light_secondary_button_background = light_primary_button_background val light_secondary_button_text = light_primary_button_text @@ -102,12 +102,12 @@ val dark_text_field_background_variant = Color(0xFF273346) val dark_text_field_border = Color(0xFF4E5A70) val dark_text_field_text = Color.White val dark_text_field_hint = Color(0xFF79889F) -val dark_text_hyper_link = Color(0xFF5478F9) +val dark_text_hyper_link = dark_primary -val dark_primary_button_background = Color(0xFF5478F9) +val dark_primary_button_background = dark_primary val dark_primary_button_text = Color.White val dark_primary_button_border = Color(0xFF4E5A70) -val dark_primary_button_bordered_text = Color(0xFF5478F9) +val dark_primary_button_bordered_text = dark_primary val dark_secondary_button_background = dark_primary_button_background val dark_secondary_button_text = dark_primary_button_text diff --git a/course/build.gradle b/course/build.gradle index 7ae8809ed..227b52a0c 100644 --- a/course/build.gradle +++ b/course/build.gradle @@ -62,6 +62,7 @@ dependencies { implementation project(path: ':core') implementation project(path: ':discussion') implementation "com.pierfrancescosoffritti.androidyoutubeplayer:core:$youtubeplayer_version" + implementation "com.pierfrancescosoffritti.androidyoutubeplayer:custom-ui:$youtubeplayer_version" // Media3 implementation "androidx.media3:media3-exoplayer:$media3_version" @@ -76,4 +77,4 @@ dependencies { testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt index c59b69638..b49a806e6 100644 --- a/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt +++ b/course/src/main/java/org/openedx/course/data/storage/CourseConverter.kt @@ -7,10 +7,20 @@ import org.openedx.core.data.model.room.BlockDb import org.openedx.core.data.model.room.GradingPolicyDb import org.openedx.core.data.model.room.SectionScoreDb import org.openedx.core.data.model.room.discovery.CourseDateBlockDb -import org.openedx.foundation.extension.genericType +import java.util.Date class CourseConverter { + @TypeConverter + fun fromDate(value: Date?): Long? { + return value?.time + } + + @TypeConverter + fun toDate(value: Long?): Date? { + return value?.let { Date(it) } + } + @TypeConverter fun fromListOfString(value: List): String { val json = Gson().toJson(value) @@ -19,7 +29,7 @@ class CourseConverter { @TypeConverter fun toListOfString(value: String): List { - val type = genericType>() + val type = object : TypeToken>() {}.type return Gson().fromJson(value, type) } @@ -31,7 +41,7 @@ class CourseConverter { @TypeConverter fun toListOfBlockDbEntity(value: String): List { - val type = genericType>() + val type = object : TypeToken>() {}.type return Gson().fromJson(value, type) } @@ -43,7 +53,7 @@ class CourseConverter { @TypeConverter fun toListOfCourseDateBlockDb(value: String): List { - val type = genericType>() + val type = object : TypeToken>() {}.type return Gson().fromJson(value, type) } diff --git a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt index a5f42db5c..7d1381505 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/CourseHomeViewModel.kt @@ -1,6 +1,5 @@ package org.openedx.course.presentation.home -import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -18,6 +17,7 @@ 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.helper.VideoPreviewHelper import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDatesBannerInfo @@ -54,7 +54,6 @@ import org.openedx.course.R as courseR class CourseHomeViewModel( val courseId: String, private val courseTitle: String, - private val context: Context, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -65,6 +64,7 @@ class CourseHomeViewModel( private val downloadDialogManager: DownloadDialogManager, private val fileUtil: FileUtil, val courseRouter: CourseRouter, + private val videoPreviewHelper: VideoPreviewHelper, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -270,11 +270,9 @@ class CourseHomeViewModel( private fun getVideoPreview(videoBlock: Block?) { viewModelScope.launch(Dispatchers.IO) { - val videoPreview = videoBlock?.getVideoPreview( - context, - networkConnection.isOnline(), - null - ) + val videoPreview = videoBlock?.let { block -> + videoPreviewHelper.getVideoPreview(block, null) + } _uiState.value = (_uiState.value as? CourseHomeUIState.CourseData) ?.copy( videoPreview = videoPreview diff --git a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt index ecbeec99b..50c15500c 100644 --- a/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt +++ b/course/src/main/java/org/openedx/course/presentation/home/VideosHomePagerCardContent.kt @@ -1,6 +1,5 @@ package org.openedx.course.presentation.home -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -10,7 +9,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.Card import androidx.compose.material.Icon import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme @@ -26,9 +24,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import org.openedx.core.domain.model.Block -import org.openedx.core.extension.getUnitChapter import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.openedx.course.R import org.openedx.course.presentation.contenttab.CourseContentVideoEmptyState @@ -131,42 +127,21 @@ fun VideosHomePagerCardContent( Spacer(modifier = Modifier.height(8.dp)) // Video card using CourseVideoItem - Card( + CourseVideoItem( modifier = Modifier - .fillMaxWidth(), - backgroundColor = MaterialTheme.appColors.cardViewBackground, - shape = MaterialTheme.appShapes.videoPreviewShape, - elevation = 0.dp, - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.appColors.cardViewBorder - ) - ) { - Column { - CourseVideoItem( - modifier = Modifier - .fillMaxWidth() - .height(180.dp), - videoBlock = firstIncompleteVideo, - preview = uiState.videoPreview, - progress = videoProgress, - onClick = { - onVideoClick(firstIncompleteVideo) - }, - titleStyle = MaterialTheme.appTypography.titleMedium, - contentModifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp), - progressModifier = Modifier.height(8.dp), - ) - Text( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - text = uiState.courseStructure.blockData - .getUnitChapter(firstIncompleteVideo.id)?.displayName ?: "", - style = MaterialTheme.appTypography.labelMedium, - color = MaterialTheme.appColors.textPrimary, - ) - } - } + .fillMaxWidth() + .height(180.dp), + videoBlock = firstIncompleteVideo, + preview = uiState.videoPreview, + progress = videoProgress, + onClick = { + onVideoClick(firstIncompleteVideo) + }, + titleStyle = MaterialTheme.appTypography.titleMedium, + contentModifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + progressModifier = Modifier.height(8.dp), + ) } else { CaughtUpMessage( message = stringResource(R.string.course_videos_caught_up) diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 82c28cb4f..19d3bb4b5 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -51,6 +51,7 @@ import androidx.compose.material.Snackbar import androidx.compose.material.Surface 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.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Close @@ -338,6 +339,7 @@ fun NavigationUnitsButtons( nextButtonText: String, hasPrevBlock: Boolean, hasNextBlock: Boolean, + showFinishButton: Boolean = true, isVerticalNavigation: Boolean, onPrevClick: () -> Unit, onNextClick: () -> Unit, @@ -375,7 +377,7 @@ fun NavigationUnitsButtons( colors = ButtonDefaults.outlinedButtonColors( backgroundColor = MaterialTheme.appColors.background ), - border = BorderStroke(1.dp, MaterialTheme.appColors.primaryButtonBorder), + border = BorderStroke(1.dp, MaterialTheme.appColors.textAccent), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, onClick = onPrevClick, @@ -384,48 +386,66 @@ fun NavigationUnitsButtons( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { + if (!isVerticalNavigation) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = MaterialTheme.appColors.textAccent + ) + Spacer(Modifier.width(8.dp)) + } Text( text = stringResource(R.string.course_navigation_prev), - color = MaterialTheme.appColors.primary, + color = MaterialTheme.appColors.textAccent, style = MaterialTheme.appTypography.labelLarge ) - Spacer(Modifier.width(8.dp)) - Icon( - modifier = Modifier.rotate(if (isVerticalNavigation) 0f else -90f), - painter = painterResource(id = coreR.drawable.core_ic_up), - contentDescription = null, - tint = MaterialTheme.appColors.primary - ) + if (isVerticalNavigation) { + Spacer(Modifier.width(8.dp)) + Icon( + painter = painterResource(id = coreR.drawable.core_ic_up), + contentDescription = null, + tint = MaterialTheme.appColors.textAccent + ) + } } } Spacer(Modifier.width(16.dp)) } - Button( - modifier = Modifier - .height(42.dp), - colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.primaryButtonBackground - ), - elevation = null, - shape = MaterialTheme.appShapes.navigationButtonShape, - onClick = onNextClick - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + if (hasNextBlock || showFinishButton) { + Button( + modifier = Modifier + .height(42.dp), + colors = ButtonDefaults.buttonColors( + backgroundColor = MaterialTheme.appColors.primaryButtonBackground + ), + elevation = null, + shape = MaterialTheme.appShapes.navigationButtonShape, + onClick = onNextClick ) { - Text( - text = nextButtonText, - color = MaterialTheme.appColors.primaryButtonText, - style = MaterialTheme.appTypography.labelLarge - ) - Spacer(Modifier.width(8.dp)) - Icon( - modifier = Modifier.rotate(if (isVerticalNavigation || !hasNextBlock) 0f else -90f), - painter = nextButtonIcon, - contentDescription = null, - tint = MaterialTheme.appColors.primaryButtonText - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = nextButtonText, + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(Modifier.width(8.dp)) + if (isVerticalNavigation || !hasNextBlock) { + Icon( + painter = nextButtonIcon, + contentDescription = null, + tint = MaterialTheme.appColors.primaryButtonText + ) + } else { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.appColors.primaryButtonText + ) + } + } } } } @@ -700,20 +720,23 @@ fun CourseVideoItem( titleStyle: TextStyle = MaterialTheme.appTypography.bodySmall, contentModifier: Modifier = Modifier.padding(8.dp), progressModifier: Modifier = Modifier.height(4.dp), + playButtonSize: Dp = 32.dp, + borderColor: Color? = null, + borderWidth: Dp = 3.dp, ) { + val borderColor = borderColor ?: if (videoBlock.isCompleted()) { + MaterialTheme.appColors.successGreen + } else { + Color.Transparent + } Box( modifier = modifier - .let { - if (videoBlock.isCompleted()) { - it.border( - width = 3.dp, - color = MaterialTheme.appColors.successGreen, - shape = MaterialTheme.appShapes.videoPreviewShape - ) - } else { - it - } - } + .clip(MaterialTheme.appShapes.videoPreviewShape) + .border( + width = borderWidth, + color = borderColor, + shape = MaterialTheme.appShapes.videoPreviewShape + ) .clickable { onClick() } ) { AsyncImage( @@ -748,7 +771,7 @@ fun CourseVideoItem( ) { Image( modifier = Modifier - .size(32.dp) + .size(playButtonSize) .align(Alignment.Center), painter = painterResource(id = R.drawable.course_video_play_button), contentDescription = null, @@ -761,7 +784,7 @@ fun CourseVideoItem( style = titleStyle, modifier = Modifier .align(Alignment.TopStart), - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis ) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt index 4c4a136ec..b7131b993 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerFragment.kt @@ -6,9 +6,22 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -16,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.os.bundleOf @@ -31,15 +45,18 @@ 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.BlockType +import org.openedx.core.domain.model.Block import org.openedx.core.presentation.global.InsetHolder 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.databinding.FragmentCourseUnitContainerBinding import org.openedx.course.presentation.ChapterEndFragmentDialog import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.DialogListener import org.openedx.course.presentation.ui.CourseUnitToolbar +import org.openedx.course.presentation.ui.CourseVideoItem import org.openedx.course.presentation.ui.HorizontalPageIndicator import org.openedx.course.presentation.ui.NavigationUnitsButtons import org.openedx.course.presentation.ui.SubSectionUnitsList @@ -56,7 +73,8 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta private val viewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(UNIT_ID, "") + requireArguments().getString(UNIT_ID, ""), + requireArguments().serializable(ARG_MODE) ) } @@ -95,7 +113,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta fm = requireActivity().supportFragmentManager, courseId = viewModel.courseId, unitId = it.id, - mode = requireArguments().serializable(ARG_MODE)!! + mode = viewModel.mode ) } } @@ -132,7 +150,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta super.onCreate(savedInstanceState) lifecycle.addObserver(viewModel) componentId = requireArguments().getString(ARG_COMPONENT_ID, "") - viewModel.loadBlocks(requireArguments().serializable(ARG_MODE)!!, componentId) + viewModel.loadBlocks(componentId) viewModel.courseUnitContainerShowedEvent() } @@ -156,6 +174,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta setupProgressIndicators() setupBackButton() setupSubSectionUnits() + setupVideoList() checkUnitsListShown() setupChapterEndDialogListener() } @@ -209,7 +228,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta blocks = descendantsBlocks, selectedPage = index, completedAndSelectedColor = - MaterialTheme.appColors.componentHorizontalProgressCompletedAndSelected, + MaterialTheme.appColors.componentHorizontalProgressCompletedAndSelected, completedColor = MaterialTheme.appColors.componentHorizontalProgressCompleted, selectedColor = MaterialTheme.appColors.componentHorizontalProgressSelected, defaultColor = MaterialTheme.appColors.componentHorizontalProgressDefault @@ -293,7 +312,7 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta fm = requireActivity().supportFragmentManager, courseId = viewModel.courseId, unitId = unit.id, - mode = requireArguments().serializable(ARG_MODE)!! + mode = viewModel.mode ) } else { handleUnitsClick() @@ -307,6 +326,41 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta } } + private fun setupVideoList() { + binding.videoList?.setContent { + OpenEdXTheme { + Column { + VideoList( + onVideoClick = { block -> + val currentBlock = viewModel.currentBlock.value + if (currentBlock?.id != block.id) { + viewModel.setSelectedVideoBlock(block) + updateViewPagerAdapter() + val blockIndex = + viewModel.getUnitBlocks().indexOfFirst { it.id == block.id } + if (blockIndex != -1) { + binding.viewPager.currentItem = blockIndex + } + } + } + ) + Spacer(modifier = Modifier.height(8.dp)) + Divider() + if (viewModel.mode == CourseViewMode.VIDEOS) { + Spacer(modifier = Modifier.height(16.dp)) + HierarchyPathText() + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + } + + private fun updateViewPagerAdapter() { + adapter = CourseUnitContainerAdapter(this, viewModel.getUnitBlocks(), viewModel) + binding.viewPager.adapter = adapter + } + private fun checkUnitsListShown() { if (viewModel.unitsListShowed.value == true) { handleUnitsClick() @@ -434,42 +488,173 @@ class CourseUnitContainerFragment : Fragment(R.layout.fragment_course_unit_conta @Composable private fun NavigationBar() { - OpenEdXTheme { - var nextButtonText by rememberSaveable { - mutableStateOf(viewModel.nextButtonText) - } - var hasNextBlock by rememberSaveable { - mutableStateOf(viewModel.hasNextBlock) - } - var hasPrevBlock by rememberSaveable { - mutableStateOf(viewModel.hasNextBlock) + if (viewModel.mode == CourseViewMode.VIDEOS) { + OpenEdXTheme { + val videoBlocks by viewModel.videoList.collectAsState() + val currentBlock by viewModel.currentBlock.collectAsState() + val hasNextBlock = videoBlocks.lastOrNull()?.id != currentBlock?.id + val nextButtonText = if (hasNextBlock) { + getString(R.string.course_navigation_next) + } else { + getString(R.string.course_navigation_finish) + } + NavigationUnitsButtons( + hasPrevBlock = videoBlocks.firstOrNull()?.id != currentBlock?.id, + nextButtonText = nextButtonText, + hasNextBlock = hasNextBlock, + isVerticalNavigation = false, + showFinishButton = false, + onPrevClick = { + if (!restrictDoubleClick()) { + val currentIndex = + videoBlocks.indexOfFirst { it.id == currentBlock?.id } + if (currentIndex > 0) { + val target = videoBlocks[currentIndex - 1] + viewModel.setSelectedVideoBlock(target) + updateViewPagerAdapter() + val blockIndex = + viewModel.getUnitBlocks().indexOfFirst { it.id == target.id } + if (blockIndex != -1) { + binding.viewPager.setCurrentItem(blockIndex, true) + } + } + } + }, + onNextClick = { + if (!restrictDoubleClick()) { + val currentIndex = + videoBlocks.indexOfFirst { it.id == currentBlock?.id } + if (currentIndex != -1 && currentIndex < videoBlocks.lastIndex) { + val target = videoBlocks[currentIndex + 1] + viewModel.setSelectedVideoBlock(target) + updateViewPagerAdapter() + val blockIndex = + viewModel.getUnitBlocks().indexOfFirst { it.id == target.id } + if (blockIndex != -1) { + binding.viewPager.setCurrentItem(blockIndex, true) + } + } + } + } + ) } + } else { + OpenEdXTheme { + var nextButtonText by rememberSaveable { + mutableStateOf(viewModel.nextButtonText) + } + var hasNextBlock by rememberSaveable { + mutableStateOf(viewModel.hasNextBlock) + } + var hasPrevBlock by rememberSaveable { + mutableStateOf(viewModel.hasNextBlock) + } + + updateNavigationButtons { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } - updateNavigationButtons { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext + NavigationUnitsButtons( + hasPrevBlock = hasPrevBlock, + nextButtonText = nextButtonText, + hasNextBlock = hasNextBlock, + isVerticalNavigation = !viewModel.isCourseUnitProgressEnabled, + onPrevClick = { + handlePrevClick { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } + }, + onNextClick = { + handleNextClick { next, hasPrev, hasNext -> + nextButtonText = next + hasPrevBlock = hasPrev + hasNextBlock = hasNext + } + } + ) } + } + } + + @Composable + private fun VideoList( + onVideoClick: (Block) -> Unit + ) { + val videoBlocks by viewModel.videoList.collectAsState() + val videoPreview by viewModel.videoPreview.collectAsState() + val videoProgress by viewModel.videoProgress.collectAsState() + val currentBlock by viewModel.currentBlock.collectAsState() + val rowState = rememberLazyListState() + + LaunchedEffect(currentBlock) { + rowState.animateScrollToItem(videoBlocks.indexOf(currentBlock)) + } - NavigationUnitsButtons( - hasPrevBlock = hasPrevBlock, - nextButtonText = nextButtonText, - hasNextBlock = hasNextBlock, - isVerticalNavigation = !viewModel.isCourseUnitProgressEnabled, - onPrevClick = { - handlePrevClick { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext + if (videoBlocks.isNotEmpty()) { + LazyRow( + state = rowState, + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + ) { + items(videoBlocks) { block -> + val isSelectedBlock = block.id == currentBlock?.id + val playButtonSize = if (isSelectedBlock) { + 0.dp + } else { + 14.dp + } + val borderColor = if (isSelectedBlock) { + MaterialTheme.appColors.primary + } else { + null } - }, - onNextClick = { - handleNextClick { next, hasPrev, hasNext -> - nextButtonText = next - hasPrevBlock = hasPrev - hasNextBlock = hasNext + val borderWidth = if (isSelectedBlock) { + 3.dp + } else { + 1.dp } + CourseVideoItem( + modifier = Modifier + .width(112.dp) + .height(63.dp), + videoBlock = block, + preview = videoPreview[block.id], + progress = if (isSelectedBlock) { + 0f + } else { + videoProgress[block.id] ?: 0f + }, + onClick = { + onVideoClick(block) + }, + titleStyle = MaterialTheme.appTypography.labelSmall, + playButtonSize = playButtonSize, + borderColor = borderColor, + borderWidth = borderWidth + ) } + } + } + } + + @Composable + private fun HierarchyPathText() { + val hierarchyPath by viewModel.hierarchyPath.collectAsState() + + if (hierarchyPath.isNotEmpty()) { + Text( + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = hierarchyPath, + style = MaterialTheme.appTypography.bodySmall, + color = MaterialTheme.appColors.textDark, + maxLines = 2, ) } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt index 596102dd9..81382f9f3 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/container/CourseUnitContainerViewModel.kt @@ -9,15 +9,19 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import org.openedx.core.BlockType import org.openedx.core.config.Config +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.domain.model.Block +import org.openedx.core.extension.safeDivBy import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSectionChanged import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.utils.VideoPreview import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseAnalyticsEvent @@ -29,11 +33,13 @@ import org.openedx.foundation.presentation.BaseViewModel class CourseUnitContainerViewModel( val courseId: String, val unitId: String, + val mode: CourseViewMode, private val config: Config, private val interactor: CourseInteractor, private val notifier: CourseNotifier, private val analytics: CourseAnalytics, private val networkConnection: NetworkConnection, + private val videoPreviewHelper: VideoPreviewHelper, ) : BaseViewModel() { private val blocks = ArrayList() @@ -48,12 +54,16 @@ class CourseUnitContainerViewModel( val isFirstIndexInContainer: Boolean get() { - return _descendantsBlocks.value.firstOrNull() == _descendantsBlocks.value.getOrNull(currentIndex) + return _descendantsBlocks.value.firstOrNull() == _descendantsBlocks.value.getOrNull( + currentIndex + ) } val isLastIndexInContainer: Boolean get() { - return _descendantsBlocks.value.lastOrNull() == _descendantsBlocks.value.getOrNull(currentIndex) + return _descendantsBlocks.value.lastOrNull() == _descendantsBlocks.value.getOrNull( + currentIndex + ) } private val _verticalBlockCounts = MutableLiveData() @@ -71,10 +81,23 @@ class CourseUnitContainerViewModel( private val _subSectionUnitBlocks = MutableStateFlow>(listOf()) val subSectionUnitBlocks = _subSectionUnitBlocks.asStateFlow() + private val _videoList = MutableStateFlow>(listOf()) + val videoList = _videoList.asStateFlow() + + private val _videoPreview = MutableStateFlow>(emptyMap()) + val videoPreview = _videoPreview.asStateFlow() + + private val _videoProgress = MutableStateFlow>(emptyMap()) + val videoProgress = _videoProgress.asStateFlow() + + private val _currentBlock = MutableStateFlow(null) + val currentBlock = _currentBlock.asStateFlow() + + private val _hierarchyPath = MutableStateFlow("") + val hierarchyPath = _hierarchyPath.asStateFlow() + var nextButtonText = "" var hasNextBlock = false - - private var currentMode: CourseViewMode? = null private var currentComponentId = "" private var courseName = "" @@ -84,8 +107,7 @@ class CourseUnitContainerViewModel( val hasNetworkConnection: Boolean get() = networkConnection.isOnline() - fun loadBlocks(mode: CourseViewMode, componentId: String = "") { - currentMode = mode + fun loadBlocks(componentId: String = "") { viewModelScope.launch { try { val courseStructure = when (mode) { @@ -95,7 +117,10 @@ class CourseUnitContainerViewModel( val blocks = courseStructure.blockData courseName = courseStructure.name this@CourseUnitContainerViewModel.blocks.clearAndAddAll(blocks) - + if (mode == CourseViewMode.VIDEOS) { + _videoList.value = getAllVideoBlocks() + loadVideoData() + } setupCurrentIndex(componentId) } catch (e: Exception) { e.printStackTrace() @@ -110,8 +135,7 @@ class CourseUnitContainerViewModel( notifier.notifier.collect { event -> if (event is CourseStructureUpdated) { if (event.courseId != courseId) return@collect - - currentMode?.let { loadBlocks(it, currentComponentId) } + loadBlocks(currentComponentId) val blockId = blocks[currentVerticalIndex].id _subSectionUnitBlocks.value = getSubSectionUnitBlocks(blocks, getSubSectionId(blockId)) @@ -151,6 +175,8 @@ class CourseUnitContainerViewModel( currentIndex = _descendantsBlocks.value.indexOfFirst { it.id == componentId } _indexInContainer.value = currentIndex } + // Initialize current block + _currentBlock.value = getCurrentBlock() return } } @@ -220,7 +246,10 @@ class CourseUnitContainerViewModel( } fun getCurrentBlock(): Block { - return blocks[currentIndex] + val block = _descendantsBlocks.value.getOrNull(currentIndex) ?: blocks[currentVerticalIndex] + _currentBlock.value = block + _hierarchyPath.value = buildHierarchyPath(block) + return block } fun moveToNextBlock(): Block? { @@ -237,6 +266,8 @@ class CourseUnitContainerViewModel( if (currentVerticalIndex != -1) { _indexInContainer.value = currentIndex } + _currentBlock.value = block + _hierarchyPath.value = buildHierarchyPath(block) return block } return null @@ -298,4 +329,110 @@ class CourseUnitContainerViewModel( fun setUnitsListVisibility(isVisible: Boolean) { _unitsListShowed.value = isVisible } + + fun getAllVideoBlocks(): List = blocks.filter { it.type == BlockType.VIDEO } + + fun setSelectedVideoBlock(videoBlock: Block) { + // Find the parent vertical block for this video + val verticalBlock = findParentBlock(videoBlock.id) ?: return + val verticalIndex = blocks.indexOfFirst { it.id == verticalBlock.id } + if (verticalIndex == -1) return + + // Update vertical index + currentVerticalIndex = verticalIndex + + // Find and update section index + val sectionIndex = blocks.indexOfFirst { + it.descendants.contains(blocks[currentVerticalIndex].id) + } + if (sectionIndex != currentSectionIndex) { + currentSectionIndex = sectionIndex + blocks.getOrNull(currentSectionIndex)?.id?.let { + sendCourseSectionChanged(it) + } + } + + // Update descendants blocks for the new vertical + val verticalBlockData = blocks[currentVerticalIndex] + if (verticalBlockData.descendants.isNotEmpty() || verticalBlockData.isGated()) { + _descendantsBlocks.value = + verticalBlockData.descendants.mapNotNull { descendant -> + blocks.firstOrNull { descendant == it.id } + } + _subSectionUnitBlocks.value = + getSubSectionUnitBlocks(blocks, getSubSectionId(verticalBlockData.id)) + + if (_descendantsBlocks.value.isEmpty()) { + _descendantsBlocks.value = listOf(verticalBlockData) + } + } + + // Update vertical block counts + _verticalBlockCounts.value = verticalBlockData.descendants.size + + // Find the video block index in the new descendants and set it as current + val blockIndex = _descendantsBlocks.value.indexOfFirst { it.id == videoBlock.id } + if (blockIndex != -1) { + currentIndex = blockIndex + _indexInContainer.value = currentIndex + _currentBlock.value = videoBlock + _hierarchyPath.value = buildHierarchyPath(videoBlock) + } + viewModelScope.launch { + loadVideoProgress() + } + } + + private fun findParentBlock(childId: String): Block? { + return blocks.firstOrNull { it.descendants.contains(childId) } + } + + private fun loadVideoData() { + viewModelScope.launch { + loadVideoPreview() + loadVideoProgress() + } + } + + private suspend fun loadVideoProgress() { + val videoBlocks = getAllVideoBlocks() + val videoProgress = videoBlocks.associate { block -> + val videoProgressEntity = interactor.getVideoProgress(block.id) + val progress = videoProgressEntity.videoTime?.toFloat() + ?.safeDivBy(videoProgressEntity.duration?.toFloat() ?: 0f) ?: 0f + block.id to progress + } + _videoProgress.value = videoProgress + } + + private suspend fun loadVideoPreview() { + val videoBlocks = getAllVideoBlocks() + val videoPreview = withContext(Dispatchers.IO) { + videoPreviewHelper.getVideoPreviews(videoBlocks) + } + _videoPreview.value = videoPreview + } + + private fun buildHierarchyPath(block: Block): String { + val pathComponents = mutableListOf() + + val verticalBlock = findParentBlock(block.id) + verticalBlock?.let { vertical -> + // Vertical name + pathComponents.add(0, vertical.displayName) + // Find the parent Sequential block (Subsection) + val sequentialBlock = findParentBlock(vertical.id) + sequentialBlock?.let { sequential -> + pathComponents.add(0, sequential.displayName) + + // Find the parent Chapter block (Section) + val chapterBlock = findParentBlock(sequential.id) + chapterBlock?.let { chapter -> + pathComponents.add(0, chapter.displayName) + } + } + } + + return pathComponents.joinToString(" > ") + } } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt index 0f4a75697..0dda9e50b 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoFullScreenFragment.kt @@ -7,12 +7,12 @@ import androidx.core.os.bundleOf import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.DefaultPlayerUiController import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.YouTubePlayerTracker -import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiController import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -66,7 +66,7 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ binding.root.requestApplyInsetsWhenAttached() lifecycle.addObserver(binding.youtubePlayerView) - val options = IFramePlayerOptions.Builder() + val options = IFramePlayerOptions.Builder(requireContext()) .controls(0) .rel(0) .build() @@ -110,7 +110,7 @@ class YoutubeVideoFullScreenFragment : Fragment(R.layout.fragment_youtube_video_ binding.youtubePlayerView.isVisible = true val defPlayerUiController = DefaultPlayerUiController(binding.youtubePlayerView, youTubePlayer) - defPlayerUiController.setFullScreenButtonClickListener { + defPlayerUiController.setFullscreenButtonClickListener { parentFragmentManager.popBackStack() } diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt index 1afe71e91..352858cbe 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/YoutubeVideoUnitFragment.kt @@ -11,12 +11,12 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import com.pierfrancescosoffritti.androidyoutubeplayer.core.customui.DefaultPlayerUiController import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.PlayerConstants import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.YouTubePlayer import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.listeners.AbstractYouTubePlayerListener import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.options.IFramePlayerOptions import com.pierfrancescosoffritti.androidyoutubeplayer.core.player.utils.YouTubePlayerTracker -import com.pierfrancescosoffritti.androidyoutubeplayer.core.ui.DefaultPlayerUiController import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -142,7 +142,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) lifecycle.addObserver(binding.youtubePlayerView) - val options = IFramePlayerOptions.Builder() + val options = IFramePlayerOptions.Builder(requireContext()) .controls(0) .rel(0) .build() @@ -189,7 +189,7 @@ class YoutubeVideoUnitFragment : Fragment(R.layout.fragment_youtube_video_unit) binding.youtubePlayerView, youTubePlayer ) - defPlayerUiController.setFullScreenButtonClickListener { + defPlayerUiController.setFullscreenButtonClickListener { router.navigateToFullScreenYoutubeVideo( requireActivity().supportFragmentManager, viewModel.videoUrl, diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 5dffd7688..456669cc0 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -1,7 +1,5 @@ package org.openedx.course.presentation.videos -import android.annotation.SuppressLint -import android.content.Context import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers @@ -15,6 +13,7 @@ import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.helper.VideoPreviewHelper import org.openedx.core.domain.model.Block import org.openedx.core.extension.safeDivBy import org.openedx.core.module.DownloadWorkerController @@ -37,10 +36,8 @@ import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil -@SuppressLint("StaticFieldLeak") class CourseVideoViewModel( val courseId: String, - private val context: Context, private val config: Config, private val interactor: CourseInteractor, private val resourceManager: ResourceManager, @@ -51,6 +48,7 @@ class CourseVideoViewModel( private val fileUtil: FileUtil, val courseRouter: CourseRouter, private val analytics: CourseAnalytics, + private val videoPreviewHelper: VideoPreviewHelper, coreAnalytics: CoreAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController, @@ -188,10 +186,11 @@ class CourseVideoViewModel( viewModelScope.launch(Dispatchers.IO) { val downloadingModels = getDownloadModelList() courseVideos.values.flatten().forEach { block -> - val previewMap = block.id to block.getVideoPreview( - context, - networkConnection.isOnline(), - downloadingModels.find { block.id == it.id }?.path + val offlineUrl = downloadingModels.find { block.id == it.id }?.path + val previewMap = videoPreviewHelper.getVideoPreviewWithId( + blockId = block.id, + block = block, + offlineUrl = offlineUrl ) val currentUiState = (_uiState.value as? CourseVideoUIState.CourseData) ?: return@forEach diff --git a/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml b/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml index f06c77405..697e0840a 100644 --- a/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml +++ b/course/src/main/res/layout-w600dp-h480dp/fragment_course_unit_container.xml @@ -39,6 +39,14 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/frameBack" /> + + + app:layout_constraintTop_toBottomOf="@+id/videoList" /> + + + app:layout_constraintTop_toBottomOf="@+id/videoList" /> () private val config = mockk() private val interactor = mockk() private val resourceManager = mockk() @@ -71,6 +70,7 @@ class CourseHomeViewModelTest { 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" @@ -130,6 +130,8 @@ class CourseHomeViewModelTest { } returns Unit coEvery { workerController.saveModels(any()) } returns Unit + + every { videoPreviewHelper.getVideoPreview(any(), any()) } returns null } @After @@ -162,7 +164,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -173,6 +174,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -218,7 +220,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -229,6 +230,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -266,7 +268,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -277,6 +278,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -315,7 +317,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -326,6 +327,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -364,7 +366,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -375,6 +376,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -421,7 +423,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -432,6 +433,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -477,7 +479,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -488,6 +489,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -535,7 +537,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -546,6 +547,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -593,7 +595,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -604,6 +605,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -649,7 +651,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -660,6 +661,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -699,7 +701,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -710,6 +711,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -747,7 +749,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -758,6 +759,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -793,7 +795,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -804,6 +805,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, @@ -841,7 +843,6 @@ class CourseHomeViewModelTest { val viewModel = CourseHomeViewModel( courseId = courseId, courseTitle = courseTitle, - context = context, config = config, interactor = interactor, resourceManager = resourceManager, @@ -852,6 +853,7 @@ class CourseHomeViewModelTest { downloadDialogManager = downloadDialogManager, fileUtil = fileUtil, courseRouter = courseRouter, + videoPreviewHelper = videoPreviewHelper, coreAnalytics = coreAnalytics, downloadDao = downloadDao, workerController = workerController, 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 909fe0e8f..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,6 +20,7 @@ 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 @@ -45,6 +46,7 @@ class CourseUnitContainerViewModelTest { private val notifier = mockk() private val analytics = mockk() private val networkConnection = mockk() + private val videoPreviewHelper = mockk() private val assignmentProgress = AssignmentProgress( assignmentType = "Homework", @@ -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/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index 38ff2e49f..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 @@ -1,6 +1,5 @@ package org.openedx.course.presentation.videos -import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner @@ -32,6 +31,7 @@ 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 @@ -66,7 +66,6 @@ class CourseVideoViewModelTest { private val dispatcher = UnconfinedTestDispatcher() - private val context = mockk() private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() @@ -81,6 +80,7 @@ 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" @@ -211,6 +211,11 @@ class CourseVideoViewModelTest { any(), ) } returns Unit + + every { videoPreviewHelper.getVideoPreviewWithId(any(), any(), any()) } returns Pair( + "test", + null + ) } @After @@ -228,7 +233,6 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -239,6 +243,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -266,7 +271,6 @@ class CourseVideoViewModelTest { every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -277,6 +281,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -310,7 +315,6 @@ class CourseVideoViewModelTest { coEvery { interactor.getVideoProgress(any()) } returns VideoProgressEntity("", "", 0L, 0L) val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -321,6 +325,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -356,7 +361,6 @@ class CourseVideoViewModelTest { every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -367,6 +371,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -398,7 +403,6 @@ class CourseVideoViewModelTest { every { downloadDao.getAllDataFlow() } returns flow { emit(emptyList()) } val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -409,6 +413,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, @@ -444,7 +449,6 @@ class CourseVideoViewModelTest { coEvery { interactor.getCourseStructureForVideos(any()) } returns courseStructure val viewModel = CourseVideoViewModel( "", - context, config, interactor, resourceManager, @@ -455,6 +459,7 @@ class CourseVideoViewModelTest { fileUtil, courseRouter, courseAnalytics, + videoPreviewHelper, coreAnalytics, downloadDao, workerController, diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index a7f265a45..952e041de 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -67,6 +67,8 @@ BRANCH: EXPERIMENTAL_FEATURES: APP_LEVEL_DOWNLOADS: ENABLED: false + APP_LEVEL_DATES: + ENABLED: false #Platform names PLATFORM_NAME: "OpenEdX"