diff --git a/app/build.gradle b/app/build.gradle index f7ad7ef16..f3d047808 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -127,11 +127,13 @@ dependencies { implementation project(path: ':profile') implementation project(path: ':discussion') implementation project(path: ':whatsnew') + implementation project(path: ':dates') implementation project(path: ':downloads') ksp "androidx.room:room-compiler:$room_version" implementation "androidx.core:core-splashscreen:$core_splashscreen_version" + implementation "androidx.datastore:datastore-preferences:1.2.0" api platform("com.google.firebase:firebase-bom:$firebase_version") api "com.google.firebase:firebase-messaging" diff --git a/app/schemas/org.openedx.app.room.AppDatabase/4.json b/app/schemas/org.openedx.app.room.AppDatabase/4.json index 0bf47775d..53c0f4277 100644 --- a/app/schemas/org.openedx.app.room.AppDatabase/4.json +++ b/app/schemas/org.openedx.app.room.AppDatabase/4.json @@ -131,26 +131,22 @@ { "fieldPath": "media.bannerImage", "columnName": "bannerImage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "media.courseImage", "columnName": "courseImage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "media.courseVideo", "columnName": "courseVideo", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "media.image", "columnName": "image", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" } ], "primaryKey": { @@ -158,9 +154,7 @@ "columnNames": [ "id" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "course_enrolled_table", @@ -301,62 +295,52 @@ { "fieldPath": "course.coursewareAccess.hasAccess", "columnName": "hasAccess", - "affinity": "INTEGER", - "notNull": false + "affinity": "INTEGER" }, { "fieldPath": "course.coursewareAccess.errorCode", "columnName": "errorCode", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "course.coursewareAccess.developerMessage", "columnName": "developerMessage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "course.coursewareAccess.userMessage", "columnName": "userMessage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "course.coursewareAccess.additionalContextUserMessage", "columnName": "additionalContextUserMessage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "course.coursewareAccess.userFragment", "columnName": "userFragment", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "course.media.bannerImage", "columnName": "bannerImage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "course.media.courseImage", "columnName": "courseImage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "course.media.courseVideo", "columnName": "courseVideo", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "course.media.image", "columnName": "image", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "course.courseSharingUtmParameters.facebook", @@ -373,8 +357,7 @@ { "fieldPath": "certificate.certificateURL", "columnName": "certificateURL", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "progress.assignmentsCompleted", @@ -391,38 +374,32 @@ { "fieldPath": "courseStatus.lastVisitedModuleId", "columnName": "lastVisitedModuleId", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseStatus.lastVisitedModulePath", "columnName": "lastVisitedModulePath", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseStatus.lastVisitedBlockId", "columnName": "lastVisitedBlockId", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseStatus.lastVisitedUnitDisplayName", "columnName": "lastVisitedUnitDisplayName", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseAssignments.futureAssignments", "columnName": "futureAssignments", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseAssignments.pastAssignments", "columnName": "pastAssignments", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" } ], "primaryKey": { @@ -430,9 +407,7 @@ "columnNames": [ "courseId" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "course_structure_table", @@ -477,8 +452,7 @@ { "fieldPath": "start", "columnName": "start", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "startDisplay", @@ -495,8 +469,7 @@ { "fieldPath": "end", "columnName": "end", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "isSelfPaced", @@ -507,68 +480,57 @@ { "fieldPath": "coursewareAccess.hasAccess", "columnName": "hasAccess", - "affinity": "INTEGER", - "notNull": false + "affinity": "INTEGER" }, { "fieldPath": "coursewareAccess.errorCode", "columnName": "errorCode", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "coursewareAccess.developerMessage", "columnName": "developerMessage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "coursewareAccess.userMessage", "columnName": "userMessage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "coursewareAccess.additionalContextUserMessage", "columnName": "additionalContextUserMessage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "coursewareAccess.userFragment", "columnName": "userFragment", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "media.bannerImage", "columnName": "bannerImage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "media.courseImage", "columnName": "courseImage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "media.courseVideo", "columnName": "courseVideo", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "media.image", "columnName": "image", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "certificate.certificateURL", "columnName": "certificateURL", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "progress.assignmentsCompleted", @@ -588,9 +550,7 @@ "columnNames": [ "id" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "download_model", @@ -647,8 +607,7 @@ { "fieldPath": "lastModified", "columnName": "lastModified", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" } ], "primaryKey": { @@ -656,9 +615,7 @@ "columnNames": [ "id" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "offline_x_block_progress_table", @@ -700,9 +657,7 @@ "columnNames": [ "id" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "course_calendar_event_table", @@ -726,9 +681,7 @@ "columnNames": [ "event_id" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "course_calendar_state_table", @@ -758,9 +711,7 @@ "columnNames": [ "course_id" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "download_course_preview_table", @@ -775,20 +726,17 @@ { "fieldPath": "name", "columnName": "course_name", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "image", "columnName": "course_image", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "totalSize", "columnName": "total_size", - "affinity": "INTEGER", - "notNull": false + "affinity": "INTEGER" } ], "primaryKey": { @@ -796,9 +744,7 @@ "columnNames": [ "course_id" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "course_enrollment_details_table", @@ -849,62 +795,52 @@ { "fieldPath": "courseAccessDetails.auditAccessExpires", "columnName": "auditAccessExpires", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseAccessDetails.coursewareAccess.hasAccess", "columnName": "hasAccess", - "affinity": "INTEGER", - "notNull": false + "affinity": "INTEGER" }, { "fieldPath": "courseAccessDetails.coursewareAccess.errorCode", "columnName": "errorCode", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseAccessDetails.coursewareAccess.developerMessage", "columnName": "developerMessage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseAccessDetails.coursewareAccess.userMessage", "columnName": "userMessage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseAccessDetails.coursewareAccess.additionalContextUserMessage", "columnName": "additionalContextUserMessage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseAccessDetails.coursewareAccess.userFragment", "columnName": "userFragment", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "certificate.certificateURL", "columnName": "certificateURL", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "enrollmentDetails.created", "columnName": "created", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "enrollmentDetails.mode", "columnName": "mode", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "enrollmentDetails.isActive", @@ -915,8 +851,7 @@ { "fieldPath": "enrollmentDetails.upgradeDeadline", "columnName": "upgradeDeadline", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseInfoOverview.name", @@ -963,26 +898,22 @@ { "fieldPath": "courseInfoOverview.media.bannerImage", "columnName": "bannerImage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseInfoOverview.media.courseImage", "columnName": "courseImage", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseInfoOverview.media.courseVideo", "columnName": "courseVideo", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseInfoOverview.media.image", "columnName": "image", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseInfoOverview.courseSharingUtmParameters.facebook", @@ -1002,9 +933,7 @@ "columnNames": [ "id" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "video_progress_table", @@ -1040,9 +969,7 @@ "columnNames": [ "block_id" ] - }, - "indices": [], - "foreignKeys": [] + } }, { "tableName": "course_progress_table", @@ -1123,98 +1050,82 @@ { "fieldPath": "certificateData.certStatus", "columnName": "certificate_certStatus", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "certificateData.certWebViewUrl", "columnName": "certificate_certWebViewUrl", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "certificateData.downloadUrl", "columnName": "certificate_downloadUrl", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "certificateData.certificateAvailableDate", "columnName": "certificate_certificateAvailableDate", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "completionSummary.completeCount", "columnName": "completion_completeCount", - "affinity": "INTEGER", - "notNull": false + "affinity": "INTEGER" }, { "fieldPath": "completionSummary.incompleteCount", "columnName": "completion_incompleteCount", - "affinity": "INTEGER", - "notNull": false + "affinity": "INTEGER" }, { "fieldPath": "completionSummary.lockedCount", "columnName": "completion_lockedCount", - "affinity": "INTEGER", - "notNull": false + "affinity": "INTEGER" }, { "fieldPath": "courseGrade.letterGrade", "columnName": "grade_letterGrade", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "courseGrade.percent", "columnName": "grade_percent", - "affinity": "REAL", - "notNull": false + "affinity": "REAL" }, { "fieldPath": "courseGrade.isPassing", "columnName": "grade_isPassing", - "affinity": "INTEGER", - "notNull": false + "affinity": "INTEGER" }, { "fieldPath": "gradingPolicy.assignmentPolicies", "columnName": "grading_assignmentPolicies", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "gradingPolicy.gradeRange", "columnName": "grading_gradeRange", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "gradingPolicy.assignmentColors", "columnName": "grading_assignmentColors", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "verificationData.link", "columnName": "verification_link", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "verificationData.status", "columnName": "verification_status", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" }, { "fieldPath": "verificationData.statusDate", "columnName": "verification_statusDate", - "affinity": "TEXT", - "notNull": false + "affinity": "TEXT" } ], "primaryKey": { @@ -1222,12 +1133,9 @@ "columnNames": [ "courseId" ] - }, - "indices": [], - "foreignKeys": [] + } } ], - "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7ea446decde04c9c16700cb3981703c2')" diff --git a/app/schemas/org.openedx.app.room.AppDatabase/6.json b/app/schemas/org.openedx.app.room.AppDatabase/6.json new file mode 100644 index 000000000..de1e51a90 --- /dev/null +++ b/app/schemas/org.openedx.app.room.AppDatabase/6.json @@ -0,0 +1,1206 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "3c35a346cc635ac7115a9f5021306a61", + "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": "course_dates_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `first_component_block_id` TEXT, `course_id` TEXT NOT NULL, `due_date` TEXT, `assignment_title` TEXT, `learner_has_access` INTEGER, `relative` INTEGER, `course_name` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstComponentBlockId", + "columnName": "first_component_block_id", + "affinity": "TEXT" + }, + { + "fieldPath": "courseId", + "columnName": "course_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dueDate", + "columnName": "due_date", + "affinity": "TEXT" + }, + { + "fieldPath": "assignmentTitle", + "columnName": "assignment_title", + "affinity": "TEXT" + }, + { + "fieldPath": "learnerHasAccess", + "columnName": "learner_has_access", + "affinity": "INTEGER" + }, + { + "fieldPath": "relative", + "columnName": "relative", + "affinity": "INTEGER" + }, + { + "fieldPath": "courseName", + "columnName": "course_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "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, '3c35a346cc635ac7115a9f5021306a61')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 65c64e538..831fe4a86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,12 +48,6 @@ - - - - - - diff --git a/app/src/main/java/org/openedx/app/AnalyticsManager.kt b/app/src/main/java/org/openedx/app/AnalyticsManager.kt index 6c29cdf12..5e96784d8 100644 --- a/app/src/main/java/org/openedx/app/AnalyticsManager.kt +++ b/app/src/main/java/org/openedx/app/AnalyticsManager.kt @@ -6,6 +6,7 @@ import org.openedx.core.presentation.DownloadsAnalytics import org.openedx.core.presentation.dialog.appreview.AppReviewAnalytics import org.openedx.course.presentation.CourseAnalytics import org.openedx.dashboard.presentation.DashboardAnalytics +import org.openedx.dates.presentation.DatesAnalytics import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.foundation.interfaces.Analytics @@ -23,7 +24,8 @@ class AnalyticsManager : DiscussionAnalytics, ProfileAnalytics, WhatsNewAnalytics, - DownloadsAnalytics { + DownloadsAnalytics, + DatesAnalytics { private val analytics: MutableList = mutableListOf() diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index b904bf6a1..f0a71f713 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -2,9 +2,6 @@ package org.openedx.app import android.content.Intent import android.content.res.Configuration -import android.graphics.Color -import android.net.Uri -import android.os.Build import android.os.Bundle import android.view.View import android.view.WindowManager @@ -26,7 +23,6 @@ import org.openedx.app.databinding.ActivityAppBinding import org.openedx.app.deeplink.DeepLink import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment -import org.openedx.core.ApiConstants import org.openedx.core.data.storage.CorePreferences import org.openedx.core.presentation.dialog.downloaddialog.DownloadDialogManager import org.openedx.core.presentation.global.InsetHolder @@ -70,13 +66,6 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private val authCode: String? get() { val data = intent?.data - if ( - data is Uri && - data.scheme == BuildConfig.APPLICATION_ID && - data.host == ApiConstants.BrowserLogin.REDIRECT_HOST - ) { - return data.getQueryParameter(ApiConstants.BrowserLogin.CODE_QUERY_PARAM) - } return null } @@ -160,12 +149,8 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { WindowCompat.setDecorFitsSystemWindows(this, false) val insetsController = WindowInsetsControllerCompat(this, binding.root) insetsController.isAppearanceLightStatusBars = !isUsingNightModeResources() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { - insetsController.systemBarsBehavior = - WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE - } else { - window.statusBarColor = Color.TRANSPARENT - } + insetsController.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE } } @@ -176,7 +161,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { val fragment = if (viewModel.isLogistrationEnabled && authCode == null) { LogistrationFragment() } else { - SignInFragment.newInstance(null, null, authCode = authCode) + SignInFragment.newInstance(null, null) } addFragment(fragment) } @@ -224,7 +209,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { this.intent = intent if (authCode != null) { - addFragment(SignInFragment.newInstance(null, null, authCode = authCode)) + addFragment(SignInFragment.newInstance(null, null)) } val extras = intent.extras diff --git a/app/src/main/java/org/openedx/app/AppAnalytics.kt b/app/src/main/java/org/openedx/app/AppAnalytics.kt index 55b26b492..997ab096d 100644 --- a/app/src/main/java/org/openedx/app/AppAnalytics.kt +++ b/app/src/main/java/org/openedx/app/AppAnalytics.kt @@ -20,6 +20,10 @@ enum class AppAnalyticsEvent(val eventName: String, val biValue: String) { "MainDashboard:Discover", "edx.bi.app.main_dashboard.discover" ), + DATES( + "MainDashboard:DATES", + "edx.bi.app.main_dashboard.dates" + ), DOWNLOADS( "MainDashboard:Downloads", "edx.bi.app.main_dashboard.downloads" diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 4678344ee..a511dc839 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -13,6 +13,7 @@ import org.openedx.core.CalendarRouter import org.openedx.core.FragmentViewType import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment +import org.openedx.core.presentation.global.webview.SSOWebContentFragment import org.openedx.core.presentation.global.webview.WebContentFragment import org.openedx.core.presentation.settings.video.VideoQualityFragment import org.openedx.core.presentation.settings.video.VideoQualityType @@ -29,6 +30,7 @@ import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment import org.openedx.course.settings.download.DownloadQueueFragment import org.openedx.courses.presentation.AllEnrolledCoursesFragment import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.dates.presentation.DatesRouter import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discovery.presentation.NativeDiscoveryFragment import org.openedx.discovery.presentation.WebViewDiscoveryFragment @@ -69,7 +71,8 @@ class AppRouter : AppUpgradeRouter, WhatsNewRouter, CalendarRouter, - DownloadsRouter { + DownloadsRouter, + DatesRouter { // region AuthRouter override fun navigateToMain( @@ -406,7 +409,7 @@ class AppRouter : if (isLogistrationEnabled) { replaceFragment(fm, LogistrationFragment()) } else { - replaceFragment(fm, SignInFragment()) + replaceFragment(fm, SignInFragment.newInstance(null, null)) } } } @@ -432,6 +435,13 @@ class AppRouter : ) } + override fun navigateToSSOWebContent(fm: FragmentManager, title: String, url: String) { + replaceFragmentWithBackStack( + fm, + SSOWebContentFragment.newInstance(title = title, url = url) + ) + } + override fun navigateToManageAccount(fm: FragmentManager) { replaceFragmentWithBackStack(fm, ManageAccountFragment()) } diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index e195a7940..bafddb19b 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -29,6 +29,7 @@ import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Directories import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.presentation.SingleEventLiveData +import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @SuppressLint("StaticFieldLeak") @@ -42,8 +43,9 @@ class AppViewModel( private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, private val downloadNotifier: DownloadNotifier, - private val context: Context -) : BaseViewModel() { + private val context: Context, + resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _logoutUser = SingleEventLiveData() val logoutUser: LiveData diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 82092e439..397216b74 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -27,6 +27,7 @@ import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.dates.presentation.dates.DatesFragment import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.downloads.presentation.download.DownloadsFragment import org.openedx.learn.presentation.LearnFragment @@ -104,6 +105,9 @@ class MainFragment : Fragment(R.layout.fragment_main) { if (viewModel.isDownloadsFragmentEnabled) { add(R.id.fragmentDownloads to { DownloadsFragment() }) } + if (viewModel.isDatesFragmentEnabled) { + add(R.id.fragmentDates to { DatesFragment() }) + } add(R.id.fragmentProfile to { ProfileFragment() }) } } @@ -113,12 +117,14 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentLearn to resources.getString(R.string.app_navigation_learn), R.id.fragmentDiscover to resources.getString(R.string.app_navigation_discovery), R.id.fragmentDownloads to resources.getString(R.string.app_navigation_downloads), + R.id.fragmentDates to resources.getString(R.string.app_navigation_dates), R.id.fragmentProfile to resources.getString(R.string.app_navigation_profile), ) val tabIconSelectors = mapOf( R.id.fragmentLearn to R.drawable.app_ic_learn_selector, R.id.fragmentDiscover to R.drawable.app_ic_discover_selector, R.id.fragmentDownloads to R.drawable.app_ic_downloads_selector, + R.id.fragmentDates to R.drawable.app_ic_dates_selector, R.id.fragmentProfile to R.drawable.app_ic_profile_selector ) @@ -136,6 +142,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentLearn -> viewModel.logLearnTabClickedEvent() R.id.fragmentDiscover -> viewModel.logDiscoveryTabClickedEvent() R.id.fragmentDownloads -> viewModel.logDownloadsTabClickedEvent() + R.id.fragmentDates -> viewModel.logDatesTabClickedEvent() R.id.fragmentProfile -> viewModel.logProfileTabClickedEvent() } menuIdToIndex[menuItem.itemId]?.let { index -> @@ -174,6 +181,7 @@ class MainFragment : Fragment(R.layout.fragment_main) { R.id.fragmentLearn } + HomeTab.DATES.name -> R.id.fragmentDates HomeTab.PROFILE.name -> R.id.fragmentProfile else -> R.id.fragmentLearn } diff --git a/app/src/main/java/org/openedx/app/MainViewModel.kt b/app/src/main/java/org/openedx/app/MainViewModel.kt index 8723d6dbe..828b14a39 100644 --- a/app/src/main/java/org/openedx/app/MainViewModel.kt +++ b/app/src/main/java/org/openedx/app/MainViewModel.kt @@ -18,13 +18,15 @@ import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.discovery.presentation.DiscoveryNavigator import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class MainViewModel( private val config: Config, private val notifier: DiscoveryNotifier, private val analytics: AppAnalytics, private val appNotifier: AppNotifier, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _isBottomBarEnabled = MutableLiveData(true) val isBottomBarEnabled: LiveData @@ -41,6 +43,7 @@ class MainViewModel( val isDiscoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() val getDiscoveryFragment get() = DiscoveryNavigator(isDiscoveryTypeWebView).getDiscoveryFragment() + val isDatesFragmentEnabled get() = config.getDatesConfig().isEnabled val isDownloadsFragmentEnabled get() = config.getDownloadsConfig().isEnabled override fun onCreate(owner: LifecycleOwner) { @@ -65,6 +68,10 @@ class MainViewModel( logScreenEvent(AppAnalyticsEvent.DOWNLOADS) } + fun logDatesTabClickedEvent() { + logScreenEvent(AppAnalyticsEvent.DATES) + } + fun logProfileTabClickedEvent() { logScreenEvent(AppAnalyticsEvent.PROFILE) } diff --git a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt index a4daf0809..baafe5a86 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt @@ -25,7 +25,7 @@ class HeadersInterceptor( addHeader("Accept", "application/json") val httpAgent = System.getProperty("http.agent") ?: "" - addHeader("User-Agent", "$httpAgent ${appData.versionName}") + addHeader("User-Agent", "$httpAgent ${appData.appUserAgent}") }.build() ) } diff --git a/app/src/main/java/org/openedx/app/data/storage/DataStoreEncryption.kt b/app/src/main/java/org/openedx/app/data/storage/DataStoreEncryption.kt new file mode 100644 index 000000000..cdf574af1 --- /dev/null +++ b/app/src/main/java/org/openedx/app/data/storage/DataStoreEncryption.kt @@ -0,0 +1,86 @@ +package org.openedx.app.data.storage + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class DataStoreEncryption { + + private companion object { + const val TAG = "DataStoreEncryption" + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val KEY_ALIAS = "openedx_datastore_key" + const val TRANSFORMATION = "AES/GCM/NoPadding" + const val GCM_IV_LENGTH = 12 + const val GCM_TAG_LENGTH = 16 + const val GCM_TAG_LENGTH_BITS = GCM_TAG_LENGTH * 8 + const val KEY_SIZE = 256 + } + + private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + + private fun getOrCreateSecretKey(): SecretKey { + keyStore.getEntry(KEY_ALIAS, null)?.let { entry -> + return (entry as KeyStore.SecretKeyEntry).secretKey + } + + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE + ) + + val keyGenParameterSpec = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(KEY_SIZE) + .build() + + keyGenerator.init(keyGenParameterSpec) + return keyGenerator.generateKey() + } + + fun encrypt(plainText: String): String { + if (plainText.isEmpty()) return "" + + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey()) + + val iv = cipher.iv + val encryptedBytes = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) + + val combined = ByteArray(iv.size + encryptedBytes.size) + System.arraycopy(iv, 0, combined, 0, iv.size) + System.arraycopy(encryptedBytes, 0, combined, iv.size, encryptedBytes.size) + + return Base64.encodeToString(combined, Base64.NO_WRAP) + } + + fun decrypt(encryptedText: String): String { + if (encryptedText.isEmpty()) return "" + + return try { + val combined = Base64.decode(encryptedText, Base64.NO_WRAP) + + val iv = combined.copyOfRange(0, GCM_IV_LENGTH) + val encryptedBytes = combined.copyOfRange(GCM_IV_LENGTH, combined.size) + + val cipher = Cipher.getInstance(TRANSFORMATION) + val spec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv) + cipher.init(Cipher.DECRYPT_MODE, getOrCreateSecretKey(), spec) + + String(cipher.doFinal(encryptedBytes), Charsets.UTF_8) + } catch (e: Exception) { + Log.e(TAG, "Decryption failed", e) + "" + } + } +} diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 3c8ea881e..48b0d58a1 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -1,23 +1,49 @@ package org.openedx.app.data.storage import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore import com.google.gson.Gson -import org.openedx.app.BuildConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import org.openedx.core.data.model.User 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.model.AppConfig +import org.openedx.core.domain.model.CalendarType import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings import org.openedx.core.system.CalendarManager +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.course.data.storage.CoursePreferences import org.openedx.foundation.extension.replaceSpace import org.openedx.profile.data.model.Account import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.whatsnew.data.storage.WhatsNewPreferences -class PreferencesManager(context: Context) : +private val Context.dataStore: DataStore by preferencesDataStore( + name = "openedx_prefs", + produceMigrations = { context -> + listOf(SharedPrefsToDataStoreMigration(context)) + } +) + +class PreferencesManager( + private val context: Context, + private val appNotifier: AppNotifier, +) : CorePreferences, ProfilePreferences, WhatsNewPreferences, @@ -25,219 +51,211 @@ class PreferencesManager(context: Context) : CoursePreferences, CalendarPreferences { - private val sharedPreferences = - context.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE) - - private fun saveString(key: String, value: String) { - sharedPreferences.edit().apply { - putString(key, value) - }.apply() + private val dataStore: DataStore + get() = context.dataStore + + private val encryption = DataStoreEncryption() + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + @Volatile + private var isKeyInvalidated = false + + private object Keys { + val ACCESS_TOKEN = stringPreferencesKey("access_token") + val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + val PUSH_TOKEN = stringPreferencesKey("push_token") + val EXPIRES_IN = longPreferencesKey("expires_in") + val USER = stringPreferencesKey("user") + val ACCOUNT = stringPreferencesKey("account") + val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY = + booleanPreferencesKey("video_settings_wifi_download_only") + val VIDEO_SETTINGS_STREAMING_QUALITY = + stringPreferencesKey("video_settings_streaming_quality") + val VIDEO_SETTINGS_DOWNLOAD_QUALITY = + stringPreferencesKey("video_settings_download_quality") + val APP_CONFIG = stringPreferencesKey("app_config") + val CAN_RESET_APP_DIRECTORY = booleanPreferencesKey("reset_app_directory") + val IS_RELATIVE_DATES_ENABLED = booleanPreferencesKey("is_relative_dates_enabled") + val CALENDAR_ID = longPreferencesKey("calendar_id") + val CALENDAR_USER = stringPreferencesKey("calendar_user") + val CALENDAR_TYPE = stringPreferencesKey("calendar_type") + val IS_CALENDAR_SYNC_ENABLED = booleanPreferencesKey("is_calendar_sync_enabled") + val IS_HIDE_INACTIVE_COURSES = booleanPreferencesKey("hide_inactive_courses") + val LAST_WHATS_NEW_VERSION = stringPreferencesKey("last_whats_new_version") + val LAST_REVIEW_VERSION = stringPreferencesKey("last_review_version") + val WAS_POSITIVE_RATED = booleanPreferencesKey("app_was_positive_rated") + + fun calendarSyncDialogShown(courseName: String) = + booleanPreferencesKey("calendar_sync_dialog_${courseName.replaceSpace("_")}") } - private fun getString(key: String, defValue: String = ""): String { - return sharedPreferences.getString(key, defValue) ?: defValue - } + private fun getValue(key: Preferences.Key, defaultValue: T): T = + runBlocking(Dispatchers.IO) { + dataStore.data.map { it[key] ?: defaultValue }.first() + } - private fun saveLong(key: String, value: Long) { - sharedPreferences.edit().apply { - putLong(key, value) - }.apply() + private fun setValue(key: Preferences.Key, value: T) { + runBlocking(Dispatchers.IO) { dataStore.edit { it[key] = value } } } - private fun getLong(key: String, defValue: Long = 0): Long = sharedPreferences.getLong(key, defValue) - - private fun saveBoolean(key: String, value: Boolean) { - sharedPreferences.edit().apply { - putBoolean(key, value) - }.apply() + private fun getEncryptedString(key: Preferences.Key, defaultValue: String): String { + val encrypted = if (!isKeyInvalidated) getValue(key, "") else "" + if (encrypted.isEmpty()) return defaultValue + val decrypted = encryption.decrypt(encrypted) + if (decrypted.isEmpty()) { + // Encrypted data exists but decryption failed — keystore likely invalidated. + // Clear corrupted data and force re-login. + isKeyInvalidated = true + scope.launch { + clearCorePreferences() + appNotifier.send(LogoutEvent(true)) + } + } + return decrypted.ifEmpty { defaultValue } } - private fun getBoolean(key: String, defValue: Boolean = false): Boolean { - return sharedPreferences.getBoolean(key, defValue) + private fun setEncryptedString(key: Preferences.Key, value: String) { + val encrypted = if (value.isEmpty()) "" else encryption.encrypt(value) + setValue(key, encrypted) } - override fun clearCorePreferences() { - sharedPreferences.edit().apply { - remove(ACCESS_TOKEN) - remove(REFRESH_TOKEN) - remove(USER) - remove(ACCOUNT) - remove(EXPIRES_IN) - }.apply() + override suspend fun clearCorePreferences() { + dataStore.edit { prefs -> + prefs.remove(Keys.ACCESS_TOKEN) + prefs.remove(Keys.REFRESH_TOKEN) + prefs.remove(Keys.PUSH_TOKEN) + prefs.remove(Keys.EXPIRES_IN) + prefs.remove(Keys.USER) + prefs.remove(Keys.ACCOUNT) + } } - override fun clearCalendarPreferences() { - sharedPreferences.edit().apply { - remove(CALENDAR_ID) - remove(IS_CALENDAR_SYNC_ENABLED) - remove(HIDE_INACTIVE_COURSES) - }.apply() + override suspend fun clearCalendarPreferences() { + dataStore.edit { prefs -> + prefs.remove(Keys.CALENDAR_ID) + prefs.remove(Keys.CALENDAR_TYPE) + prefs.remove(Keys.IS_CALENDAR_SYNC_ENABLED) + prefs.remove(Keys.IS_HIDE_INACTIVE_COURSES) + } } override var accessToken: String - set(value) { - saveString(ACCESS_TOKEN, value) - } - get() = getString(ACCESS_TOKEN) + get() = getEncryptedString(Keys.ACCESS_TOKEN, "") + set(value) = setEncryptedString(Keys.ACCESS_TOKEN, value) override var refreshToken: String - set(value) { - saveString(REFRESH_TOKEN, value) - } - get() = getString(REFRESH_TOKEN) + get() = getEncryptedString(Keys.REFRESH_TOKEN, "") + set(value) = setEncryptedString(Keys.REFRESH_TOKEN, value) override var pushToken: String - set(value) { - saveString(PUSH_TOKEN, value) - } - get() = getString(PUSH_TOKEN) + get() = getEncryptedString(Keys.PUSH_TOKEN, "") + set(value) = setEncryptedString(Keys.PUSH_TOKEN, value) override var accessTokenExpiresAt: Long - set(value) { - saveLong(EXPIRES_IN, value) - } - get() = getLong(EXPIRES_IN) - - override var calendarId: Long - set(value) { - saveLong(CALENDAR_ID, value) - } - get() = getLong(CALENDAR_ID, CalendarManager.CALENDAR_DOES_NOT_EXIST) + get() = getValue(Keys.EXPIRES_IN, 0L) + set(value) = setValue(Keys.EXPIRES_IN, value) override var user: User? - set(value) { - val userJson = Gson().toJson(value) - saveString(USER, userJson) - } get() { - val userString = getString(USER) - return Gson().fromJson(userString, User::class.java) - } - - override var profile: Account? - set(value) { - val accountJson = Gson().toJson(value) - saveString(ACCOUNT, accountJson) - } - get() { - val accountString = getString(ACCOUNT) - return Gson().fromJson(accountString, Account::class.java) + val json = getEncryptedString(Keys.USER, "") + return if (json.isEmpty()) null else Gson().fromJson(json, User::class.java) } + set(value) = setEncryptedString(Keys.USER, Gson().toJson(value)) override var videoSettings: VideoSettings - set(value) { - saveBoolean(VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY, value.wifiDownloadOnly) - saveString(VIDEO_SETTINGS_STREAMING_QUALITY, value.videoStreamingQuality.name) - saveString(VIDEO_SETTINGS_DOWNLOAD_QUALITY, value.videoDownloadQuality.name) - } get() { - val wifiDownloadOnly = getBoolean(VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY, defValue = true) - val streamingQualityString = - getString(VIDEO_SETTINGS_STREAMING_QUALITY, defValue = VideoQuality.AUTO.name) - val downloadQualityString = - getString(VIDEO_SETTINGS_DOWNLOAD_QUALITY, defValue = VideoQuality.AUTO.name) - + val wifiDownloadOnly = getValue(Keys.VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY, true) + val streamingQuality = + getValue(Keys.VIDEO_SETTINGS_STREAMING_QUALITY, VideoQuality.AUTO.name) + val downloadQuality = + getValue(Keys.VIDEO_SETTINGS_DOWNLOAD_QUALITY, VideoQuality.AUTO.name) return VideoSettings( wifiDownloadOnly = wifiDownloadOnly, - videoStreamingQuality = VideoQuality.valueOf(streamingQualityString), - videoDownloadQuality = VideoQuality.valueOf(downloadQualityString) + videoStreamingQuality = VideoQuality.valueOf(streamingQuality), + videoDownloadQuality = VideoQuality.valueOf(downloadQuality) ) } - - override var appConfig: AppConfig set(value) { - val appConfigJson = Gson().toJson(value) - saveString(APP_CONFIG, appConfigJson) + runBlocking(Dispatchers.IO) { + dataStore.edit { prefs -> + prefs[Keys.VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY] = value.wifiDownloadOnly + prefs[Keys.VIDEO_SETTINGS_STREAMING_QUALITY] = value.videoStreamingQuality.name + prefs[Keys.VIDEO_SETTINGS_DOWNLOAD_QUALITY] = value.videoDownloadQuality.name + } + } } + + override var appConfig: AppConfig get() { - val appConfigString = getString(APP_CONFIG, getDefaultAppConfig()) - return Gson().fromJson(appConfigString, AppConfig::class.java) + val json = getValue(Keys.APP_CONFIG, Gson().toJson(AppConfig())) + return Gson().fromJson(json, AppConfig::class.java) } + set(value) = setValue(Keys.APP_CONFIG, Gson().toJson(value)) - private fun getDefaultAppConfig() = Gson().toJson(AppConfig()) + override var canResetAppDirectory: Boolean + get() = getValue(Keys.CAN_RESET_APP_DIRECTORY, true) + set(value) = setValue(Keys.CAN_RESET_APP_DIRECTORY, value) - override var lastWhatsNewVersion: String - set(value) { - saveString(LAST_WHATS_NEW_VERSION, value) + override var isRelativeDatesEnabled: Boolean + get() = getValue(Keys.IS_RELATIVE_DATES_ENABLED, true) + set(value) = setValue(Keys.IS_RELATIVE_DATES_ENABLED, value) + + override var profile: Account? + get() { + val json = getEncryptedString(Keys.ACCOUNT, "") + return if (json.isEmpty()) null else Gson().fromJson(json, Account::class.java) } - get() = getString(LAST_WHATS_NEW_VERSION) + set(value) = setEncryptedString(Keys.ACCOUNT, Gson().toJson(value)) + + override var lastWhatsNewVersion: String + get() = getValue(Keys.LAST_WHATS_NEW_VERSION, "") + set(value) = setValue(Keys.LAST_WHATS_NEW_VERSION, value) override var lastReviewVersion: InAppReviewPreferences.VersionName - set(value) { - val versionNameJson = Gson().toJson(value) - saveString(LAST_REVIEW_VERSION, versionNameJson) - } get() { - val versionNameString = getString(LAST_REVIEW_VERSION) - return Gson().fromJson( - versionNameString, - InAppReviewPreferences.VersionName::class.java - ) - ?: InAppReviewPreferences.VersionName.default + val json = getValue(Keys.LAST_REVIEW_VERSION, "") + return if (json.isEmpty()) { + InAppReviewPreferences.VersionName.default + } else { + Gson().fromJson(json, InAppReviewPreferences.VersionName::class.java) + ?: InAppReviewPreferences.VersionName.default + } } + set(value) = setValue(Keys.LAST_REVIEW_VERSION, Gson().toJson(value)) override var wasPositiveRated: Boolean - set(value) { - saveBoolean(APP_WAS_POSITIVE_RATED, value) - } - get() = getBoolean(APP_WAS_POSITIVE_RATED) + get() = getValue(Keys.WAS_POSITIVE_RATED, false) + set(value) = setValue(Keys.WAS_POSITIVE_RATED, value) - override var canResetAppDirectory: Boolean - set(value) { - saveBoolean(RESET_APP_DIRECTORY, value) - } - get() = getBoolean(RESET_APP_DIRECTORY, true) - - override var isCalendarSyncEnabled: Boolean - set(value) { - saveBoolean(IS_CALENDAR_SYNC_ENABLED, value) - } - get() = getBoolean(IS_CALENDAR_SYNC_ENABLED, true) + override var calendarId: Long + get() = getValue(Keys.CALENDAR_ID, CalendarManager.CALENDAR_DOES_NOT_EXIST) + set(value) = setValue(Keys.CALENDAR_ID, value) override var calendarUser: String - set(value) { - saveString(CALENDAR_USER, value) - } - get() = getString(CALENDAR_USER) + get() = getValue(Keys.CALENDAR_USER, "") + set(value) = setValue(Keys.CALENDAR_USER, value) - override var isRelativeDatesEnabled: Boolean - set(value) { - saveBoolean(IS_RELATIVE_DATES_ENABLED, value) + override var calendarType: CalendarType + get() { + val stored = getValue(Keys.CALENDAR_TYPE, CalendarType.LOCAL.name) + return runCatching { CalendarType.valueOf(stored) }.getOrDefault(CalendarType.LOCAL) } - get() = getBoolean(IS_RELATIVE_DATES_ENABLED, true) + set(value) = setValue(Keys.CALENDAR_TYPE, value.name) + + override var isCalendarSyncEnabled: Boolean + get() = getValue(Keys.IS_CALENDAR_SYNC_ENABLED, true) + set(value) = setValue(Keys.IS_CALENDAR_SYNC_ENABLED, value) override var isHideInactiveCourses: Boolean - set(value) { - saveBoolean(HIDE_INACTIVE_COURSES, value) - } - get() = getBoolean(HIDE_INACTIVE_COURSES, true) + get() = getValue(Keys.IS_HIDE_INACTIVE_COURSES, true) + set(value) = setValue(Keys.IS_HIDE_INACTIVE_COURSES, value) override fun setCalendarSyncEventsDialogShown(courseName: String) { - saveBoolean(courseName.replaceSpace("_"), true) + setValue(Keys.calendarSyncDialogShown(courseName), true) } - override fun isCalendarSyncEventsDialogShown(courseName: String): Boolean = - getBoolean(courseName.replaceSpace("_")) - - companion object { - private const val ACCESS_TOKEN = "access_token" - private const val REFRESH_TOKEN = "refresh_token" - private const val PUSH_TOKEN = "push_token" - private const val EXPIRES_IN = "expires_in" - private const val USER = "user" - private const val ACCOUNT = "account" - private const val LAST_WHATS_NEW_VERSION = "last_whats_new_version" - private const val LAST_REVIEW_VERSION = "last_review_version" - private const val APP_WAS_POSITIVE_RATED = "app_was_positive_rated" - private const val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY = "video_settings_wifi_download_only" - private const val VIDEO_SETTINGS_STREAMING_QUALITY = "video_settings_streaming_quality" - private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" - private const val APP_CONFIG = "app_config" - private const val CALENDAR_ID = "CALENDAR_ID" - private const val RESET_APP_DIRECTORY = "reset_app_directory" - private const val IS_CALENDAR_SYNC_ENABLED = "IS_CALENDAR_SYNC_ENABLED" - private const val IS_RELATIVE_DATES_ENABLED = "IS_RELATIVE_DATES_ENABLED" - private const val HIDE_INACTIVE_COURSES = "HIDE_INACTIVE_COURSES" - private const val CALENDAR_USER = "CALENDAR_USER" + override fun isCalendarSyncEventsDialogShown(courseName: String): Boolean { + return getValue(Keys.calendarSyncDialogShown(courseName), false) } } diff --git a/app/src/main/java/org/openedx/app/data/storage/SharedPrefsToDataStoreMigration.kt b/app/src/main/java/org/openedx/app/data/storage/SharedPrefsToDataStoreMigration.kt new file mode 100644 index 000000000..2d5b6a805 --- /dev/null +++ b/app/src/main/java/org/openedx/app/data/storage/SharedPrefsToDataStoreMigration.kt @@ -0,0 +1,149 @@ +package org.openedx.app.data.storage + +import android.content.Context +import android.content.SharedPreferences +import androidx.datastore.core.DataMigration +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import org.openedx.app.BuildConfig + +class SharedPrefsToDataStoreMigration( + private val context: Context, +) : DataMigration { + + private fun getOldPrefs(): SharedPreferences = + context.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE) + + override suspend fun shouldMigrate(currentData: Preferences): Boolean = + getOldPrefs().all.isNotEmpty() + + override suspend fun migrate(currentData: Preferences): Preferences { + val oldPrefs = getOldPrefs() + val encryption = DataStoreEncryption() + + return currentData.toMutablePreferences().apply { + migrateEncryptedStrings(oldPrefs, encryption) + migrateLongs(oldPrefs) + migrateStrings(oldPrefs) + migrateBooleans(oldPrefs) + } + } + + private fun MutablePreferences.migrateEncryptedStrings( + oldPrefs: SharedPreferences, + encryption: DataStoreEncryption, + ) { + val key = ::stringPreferencesKey + oldPrefs.migrateEncrypted(this, encryption, "access_token", key("access_token")) + oldPrefs.migrateEncrypted(this, encryption, "refresh_token", key("refresh_token")) + oldPrefs.migrateEncrypted(this, encryption, "push_token", key("push_token")) + oldPrefs.migrateEncrypted(this, encryption, "user", key("user")) + oldPrefs.migrateEncrypted(this, encryption, "account", key("account")) + } + + private fun MutablePreferences.migrateLongs(oldPrefs: SharedPreferences) { + val key = ::longPreferencesKey + oldPrefs.migrateLong(this, "expires_in", key("expires_in")) + oldPrefs.migrateLong(this, "CALENDAR_ID", key("calendar_id")) + } + + private fun MutablePreferences.migrateStrings(oldPrefs: SharedPreferences) { + val key = ::stringPreferencesKey + oldPrefs.migrateString( + this, + "video_settings_streaming_quality", + key("video_settings_streaming_quality") + ) + oldPrefs.migrateString( + this, + "video_settings_download_quality", + key("video_settings_download_quality") + ) + oldPrefs.migrateString(this, "app_config", key("app_config")) + oldPrefs.migrateString(this, "last_whats_new_version", key("last_whats_new_version")) + oldPrefs.migrateString(this, "last_review_version", key("last_review_version")) + oldPrefs.migrateString(this, "CALENDAR_USER", key("calendar_user")) + } + + private fun MutablePreferences.migrateBooleans(oldPrefs: SharedPreferences) { + val key = ::booleanPreferencesKey + oldPrefs.migrateBoolean( + this, + "video_settings_wifi_download_only", + key("video_settings_wifi_download_only"), + true, + ) + oldPrefs.migrateBoolean( + this, + "reset_app_directory", + key("reset_app_directory"), + true, + ) + oldPrefs.migrateBoolean( + this, + "app_was_positive_rated", + key("app_was_positive_rated"), + ) + oldPrefs.migrateBoolean( + this, + "IS_RELATIVE_DATES_ENABLED", + key("is_relative_dates_enabled"), + true, + ) + oldPrefs.migrateBoolean( + this, + "IS_CALENDAR_SYNC_ENABLED", + key("is_calendar_sync_enabled"), + true, + ) + oldPrefs.migrateBoolean( + this, + "HIDE_INACTIVE_COURSES", + key("hide_inactive_courses"), + true, + ) + } + + override suspend fun cleanUp() { + getOldPrefs().edit().clear().commit() + } + + private fun SharedPreferences.migrateEncrypted( + prefs: MutablePreferences, + encryption: DataStoreEncryption, + oldKey: String, + newKey: Preferences.Key, + ) { + getString(oldKey, null)?.takeIf { it.isNotEmpty() }?.let { + prefs[newKey] = encryption.encrypt(it) + } + } + + private fun SharedPreferences.migrateString( + prefs: MutablePreferences, + oldKey: String, + newKey: Preferences.Key, + ) { + getString(oldKey, null)?.let { prefs[newKey] = it } + } + + private fun SharedPreferences.migrateLong( + prefs: MutablePreferences, + oldKey: String, + newKey: Preferences.Key, + ) { + if (contains(oldKey)) prefs[newKey] = getLong(oldKey, 0L) + } + + private fun SharedPreferences.migrateBoolean( + prefs: MutablePreferences, + oldKey: String, + newKey: Preferences.Key, + defValue: Boolean = false, + ) { + if (contains(oldKey)) prefs[newKey] = getBoolean(oldKey, defValue) + } +} diff --git a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt index 2192a6b89..32d8ed20e 100644 --- a/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt +++ b/app/src/main/java/org/openedx/app/deeplink/DeepLinkRouter.kt @@ -212,7 +212,9 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "VIDEOS" + openTab = "VIDEOS", + resumeBlockId = "", + ) } } @@ -223,7 +225,8 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "DATES" + openTab = "DATES", + resumeBlockId = "", ) } } @@ -234,7 +237,8 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "DISCUSSIONS" + openTab = "DISCUSSIONS", + resumeBlockId = "", ) } } @@ -245,7 +249,8 @@ class DeepLinkRouter( fm = fm, courseId = courseId, courseTitle = "", - openTab = "MORE" + openTab = "MORE", + resumeBlockId = "", ) } } diff --git a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt index ce72703ad..e687f1589 100644 --- a/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt +++ b/app/src/main/java/org/openedx/app/deeplink/HomeTab.kt @@ -4,6 +4,7 @@ enum class HomeTab { LEARN, PROGRAMS, DISCOVER, + DATES, DOWNLOADS, PROFILE } diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index cdb240387..267b73432 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -23,7 +23,6 @@ import org.openedx.app.room.DatabaseManager import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter -import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.auth.presentation.sso.FacebookAuthHelper import org.openedx.auth.presentation.sso.GoogleAuthHelper import org.openedx.auth.presentation.sso.MicrosoftAuthHelper @@ -65,6 +64,8 @@ import org.openedx.course.utils.ImageProcessor import org.openedx.course.worker.OfflineProgressSyncScheduler import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.dates.presentation.DatesAnalytics +import org.openedx.dates.presentation.DatesRouter import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics @@ -86,7 +87,7 @@ import org.openedx.core.DatabaseManager as IDatabaseManager val appModule = module { single { Config(get()) } - single { PreferencesManager(get()) } + single { PreferencesManager(get(), get()) } single { get() } single { get() } single { get() } @@ -131,6 +132,7 @@ val appModule = module { single { DeepLinkRouter(get(), get(), get(), get(), get(), get()) } single { get() } single { get() } + single { get() } single { NetworkConnection(get()) } @@ -177,6 +179,11 @@ val appModule = module { room.calendarDao() } + single { + val room = get() + room.datesDao() + } + single { FileDownloader() } @@ -209,13 +216,13 @@ val appModule = module { single { get() } single { get() } single { get() } + single { get() } single { get() } factory { AgreementProvider(get(), get()) } factory { FacebookAuthHelper() } factory { GoogleAuthHelper(get()) } factory { MicrosoftAuthHelper() } - factory { BrowserAuthHelper(get()) } factory { OAuthHelper(get(), get(), get()) } factory { VideoPreviewHelper(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 1d3604050..1799dafc6 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -1,6 +1,7 @@ package org.openedx.app.di -import org.koin.androidx.viewmodel.dsl.viewModel +import android.content.res.Resources +import org.koin.core.module.dsl.viewModel import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AppViewModel @@ -42,6 +43,9 @@ import org.openedx.courses.presentation.DashboardGalleryViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.DashboardListViewModel +import org.openedx.dates.data.repository.DatesRepository +import org.openedx.dates.domain.interactor.DatesInteractor +import org.openedx.dates.presentation.dates.DatesViewModel import org.openedx.discovery.data.repository.DiscoveryRepository import org.openedx.discovery.domain.interactor.DiscoveryInteractor import org.openedx.discovery.presentation.NativeDiscoveryViewModel @@ -84,19 +88,20 @@ val screenModule = module { viewModel { AppViewModel( - get(), - get(), - get(), - get(), - get(named("IODispatcher")), - get(), - get(), - get(), - get(), - get(), + config = get(), + appNotifier = get(), + room = get(), + preferencesManager = get(), + dispatcher = get(named("IODispatcher")), + analytics = get(), + deepLinkRouter = get(), + fileUtil = get(), + downloadNotifier = get(), + context = get(), + resourceManager = get(), ) } - viewModel { MainViewModel(get(), get(), get(), get()) } + viewModel { MainViewModel(get(), get(), get(), get(), get()) } factory { AuthRepository(get(), get(), get()) } factory { AuthInteractor(get()) } @@ -112,7 +117,8 @@ val screenModule = module { ) } - viewModel { (courseId: String?, infoType: String?, authCode: String) -> + val lang = Resources.getSystem().configuration.locales[0].language + viewModel { (courseId: String?, infoType: String?) -> SignInViewModel( get(), get(), @@ -130,7 +136,8 @@ val screenModule = module { get(), courseId, infoType, - authCode, + lang, + get() ) } @@ -156,20 +163,30 @@ val screenModule = module { viewModel { DashboardListViewModel(get(), get(), get(), get(), get(), get()) } viewModel { (windowSize: WindowSize) -> DashboardGalleryViewModel( - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - windowSize + config = get(), + interactor = get(), + resourceManager = get(), + discoveryNotifier = get(), + networkConnection = get(), + fileUtil = get(), + dashboardRouter = get(), + corePreferences = get(), + windowSize = windowSize + ) + } + viewModel { + AllEnrolledCoursesViewModel( + config = get(), + networkConnection = get(), + interactor = get(), + resourceManager = get(), + discoveryNotifier = get(), + analytics = get(), + dashboardRouter = get(), ) } - viewModel { AllEnrolledCoursesViewModel(get(), get(), get(), get(), get(), get(), get()) } viewModel { (openTab: String) -> - LearnViewModel(openTab, get(), get(), get()) + LearnViewModel(openTab, get(), get(), get(), get()) } factory { DiscoveryRepository(get(), get(), get()) } @@ -177,13 +194,14 @@ val screenModule = module { viewModel { NativeDiscoveryViewModel(get(), get(), get(), get(), get(), get()) } viewModel { (querySearch: String) -> WebViewDiscoveryViewModel( - querySearch, - get(), - get(), - get(), - get(), - get(), - get(), + querySearch = querySearch, + appData = get(), + config = get(), + networkConnection = get(), + corePreferences = get(), + router = get(), + analytics = get(), + resourceManager = get(), ) } @@ -208,8 +226,16 @@ val screenModule = module { account ) } - viewModel { VideoSettingsViewModel(get(), get(), get(), get()) } - viewModel { (qualityType: String) -> VideoQualityViewModel(qualityType, get(), get(), get()) } + viewModel { VideoSettingsViewModel(get(), get(), get(), get(), get()) } + viewModel { (qualityType: String) -> + VideoQualityViewModel( + qualityType, + get(), + get(), + get(), + get() + ) + } viewModel { DeleteProfileViewModel(get(), get(), get(), get(), get()) } viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } viewModel { @@ -227,11 +253,19 @@ val screenModule = module { get(), ) } - viewModel { ManageAccountViewModel(get(), get(), get(), get(), get()) } - viewModel { CalendarViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { + ManageAccountViewModel( + interactor = get(), + resourceManager = get(), + notifier = get(), + analytics = get(), + profileRouter = get(), + ) + } + viewModel { CalendarViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { CoursesToSyncViewModel(get(), get(), get(), get()) } viewModel { NewCalendarDialogViewModel(get(), get(), get(), get(), get(), get()) } - viewModel { DisableCalendarSyncDialogViewModel(get(), get(), get(), get()) } + viewModel { DisableCalendarSyncDialogViewModel(get(), get(), get(), get(), get()) } factory { CalendarRepository(get(), get(), get()) } factory { CalendarInteractor(get()) } @@ -309,6 +343,7 @@ val screenModule = module { courseId, courseTitle, get(), + get(), ) } viewModel { (courseId: String, courseTitle: String) -> @@ -352,30 +387,31 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { (courseId: String) -> CourseVideoViewModel( - courseId, - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), + courseId = courseId, + config = get(), + interactor = get(), + resourceManager = get(), + networkConnection = get(), + preferencesManager = get(), + courseNotifier = get(), + downloadDialogManager = get(), + fileUtil = get(), + courseRouter = get(), + analytics = get(), + videoPreviewHelper = get(), + coreAnalytics = get(), + downloadDao = get(), + workerController = get(), + downloadHelper = get(), ) } - viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get()) } - viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get(), get()) } + viewModel { (courseId: String) -> BaseVideoViewModel(courseId, get(), get()) } + viewModel { (courseId: String) -> VideoViewModel(courseId, get(), get(), get(), get(), get()) } viewModel { (courseId: String, videoUrl: String, blockId: String) -> VideoUnitViewModel( courseId, @@ -385,7 +421,8 @@ val screenModule = module { get(), get(), get(), - get() + get(), + get(), ) } viewModel { (courseId: String, videoUrl: String, blockId: String) -> @@ -400,35 +437,37 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (courseId: String, enrollmentMode: String) -> CourseDatesViewModel( - courseId, - enrollmentMode, - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), + courseId = courseId, + enrollmentMode = enrollmentMode, + courseNotifier = get(), + interactor = get(), + courseAnalytics = get(), + config = get(), + calendarInteractor = get(), + calendarNotifier = get(), + corePreferences = get(), + courseRouter = get(), + calendarRouter = get(), + resourceManager = get(), ) } viewModel { (courseId: String, handoutsType: String) -> HandoutsViewModel( courseId, handoutsType, - get(), - get(), - get(), + config = get(), + interactor = get(), + courseAnalytics = get(), + resourceManager = get(), ) } viewModel { CourseSearchViewModel(get(), get(), get(), get(), get()) } - viewModel { SelectDialogViewModel(get()) } + viewModel { SelectDialogViewModel(get(), get()) } single { DiscussionRepository(get(), get(), get()) } factory { DiscussionInteractor(get()) } @@ -436,11 +475,11 @@ val screenModule = module { DiscussionTopicsViewModel( courseId, courseTitle, - get(), - get(), - get(), - get(), - get() + interactor = get(), + resourceManager = get(), + analytics = get(), + courseNotifier = get(), + discussionRouter = get(), ) } viewModel { (courseId: String, topicId: String, threadType: String) -> @@ -488,6 +527,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } @@ -500,6 +540,7 @@ val screenModule = module { get(), get(), get(), + get() ) } viewModel { (blockId: String, courseId: String) -> @@ -512,10 +553,22 @@ val screenModule = module { get(), get(), get(), + get() ) } - viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } + viewModel { + ProgramViewModel( + appData = get(), + config = get(), + networkConnection = get(), + router = get(), + notifier = get(), + edxCookieManager = get(), + resourceManager = get(), + interactor = get(), + ) + } viewModel { (courseId: String, courseTitle: String) -> CourseOfflineViewModel( @@ -530,6 +583,7 @@ val screenModule = module { get(), get(), get(), + get(), get() ) } @@ -537,7 +591,8 @@ val screenModule = module { CourseProgressViewModel( courseId, get(), - get() + get(), + get(), ) } @@ -559,19 +614,19 @@ val screenModule = module { downloadsRouter = get(), networkConnection = get(), interactor = get(), + downloadDialogManager = get(), resourceManager = get(), + fileUtil = get(), config = get(), + analytics = get(), + discoveryNotifier = get(), + courseNotifier = get(), + router = get(), preferencesManager = get(), coreAnalytics = get(), downloadDao = get(), workerController = get(), downloadHelper = get(), - downloadDialogManager = get(), - fileUtil = get(), - analytics = get(), - discoveryNotifier = get(), - courseNotifier = get(), - router = get() ) } viewModel { (courseId: String) -> @@ -583,4 +638,28 @@ val screenModule = module { analytics = get() ) } + + factory { + DatesRepository( + api = get(), + dao = get(), + preferencesManager = get(), + ) + } + factory { + DatesInteractor( + repository = get() + ) + } + viewModel { + DatesViewModel( + datesRouter = get(), + networkConnection = get(), + resourceManager = get(), + datesInteractor = get(), + corePreferences = get(), + analytics = get(), + calendarSyncScheduler = 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 b2f275bb3..3a3316bd0 100644 --- a/app/src/main/java/org/openedx/app/room/AppDatabase.kt +++ b/app/src/main/java/org/openedx/app/room/AppDatabase.kt @@ -6,6 +6,7 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import org.openedx.core.data.model.room.CourseCalendarEventEntity import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.model.room.CourseDateEntity import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity import org.openedx.core.data.model.room.CourseProgressEntity import org.openedx.core.data.model.room.CourseStructureEntity @@ -19,11 +20,12 @@ import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModelEntity import org.openedx.course.data.storage.CourseConverter import org.openedx.dashboard.data.DashboardDao +import org.openedx.dates.data.storage.DatesDao 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 = 5 +const val DATABASE_VERSION = 6 const val DATABASE_NAME = "OpenEdX_db" @Suppress("MagicNumber") @@ -38,6 +40,7 @@ const val DATABASE_NAME = "OpenEdX_db" CourseCalendarStateEntity::class, DownloadCoursePreview::class, CourseEnrollmentDetailsEntity::class, + CourseDateEntity::class, VideoProgressEntity::class, CourseProgressEntity::class, ], @@ -45,7 +48,8 @@ const val DATABASE_NAME = "OpenEdX_db" AutoMigration(1, 2), AutoMigration(2, 3), AutoMigration(3, 4), - AutoMigration(4, DATABASE_VERSION), + AutoMigration(4, 5), + AutoMigration(5, DATABASE_VERSION), ], version = DATABASE_VERSION ) @@ -55,5 +59,6 @@ abstract class AppDatabase : RoomDatabase() { abstract fun courseDao(): CourseDao abstract fun dashboardDao(): DashboardDao abstract fun downloadDao(): DownloadDao + abstract fun datesDao(): DatesDao abstract fun calendarDao(): CalendarDao } diff --git a/app/src/main/res/drawable/app_ic_dates_cloud_fill.xml b/app/src/main/res/drawable/app_ic_dates_cloud_fill.xml new file mode 100644 index 000000000..a3fdccec3 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_cloud_fill.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_dates_cloud_outline.xml b/app/src/main/res/drawable/app_ic_dates_cloud_outline.xml new file mode 100644 index 000000000..000fc5893 --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_cloud_outline.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_ic_dates_selector.xml b/app/src/main/res/drawable/app_ic_dates_selector.xml new file mode 100644 index 000000000..9e20819bf --- /dev/null +++ b/app/src/main/res/drawable/app_ic_dates_selector.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/main_manu_tab_ids.xml b/app/src/main/res/values/main_manu_tab_ids.xml index f769b5bde..d78543a76 100644 --- a/app/src/main/res/values/main_manu_tab_ids.xml +++ b/app/src/main/res/values/main_manu_tab_ids.xml @@ -3,5 +3,6 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 801ce0c80..65440a993 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,5 +2,6 @@ Discover Learn Profile + Dates Downloads diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 23b1c4120..d661a1c17 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -5,6 +5,8 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -26,12 +28,13 @@ import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase +import org.openedx.core.CoreMocks import org.openedx.core.config.Config import org.openedx.core.config.FirebaseConfig -import org.openedx.core.data.model.User import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent +import org.openedx.foundation.system.ResourceManager import org.openedx.foundation.utils.FileUtil @ExperimentalCoroutinesApi @@ -51,8 +54,7 @@ class AppViewModelTest { private val deepLinkRouter = mockk() private val context = mockk() private val downloadNotifier = mockk() - - private val user = User(0, "", "", "") + private val resourceManager = mockk() @Before fun before() { @@ -68,7 +70,7 @@ class AppViewModelTest { @Test fun setIdSuccess() = runTest { every { analytics.setUserIdForSession(any()) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { notifier.notifier } returns flow { } every { preferencesManager.canResetAppDirectory } returns false every { preferencesManager.pushToken } returns "" @@ -84,6 +86,7 @@ class AppViewModelTest { fileUtil, downloadNotifier, context, + resourceManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -100,9 +103,9 @@ class AppViewModelTest { every { notifier.notifier } returns flow { emit(LogoutEvent(true)) } - every { preferencesManager.clearCorePreferences() } returns Unit + coEvery { preferencesManager.clearCorePreferences() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit every { preferencesManager.canResetAppDirectory } returns false @@ -120,6 +123,7 @@ class AppViewModelTest { fileUtil, downloadNotifier, context, + resourceManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -138,9 +142,9 @@ class AppViewModelTest { emit(LogoutEvent(true)) emit(LogoutEvent(true)) } - every { preferencesManager.clearCorePreferences() } returns Unit + coEvery { preferencesManager.clearCorePreferences() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit every { preferencesManager.canResetAppDirectory } returns false @@ -158,6 +162,7 @@ class AppViewModelTest { fileUtil, downloadNotifier, context, + resourceManager, ) val mockLifeCycleOwner: LifecycleOwner = mockk() @@ -167,7 +172,7 @@ class AppViewModelTest { advanceUntilIdle() verify(exactly = 1) { analytics.logoutEvent(true) } - verify(exactly = 1) { preferencesManager.clearCorePreferences() } + coVerify(exactly = 1) { preferencesManager.clearCorePreferences() } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { preferencesManager.user } verify(exactly = 1) { room.clearAllTables() } diff --git a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt index b837648fe..673168c57 100644 --- a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt +++ b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt @@ -37,17 +37,6 @@ interface AuthApi { @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, ): AuthResponse - @FormUrlEncoded - @POST(ApiConstants.URL_ACCESS_TOKEN) - suspend fun getAccessTokenFromCode( - @Field("grant_type") grantType: String, - @Field("client_id") clientId: String, - @Field("code") code: String, - @Field("redirect_uri") redirectUri: String, - @Field("token_type") tokenType: String, - @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, - ): AuthResponse - @FormUrlEncoded @POST(ApiConstants.URL_ACCESS_TOKEN) fun refreshAccessToken( diff --git a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt index c56ba0cf1..5addd621c 100644 --- a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt +++ b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt @@ -13,5 +13,4 @@ enum class AuthType(val postfix: String, val methodName: String) { GOOGLE(ApiConstants.AUTH_TYPE_GOOGLE, "Google"), FACEBOOK(ApiConstants.AUTH_TYPE_FB, "Facebook"), MICROSOFT(ApiConstants.AUTH_TYPE_MICROSOFT, "Microsoft"), - BROWSER(ApiConstants.AUTH_TYPE_BROWSER, "Browser") } diff --git a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt index 20499baf9..31a869703 100644 --- a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt +++ b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt @@ -31,6 +31,16 @@ class AuthRepository( .processAuthResponse() } + suspend fun ssoLogin( + jwtToken: String + ) { + if (preferencesManager.accessToken.isBlank()) { + preferencesManager.accessToken = jwtToken + } + val user = api.getProfile() + preferencesManager.user = user + } + suspend fun socialLogin(token: String?, authType: AuthType) { require(!token.isNullOrBlank()) { "Token is null" } api.exchangeAccessToken( @@ -43,16 +53,6 @@ class AuthRepository( .processAuthResponse() } - suspend fun browserAuthCodeLogin(code: String) { - api.getAccessTokenFromCode( - grantType = ApiConstants.GRANT_TYPE_CODE, - clientId = config.getOAuthClientId(), - code = code, - redirectUri = "${config.getAppId()}://${ApiConstants.BrowserLogin.REDIRECT_HOST}", - tokenType = config.getAccessTokenType(), - ).mapToDomain().processAuthResponse() - } - suspend fun getRegistrationFields(): List { return api.getRegistrationFields().fields?.map { it.mapToDomain() } ?: emptyList() } diff --git a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt index 727f77a48..9b12359bb 100644 --- a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt +++ b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt @@ -14,12 +14,14 @@ class AuthInteractor(private val repository: AuthRepository) { repository.login(username, password) } - suspend fun loginSocial(token: String?, authType: AuthType) { - repository.socialLogin(token, authType) + suspend fun ssoLogin( + jwtToken: String + ) { + repository.ssoLogin(jwtToken) } - suspend fun loginAuthCode(authCode: String) { - repository.browserAuthCodeLogin(authCode) + suspend fun loginSocial(token: String?, authType: AuthType) { + repository.socialLogin(token, authType) } suspend fun getRegistrationFields(): List { diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt index 945acf02e..ac657271f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -27,5 +27,7 @@ interface AuthRouter { fun navigateToWebContent(fm: FragmentManager, title: String, url: String) + fun navigateToSSOWebContent(fm: FragmentManager, title: String, url: String) + fun clearBackStack(fm: FragmentManager) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt index f8dbba635..d2c0734f6 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt @@ -6,6 +6,7 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -13,11 +14,10 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -42,7 +42,6 @@ import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.auth.R -import org.openedx.core.ApiConstants import org.openedx.core.ui.AuthButtonsPanel import org.openedx.core.ui.SearchBar import org.openedx.core.ui.displayCutoutForLandscape @@ -51,7 +50,6 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.theme.compose.LogistrationLogoView -import org.openedx.foundation.utils.UrlUtils class LogistrationFragment : Fragment() { @@ -69,22 +67,10 @@ class LogistrationFragment : Fragment() { OpenEdXTheme { LogistrationScreen( onSignInClick = { - if (viewModel.isBrowserLoginEnabled) { - viewModel.signInBrowser(requireActivity()) - } else { - viewModel.navigateToSignIn(parentFragmentManager) - } + viewModel.navigateToSignIn(parentFragmentManager) }, onRegisterClick = { - if (viewModel.isBrowserRegistrationEnabled) { - UrlUtils.openInBrowser( - activity = context, - apiHostUrl = viewModel.apiHostUrl, - url = ApiConstants.URL_REGISTER_BROWSER, - ) - } else { - viewModel.navigateToSignUp(parentFragmentManager) - } + viewModel.navigateToSignUp(parentFragmentManager) }, onSearchClick = { querySearch -> viewModel.navigateToDiscovery(parentFragmentManager, querySearch) @@ -118,23 +104,22 @@ private fun LogistrationScreen( var textFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } - val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() Scaffold( - scaffoldState = scaffoldState, modifier = Modifier .semantics { testTagsAsResourceId = true } - .fillMaxSize() - .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background + .fillMaxSize(), + containerColor = MaterialTheme.appColors.background, + contentWindowInsets = WindowInsets() ) { Surface( modifier = Modifier .padding(it) .fillMaxSize() .verticalScroll(scrollState) + .navigationBarsPadding() .displayCutoutForLandscape(), color = MaterialTheme.appColors.background ) { diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt index d7ca6e894..c67c16b4d 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt @@ -1,34 +1,25 @@ package org.openedx.auth.presentation.logistration -import android.app.Activity import androidx.fragment.app.FragmentManager -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter -import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.core.config.Config -import org.openedx.core.utils.Logger import org.openedx.foundation.extension.takeIfNotEmpty import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class LogistrationViewModel( private val courseId: String, private val router: AuthRouter, private val config: Config, private val analytics: AuthAnalytics, - private val browserAuthHelper: BrowserAuthHelper, -) : BaseViewModel() { - - private val logger = Logger("LogistrationViewModel") + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val discoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView() val isRegistrationEnabled get() = config.isRegistrationEnabled() - val isBrowserRegistrationEnabled get() = config.isBrowserRegistrationEnabled() - val isBrowserLoginEnabled get() = config.isBrowserLoginEnabled() - val apiHostUrl get() = config.getApiHostURL() init { logLogistrationScreenEvent() @@ -39,16 +30,6 @@ class LogistrationViewModel( logEvent(AuthAnalyticsEvent.SIGN_IN_CLICKED) } - fun signInBrowser(activityContext: Activity) { - viewModelScope.launch { - runCatching { - browserAuthHelper.signIn(activityContext) - }.onFailure { - logger.e { "Browser auth error: $it" } - } - } - } - fun navigateToSignUp(parentFragmentManager: FragmentManager) { router.navigateToSignUp(parentFragmentManager, courseId, null) logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index 81d216c39..beebf4eaa 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -20,14 +21,16 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -90,7 +93,7 @@ class RestorePasswordFragment : Fragment() { val windowSize = rememberWindowSize() val uiState by viewModel.uiState.observeAsState(RestorePasswordUIState.Initial) - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val appUpgradeEvent by viewModel.appUpgradeEventUIState.observeAsState(null) if (appUpgradeEvent == null) { @@ -126,21 +129,22 @@ private fun RestorePasswordScreen( onBackClick: () -> Unit, onRestoreButtonClick: (String) -> Unit, ) { - val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() var email by rememberSaveable { mutableStateOf("") } var isEmailError by rememberSaveable { mutableStateOf(false) } val keyboardController = LocalSoftwareKeyboardController.current + val snackbarHostState = remember { SnackbarHostState() } Scaffold( - scaffoldState = scaffoldState, modifier = Modifier .semantics { testTagsAsResourceId = true } .fillMaxSize() .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background + containerColor = MaterialTheme.appColors.background, + snackbarHost = { SnackbarHost(snackbarHostState) }, + contentWindowInsets = WindowInsets() ) { paddingValues -> val contentPaddings by remember { @@ -191,10 +195,7 @@ private fun RestorePasswordScreen( contentDescription = null ) - HandleUIMessage( - uiMessage = uiMessage, - scaffoldState = scaffoldState - ) + HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) Column( modifier = Modifier diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt index 6c5e3adf1..53e7be439 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordViewModel.kt @@ -4,18 +4,16 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch +import org.openedx.auth.R import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey -import org.openedx.core.R import org.openedx.core.system.EdxError import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.foundation.extension.isEmailValid -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager @@ -24,16 +22,12 @@ class RestorePasswordViewModel( private val resourceManager: ResourceManager, private val analytics: AuthAnalytics, private val appNotifier: AppNotifier -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val _uiState = MutableLiveData() val uiState: LiveData get() = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _appUpgradeEvent = MutableLiveData() val appUpgradeEventUIState: LiveData get() = _appUpgradeEvent @@ -53,33 +47,30 @@ class RestorePasswordViewModel( logResetPasswordEvent(true) } else { _uiState.value = RestorePasswordUIState.Initial - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.core_error_unknown_error)) + handleErrorUiMessage( + throwable = null, + ) logResetPasswordEvent(false) } } else { _uiState.value = RestorePasswordUIState.Initial - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email) - ) + handleErrorUiMessage( + throwable = null, + defaultErrorRes = R.string.auth_invalid_email, + ) logResetPasswordEvent(false) } } catch (e: Exception) { _uiState.value = RestorePasswordUIState.Initial logResetPasswordEvent(false) - if (e is EdxError.ValidationException) { - _uiMessage.value = UIMessage.SnackBarMessage(e.error) - } else if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_no_connection) - ) - } else { - _uiMessage.value = - UIMessage.SnackBarMessage( - resourceManager.getString(R.string.core_error_unknown_error) - ) + when (e) { + is EdxError.ValidationException -> sendMessage( + UIMessage.SnackBarMessage(e.error) + ) + + else -> handleErrorUiMessage( + throwable = e, + ) } } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index e5da6fbd9..fc72523a8 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -2,6 +2,7 @@ package org.openedx.auth.presentation.signin import android.os.Bundle import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -11,12 +12,14 @@ import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResultListener import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import org.openedx.auth.data.model.AuthType import org.openedx.auth.presentation.signin.compose.LoginScreen import org.openedx.core.AppUpdateState import org.openedx.core.presentation.global.appupgrade.AppUpgradeRequiredScreen +import org.openedx.core.presentation.global.webview.SSOWebContentFragment import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.foundation.presentation.rememberWindowSize @@ -25,8 +28,7 @@ class SignInFragment : Fragment() { private val viewModel: SignInViewModel by viewModel { parametersOf( requireArguments().getString(ARG_COURSE_ID, ""), - requireArguments().getString(ARG_INFO_TYPE, ""), - requireArguments().getString(ARG_AUTH_CODE, ""), + requireArguments().getString(ARG_INFO_TYPE, "") ) } @@ -40,13 +42,10 @@ class SignInFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() val state by viewModel.uiState.collectAsState() - val uiMessage by viewModel.uiMessage.observeAsState() + val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) if (appUpgradeEvent == null) { - if (viewModel.authCode != "" && !state.loginFailure && !state.loginSuccess) { - viewModel.signInAuthCode(viewModel.authCode) - } LoginScreen( windowSize = windowSize, state = state, @@ -54,6 +53,7 @@ class SignInFragment : Fragment() { onEvent = { event -> when (event) { is AuthEvent.SignIn -> viewModel.login(event.login, event.password) + is AuthEvent.SsoSignIn -> viewModel.ssoClicked(parentFragmentManager) is AuthEvent.SocialSignIn -> viewModel.socialAuth( this@SignInFragment, event.authType @@ -63,10 +63,6 @@ class SignInFragment : Fragment() { viewModel.navigateToForgotPassword(parentFragmentManager) } - AuthEvent.SignInBrowser -> { - viewModel.signInBrowser(requireActivity()) - } - AuthEvent.RegisterClick -> { viewModel.navigateToSignUp(parentFragmentManager) } @@ -97,16 +93,23 @@ class SignInFragment : Fragment() { } } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setFragmentResultListener(SSOWebContentFragment.REQUEST_KEY_SSO) { _, bundle -> + val token = bundle.getString(SSOWebContentFragment.BUNDLE_KEY_TOKEN).orEmpty() + if (token.isNotEmpty()) { + viewModel.ssoLogin(token) + } + } + } companion object { private const val ARG_COURSE_ID = "courseId" private const val ARG_INFO_TYPE = "info_type" - private const val ARG_AUTH_CODE = "auth_code" - fun newInstance(courseId: String?, infoType: String?, authCode: String? = null): SignInFragment { + fun newInstance(courseId: String?, infoType: String?): SignInFragment { val fragment = SignInFragment() fragment.arguments = bundleOf( ARG_COURSE_ID to courseId, - ARG_INFO_TYPE to infoType, - ARG_AUTH_CODE to authCode, + ARG_INFO_TYPE to infoType ) return fragment } @@ -115,9 +118,9 @@ class SignInFragment : Fragment() { internal sealed interface AuthEvent { data class SignIn(val login: String, val password: String) : AuthEvent + data class SsoSignIn(val jwtToken: String) : AuthEvent data class SocialSignIn(val authType: AuthType) : AuthEvent data class OpenLink(val links: Map, val link: String) : AuthEvent - object SignInBrowser : AuthEvent object RegisterClick : AuthEvent object ForgotPasswordClick : AuthEvent object BackClick : AuthEvent diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt index c2a5f915c..f7b56084c 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -13,16 +13,17 @@ import org.openedx.core.domain.model.RegistrationField * @param loginSuccess is login succeed */ internal data class SignInUIState( + val isLoginRegistrationFormEnabled: Boolean = true, + val isSSOLoginEnabled: Boolean = false, + val ssoButtonTitle: String = "", + val isSSODefaultLoginButton: Boolean = false, val isFacebookAuthEnabled: Boolean = false, val isGoogleAuthEnabled: Boolean = false, val isMicrosoftAuthEnabled: Boolean = false, val isSocialAuthEnabled: Boolean = false, - val isBrowserLoginEnabled: Boolean = false, - val isBrowserRegistrationEnabled: Boolean = false, val isLogistrationEnabled: Boolean = false, val isRegistrationEnabled: Boolean = true, val showProgress: Boolean = false, val loginSuccess: Boolean = false, val agreement: RegistrationField? = null, - val loginFailure: Boolean = false, ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index f271927e1..e7053f2c6 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -1,6 +1,5 @@ package org.openedx.auth.presentation.signin -import android.app.Activity import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.LiveData @@ -21,7 +20,6 @@ import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthAnalyticsEvent import org.openedx.auth.presentation.AuthAnalyticsKey import org.openedx.auth.presentation.AuthRouter -import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.Validator import org.openedx.core.config.Config @@ -35,9 +33,7 @@ import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.SingleEventLiveData import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import org.openedx.core.R as CoreRes @@ -50,27 +46,30 @@ class SignInViewModel( private val appNotifier: AppNotifier, private val analytics: AuthAnalytics, private val oAuthHelper: OAuthHelper, + private val configuration: Config, private val router: AuthRouter, private val whatsNewGlobalManager: WhatsNewGlobalManager, private val calendarPreferences: CalendarPreferences, private val calendarInteractor: CalendarInteractor, agreementProvider: AgreementProvider, - private val browserAuthHelper: BrowserAuthHelper, - val config: Config, + config: Config, val courseId: String?, val infoType: String?, val authCode: String, -) : BaseViewModel() { + currentLang: String, +) : BaseViewModel(resourceManager) { private val logger = Logger("SignInViewModel") private val _uiState = MutableStateFlow( SignInUIState( + isLoginRegistrationFormEnabled = config.isLoginRegistrationEnabled(), + isSSOLoginEnabled = config.isSSOLoginEnabled(), + ssoButtonTitle = config.getSSOButtonTitle(currentLang, "Login"), + isSSODefaultLoginButton = config.isSSODefaultLoginButton(), isFacebookAuthEnabled = config.getFacebookConfig().isEnabled(), isGoogleAuthEnabled = config.getGoogleConfig().isEnabled(), isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(), - isBrowserLoginEnabled = config.isBrowserLoginEnabled(), - isBrowserRegistrationEnabled = config.isBrowserRegistrationEnabled(), isSocialAuthEnabled = config.isSocialAuthEnabled(), isLogistrationEnabled = config.isPreLoginExperienceEnabled(), isRegistrationEnabled = config.isRegistrationEnabled(), @@ -79,10 +78,6 @@ class SignInViewModel( ) internal val uiState: StateFlow = _uiState - private val _uiMessage = SingleEventLiveData() - val uiMessage: LiveData - get() = _uiMessage - private val _appUpgradeEvent = MutableLiveData() val appUpgradeEvent: LiveData get() = _appUpgradeEvent @@ -95,13 +90,21 @@ class SignInViewModel( fun login(username: String, password: String) { logEvent(AuthAnalyticsEvent.USER_SIGN_IN_CLICKED) if (!validator.isEmailOrUserNameValid(username)) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.auth_invalid_email_username)) + viewModelScope.launch { + handleErrorUiMessage( + throwable = null, + defaultErrorRes = R.string.auth_invalid_email_username, + ) + } return } if (!validator.isPasswordValid(password)) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(R.string.auth_invalid_password)) + viewModelScope.launch { + handleErrorUiMessage( + throwable = null, + defaultErrorRes = R.string.auth_invalid_password, + ) + } return } @@ -125,16 +128,47 @@ class SignInViewModel( } ) appNotifier.send(SignInEvent()) + } catch (e: Exception) { + when (e) { + is EdxError.InvalidGrantException -> handleErrorUiMessage( + throwable = null, + defaultErrorRes = CoreRes.string.core_error_invalid_grant, + ) + + else -> handleErrorUiMessage( + throwable = e, + ) + } + } + _uiState.update { it.copy(showProgress = false) } + } + } + + fun ssoClicked(fragmentManager: FragmentManager) { + router.navigateToSSOWebContent( + fm = fragmentManager, + title = resourceManager.getString(CoreRes.string.core_sso_sign_in), + url = configuration.getSSOURL(), + ) + } + + fun ssoLogin(token: String) { + logEvent(AuthAnalyticsEvent.USER_SIGN_IN_CLICKED) + + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + try { + interactor.ssoLogin(token) + _uiState.update { it.copy(loginSuccess = true) } + setUserId() } catch (e: Exception) { if (e is EdxError.InvalidGrantException) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_invalid_grant)) - } else if (e.isInternetError()) { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_no_connection)) + handleErrorUiMessage( + throwable = null, + defaultErrorRes = CoreRes.string.core_error_invalid_grant + ) } else { - _uiMessage.value = - UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_unknown_error)) + handleErrorUiMessage(e) } } _uiState.update { it.copy(showProgress = false) } @@ -164,41 +198,11 @@ class SignInViewModel( } } - fun signInBrowser(activityContext: Activity) { - _uiState.update { it.copy(showProgress = true) } - viewModelScope.launch { - runCatching { - browserAuthHelper.signIn(activityContext) - }.onFailure { - logger.e { "Browser auth error: $it" } - } - } - } - fun navigateToSignUp(parentFragmentManager: FragmentManager) { router.navigateToSignUp(parentFragmentManager, null, null) logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) } - fun signInAuthCode(authCode: String) { - _uiState.update { it.copy(showProgress = true) } - viewModelScope.launch { - runCatching { - interactor.loginAuthCode(authCode) - } - .onFailure { - logger.e { "OAuth2 code error: $it" } - onUnknownError() - _uiState.update { it.copy(loginFailure = true) } - }.onSuccess { - _uiState.update { it.copy(loginSuccess = true) } - setUserId() - appNotifier.send(SignInEvent()) - _uiState.update { it.copy(showProgress = false) } - } - } - } - fun navigateToForgotPassword(parentFragmentManager: FragmentManager) { router.navigateToRestorePassword(parentFragmentManager) logEvent(AuthAnalyticsEvent.FORGOT_PASSWORD_CLICKED) @@ -214,7 +218,23 @@ class SignInViewModel( interactor.loginSocial(token, authType) }.onFailure { error -> logger.e { "Social login error: $error" } - onUnknownError() + if (error is EdxError.InvalidGrantException) { + // The social identity resolved on Google's side but is not linked to any + // account on this platform. The mobile token-exchange endpoint only signs in + // existing/linked users, so guide the user to register (mirrors iOS/web). + sendMessage( + UIMessage.SnackBarMessage( + resourceManager.getString( + R.string.auth_social_account_not_registered, + authType.methodName, + configuration.getPlatformName(), + ) + ) + ) + _uiState.update { it.copy(showProgress = false) } + } else { + onUnknownError() + } }.onSuccess { logger.d { "Social login (${authType.methodName}) success" } _uiState.update { it.copy(loginSuccess = true) } @@ -228,9 +248,11 @@ class SignInViewModel( message?.let { logger.e { it() } } - _uiMessage.value = UIMessage.SnackBarMessage( - resourceManager.getString(CoreRes.string.core_error_unknown_error) - ) + viewModelScope.launch { + handleErrorUiMessage( + throwable = null, + ) + } _uiState.update { it.copy(showProgress = false) } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index e182f51d7..f5b9bc867 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -20,14 +21,15 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,6 +53,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview @@ -65,8 +68,10 @@ import org.openedx.auth.presentation.ui.SocialAuthView import org.openedx.core.extension.TextConverter import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.HorizontalLine import org.openedx.core.ui.HyperlinkText import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.theme.OpenEdXTheme @@ -88,65 +93,67 @@ internal fun LoginScreen( uiMessage: UIMessage?, onEvent: (AuthEvent) -> Unit, ) { - val scaffoldState = rememberScaffoldState() val scrollState = rememberScrollState() + val snackbarHostState = remember { SnackbarHostState() } Scaffold( - scaffoldState = scaffoldState, modifier = Modifier .semantics { testTagsAsResourceId = true } .fillMaxSize() .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background + containerColor = MaterialTheme.appColors.background, + snackbarHost = { SnackbarHost(snackbarHostState) }, + contentWindowInsets = WindowInsets() ) { val contentPaddings by remember { mutableStateOf( windowSize.windowSizeValue( - expanded = Modifier - .widthIn(Dp.Unspecified, 420.dp) - .padding( - top = 32.dp, - bottom = 40.dp - ), - compact = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 28.dp) - ) + expanded = + Modifier + .widthIn(Dp.Unspecified, 420.dp) + .padding( + top = 32.dp, + bottom = 40.dp, + ), + compact = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp), + ), ) } val buttonWidth by remember(key1 = windowSize) { mutableStateOf( windowSize.windowSizeValue( expanded = Modifier.widthIn(232.dp, Dp.Unspecified), - compact = Modifier.fillMaxWidth() - ) + compact = Modifier.fillMaxWidth(), + ), ) } Image( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(fraction = 0.3f), + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.3f), painter = painterResource(id = coreR.drawable.core_top_header), contentScale = ContentScale.FillBounds, - contentDescription = null - ) - HandleUIMessage( - uiMessage = uiMessage, - scaffoldState = scaffoldState + contentDescription = null, ) + HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) if (state.isLogistrationEnabled) { Box( - modifier = Modifier - .statusBarsPadding() - .fillMaxWidth(), - contentAlignment = Alignment.CenterStart + modifier = + Modifier + .statusBarsPadding() + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart, ) { BackBtn( modifier = Modifier.padding(end = 16.dp), - tint = Color.White + tint = Color.White, ) { onEvent(AuthEvent.BackClick) } @@ -154,37 +161,43 @@ internal fun LoginScreen( } Column( Modifier.padding(it), - horizontalAlignment = Alignment.CenterHorizontally + horizontalAlignment = Alignment.CenterHorizontally, ) { SignInLogoView() Surface( color = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.screenBackgroundShape, - modifier = Modifier - .fillMaxSize() + modifier = + Modifier + .fillMaxSize(), ) { Box(contentAlignment = Alignment.TopCenter) { Column( - modifier = Modifier - .background(MaterialTheme.appColors.background) - .verticalScroll(scrollState) - .displayCutoutForLandscape() - .then(contentPaddings), + modifier = + Modifier + .background(MaterialTheme.appColors.background) + .verticalScroll(scrollState) + .displayCutoutForLandscape() + .then(contentPaddings), ) { - Text( - modifier = Modifier.testTag("txt_sign_in_title"), - text = stringResource(id = coreR.string.core_sign_in), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.displaySmall - ) - Text( - modifier = Modifier - .testTag("txt_sign_in_description") - .padding(top = 4.dp), - text = stringResource(id = R.string.auth_welcome_back), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleSmall - ) + if (state.isLoginRegistrationFormEnabled) { + Text( + modifier = Modifier.testTag("txt_sign_in_title"), + text = stringResource(id = coreR.string.core_sign_in), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.displaySmall, + ) + Text( + modifier = + Modifier + .testTag("txt_sign_in_description") + .padding(top = 4.dp), + text = stringResource(id = R.string.auth_welcome_back), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleSmall, + ) + } + Spacer(modifier = Modifier.height(24.dp)) AuthForm( buttonWidth, @@ -224,9 +237,8 @@ private fun AuthForm( val keyboardController = LocalSoftwareKeyboardController.current var isEmailError by rememberSaveable { mutableStateOf(false) } var isPasswordError by rememberSaveable { mutableStateOf(false) } - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - if (!state.isBrowserLoginEnabled) { + if (state.isLoginRegistrationFormEnabled) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { LoginTextField( modifier = Modifier .fillMaxWidth(), @@ -259,16 +271,12 @@ private fun AuthForm( }, isError = isPasswordError, ) - } else { - Spacer(modifier = Modifier.height(40.dp)) - } - Row( - Modifier - .fillMaxWidth() - .padding(top = 20.dp, bottom = 36.dp) - ) { - if (!state.isBrowserLoginEnabled) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 36.dp) + ) { if (state.isLogistrationEnabled.not() && state.isRegistrationEnabled) { Text( modifier = Modifier @@ -293,20 +301,16 @@ private fun AuthForm( style = MaterialTheme.appTypography.labelLarge ) } - } - if (state.showProgress) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } else { - OpenEdXButton( - modifier = buttonWidth.testTag("btn_sign_in"), - text = stringResource(id = coreR.string.core_sign_in), - textColor = MaterialTheme.appColors.primaryButtonText, - backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, - onClick = { - if (state.isBrowserLoginEnabled) { - onEvent(AuthEvent.SignInBrowser) - } else { + if (state.showProgress) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } else { + OpenEdXButton( + modifier = buttonWidth.testTag("btn_sign_in"), + text = stringResource(id = coreR.string.core_sign_in), + textColor = MaterialTheme.appColors.primaryButtonText, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + onClick = { keyboardController?.hide() if (login.isNotEmpty() && password.isNotEmpty()) { onEvent(AuthEvent.SignIn(login = login, password = password)) @@ -315,19 +319,88 @@ private fun AuthForm( isPasswordError = password.isEmpty() } } + ) + } + if (state.isSocialAuthEnabled) { + SocialAuthView( + modifier = buttonWidth, + isGoogleAuthEnabled = state.isGoogleAuthEnabled, + isFacebookAuthEnabled = state.isFacebookAuthEnabled, + isMicrosoftAuthEnabled = state.isMicrosoftAuthEnabled, + isSignIn = true, + ) { + keyboardController?.hide() + onEvent(AuthEvent.SocialSignIn(it)) } + } + } + } + if (state.isSSOLoginEnabled) { + Spacer(modifier = Modifier.height(18.dp)) + if (!state.isLoginRegistrationFormEnabled) { + Text( + modifier = + Modifier + .testTag("txt_sso_header") + .padding(top = 4.dp) + .fillMaxWidth(), + text = stringResource(id = coreR.string.core_sign_in_sso_heading), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(18.dp)) + HorizontalLine() + Spacer(modifier = Modifier.height(18.dp)) + Text( + modifier = + Modifier + .testTag("txt_sso_login_title") + .padding(top = 4.dp) + .fillMaxWidth(), + text = stringResource(id = org.openedx.core.R.string.core_sign_in_sso_login_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center, ) + Text( + modifier = + Modifier + .testTag("txt_sso_login_subtitle") + .padding(top = 4.dp) + .fillMaxWidth(), + text = stringResource(id = org.openedx.core.R.string.core_sign_in_sso_login_subtitle), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyLarge, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(18.dp)) } - if (state.isSocialAuthEnabled) { - SocialAuthView( - modifier = buttonWidth, - isGoogleAuthEnabled = state.isGoogleAuthEnabled, - isFacebookAuthEnabled = state.isFacebookAuthEnabled, - isMicrosoftAuthEnabled = state.isMicrosoftAuthEnabled, - isSignIn = true, - ) { - keyboardController?.hide() - onEvent(AuthEvent.SocialSignIn(it)) + if (state.showProgress) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } else { + if (state.isSSODefaultLoginButton) { + OpenEdXButton( + modifier = + buttonWidth + .testTag("btn_sso") + .fillMaxWidth(), + text = state.ssoButtonTitle, + onClick = { + onEvent(AuthEvent.SsoSignIn(jwtToken = "")) + }, + ) + } else { + OpenEdXOutlinedButton( + modifier = + buttonWidth + .testTag("btn_sso") + .fillMaxWidth(), + text = state.ssoButtonTitle, + borderColor = MaterialTheme.appColors.primary, + textColor = MaterialTheme.appColors.textPrimary, + onClick = { onEvent(AuthEvent.SsoSignIn(jwtToken = "")) }, + ) } } } @@ -347,12 +420,13 @@ private fun PasswordTextField( val focusManager = LocalFocusManager.current Text( - modifier = Modifier - .testTag("txt_password_label") - .fillMaxWidth(), + modifier = + Modifier + .testTag("txt_password_label") + .fillMaxWidth(), text = stringResource(id = coreR.string.core_password), color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.labelLarge + style = MaterialTheme.appTypography.labelLarge, ) Spacer(modifier = Modifier.height(8.dp)) @@ -364,9 +438,11 @@ private fun PasswordTextField( passwordTextFieldValue = it onValueChanged(it.text.trim()) }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = MaterialTheme.appColors.textFieldText, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.textFieldText, ), @@ -376,38 +452,42 @@ private fun PasswordTextField( modifier = Modifier.testTag("txt_password_placeholder"), text = stringResource(id = R.string.auth_enter_password), color = MaterialTheme.appColors.textFieldHint, - style = MaterialTheme.appTypography.bodyMedium + style = MaterialTheme.appTypography.bodyMedium, ) }, trailingIcon = { PasswordVisibilityIcon( isPasswordVisible = isPasswordVisible, - onClick = { isPasswordVisible = !isPasswordVisible } + onClick = { isPasswordVisible = !isPasswordVisible }, ) }, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - visualTransformation = if (isPasswordVisible) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, - keyboardActions = KeyboardActions { - focusManager.clearFocus() - onPressDone() - }, + keyboardOptions = + KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + visualTransformation = + if (isPasswordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardActions = + KeyboardActions { + focusManager.clearFocus() + onPressDone() + }, isError = isError, textStyle = MaterialTheme.appTypography.bodyMedium, singleLine = true, ) if (isError) { Text( - modifier = Modifier - .testTag("txt_password_error") - .fillMaxWidth() - .padding(top = 4.dp), + modifier = + Modifier + .testTag("txt_password_error") + .fillMaxWidth() + .padding(top = 4.dp), text = stringResource(id = R.string.auth_error_empty_password), style = MaterialTheme.appTypography.bodySmall, color = MaterialTheme.appColors.error, @@ -431,24 +511,6 @@ private fun SignInScreenPreview() { } } -@Preview(uiMode = UI_MODE_NIGHT_NO) -@Preview(uiMode = UI_MODE_NIGHT_YES) -@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_NO) -@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_YES) -@Composable -private fun SignInUsingBrowserScreenPreview() { - OpenEdXTheme { - LoginScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - state = SignInUIState().copy( - isBrowserLoginEnabled = true, - ), - uiMessage = null, - onEvent = {}, - ) - } -} - @Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO) @Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES) @Composable @@ -456,12 +518,16 @@ private fun SignInScreenTabletPreview() { OpenEdXTheme { LoginScreen( windowSize = WindowSize(WindowType.Expanded, WindowType.Expanded), - state = SignInUIState().copy( - isSocialAuthEnabled = true, - isFacebookAuthEnabled = true, - isGoogleAuthEnabled = true, - isMicrosoftAuthEnabled = true, - ), + state = + SignInUIState().copy( + isLoginRegistrationFormEnabled = false, + isSSOLoginEnabled = true, + isSSODefaultLoginButton = true, + isSocialAuthEnabled = true, + isFacebookAuthEnabled = true, + isGoogleAuthEnabled = true, + isMicrosoftAuthEnabled = true, + ), uiMessage = null, onEvent = {}, ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt index a87ffef3e..150eacb1a 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt @@ -66,7 +66,6 @@ class SignUpFragment : Fragment() { this@SignUpFragment, authType ) - AuthType.BROWSER -> null } }, onFieldUpdated = { key, value -> diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt index 21e12029e..07987c90c 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpViewModel.kt @@ -4,10 +4,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -31,11 +28,8 @@ import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.core.utils.Logger -import org.openedx.foundation.extension.isInternetError import org.openedx.foundation.presentation.BaseViewModel -import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager -import org.openedx.core.R as coreR class SignUpViewModel( private val interactor: AuthInteractor, @@ -49,7 +43,7 @@ class SignUpViewModel( private val router: AuthRouter, val courseId: String?, val infoType: String?, -) : BaseViewModel() { +) : BaseViewModel(resourceManager) { private val logger = Logger("SignUpViewModel") @@ -64,13 +58,6 @@ class SignUpViewModel( ) val uiState = _uiState.asStateFlow() - private val _uiMessage = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - val uiMessage = _uiMessage.asSharedFlow() - init { collectAppUpgradeEvent() logRegisterScreenEvent() @@ -82,19 +69,9 @@ class SignUpViewModel( try { updateFields(interactor.getRegistrationFields()) } catch (e: Exception) { - if (e.isInternetError()) { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(coreR.string.core_error_no_connection) - ) - ) - } else { - _uiMessage.emit( - UIMessage.SnackBarMessage( - resourceManager.getString(coreR.string.core_error_unknown_error) - ) - ) - } + handleErrorUiMessage( + throwable = e, + ) } finally { _uiState.update { state -> state.copy(isLoading = false) @@ -212,12 +189,9 @@ class SignUpViewModel( private suspend fun handleRegistrationError(e: Exception) { _uiState.update { it.copy(isButtonLoading = false) } - val errorMessage = if (e.isInternetError()) { - coreR.string.core_error_no_connection - } else { - coreR.string.core_error_unknown_error - } - _uiMessage.emit(UIMessage.SnackBarMessage(resourceManager.getString(errorMessage))) + handleErrorUiMessage( + throwable = e, + ) } fun socialAuth(fragment: Fragment, authType: AuthType) { diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 8b917ebaa..5354a081a 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -20,15 +21,16 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.ModalBottomSheetLayout -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.rememberModalBottomSheetState -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -74,8 +76,6 @@ import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.SheetContent import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.isImeVisibleState -import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.rememberSaveableMap import org.openedx.core.ui.statusBarsInset import org.openedx.core.ui.theme.OpenEdXTheme @@ -88,7 +88,7 @@ import org.openedx.foundation.presentation.WindowType import org.openedx.foundation.presentation.windowSizeValue import org.openedx.core.R as coreR -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable internal fun SignUpView( windowSize: WindowSize, @@ -99,12 +99,9 @@ internal fun SignUpView( onRegisterClick: (authType: AuthType) -> Unit, onHyperLinkClick: (Map, String) -> Unit, ) { - val scaffoldState = rememberScaffoldState() val focusManager = LocalFocusManager.current - val bottomSheetScaffoldState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - skipHalfExpanded = true - ) + val bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var showBottomSheet by rememberSaveable { mutableStateOf(false) } val coroutine = rememberCoroutineScope() val keyboardController = LocalSoftwareKeyboardController.current var expandedList by rememberSaveable { @@ -123,6 +120,7 @@ internal fun SignUpView( mutableStateMapOf() } val scrollState = rememberScrollState() + val snackbarHostState = remember { SnackbarHostState() } val haptic = LocalHapticFeedback.current @@ -136,8 +134,6 @@ internal fun SignUpView( mutableStateOf(TextFieldValue()) } - val isImeVisible by isImeVisibleState() - LaunchedEffect(uiState.validationError) { if (uiState.validationError) { coroutine.launch { @@ -156,22 +152,23 @@ internal fun SignUpView( } } - LaunchedEffect(bottomSheetScaffoldState.isVisible) { - if (!bottomSheetScaffoldState.isVisible) { + LaunchedEffect(showBottomSheet) { + if (!showBottomSheet) { focusManager.clearFocus() searchValue = TextFieldValue("") } } Scaffold( - scaffoldState = scaffoldState, modifier = Modifier .semantics { testTagsAsResourceId = true } .fillMaxSize() .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background + containerColor = MaterialTheme.appColors.background, + snackbarHost = { SnackbarHost(snackbarHostState) }, + contentWindowInsets = WindowInsets() ) { val topBarPadding by remember { mutableStateOf( @@ -209,21 +206,14 @@ internal fun SignUpView( ) } - ModalBottomSheetLayout( - modifier = Modifier - .padding(bottom = if (isImeVisible && bottomSheetScaffoldState.isVisible) 120.dp else 0.dp) - .noRippleClickable { - if (bottomSheetScaffoldState.isVisible) { - coroutine.launch { - bottomSheetScaffoldState.hide() - } - } - }, - sheetState = bottomSheetScaffoldState, - sheetShape = MaterialTheme.appShapes.screenBackgroundShape, - scrimColor = Color.Black.copy(alpha = 0.4f), - sheetBackgroundColor = MaterialTheme.appColors.background, - sheetContent = { + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { showBottomSheet = false }, + sheetState = bottomSheetState, + shape = MaterialTheme.appShapes.screenBackgroundShape, + scrimColor = Color.Black.copy(alpha = 0.4f), + containerColor = MaterialTheme.appColors.background, + ) { SheetContent( title = bottomDialogTitle, searchValue = searchValue, @@ -233,7 +223,8 @@ internal fun SignUpView( onFieldUpdated(serverFieldName.value, item.value) selectableNamesMap[serverFieldName.value] = item.name coroutine.launch { - bottomSheetScaffoldState.hide() + bottomSheetState.hide() + showBottomSheet = false } }, searchValueChanged = { @@ -241,20 +232,18 @@ internal fun SignUpView( } ) } - ) { - Image( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(fraction = 0.3f), - painter = painterResource(id = coreR.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null - ) - HandleUIMessage( - uiMessage = uiMessage, - scaffoldState = scaffoldState - ) - Column( + } + + Image( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.3f), + painter = painterResource(id = coreR.drawable.core_top_header), + contentScale = ContentScale.FillBounds, + contentDescription = null + ) + HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) + Column( Modifier .fillMaxWidth() .padding(it) @@ -355,12 +344,13 @@ internal fun SignUpView( serverFieldName.value = serverName expandedList = list coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() + if (showBottomSheet) { + bottomSheetState.hide() + showBottomSheet = false } else { bottomDialogTitle = field.label showErrorMap[field.name] = false - bottomSheetScaffoldState.show() + showBottomSheet = true } } }, @@ -385,12 +375,13 @@ internal fun SignUpView( serverName expandedList = list coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() + if (showBottomSheet) { + bottomSheetState.hide() + showBottomSheet = false } else { bottomDialogTitle = field.label showErrorMap[field.name] = false - bottomSheetScaffoldState.show() + showBottomSheet = true } } }, @@ -408,12 +399,13 @@ internal fun SignUpView( serverFieldName.value = serverName expandedList = list coroutine.launch { - if (bottomSheetScaffoldState.isVisible) { - bottomSheetScaffoldState.hide() + if (showBottomSheet) { + bottomSheetState.hide() + showBottomSheet = false } else { bottomDialogTitle = field.label showErrorMap[field.name] = false - bottomSheetScaffoldState.show() + showBottomSheet = true } } }, @@ -463,7 +455,6 @@ internal fun SignUpView( } } } - } } } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt index 2045297a5..30cdf50e4 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SocialSignedView.kt @@ -6,9 +6,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -48,7 +48,7 @@ internal fun SocialSignedView(authType: AuthType) { Text( fontSize = 18.sp, fontWeight = FontWeight.Bold, - color = MaterialTheme.colors.primary, + color = MaterialTheme.appColors.primary, text = stringResource( id = R.string.auth_social_signed_title, authType.methodName diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt deleted file mode 100644 index cd3233b39..000000000 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.openedx.auth.presentation.sso - -import android.app.Activity -import android.content.Intent.FLAG_ACTIVITY_NEW_TASK -import android.net.Uri -import androidx.annotation.WorkerThread -import androidx.browser.customtabs.CustomTabsIntent -import org.openedx.core.ApiConstants -import org.openedx.core.config.Config -import org.openedx.core.utils.Logger - -class BrowserAuthHelper(private val config: Config) { - - private val logger = Logger(TAG) - - @WorkerThread - suspend fun signIn(activityContext: Activity) { - logger.d { "Browser-based auth initiated" } - val uri = Uri.parse("${config.getApiHostURL()}${ApiConstants.URL_AUTHORIZE}").buildUpon() - .appendQueryParameter("client_id", config.getOAuthClientId()) - .appendQueryParameter( - "redirect_uri", - "${activityContext.packageName}://${ApiConstants.BrowserLogin.REDIRECT_HOST}" - ) - .appendQueryParameter("response_type", ApiConstants.BrowserLogin.RESPONSE_TYPE).build() - val intent = - CustomTabsIntent.Builder().setUrlBarHidingEnabled(true).setShowTitle(true).build() - intent.intent.flags = FLAG_ACTIVITY_NEW_TASK - intent.launchUrl(activityContext, uri) - } - - private companion object { - const val TAG = "BrowserAuthHelper" - } -} diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt index 99985b882..c5e2ced6e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt @@ -105,6 +105,20 @@ class GoogleAuthHelper(private val config: Config) { private companion object { const val TAG = "GoogleAuthHelper" - const val SCOPE = "oauth2: https://www.googleapis.com/auth/userinfo.email" + + /** + * Scopes requested for the Google access token that is exchanged on the backend via + * `/oauth2/exchange_access_token/google-oauth2/`. + * + * Must match the scopes expected by the server-side social-auth `google-oauth2` backend + * (`DEFAULT_SCOPE = ["openid", "email", "profile"]`) and the iOS/web clients. Requesting + * only `userinfo.email` yields a token whose `userinfo` response lacks the profile/name + * claims, so first-time SSO account creation fails and the exchange returns + * `invalid_grant` ("access_token is not valid"). The `oauth2:` prefix is immediately + * followed by a space-separated scope list (no leading space). + */ + const val SCOPE = "oauth2:openid " + + "https://www.googleapis.com/auth/userinfo.email " + + "https://www.googleapis.com/auth/userinfo.profile" } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt index ccb094fae..776df7c46 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt @@ -21,7 +21,6 @@ class OAuthHelper( AuthType.GOOGLE -> googleAuthHelper.socialAuth(fragment.requireActivity()) AuthType.FACEBOOK -> facebookAuthHelper.socialAuth(fragment) AuthType.MICROSOFT -> microsoftAuthHelper.socialAuth(fragment.requireActivity()) - AuthType.BROWSER -> null } } diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt index 61d8f7450..726f14e1b 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/AuthUI.kt @@ -16,17 +16,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -261,9 +261,11 @@ fun LoginTextField( loginTextFieldValue = it onValueChanged(it.text.trim()) }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = MaterialTheme.appColors.textFieldText, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.textFieldText, ), @@ -373,9 +375,11 @@ fun InputRegistrationField( onValueChanged(registrationField.name, it.trim(), true) } }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = MaterialTheme.appColors.textFieldText, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, focusedBorderColor = MaterialTheme.appColors.textFieldBorder, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.textFieldText, @@ -459,11 +463,13 @@ fun SelectableRegisterField( enabled = false, singleLine = true, value = initialValue, - colors = TextFieldDefaults.outlinedTextFieldColors( + colors = OutlinedTextFieldDefaults.colors( unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, disabledBorderColor = MaterialTheme.appColors.textFieldBorder, disabledTextColor = MaterialTheme.appColors.textPrimary, - backgroundColor = MaterialTheme.appColors.textFieldBackground, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, + disabledContainerColor = MaterialTheme.appColors.textFieldBackground, disabledPlaceholderColor = MaterialTheme.appColors.textFieldHint ), shape = MaterialTheme.appShapes.textFieldShape, diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt index b134cb59a..ffc46caca 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/CheckboxField.kt @@ -2,10 +2,10 @@ package org.openedx.auth.presentation.ui import android.content.res.Configuration import androidx.compose.foundation.layout.Row -import androidx.compose.material.Checkbox -import androidx.compose.material.CheckboxDefaults -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt index e4962d072..ceb52dfb4 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/ui/SocialAuthView.kt @@ -5,9 +5,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 77401c27f..9838dab6b 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ Email or Username Invalid email or username Password is too short + This %1$s account is not linked with any %2$s account. Please register. Welcome back! Sign in to access your courses. Show optional fields Hide optional fields diff --git a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt index 4e780121d..3dcb4aa18 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/restore/RestorePasswordViewModelTest.kt @@ -22,12 +22,13 @@ import org.junit.Test import org.junit.rules.TestRule import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.core.R import org.openedx.core.system.EdxError import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @OptIn(ExperimentalCoroutinesApi::class) class RestorePasswordViewModelTest { @@ -56,8 +57,12 @@ class RestorePasswordViewModelTest { @Before fun before() { Dispatchers.setMain(dispatcher) - every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet - every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { resourceManager.getString(org.openedx.auth.R.string.auth_invalid_email) } returns invalidEmail every { resourceManager.getString(org.openedx.auth.R.string.auth_invalid_password) } returns invalidPassword every { appNotifier.notifier } returns emptyFlow() @@ -80,10 +85,10 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals(invalidEmail, message?.message) + assertEquals(invalidEmail, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -98,10 +103,10 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals(invalidEmail, message?.message) + assertEquals(invalidEmail, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -116,10 +121,9 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals("error", message?.message) + assertEquals("error", (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -134,10 +138,10 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals(noInternet, message?.message) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -152,10 +156,10 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals(somethingWrong, message?.message) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -170,10 +174,10 @@ class RestorePasswordViewModelTest { verify(exactly = 2) { analytics.logEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Initial) - assertEquals(somethingWrong, message?.message) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) } @Test @@ -189,10 +193,10 @@ class RestorePasswordViewModelTest { verify(exactly = 1) { appNotifier.notifier } val state = viewModel.uiState.value as? RestorePasswordUIState.Success - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) assertEquals(correctEmail, state?.email) assertEquals(true, viewModel.uiState.value is RestorePasswordUIState.Success) - assertEquals(null, message) + assertEquals(null, message.await()) } } diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index 52c9e96a7..ce6044eaf 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -26,25 +26,27 @@ import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter -import org.openedx.auth.presentation.sso.BrowserAuthHelper import org.openedx.auth.presentation.sso.OAuthHelper +import org.openedx.core.CoreMocks import org.openedx.core.Validator import org.openedx.core.config.Config import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig import org.openedx.core.config.MicrosoftConfig -import org.openedx.core.data.model.User import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor +import org.openedx.core.domain.model.CalendarType import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.SignInEvent import org.openedx.foundation.presentation.UIMessage +import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException import org.openedx.core.R as CoreRes +import org.openedx.foundation.R as foundationR @ExperimentalCoroutinesApi class SignInViewModelTest { @@ -67,7 +69,6 @@ class SignInViewModelTest { private val whatsNewGlobalManager = mockk() private val calendarInteractor = mockk() private val calendarPreferences = mockk() - private val browserAuthHelper = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -75,14 +76,16 @@ class SignInViewModelTest { private val invalidEmailOrUsername = "Invalid email or username" private val invalidPassword = "Password too short" - private val user = User(0, "", "", "") - @Before fun before() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(CoreRes.string.core_error_invalid_grant) } returns invalidCredential - every { resourceManager.getString(CoreRes.string.core_error_no_connection) } returns noInternet - every { resourceManager.getString(CoreRes.string.core_error_unknown_error) } returns somethingWrong + every { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { resourceManager.getString(R.string.auth_invalid_email_username) } returns invalidEmailOrUsername every { resourceManager.getString(R.string.auth_invalid_password) } returns invalidPassword every { appNotifier.notifier } returns emptyFlow() @@ -93,12 +96,15 @@ class SignInViewModelTest { every { config.getGoogleConfig() } returns GoogleConfig() every { config.getMicrosoftConfig() } returns MicrosoftConfig() every { calendarPreferences.calendarUser } returns "" - every { calendarPreferences.clearCalendarPreferences() } returns Unit + every { calendarPreferences.calendarType } returns CalendarType.LOCAL + coEvery { calendarPreferences.clearCalendarPreferences() } returns Unit coEvery { calendarInteractor.clearCalendarCachedData() } returns Unit every { analytics.logScreenEvent(any(), any()) } returns Unit every { config.isRegistrationEnabled() } returns true - every { config.isBrowserLoginEnabled() } returns false - every { config.isBrowserRegistrationEnabled() } returns false + every { config.isLoginRegistrationEnabled() } returns true + every { config.isSSOLoginEnabled() } returns false + every { config.isSSODefaultLoginButton() } returns false + every { config.getSSOButtonTitle(any(), any()) } returns "SSO Login" } @After @@ -109,7 +115,7 @@ class SignInViewModelTest { @Test fun `login empty credentials validation error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns false - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -124,12 +130,13 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, - browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + authCode = "", calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, - authCode = "", + configuration = config, + currentLang = "EN" ) viewModel.login("", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -137,9 +144,9 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) val uiState = viewModel.uiState.value - assertEquals(invalidEmailOrUsername, message.message) + assertEquals(invalidEmailOrUsername, (message.await() as UIMessage.SnackBarMessage).message) assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) } @@ -147,7 +154,7 @@ class SignInViewModelTest { @Test fun `login invalid email validation error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns false - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -162,20 +169,21 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, - browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + authCode = "", calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, - authCode = "", + configuration = config, + currentLang = "EN" ) viewModel.login("acc@test.o", "") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) val uiState = viewModel.uiState.value - assertEquals(invalidEmailOrUsername, message.message) + assertEquals(invalidEmailOrUsername, (message.await() as UIMessage.SnackBarMessage).message) assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) } @@ -184,7 +192,7 @@ class SignInViewModelTest { fun `login empty password validation error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns false - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -200,20 +208,21 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, - browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + authCode = "", calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, - authCode = "", + configuration = config, + currentLang = "EN" ) viewModel.login("acc@test.org", "") verify(exactly = 0) { analytics.setUserIdForSession(any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) val uiState = viewModel.uiState.value - assertEquals(invalidPassword, message.message) + assertEquals(invalidPassword, (message.await() as UIMessage.SnackBarMessage).message) assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) } @@ -222,7 +231,7 @@ class SignInViewModelTest { fun `login invalid password validation error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns false - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -237,12 +246,13 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, - browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + authCode = "", calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, - authCode = "", + configuration = config, + currentLang = "EN" ) viewModel.login("acc@test.org", "ed") @@ -251,9 +261,9 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) + assertEquals(invalidPassword, (message.await() as? UIMessage.SnackBarMessage)?.message) val uiState = viewModel.uiState.value - assertEquals(invalidPassword, message.message) assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) } @@ -262,7 +272,7 @@ class SignInViewModelTest { fun `login success`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit coEvery { appNotifier.send(any()) } returns Unit @@ -278,12 +288,13 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, - browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + authCode = "", calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, - authCode = "", + configuration = config, + currentLang = "EN" ) coEvery { interactor.login("acc@test.org", "edx") } returns Unit viewModel.login("acc@test.org", "edx") @@ -297,14 +308,15 @@ class SignInViewModelTest { val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assert(uiState.loginSuccess) - assertEquals(null, viewModel.uiMessage.value) + val message = captureUiMessage(viewModel) + assertEquals(null, message.await()) } @Test fun `login network error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -319,12 +331,13 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, - browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + authCode = "", calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, - authCode = "", + configuration = config, + currentLang = "EN" ) coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException() viewModel.login("acc@test.org", "edx") @@ -336,18 +349,18 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } verify(exactly = 1) { appNotifier.notifier } - val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) + assertEquals(noInternet, (message.await() as? UIMessage.SnackBarMessage)?.message) val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) - assertEquals(noInternet, message?.message) } @Test fun `login invalid grant error`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -362,12 +375,13 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, - browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + authCode = "", calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, - authCode = "", + configuration = config, + currentLang = "EN" ) coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException() viewModel.login("acc@test.org", "edx") @@ -379,18 +393,18 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) + assertEquals(invalidCredential, (message.await() as? UIMessage.SnackBarMessage)?.message) val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) - assertEquals(invalidCredential, message.message) } @Test fun `login unknown exception`() = runTest { every { validator.isEmailOrUserNameValid(any()) } returns true every { validator.isPasswordValid(any()) } returns true - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit val viewModel = SignInViewModel( @@ -405,12 +419,13 @@ class SignInViewModelTest { config = config, router = router, whatsNewGlobalManager = whatsNewGlobalManager, - browserAuthHelper = browserAuthHelper, courseId = "", infoType = "", + authCode = "", calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, - authCode = "", + configuration = config, + currentLang = "EN" ) coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException() viewModel.login("acc@test.org", "edx") @@ -422,10 +437,10 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.logEvent(any(), any()) } verify(exactly = 1) { analytics.logScreenEvent(any(), any()) } - val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val message = captureUiMessage(viewModel) + assertEquals(somethingWrong, (message.await() as? UIMessage.SnackBarMessage)?.message) val uiState = viewModel.uiState.value assertFalse(uiState.showProgress) assertFalse(uiState.loginSuccess) - assertEquals(somethingWrong, message.message) } } diff --git a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt index 7426f752b..6a6a4b2a2 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signup/SignUpViewModelTest.kt @@ -32,12 +32,12 @@ import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.ApiConstants +import org.openedx.core.CoreMocks import org.openedx.core.R import org.openedx.core.config.Config import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig import org.openedx.core.config.MicrosoftConfig -import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AgreementUrls import org.openedx.core.domain.model.RegistrationField @@ -46,6 +46,7 @@ import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.system.ResourceManager import java.net.UnknownHostException +import org.openedx.foundation.R as foundationR @ExperimentalCoroutinesApi class SignUpViewModelTest { @@ -98,8 +99,6 @@ class SignUpViewModelTest { ) ) - private val user = User(0, "", "", "") - //endregion private val noInternet = "Slow or no internet connection" @@ -109,8 +108,12 @@ class SignUpViewModelTest { fun before() { Dispatchers.setMain(dispatcher) every { resourceManager.getString(R.string.core_error_invalid_grant) } returns "Invalid credentials" - every { resourceManager.getString(R.string.core_error_no_connection) } returns noInternet - every { resourceManager.getString(R.string.core_error_unknown_error) } returns somethingWrong + every { + resourceManager.getString(foundationR.string.foundation_error_no_connection) + } returns noInternet + every { + resourceManager.getString(foundationR.string.foundation_error_unknown_error) + } returns somethingWrong every { appNotifier.notifier } returns emptyFlow() every { agreementProvider.getAgreement(false) } returns null every { config.isSocialAuthEnabled() } returns false @@ -149,7 +152,7 @@ class SignUpViewModelTest { every { analytics.logEvent(any(), any()) } returns Unit coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit viewModel.getRegistrationFields() advanceUntilIdle() @@ -198,7 +201,7 @@ class SignUpViewModelTest { ) } returns Unit every { analytics.logEvent(any(), any()) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit viewModel.getRegistrationFields() advanceUntilIdle() @@ -242,7 +245,7 @@ class SignUpViewModelTest { coEvery { interactor.register(parametersMap) } returns Unit coEvery { interactor.login("", "") } returns Unit every { analytics.logEvent(any(), any()) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit viewModel.register() advanceUntilIdle() @@ -288,7 +291,7 @@ class SignUpViewModelTest { parametersMap.getValue(ApiConstants.PASSWORD) ) } returns Unit - every { preferencesManager.user } returns user + every { preferencesManager.user } returns CoreMocks.mockUser every { analytics.setUserIdForSession(any()) } returns Unit viewModel.getRegistrationFields() advanceUntilIdle() diff --git a/build.gradle b/build.gradle index 674a1057f..889599361 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ buildscript { play_services_ads_identifier_version = '18.2.0' install_referrer_version = '2.2' snakeyaml_version = '2.4' - openedx_foundation_version = '1.0.2' + openedx_foundation_version = '1.1.1' openedx_firebase_analytics_version = '1.0.1' braze_sdk_version = '37.0.0' @@ -87,6 +87,8 @@ ext { java_version = JavaVersion.VERSION_17 jvm_target_version = JvmTarget.JVM_17 + webkit_version = "1.11.0" + configHelper = new ConfigHelper(projectDir, getCurrentFlavor()) } diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 959d3c224..786d63cc4 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -2,7 +2,6 @@ package org.openedx.core object ApiConstants { const val URL_LOGIN = "/oauth2/login/" - const val URL_AUTHORIZE = "/oauth2/authorize/" const val URL_ACCESS_TOKEN = "/oauth2/access_token/" const val URL_EXCHANGE_TOKEN = "/oauth2/exchange_access_token/{auth_type}/" const val GET_USER_PROFILE = "/api/mobile/v0.5/my_user_info" @@ -10,18 +9,15 @@ object ApiConstants { const val URL_REGISTRATION_FIELDS = "/user_api/v1/account/registration" const val URL_VALIDATE_REGISTRATION_FIELDS = "/api/user/v1/validation/registration" const val URL_REGISTER = "/api/user/v1/account/registration/" - const val URL_REGISTER_BROWSER = "/register" const val URL_PASSWORD_RESET = "/password_reset/" const val GRANT_TYPE_PASSWORD = "password" - const val GRANT_TYPE_CODE = "authorization_code" const val TOKEN_TYPE_BEARER = "Bearer" const val TOKEN_TYPE_JWT = "jwt" const val TOKEN_TYPE_REFRESH = "refresh_token" const val ACCESS_TOKEN = "access_token" - const val CLIENT_ID = "client_id" const val EMAIL = "email" const val NAME = "name" @@ -31,7 +27,6 @@ object ApiConstants { const val AUTH_TYPE_GOOGLE = "google-oauth2" const val AUTH_TYPE_FB = "facebook" const val AUTH_TYPE_MICROSOFT = "azuread-oauth2" - const val AUTH_TYPE_BROWSER = "browser" const val COURSE_KEY = "course_key" @@ -39,10 +34,4 @@ object ApiConstants { const val HONOR_CODE = "honor_code" const val MARKETING_EMAILS = "marketing_emails_opt_in" } - - object BrowserLogin { - const val REDIRECT_HOST = "oauth2Callback" - const val CODE_QUERY_PARAM = "code" - const val RESPONSE_TYPE = "code" - } } diff --git a/core/src/main/java/org/openedx/core/Mock.kt b/core/src/main/java/org/openedx/core/CoreMocks.kt similarity index 60% rename from core/src/main/java/org/openedx/core/Mock.kt rename to core/src/main/java/org/openedx/core/CoreMocks.kt index 445fc7a05..e1638d235 100644 --- a/core/src/main/java/org/openedx/core/Mock.kt +++ b/core/src/main/java/org/openedx/core/CoreMocks.kt @@ -1,17 +1,25 @@ package org.openedx.core +import org.openedx.core.data.model.User import org.openedx.core.data.model.room.VideoProgressEntity +import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.AssignmentProgress import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseAccessDetails import org.openedx.core.domain.model.CourseComponentStatus import org.openedx.core.domain.model.CourseDatesBannerInfo +import org.openedx.core.domain.model.CourseDatesCalendarSync import org.openedx.core.domain.model.CourseDatesResult +import org.openedx.core.domain.model.CourseEnrollmentDetails +import org.openedx.core.domain.model.CourseInfoOverview import org.openedx.core.domain.model.CourseProgress +import org.openedx.core.domain.model.CourseSharingUtmParameters import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.DownloadCoursePreview import org.openedx.core.domain.model.EncodedVideos -import org.openedx.core.domain.model.OfflineDownload +import org.openedx.core.domain.model.EnrollmentDetails import org.openedx.core.domain.model.Progress import org.openedx.core.domain.model.ResetCourseDates import org.openedx.core.domain.model.StudentViewData @@ -19,15 +27,32 @@ import org.openedx.core.domain.model.VideoInfo import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType +import org.openedx.core.module.download.DownloadModelsSize import java.util.Date -object Mock { - private val mockAssignmentProgress = AssignmentProgress( +object CoreMocks { + val mockAssignmentProgress = AssignmentProgress( assignmentType = "Home", numPointsEarned = 1f, numPointsPossible = 3f, shortLabel = "HM1" ) + + val mockUser = User( + id = 0, + username = "", + email = "", + name = "" + ) + + val mockAppConfig = AppConfig( + courseDatesCalendarSync = CourseDatesCalendarSync( + isEnabled = true, + isSelfPacedEnabled = true, + isInstructorPacedEnabled = true, + isDeepLinkEnabled = false, + ) + ) val mockChapterBlock = Block( id = "id", blockId = "blockId", @@ -40,7 +65,7 @@ object Mock { studentViewData = null, studentViewMultiDevice = false, blockCounts = BlockCounts(1), - descendants = emptyList(), + descendants = listOf("1"), descendantsType = BlockType.CHAPTER, completion = 0.0, containsGatedContent = false, @@ -48,30 +73,61 @@ object Mock { due = Date(), offlineDownload = null ) - private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false, - assignmentProgress = mockAssignmentProgress, - due = Date(), - offlineDownload = OfflineDownload("fileUrl", "", 1), + + val mockBlockData = listOf( + mockChapterBlock.copy( + id = "id", + type = BlockType.HTML, + blockCounts = BlockCounts(0), + descendants = listOf("id2", "id1"), + descendantsType = BlockType.HTML, + assignmentProgress = mockAssignmentProgress.copy( + assignmentType = "Homework", + shortLabel = "HW1" + ), + due = Date() + ), + mockChapterBlock.copy( + id = "id1", + type = BlockType.VERTICAL, + blockCounts = BlockCounts(0), + descendants = listOf("id2", "id"), + descendantsType = BlockType.HTML, + assignmentProgress = mockAssignmentProgress.copy( + assignmentType = "Homework", + shortLabel = "HW1" + ), + due = Date() + ), + mockChapterBlock.copy( + id = "id2", + type = BlockType.SEQUENTIAL, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.HTML, + assignmentProgress = mockAssignmentProgress.copy( + assignmentType = "Homework", + shortLabel = "HW1" + ), + due = Date() + ), + mockChapterBlock.copy( + id = "id3", + type = BlockType.HTML, + blockCounts = BlockCounts(0), + descendants = emptyList(), + descendantsType = BlockType.HTML, + assignmentProgress = mockAssignmentProgress.copy( + assignmentType = "Homework", + shortLabel = "HW1" + ), + due = Date() + ) ) val mockCourseStructure = CourseStructure( root = "", - blockData = listOf(mockSequentialBlock, mockSequentialBlock), + blockData = mockBlockData, id = "id", name = "Course name", number = "", @@ -111,6 +167,15 @@ object Mock { courseBanner = mockCourseDatesBannerInfo ) + val mockCoursewareAccess = CoursewareAccess( + hasAccess = true, + errorCode = "", + developerMessage = "", + userMessage = "", + userFragment = "", + additionalContextUserMessage = "" + ) + val mockCourseProgress = CourseProgress( verifiedMode = "audit", accessExpiration = "", @@ -130,6 +195,46 @@ object Mock { disableProgressGraph = false ) + val mockCourseAccessDetails = CourseAccessDetails( + hasUnmetPrerequisites = false, + isTooEarly = false, + isStaff = false, + auditAccessExpires = null, + coursewareAccess = mockCoursewareAccess + ) + + val mockEnrollmentDetails = EnrollmentDetails( + created = Date(), + mode = "audit", + isActive = true, + upgradeDeadline = Date() + ) + + val mockCourseInfoOverview = CourseInfoOverview( + name = "Open edX Demo Course", + number = "DemoX", + org = "edX", + start = Date(), + startDisplay = "Today", + startType = "", + end = null, + isSelfPaced = false, + media = null, + courseSharingUtmParameters = CourseSharingUtmParameters("", ""), + courseAbout = "About course" + ) + + val mockCourseEnrollmentDetails = CourseEnrollmentDetails( + id = "course-id", + courseUpdates = "Course updates", + courseHandouts = "Course handouts", + discussionUrl = "https://example.com/discussion", + courseAccessDetails = mockCourseAccessDetails, + certificate = null, + enrollmentDetails = mockEnrollmentDetails, + courseInfoOverview = mockCourseInfoOverview + ) + val mockVideoProgress = VideoProgressEntity( blockId = "video1", videoUrl = "test-video-url", @@ -260,4 +365,19 @@ object Mock { isSelfPaced = false, progress = null ) + + val coursePreview = DownloadCoursePreview( + id = "course-id", + name = "Preview Course", + image = "", + totalSize = 100L + ) + + val mockDownloadModelsSize = DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 1, + allSize = 0 + ) } diff --git a/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt b/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt new file mode 100644 index 000000000..73392bf72 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/AppLevelDatesConfig.kt @@ -0,0 +1,8 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +data class AppLevelDatesConfig( + @SerializedName("ENABLED") + val isEnabled: Boolean = true, +) diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index d26741699..1f07f43ad 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -28,6 +28,13 @@ class Config(context: Context) { return getString(API_HOST_URL) } + fun getSSOURL(): String { + return getString(SSO_URL, "") + } + + fun getSSOFinishedURL(): String { + return getString(SSO_FINISHED_URL, "") + } fun getUriScheme(): String { return getString(URI_SCHEME) } @@ -96,6 +103,10 @@ class Config(context: Context) { return getExperimentalFeaturesConfig().appLevelDownloadsConfig } + fun getDatesConfig(): AppLevelDatesConfig { + return getExperimentalFeaturesConfig().appLevelDatesConfig + } + fun getBranchConfig(): BranchConfig { return getObjectOrNewInstance(BRANCH, BranchConfig::class.java) } @@ -108,20 +119,31 @@ class Config(context: Context) { return getBoolean(PRE_LOGIN_EXPERIENCE_ENABLED, true) } - fun getCourseUIConfig(): UIConfig { - return getObjectOrNewInstance(UI_COMPONENTS, UIConfig::class.java) + fun isLoginRegistrationEnabled(): Boolean { + return getBoolean(LOGIN_REGISTRATION_ENABLED, true) } - fun isRegistrationEnabled(): Boolean { - return getBoolean(REGISTRATION_ENABLED, true) + fun isSSOLoginEnabled(): Boolean { + return getBoolean(SAML_SSO_LOGIN_ENABLED, false) } - fun isBrowserLoginEnabled(): Boolean { - return getBoolean(BROWSER_LOGIN, false) + fun isSSODefaultLoginButton(): Boolean { + return getBoolean(SAML_SSO_DEFAULT_LOGIN_BUTTON, false) } - fun isBrowserRegistrationEnabled(): Boolean { - return getBoolean(BROWSER_REGISTRATION, false) + fun getSSOButtonTitle(key: String, defaultValue: String): String { + print("getSSOButtonTitle") + val element = getObject(SSO_BUTTON_TITLE) + print("element: $element, key: ${key.uppercase()}") + return element?.asJsonObject?.get(key.uppercase())?.asString ?: defaultValue + } + + fun getCourseUIConfig(): UIConfig { + return getObjectOrNewInstance(UI_COMPONENTS, UIConfig::class.java) + } + + fun isRegistrationEnabled(): Boolean { + return getBoolean(REGISTRATION_ENABLED, true) } private fun getExperimentalFeaturesConfig(): ExperimentalFeaturesConfig { @@ -167,6 +189,12 @@ class Config(context: Context) { companion object { private const val APPLICATION_ID = "APPLICATION_ID" private const val API_HOST_URL = "API_HOST_URL" + private const val SSO_URL = "SSO_URL" + private const val SSO_FINISHED_URL = "SSO_FINISHED_URL" + private const val SSO_BUTTON_TITLE = "SSO_BUTTON_TITLE" + private const val SAML_SSO_LOGIN_ENABLED = "SAML_SSO_LOGIN_ENABLED" + private const val SAML_SSO_DEFAULT_LOGIN_BUTTON = "SAML_SSO_DEFAULT_LOGIN_BUTTON" + private const val LOGIN_REGISTRATION_ENABLED = "LOGIN_REGISTRATION_ENABLED" private const val URI_SCHEME = "URI_SCHEME" private const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" private const val TOKEN_TYPE = "TOKEN_TYPE" @@ -182,8 +210,6 @@ class Config(context: Context) { private const val MICROSOFT = "MICROSOFT" private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" private const val REGISTRATION_ENABLED = "REGISTRATION_ENABLED" - private const val BROWSER_LOGIN = "BROWSER_LOGIN" - private const val BROWSER_REGISTRATION = "BROWSER_REGISTRATION" private const val DISCOVERY = "DISCOVERY" private const val PROGRAM = "PROGRAM" private const val DASHBOARD = "DASHBOARD" diff --git a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt index 03dd43150..738938835 100644 --- a/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt +++ b/core/src/main/java/org/openedx/core/config/ExperimentalFeaturesConfig.kt @@ -5,4 +5,6 @@ import com.google.gson.annotations.SerializedName data class ExperimentalFeaturesConfig( @SerializedName("APP_LEVEL_DOWNLOADS") val appLevelDownloadsConfig: AppLevelDownloadsConfig = AppLevelDownloadsConfig(), + @SerializedName("APP_LEVEL_DATES") + val appLevelDatesConfig: AppLevelDatesConfig = AppLevelDatesConfig(), ) diff --git a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt index d6e44cfe2..bcc57d826 100644 --- a/core/src/main/java/org/openedx/core/data/api/CourseApi.kt +++ b/core/src/main/java/org/openedx/core/data/api/CourseApi.kt @@ -6,6 +6,7 @@ import org.openedx.core.data.model.BlocksCompletionBody import org.openedx.core.data.model.CourseComponentStatus import org.openedx.core.data.model.CourseDates import org.openedx.core.data.model.CourseDatesBannerInfo +import org.openedx.core.data.model.CourseDatesResponse import org.openedx.core.data.model.CourseEnrollmentDetails import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.model.CourseProgressResponse @@ -64,7 +65,8 @@ interface CourseApi { @GET("/api/course_home/v1/dates/{course_id}") suspend fun getCourseDates( @Path("course_id") courseId: String, - @Query("allow_not_started_courses") allowNotStartedCourses: Boolean = true + @Query("allow_not_started_courses") allowNotStartedCourses: Boolean = true, + @Query("mobile") mobile: Boolean = true, ): CourseDates @POST("/api/course_experience/v1/reset_course_deadlines") @@ -111,8 +113,17 @@ interface CourseApi { @Path("username") username: String ): List + @GET("/api/mobile/v1/course_dates/{username}/") + suspend fun getUserDates( + @Path("username") username: String, + @Query("page") page: Int + ): CourseDatesResponse + @GET("/api/course_home/progress/{course_id}") suspend fun getCourseProgress( @Path("course_id") courseId: String, ): CourseProgressResponse + + @POST("/api/course_experience/v1/reset_all_relative_course_deadlines/") + suspend fun shiftAllDueDates() } diff --git a/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt new file mode 100644 index 000000000..c86500671 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/CourseDatesResponse.kt @@ -0,0 +1,56 @@ +package org.openedx.core.data.model + +import com.google.gson.annotations.SerializedName +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseDate as DomainCourseDate +import org.openedx.core.domain.model.CourseDatesResponse as DomainCourseDatesResponse + +data class CourseDate( + @SerializedName("course_id") + val courseId: String, + @SerializedName("first_component_block_id") + val firstComponentBlockId: String?, + @SerializedName("due_date") + val dueDate: String?, + @SerializedName("assignment_title") + val assignmentTitle: String?, + @SerializedName("learner_has_access") + val learnerHasAccess: Boolean?, + @SerializedName("relative") + val relative: Boolean?, + @SerializedName("course_name") + val courseName: String? +) { + fun mapToDomain(): DomainCourseDate? { + val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") + return DomainCourseDate( + courseId = courseId, + firstComponentBlockId = firstComponentBlockId ?: "", + dueDate = dueDate ?: return null, + assignmentTitle = assignmentTitle ?: "", + learnerHasAccess = learnerHasAccess ?: false, + courseName = courseName ?: "", + relative = relative ?: false + ) + } +} + +data class CourseDatesResponse( + @SerializedName("count") + val count: Int, + @SerializedName("next") + val next: String?, + @SerializedName("previous") + val previous: String?, + @SerializedName("results") + val results: List +) { + fun mapToDomain(): DomainCourseDatesResponse { + return DomainCourseDatesResponse( + count = count, + next = next, + previous = previous, + results = results.mapNotNull { it.mapToDomain() } + ) + } +} diff --git a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt index 00d55a9b5..6c191ee3a 100644 --- a/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt +++ b/core/src/main/java/org/openedx/core/data/model/CourseProgressResponse.kt @@ -93,22 +93,24 @@ data class CourseProgressResponse( @SerializedName("assignment_colors") val assignmentColors: List? ) { // TODO Temporary solution. Backend will returns color list later - val defaultColors = listOf( - "#D24242", - "#7B9645", - "#5A5AD8", - "#B0842C", - "#2E90C2", - "#D13F88", - "#36A17D", - "#AE5AD8", - "#3BA03B" - ) + companion object { + val DEFAULT_COLORS = listOf( + "#D24242", + "#7B9645", + "#5A5AD8", + "#B0842C", + "#2E90C2", + "#D13F88", + "#36A17D", + "#AE5AD8", + "#3BA03B" + ) + } fun mapToRoomEntity() = GradingPolicyDb( assignmentPolicies = assignmentPolicies?.map { it.mapToRoomEntity() } ?: emptyList(), gradeRange = gradeRange ?: emptyMap(), - assignmentColors = assignmentColors ?: defaultColors + assignmentColors = assignmentColors ?: DEFAULT_COLORS ) fun mapToDomain() = CourseProgress.GradingPolicy( @@ -116,7 +118,7 @@ data class CourseProgressResponse( gradeRange = gradeRange ?: emptyMap(), assignmentColors = assignmentColors?.map { colorString -> Color(colorString.toColorInt()) - } ?: defaultColors.map { Color(it.toColorInt()) } + } ?: DEFAULT_COLORS.map { Color(it.toColorInt()) } ) data class AssignmentPolicy( diff --git a/core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt b/core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt new file mode 100644 index 000000000..9d1c1b9a4 --- /dev/null +++ b/core/src/main/java/org/openedx/core/data/model/room/CourseDateEntity.kt @@ -0,0 +1,60 @@ +package org.openedx.core.data.model.room + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import org.openedx.core.data.model.CourseDate +import org.openedx.core.utils.TimeUtils +import org.openedx.core.domain.model.CourseDate as DomainCourseDate + +@Entity(tableName = "course_dates_table") +data class CourseDateEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo("id") + val id: Int, + @ColumnInfo("first_component_block_id") + val firstComponentBlockId: String?, + @ColumnInfo("course_id") + val courseId: String, + @ColumnInfo("due_date") + val dueDate: String?, + @ColumnInfo("assignment_title") + val assignmentTitle: String?, + @ColumnInfo("learner_has_access") + val learnerHasAccess: Boolean?, + @ColumnInfo("relative") + val relative: Boolean?, + @ColumnInfo("course_name") + val courseName: String?, +) { + + fun mapToDomain(): DomainCourseDate? { + val dueDate = TimeUtils.iso8601ToDate(dueDate ?: "") + return DomainCourseDate( + courseId = courseId, + firstComponentBlockId = firstComponentBlockId ?: "", + dueDate = dueDate ?: return null, + assignmentTitle = assignmentTitle ?: "", + learnerHasAccess = learnerHasAccess ?: false, + relative = relative ?: false, + courseName = courseName ?: "" + ) + } + + companion object { + fun createFrom(courseDate: CourseDate): CourseDateEntity { + with(courseDate) { + return CourseDateEntity( + id = 0, + courseId = courseId, + firstComponentBlockId = firstComponentBlockId, + dueDate = dueDate, + assignmentTitle = assignmentTitle, + learnerHasAccess = learnerHasAccess, + relative = relative, + courseName = courseName + ) + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt index 91e38b35c..ae8956f83 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CalendarPreferences.kt @@ -1,10 +1,13 @@ package org.openedx.core.data.storage +import org.openedx.core.domain.model.CalendarType + interface CalendarPreferences { var calendarId: Long var calendarUser: String + var calendarType: CalendarType var isCalendarSyncEnabled: Boolean var isHideInactiveCourses: Boolean - fun clearCalendarPreferences() + suspend fun clearCalendarPreferences() } diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 5435494ba..9e42a5273 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -15,5 +15,5 @@ interface CorePreferences { var canResetAppDirectory: Boolean var isRelativeDatesEnabled: Boolean - fun clearCorePreferences() + suspend fun clearCorePreferences() } diff --git a/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt index da84dba1a..91935ad61 100644 --- a/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt +++ b/core/src/main/java/org/openedx/core/domain/interactor/CalendarInteractor.kt @@ -22,6 +22,10 @@ class CalendarInteractor( return repository.getCourseCalendarEventsByIdFromCache(courseId) } + suspend fun getAllCourseCalendarEventsFromCache(): List { + return repository.getAllCourseCalendarEventsFromCache() + } + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { repository.deleteCourseCalendarEntitiesByIdFromCache(courseId) } diff --git a/core/src/main/java/org/openedx/core/domain/model/CalendarType.kt b/core/src/main/java/org/openedx/core/domain/model/CalendarType.kt new file mode 100644 index 000000000..327a9f651 --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CalendarType.kt @@ -0,0 +1,6 @@ +package org.openedx.core.domain.model + +enum class CalendarType { + LOCAL, + GOOGLE, +} diff --git a/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt new file mode 100644 index 000000000..5a317b69c --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/CourseDatesResponse.kt @@ -0,0 +1,20 @@ +package org.openedx.core.domain.model + +import java.util.Date + +data class CourseDatesResponse( + val count: Int, + val next: String?, + val previous: String?, + val results: List +) + +data class CourseDate( + val courseId: String, + val firstComponentBlockId: String, + val dueDate: Date, + val assignmentTitle: String, + val learnerHasAccess: Boolean, + val relative: Boolean, + val courseName: String +) diff --git a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt index d641c79d8..01a769819 100644 --- a/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt +++ b/core/src/main/java/org/openedx/core/domain/model/DatesSection.kt @@ -1,6 +1,10 @@ package org.openedx.core.domain.model +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color import org.openedx.core.R +import org.openedx.core.ui.theme.appColors enum class DatesSection(val stringResId: Int) { COMPLETED(R.string.core_date_type_completed), @@ -9,5 +13,19 @@ enum class DatesSection(val stringResId: Int) { THIS_WEEK(R.string.core_date_type_this_week), NEXT_WEEK(R.string.core_date_type_next_week), UPCOMING(R.string.core_date_type_upcoming), - NONE(R.string.core_date_type_none) + NONE(R.string.core_date_type_none); + + val color: Color + @Composable + get() { + return when (this) { + COMPLETED -> MaterialTheme.appColors.cardViewBackground + PAST_DUE -> MaterialTheme.appColors.datesSectionBarPastDue + TODAY -> MaterialTheme.appColors.datesSectionBarToday + THIS_WEEK -> MaterialTheme.appColors.datesSectionBarThisWeek + NEXT_WEEK -> MaterialTheme.appColors.datesSectionBarNextWeek + UPCOMING -> MaterialTheme.appColors.datesSectionBarUpcoming + else -> MaterialTheme.appColors.background + } + } } diff --git a/core/src/main/java/org/openedx/core/domain/model/UserCalendar.kt b/core/src/main/java/org/openedx/core/domain/model/UserCalendar.kt new file mode 100644 index 000000000..ed40a38db --- /dev/null +++ b/core/src/main/java/org/openedx/core/domain/model/UserCalendar.kt @@ -0,0 +1,7 @@ +package org.openedx.core.domain.model + +data class UserCalendar( + val id: Long, + val title: String, + val color: Int +) diff --git a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt index 686009b92..bc14efa88 100644 --- a/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/CalendarDao.kt @@ -21,6 +21,9 @@ interface CalendarDao { @Query("SELECT * FROM course_calendar_event_table WHERE course_id=:courseId") suspend fun readCourseCalendarEventsById(courseId: String): List + @Query("SELECT * FROM course_calendar_event_table") + suspend fun readAllCourseCalendarEvents(): List + @Query("DELETE FROM course_calendar_event_table") suspend fun clearCourseCalendarEventsCachedData() diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index ba87e6ab0..0180a4845 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -17,6 +17,7 @@ import org.openedx.core.presentation.CoreAnalytics import org.openedx.core.presentation.CoreAnalyticsEvent import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager abstract class BaseDownloadViewModel( private val downloadDao: DownloadDao, @@ -24,7 +25,8 @@ abstract class BaseDownloadViewModel( private val workerController: DownloadWorkerController, private val analytics: CoreAnalytics, private val downloadHelper: DownloadHelper, -) : BaseViewModel() { + resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { val allBlocks = hashMapOf() diff --git a/core/src/main/java/org/openedx/core/presentation/ListItemPosition.kt b/core/src/main/java/org/openedx/core/presentation/ListItemPosition.kt new file mode 100644 index 000000000..016856eb8 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/ListItemPosition.kt @@ -0,0 +1,16 @@ +package org.openedx.core.presentation + +enum class ListItemPosition { + FIRST, MIDDLE, LAST, SINGLE; + + companion object { + fun detectPosition(index: Int, list: List): ListItemPosition { + return when { + list.lastIndex == 0 -> SINGLE + index == 0 -> FIRST + index == list.lastIndex -> LAST + else -> MIDDLE + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt new file mode 100644 index 000000000..98b4bb892 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/dates/DatesUI.kt @@ -0,0 +1,323 @@ +package org.openedx.core.presentation.dates + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.openedx.core.R +import org.openedx.core.domain.model.CourseDate +import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.DatesSection +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.utils.TimeUtils.formatToString +import org.openedx.core.utils.clearTime +import org.openedx.core.utils.isToday +import java.util.Date + +@Composable +private fun CourseDateBlockSectionGeneric( + sectionKey: DatesSection = DatesSection.NONE, + content: @Composable () -> Unit +) { + Column(modifier = Modifier.padding(start = 8.dp)) { + if (sectionKey != DatesSection.COMPLETED) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 4.dp), + text = stringResource(id = sectionKey.stringResId), + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.titleMedium, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) // ensures all cards share the height of the tallest one. + ) { + if (sectionKey != DatesSection.COMPLETED) { + DateBullet(section = sectionKey) + } + content() + } + } +} + +@Composable +private fun DateBlockContainer(content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(start = 8.dp, end = 8.dp) + ) { + content() + } +} + +@Composable +fun CourseDateBlockSection( + sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, + sectionDates: List, + onItemClick: (CourseDateBlock) -> Unit, +) { + CourseDateBlockSectionGeneric(sectionKey = sectionKey) { + DateBlock( + dateBlocks = sectionDates, + onItemClick = onItemClick, + useRelativeDates = useRelativeDates + ) + } +} + +@JvmName("CourseDateBlockSectionCourseDates") +@Composable +fun CourseDateBlockSection( + sectionKey: DatesSection = DatesSection.NONE, + useRelativeDates: Boolean, + sectionDates: List, + onItemClick: (CourseDate) -> Unit, +) { + CourseDateBlockSectionGeneric(sectionKey = sectionKey) { + DateBlock( + dateBlocks = sectionDates, + onItemClick = onItemClick, + useRelativeDates = useRelativeDates + ) + } +} + +@Composable +private fun DateBullet( + section: DatesSection = DatesSection.NONE, +) { + Box( + modifier = Modifier + .width(8.dp) + .fillMaxHeight() + .padding(top = 2.dp, bottom = 2.dp) + .background( + color = section.color, + shape = MaterialTheme.shapes.medium + ) + ) +} + +@Composable +private fun DateBlock( + dateBlocks: List, + useRelativeDates: Boolean, + onItemClick: (CourseDateBlock) -> Unit, +) { + DateBlockContainer { + var lastAssignmentDate = dateBlocks.first().date.clearTime() + dateBlocks.forEachIndexed { index, dateBlock -> + val canShowDate = if (index == 0) true else (lastAssignmentDate != dateBlock.date) + CourseDateItem(dateBlock, canShowDate, index != 0, useRelativeDates, onItemClick) + lastAssignmentDate = dateBlock.date + } + } +} + +@JvmName("DateBlockCourseDate") +@Composable +private fun DateBlock( + dateBlocks: List, + useRelativeDates: Boolean, + onItemClick: (CourseDate) -> Unit, +) { + DateBlockContainer { + dateBlocks.forEachIndexed { index, dateBlock -> + CourseDateItem(dateBlock, index != 0, useRelativeDates, onItemClick) + } + } +} + +@Composable +private fun CourseDateItem( + dateBlock: CourseDateBlock, + canShowDate: Boolean, + isMiddleChild: Boolean, + useRelativeDates: Boolean, + onItemClick: (CourseDateBlock) -> Unit, +) { + val context = LocalContext.current + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + ) { + if (isMiddleChild) { + Spacer(modifier = Modifier.height(20.dp)) + } + if (canShowDate) { + val timeTitle = formatToString(context, dateBlock.date, useRelativeDates) + Text( + text = timeTitle, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 4.dp) + .clickable( + enabled = dateBlock.blockId.isNotEmpty() && dateBlock.learnerHasAccess, + onClick = { onItemClick(dateBlock) } + ) + ) { + dateBlock.dateType.drawableResId?.let { icon -> + Icon( + modifier = Modifier + .padding(end = 4.dp) + .align(Alignment.CenterVertically), + painter = painterResource( + id = if (!dateBlock.learnerHasAccess) { + R.drawable.core_ic_lock + } else { + icon + } + ), + contentDescription = null, + tint = MaterialTheme.appColors.textDark + ) + } + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = if (!dateBlock.assignmentType.isNullOrEmpty()) { + "${dateBlock.assignmentType}: ${dateBlock.title}" + } else { + dateBlock.title + }, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(7.dp)) + if (dateBlock.blockId.isNotEmpty() && dateBlock.learnerHasAccess) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.appColors.textDark, + contentDescription = "Open Block Arrow", + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically) + ) + } + } + if (dateBlock.description.isNotEmpty()) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = dateBlock.description, + style = MaterialTheme.appTypography.labelMedium, + ) + } + } +} + +@Composable +private fun CourseDateItem( + dateBlock: CourseDate, + isMiddleChild: Boolean, + useRelativeDates: Boolean, + onItemClick: (CourseDate) -> Unit, +) { + val context = LocalContext.current + Column( + modifier = Modifier + .wrapContentHeight() + .fillMaxWidth() + ) { + if (isMiddleChild) { + Spacer(modifier = Modifier.height(20.dp)) + } + if (!dateBlock.dueDate.isToday() || dateBlock.dueDate < Date()) { + val timeTitle = formatToString(context, dateBlock.dueDate, useRelativeDates) + Text( + text = timeTitle, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(end = 4.dp) + .clickable( + enabled = dateBlock.firstComponentBlockId.isNotEmpty() && dateBlock.learnerHasAccess, + onClick = { onItemClick(dateBlock) } + ) + ) { + Icon( + modifier = Modifier + .padding(end = 4.dp) + .align(Alignment.CenterVertically), + painter = painterResource(R.drawable.core_ic_assignment), + contentDescription = null, + tint = MaterialTheme.appColors.textDark + ) + Text( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + text = dateBlock.assignmentTitle, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textDark, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(7.dp)) + if (dateBlock.firstComponentBlockId.isNotEmpty() && dateBlock.learnerHasAccess) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + tint = MaterialTheme.appColors.textDark, + contentDescription = "Open Block Arrow", + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically) + ) + } + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + text = dateBlock.courseName, + maxLines = 1, + style = MaterialTheme.appTypography.labelMedium, + ) + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt index 17b1d2874..9b49b2ae8 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/DialogUI.kt @@ -7,8 +7,8 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt index 28f357896..9f0b00c24 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/alert/ActionDialogFragment.kt @@ -14,8 +14,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt index 77c413924..de172374e 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/alert/InfoDialogFragment.kt @@ -12,8 +12,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt index 632669c11..e7645b0af 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/appreview/AppReviewUI.kt @@ -13,16 +13,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Star import androidx.compose.material.icons.outlined.StarOutline +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.MutableState @@ -170,10 +170,12 @@ fun FeedbackDialog( style = MaterialTheme.appTypography.labelLarge, ) }, - colors = TextFieldDefaults.outlinedTextFieldColors( - backgroundColor = MaterialTheme.appColors.cardViewBackground, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.appColors.cardViewBackground, + unfocusedContainerColor = MaterialTheme.appColors.cardViewBackground, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - textColor = MaterialTheme.appColors.textFieldText + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText ), ) @@ -254,7 +256,7 @@ fun TransparentTextButton( modifier = Modifier .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = Color.Transparent + containerColor = Color.Transparent ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -288,7 +290,7 @@ fun DefaultTextButton( modifier = Modifier .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = backgroundColor, + containerColor = backgroundColor, contentColor = textColor ), elevation = null, diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt index 5ab8db529..7881b054a 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadConfirmDialogFragment.kt @@ -16,11 +16,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CloudDownload import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt index f7bbe6ea5..e6bdf8e64 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadErrorDialogFragment.kt @@ -16,8 +16,8 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt index 8c026bdf2..67c556043 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadStorageErrorDialogFragment.kt @@ -25,8 +25,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt index 58a5f9d22..14ab4e66c 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/downloaddialog/DownloadView.kt @@ -4,9 +4,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt index 3890aa360..11e007e51 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectBottomDialogFragment.kt @@ -12,8 +12,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable diff --git a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt index f215974ce..db17aa625 100644 --- a/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/dialog/selectorbottomsheet/SelectDialogViewModel.kt @@ -6,10 +6,12 @@ import org.openedx.core.domain.model.RegistrationField import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseSubtitleLanguageChanged import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class SelectDialogViewModel( - private val notifier: CourseNotifier -) : BaseViewModel() { + private val notifier: CourseNotifier, + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { var values = mutableListOf() diff --git a/core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpdateUI.kt b/core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpdateUI.kt index e0cbae480..e755e0aa5 100644 --- a/core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpdateUI.kt +++ b/core/src/main/java/org/openedx/core/presentation/global/appupgrade/AppUpdateUI.kt @@ -16,13 +16,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.Card -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi @@ -267,7 +268,7 @@ fun TransparentTextButton( .testTag("btn_secondary") .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = Color.Transparent + containerColor = Color.Transparent ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -292,7 +293,7 @@ fun DefaultTextButton( .testTag("btn_primary") .height(42.dp), colors = ButtonDefaults.buttonColors( - backgroundColor = MaterialTheme.appColors.primaryButtonBackground + containerColor = MaterialTheme.appColors.primaryButtonBackground ), elevation = null, shape = MaterialTheme.appShapes.navigationButtonShape, @@ -326,7 +327,7 @@ fun AppUpgradeRecommendedBox( onClick() }, shape = MaterialTheme.appShapes.cardShape, - backgroundColor = MaterialTheme.appColors.primary + colors = CardDefaults.cardColors(containerColor = MaterialTheme.appColors.primary) ) { Row( modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), diff --git a/core/src/main/java/org/openedx/core/presentation/global/webview/SSOWebContentFragment.kt b/core/src/main/java/org/openedx/core/presentation/global/webview/SSOWebContentFragment.kt new file mode 100644 index 000000000..425cfc156 --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/global/webview/SSOWebContentFragment.kt @@ -0,0 +1,73 @@ +package org.openedx.core.presentation.global.webview + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import org.koin.android.ext.android.inject +import org.openedx.core.config.Config +import org.openedx.core.ui.SSOWebContentScreen +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize + +class SSOWebContentFragment : Fragment() { + + private val config: Config by inject() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + SSOWebContentScreen( + windowSize = windowSize, + url = config.getSSOURL(), + title = "", + ssoFinishedUrl = config.getSSOFinishedURL().toString(), + onBackClick = { + // use it to close the webView + requireActivity().supportFragmentManager.popBackStack() + }, + onWebPageLoaded = { + }, + onWebPageUpdated = { + val token = it + if (token.isNotEmpty()) { + setFragmentResult(REQUEST_KEY_SSO, bundleOf(BUNDLE_KEY_TOKEN to token)) + requireActivity().supportFragmentManager.popBackStack() + } + } + ) + } + } + } + +// override fun onDestroy() { +// super.onDestroy() +// CookieManager.getInstance().flush() +// } + + companion object { + private const val ARG_TITLE = "argTitle" + private const val ARG_URL = "argUrl" + const val REQUEST_KEY_SSO = "requestKey" + const val BUNDLE_KEY_TOKEN = "bundleKey" + + fun newInstance(title: String, url: String): SSOWebContentFragment { + val fragment = SSOWebContentFragment() + fragment.arguments = bundleOf( + ARG_TITLE to title, + ARG_URL to url, + ) + return fragment + } + } +} diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt index 15f94d338..10c4cf73a 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncDialog.kt @@ -7,11 +7,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.AlertDialog -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Surface -import androidx.compose.material.Text +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -124,7 +124,7 @@ private fun CalendarAlertDialog(dialogProperties: DialogProperties, onDismiss: ( shape = MaterialTheme.appShapes.cardShape ), shape = MaterialTheme.appShapes.cardShape, - backgroundColor = MaterialTheme.appColors.background, + containerColor = MaterialTheme.appColors.background, properties = AlertDialogProperties( dismissOnBackPress = false, @@ -133,7 +133,7 @@ private fun CalendarAlertDialog(dialogProperties: DialogProperties, onDismiss: ( onDismissRequest = onDismiss, title = dialogProperties.title.takeIfNotEmpty()?.let { - @Composable { + { Text( text = dialogProperties.title, color = MaterialTheme.appColors.textPrimary, diff --git a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt index 95a851442..5ad386dee 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/calendarsync/CalendarSyncState.kt @@ -1,12 +1,12 @@ package org.openedx.core.presentation.settings.calendarsync import androidx.annotation.StringRes -import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CloudSync import androidx.compose.material.icons.filled.SyncDisabled import androidx.compose.material.icons.rounded.EventRepeat import androidx.compose.material.icons.rounded.FreeCancellation +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.ui.graphics.Color diff --git a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt index b370cd56d..94dbad546 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityFragment.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -18,14 +19,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Done -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -129,7 +129,6 @@ private fun VideoQualityScreen( onQualityChanged: (VideoQuality) -> Unit, onBackClick: () -> Unit ) { - val scaffoldState = rememberScaffoldState() Scaffold( modifier = Modifier .fillMaxSize() @@ -137,7 +136,7 @@ private fun VideoQualityScreen( .semantics { testTagsAsResourceId = true }, - scaffoldState = scaffoldState, + contentWindowInsets = WindowInsets() ) { paddingValues -> val topBarWidth by remember(key1 = windowSize) { @@ -249,7 +248,7 @@ private fun QualityOption( ) } } - Divider() + HorizontalDivider() } @Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) diff --git a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt index 95ecca130..05fc6077e 100644 --- a/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/video/VideoQualityViewModel.kt @@ -12,13 +12,15 @@ import org.openedx.core.presentation.CoreAnalyticsKey import org.openedx.core.system.notifier.VideoNotifier import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager class VideoQualityViewModel( private val qualityType: String, private val preferencesManager: CorePreferences, private val notifier: VideoNotifier, private val analytics: CoreAnalytics, -) : BaseViewModel() { + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { private val _videoQuality = MutableLiveData() val videoQuality: LiveData diff --git a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt index 726709d8a..f2555584d 100644 --- a/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt +++ b/core/src/main/java/org/openedx/core/repository/CalendarRepository.kt @@ -30,6 +30,10 @@ class CalendarRepository( return calendarDao.readCourseCalendarEventsById(courseId).map { it.mapToDomain() } } + suspend fun getAllCourseCalendarEventsFromCache(): List { + return calendarDao.readAllCourseCalendarEvents().map { it.mapToDomain() } + } + suspend fun deleteCourseCalendarEntitiesByIdFromCache(courseId: String) { calendarDao.deleteCourseCalendarEntitiesById(courseId) } diff --git a/core/src/main/java/org/openedx/core/system/CalendarManager.kt b/core/src/main/java/org/openedx/core/system/CalendarManager.kt index c1a393767..431112641 100644 --- a/core/src/main/java/org/openedx/core/system/CalendarManager.kt +++ b/core/src/main/java/org/openedx/core/system/CalendarManager.kt @@ -3,6 +3,7 @@ package org.openedx.core.system import android.content.ContentUris import android.content.ContentValues import android.content.Context +import android.content.Intent import android.content.pm.PackageManager import android.database.Cursor import android.net.Uri @@ -11,9 +12,13 @@ import androidx.core.content.ContextCompat import io.branch.indexing.BranchUniversalObject import io.branch.referral.util.ContentMetadata import io.branch.referral.util.LinkProperties +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.CalendarData +import org.openedx.core.domain.model.CalendarType import org.openedx.core.domain.model.CourseDateBlock +import org.openedx.core.domain.model.UserCalendar import org.openedx.core.utils.Logger import org.openedx.core.utils.toCalendar import java.util.TimeZone @@ -25,6 +30,8 @@ class CalendarManager( ) { private val logger = Logger(TAG) + private data class CalendarAccount(val name: String, val type: String) + val permissions = arrayOf( android.Manifest.permission.WRITE_CALENDAR, android.Manifest.permission.READ_CALENDAR @@ -33,16 +40,10 @@ class CalendarManager( val accountName: String get() = getUserAccountForSync() - /** - * Check if the app has the calendar READ/WRITE permissions or not - */ fun hasPermissions(): Boolean = permissions.all { PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(context, it) } - /** - * Check if the calendar is already existed in mobile calendar app or not - */ fun isCalendarExist(calendarId: Long): Boolean { val projection = arrayOf(CalendarContract.Calendars._ID) val selection = "${CalendarContract.Calendars._ID} = ?" @@ -62,109 +63,255 @@ class CalendarManager( return exists } - /** - * Create or update the calendar if it is already existed in mobile calendar app - */ fun createOrUpdateCalendar( calendarId: Long = CALENDAR_DOES_NOT_EXIST, calendarTitle: String, - calendarColor: Long + calendarColor: Long, + calendarType: CalendarType ): Long { - if (calendarId != CALENDAR_DOES_NOT_EXIST) { + if (calendarId != CALENDAR_DOES_NOT_EXIST && calendarType == CalendarType.LOCAL) { deleteCalendar(calendarId = calendarId) } return createCalendar( calendarTitle = calendarTitle, - calendarColor = calendarColor + calendarColor = calendarColor, + calendarType = calendarType ) } - /** - * Method to create a separate calendar based on course name in mobile calendar app - */ private fun createCalendar( calendarTitle: String, - calendarColor: Long + calendarColor: Long, + calendarType: CalendarType ): Long { - val contentValues = ContentValues() - contentValues.put(CalendarContract.Calendars.NAME, calendarTitle) - contentValues.put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, calendarTitle) - contentValues.put(CalendarContract.Calendars.ACCOUNT_NAME, accountName) - contentValues.put( - CalendarContract.Calendars.ACCOUNT_TYPE, - CalendarContract.ACCOUNT_TYPE_LOCAL + if (calendarType == CalendarType.GOOGLE) { + val existingGoogleCalendar = findOrCreateGoogleCalendar() + if (existingGoogleCalendar != CALENDAR_DOES_NOT_EXIST) { + return existingGoogleCalendar + } + } + + val calendarAccount = when (calendarType) { + CalendarType.LOCAL -> CalendarAccount(accountName, CalendarContract.ACCOUNT_TYPE_LOCAL) + CalendarType.GOOGLE -> getCalendarOwnerAccount() + } + val contentValues = ContentValues().apply { + put(CalendarContract.Calendars.NAME, calendarTitle) + put(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, calendarTitle) + put(CalendarContract.Calendars.ACCOUNT_NAME, calendarAccount.name) + put(CalendarContract.Calendars.ACCOUNT_TYPE, calendarAccount.type) + put(CalendarContract.Calendars.OWNER_ACCOUNT, calendarAccount.name) + put( + CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, + CalendarContract.Calendars.CAL_ACCESS_ROOT + ) + put(CalendarContract.Calendars.SYNC_EVENTS, 1) + put(CalendarContract.Calendars.VISIBLE, 1) + put( + CalendarContract.Calendars.CALENDAR_COLOR, + calendarColor.toInt() + ) + } + + val calendarData = context.contentResolver.insert( + CalendarContract.Calendars.CONTENT_URI, + contentValues ) - contentValues.put(CalendarContract.Calendars.OWNER_ACCOUNT, accountName) - contentValues.put( - CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL, - CalendarContract.Calendars.CAL_ACCESS_ROOT + + return calendarData?.lastPathSegment?.toLong()?.also { + logger.d { "Calendar ID $it created" } + } ?: CALENDAR_DOES_NOT_EXIST + } + + private fun findOrCreateGoogleCalendar(): Long { + return findPrimaryGoogleCalendar()?.also { + logger.d { "Using existing primary Google Calendar ID $it" } + } ?: findWritableGoogleCalendar()?.also { + logger.d { "Using existing Google Calendar ID $it" } + } ?: run { + logger.d { "No Google Calendar found, will create local calendar" } + CALENDAR_DOES_NOT_EXIST + } + } + + private fun findPrimaryGoogleCalendar(): Long? { + val projection = arrayOf(CalendarContract.Calendars._ID) + val selection = "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.IS_PRIMARY} = 1 AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1" + val selectionArgs = arrayOf(GOOGLE_ACCOUNT_TYPE) + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + null ) - contentValues.put(CalendarContract.Calendars.SYNC_EVENTS, 1) - contentValues.put(CalendarContract.Calendars.VISIBLE, 1) - contentValues.put( - CalendarContract.Calendars.CALENDAR_COLOR, - calendarColor.toInt() + + return cursor?.use { + if (it.moveToFirst()) { + it.getLong(it.getColumnIndexOrThrow(CalendarContract.Calendars._ID)) + } else { + null + } + } + } + + private fun findWritableGoogleCalendar(): Long? { + val projection = arrayOf(CalendarContract.Calendars._ID) + val selection = "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1 AND " + + "${CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL} >= ?" + val selectionArgs = arrayOf( + GOOGLE_ACCOUNT_TYPE, + CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR.toString() ) - val creationUri: Uri? = asSyncAdapter( - Uri.parse(CalendarContract.Calendars.CONTENT_URI.toString()), - accountName + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + "${CalendarContract.Calendars.IS_PRIMARY} DESC" ) - creationUri?.let { - val calendarData: Uri? = context.contentResolver.insert(creationUri, contentValues) - calendarData?.let { - val id = calendarData.lastPathSegment?.toLong() - logger.d { "Calendar ID $id" } - return id ?: CALENDAR_DOES_NOT_EXIST + + return cursor?.use { + if (it.moveToFirst()) { + it.getLong(it.getColumnIndexOrThrow(CalendarContract.Calendars._ID)) + } else { + null } } - return CALENDAR_DOES_NOT_EXIST } - /** - * Method to add important dates of course as calendar event into calendar of mobile app - */ + fun getGoogleCalendars(): List { + val projection = arrayOf( + CalendarContract.Calendars._ID, + CalendarContract.Calendars.CALENDAR_DISPLAY_NAME, + CalendarContract.Calendars.CALENDAR_COLOR + ) + val selection = "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1 AND " + + "${CalendarContract.Calendars.CALENDAR_ACCESS_LEVEL} >= ?" + val selectionArgs = arrayOf( + GOOGLE_ACCOUNT_TYPE, + CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR.toString() + ) + val sortOrder = + "${CalendarContract.Calendars.IS_PRIMARY} DESC, ${CalendarContract.Calendars.CALENDAR_DISPLAY_NAME} ASC" + + return try { + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder + ) + + cursor?.use { + val idIndex = it.getColumnIndexOrThrow(CalendarContract.Calendars._ID) + val titleIndex = + it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME) + val colorIndex = it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR) + + buildList { + while (it.moveToNext()) { + add( + UserCalendar( + id = it.getLong(idIndex), + title = it.getString(titleIndex), + color = it.getInt(colorIndex) + ) + ) + } + } + } ?: emptyList() + } catch (e: SecurityException) { + logger.d { "Failed to load Google calendars: ${e.message}" } + emptyList() + } + } + + fun hasAlternativeCalendarApp(): Boolean { + val intent = Intent(Intent.ACTION_INSERT).setData(CalendarContract.Events.CONTENT_URI) + val activities = context.packageManager.queryIntentActivities(intent, 0) + return activities.any { it.activityInfo.packageName != GOOGLE_CALENDAR_PACKAGE } + } + fun addEventsIntoCalendar( calendarId: Long, courseId: String, courseName: String, courseDateBlock: CourseDateBlock ): Long { - val date = courseDateBlock.date.toCalendar() - // start time of the event, adjusted 1 hour earlier for a 1-hour duration - val startMillis: Long = date.timeInMillis - TimeUnit.HOURS.toMillis(1) - // end time of the event added to the calendar - val endMillis: Long = date.timeInMillis - - val values = ContentValues().apply { - put(CalendarContract.Events.DTSTART, startMillis) - put(CalendarContract.Events.DTEND, endMillis) - put( - CalendarContract.Events.TITLE, - "${courseDateBlock.title} : $courseName" - ) - put( - CalendarContract.Events.DESCRIPTION, - getEventDescription( - courseId = courseId, - courseDateBlock = courseDateBlock, - isDeeplinkEnabled = corePreferences.appConfig.courseDatesCalendarSync.isDeepLinkEnabled + repeat(EVENT_ATTEMPTS) { attemptIndex -> + val attemptNumber = attemptIndex + 1 + val eventId = + tryCreateEvent(calendarId, courseId, courseName, courseDateBlock, attemptNumber) + if (eventId != EVENT_DOES_NOT_EXIST) { + return eventId + } + if (attemptNumber < EVENT_ATTEMPTS) { + runBlocking { delay(ACTION_RETRY_DELAY) } + } + } + logger.d { "Failed to create event after $EVENT_ATTEMPTS attempts" } + return EVENT_DOES_NOT_EXIST + } + + private fun tryCreateEvent( + calendarId: Long, + courseId: String, + courseName: String, + courseDateBlock: CourseDateBlock, + attemptNumber: Int + ): Long { + return try { + val date = courseDateBlock.date.toCalendar() + val startMillis = date.timeInMillis - TimeUnit.HOURS.toMillis(1) + val endMillis = date.timeInMillis + + val values = ContentValues().apply { + put(CalendarContract.Events.DTSTART, startMillis) + put(CalendarContract.Events.DTEND, endMillis) + put( + CalendarContract.Events.TITLE, + "${courseDateBlock.title} : $courseName" ) - ) - put(CalendarContract.Events.CALENDAR_ID, calendarId) - put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) + put( + CalendarContract.Events.DESCRIPTION, + getEventDescription( + courseId = courseId, + courseDateBlock = courseDateBlock, + isDeeplinkEnabled = corePreferences.appConfig.courseDatesCalendarSync.isDeepLinkEnabled + ) + ) + put(CalendarContract.Events.CALENDAR_ID, calendarId) + put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id) + } + val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values) + val insertedEventId = uri?.lastPathSegment?.toLong() ?: EVENT_DOES_NOT_EXIST + + if (insertedEventId != EVENT_DOES_NOT_EXIST && isEventExists(insertedEventId)) { + uri?.let { addReminderToEvent(uri = it) } + logger.d { "Event created successfully: $insertedEventId (attempt $attemptNumber)" } + insertedEventId + } else { + logger.d { "Event creation failed, retrying... (attempt $attemptNumber/$EVENT_ATTEMPTS)" } + EVENT_DOES_NOT_EXIST + } + } catch (e: Exception) { + logger.d { "Event creation error on attempt $attemptNumber: ${e.message}" } + EVENT_DOES_NOT_EXIST } - val uri = context.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values) - uri?.let { addReminderToEvent(uri = it) } - val eventId = uri?.lastPathSegment?.toLong() ?: EVENT_DOES_NOT_EXIST - return eventId } - /** - * Method to generate & add deeplink into event description - * - * @return event description with deeplink for assignment block else block title - */ private fun getEventDescription( courseId: String, courseDateBlock: CourseDateBlock, @@ -194,69 +341,169 @@ class CalendarManager( return eventDescription } - /** - * Method to add a reminder to the given calendar events - * - * @param uri Calendar event Uri - */ private fun addReminderToEvent(uri: Uri) { - val eventId: Long? = uri.lastPathSegment?.toLong() + val eventId = uri.lastPathSegment?.toLong() ?: return logger.d { "Event ID $eventId" } - // Adding reminder on the start of event val eventValues = ContentValues().apply { - put(CalendarContract.Reminders.MINUTES, 0) put(CalendarContract.Reminders.EVENT_ID, eventId) put(CalendarContract.Reminders.METHOD, CalendarContract.Reminders.METHOD_ALERT) } - context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) - // Adding reminder 24 hours before the event get started - eventValues.apply { - put(CalendarContract.Reminders.MINUTES, TimeUnit.DAYS.toMinutes(1)) - } - context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) - // Adding reminder 48 hours before the event get started - eventValues.apply { - put(CalendarContract.Reminders.MINUTES, TimeUnit.DAYS.toMinutes(2)) + + listOf(0, TimeUnit.DAYS.toMinutes(1), TimeUnit.DAYS.toMinutes(2)).forEach { minutes -> + eventValues.put(CalendarContract.Reminders.MINUTES, minutes) + context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) } - context.contentResolver.insert(CalendarContract.Reminders.CONTENT_URI, eventValues) } - /** - * Method to delete the course calendar from the mobile calendar app - */ fun deleteCalendar(calendarId: Long) { - context.contentResolver.delete( - Uri.parse("content://com.android.calendar/calendars/$calendarId"), - null, + val calendarAccount = getCalendarAccountById(calendarId) + if (calendarAccount?.type == GOOGLE_ACCOUNT_TYPE) { + logger.d { "Cannot delete Google Calendar" } + return + } + + val calendarUri = ContentUris.withAppendedId( + CalendarContract.Calendars.CONTENT_URI, + calendarId + ) + val rowsDeleted = context.contentResolver.delete(calendarUri, null, null) + logger.d { + if (rowsDeleted > 0) { + "Calendar $calendarId deleted successfully" + } else { + "Calendar $calendarId deletion failed or calendar doesn't exist" + } + } + } + + suspend fun deleteEvents(eventIds: List) { + val deletedCount = eventIds.count { eventId -> + var deleted = false + var attempts = 0 + + while (!deleted && attempts < EVENT_ATTEMPTS) { + attempts++ + try { + deleted = deleteEventWithRetry(eventId) + if (!deleted && attempts < EVENT_ATTEMPTS) { + delay(ACTION_RETRY_DELAY) + } + } catch (e: Exception) { + logger.d { "Failed to delete event $eventId on attempt $attempts: ${e.message}" } + if (attempts < EVENT_ATTEMPTS) { + delay(ACTION_RETRY_DELAY) + } + } + } + + if (!deleted) { + logger.d { "Failed to delete event $eventId after $EVENT_ATTEMPTS attempts" } + } + + deleted + } + logger.d { "Successfully deleted $deletedCount out of ${eventIds.size} events" } + } + + private fun deleteEventWithRetry(eventId: Long): Boolean { + val deleteUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) + val rows = context.contentResolver.delete(deleteUri, null, null) + + val deleted = if (rows > 0) { + val stillExists = isEventExists(eventId) + if (!stillExists) { + logger.d { "Event $eventId deleted successfully" } + true + } else { + logger.d { "Event $eventId deletion reported success but event still exists" } + false + } + } else { + val exists = isEventExists(eventId) + if (!exists) { + logger.d { "Event $eventId doesn't exist (already deleted)" } + true + } else { + logger.d { "Event $eventId deletion failed" } + false + } + } + return deleted + } + + private fun getCalendarOwnerAccount(): CalendarAccount { + return getSyncedAccountByType(GOOGLE_ACCOUNT_TYPE) + ?: getFirstSyncedAccount(excludeLocal = true) + ?: CalendarAccount(accountName, CalendarContract.ACCOUNT_TYPE_LOCAL) + } + + private fun getFirstSyncedAccount(excludeLocal: Boolean): CalendarAccount? { + val selection = buildString { + append("${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND ${CalendarContract.Calendars.VISIBLE} = 1") + if (excludeLocal) { + append(" AND ${CalendarContract.Calendars.ACCOUNT_TYPE} != ?") + } + } + val selectionArgs = if (excludeLocal) { + arrayOf(CalendarContract.ACCOUNT_TYPE_LOCAL) + } else { null + } + + return queryCalendarAccount(selection, selectionArgs) + } + + private fun getSyncedAccountByType(accountType: String): CalendarAccount? { + val selection = + "${CalendarContract.Calendars.ACCOUNT_TYPE} = ? AND " + + "${CalendarContract.Calendars.SYNC_EVENTS} = 1 AND " + + "${CalendarContract.Calendars.VISIBLE} = 1" + val selectionArgs = arrayOf(accountType) + + return queryCalendarAccount(selection, selectionArgs) + } + + private fun queryCalendarAccount( + selection: String, + selectionArgs: Array? + ): CalendarAccount? { + val projection = arrayOf( + CalendarContract.Calendars.ACCOUNT_NAME, + CalendarContract.Calendars.ACCOUNT_TYPE, + CalendarContract.Calendars.IS_PRIMARY + ) + val sortOrder = "${CalendarContract.Calendars.IS_PRIMARY} DESC" + + val cursor = context.contentResolver.query( + CalendarContract.Calendars.CONTENT_URI, + projection, + selection, + selectionArgs, + sortOrder ) + + return cursor?.use { + if (it.moveToFirst()) { + val accountName = it.getString( + it.getColumnIndexOrThrow(CalendarContract.Calendars.ACCOUNT_NAME) + ) + val accountType = it.getString( + it.getColumnIndexOrThrow(CalendarContract.Calendars.ACCOUNT_TYPE) + ) + CalendarAccount(accountName, accountType) + } else { + null + } + } } - /** - * Helper method used to return a URI for use with a sync adapter (how an application and a - * sync adapter access the Calendar Provider) - * - * @param uri URI to access the calendar - * @param account Name of the calendar owner - * - * @return URI of the calendar - * - */ - private fun asSyncAdapter(uri: Uri, account: String): Uri? { - return uri.buildUpon().appendQueryParameter(CalendarContract.CALLER_IS_SYNCADAPTER, "true") - .appendQueryParameter(CalendarContract.SyncState.ACCOUNT_NAME, account) - .appendQueryParameter( - CalendarContract.SyncState.ACCOUNT_TYPE, - CalendarContract.ACCOUNT_TYPE_LOCAL - ).build() + private fun getCalendarAccountById(calendarId: Long): CalendarAccount? { + val selection = "${CalendarContract.Calendars._ID} = ?" + val selectionArgs = arrayOf(calendarId.toString()) + return queryCalendarAccount(selection, selectionArgs) } - /** - * Method to get the current user account as the Calendar owner - * - * @return calendar owner account or "local_user" - */ private fun getUserAccountForSync(): String { return corePreferences.user?.email ?: LOCAL_USER } @@ -279,8 +526,10 @@ class CalendarManager( return cursor?.use { if (it.moveToFirst()) { - val title = it.getString(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)) - val color = it.getInt(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR)) + val title = + it.getString(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_DISPLAY_NAME)) + val color = + it.getInt(it.getColumnIndexOrThrow(CalendarContract.Calendars.CALENDAR_COLOR)) CalendarData( title = title, color = color @@ -294,17 +543,44 @@ class CalendarManager( fun deleteEvent(eventId: Long) { val deleteUri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId) val rows = context.contentResolver.delete(deleteUri, null, null) - if (rows > 0) { - logger.d { "Event deleted successfully" } - } else { - logger.d { "Event deletion failed" } + logger.d { + if (rows > 0) { + "Event deleted successfully" + } else { + "Event deletion failed" + } } } + private fun isEventExists(eventId: Long): Boolean { + if (eventId == EVENT_DOES_NOT_EXIST) return false + + val projection = arrayOf(CalendarContract.Events._ID) + val selection = "${CalendarContract.Events._ID} = ?" + val selectionArgs = arrayOf(eventId.toString()) + + val cursor = context.contentResolver.query( + CalendarContract.Events.CONTENT_URI, + projection, + selection, + selectionArgs, + null + ) + + return cursor?.use { + it.count > 0 + } ?: false + } + companion object { const val CALENDAR_DOES_NOT_EXIST = -1L const val EVENT_DOES_NOT_EXIST = -1L private const val TAG = "CalendarManager" private const val LOCAL_USER = "local_user" + private const val GOOGLE_ACCOUNT_TYPE = "com.google" + private const val GOOGLE_CALENDAR_PACKAGE = "com.google.android.calendar" + + private const val ACTION_RETRY_DELAY = 500L + private const val EVENT_ATTEMPTS = 3 } } diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index eed214567..d2a3c8dbc 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -37,18 +37,6 @@ import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.Divider -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.ScaffoldState -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AccountCircle @@ -56,6 +44,18 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.NonRestartableComposable @@ -214,40 +214,6 @@ fun Toolbar( } } -@Composable -fun MainToolbar( - modifier: Modifier = Modifier, - label: String, - onSettingsClick: () -> Unit, -) { - Box( - modifier = modifier.fillMaxWidth() - ) { - Text( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp), - text = label, - color = MaterialTheme.appColors.textDark, - style = MaterialTheme.appTypography.headlineBold - ) - IconButton( - modifier = Modifier - .align(Alignment.CenterEnd) - .padding(end = 12.dp), - onClick = { - onSettingsClick() - } - ) { - Icon( - imageVector = Icons.Default.ManageAccounts, - tint = MaterialTheme.appColors.textAccent, - contentDescription = stringResource(id = R.string.core_accessibility_settings) - ) - } - } -} - @Composable fun SearchBar( modifier: Modifier, @@ -292,17 +258,16 @@ fun SearchBar( textFieldValue = it } }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = MaterialTheme.appColors.textPrimary, - backgroundColor = if (isFocused) { - MaterialTheme.appColors.background - } else { - MaterialTheme.appColors.textFieldBackground - }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textPrimary, + unfocusedTextColor = MaterialTheme.appColors.textPrimary, + focusedContainerColor = MaterialTheme.appColors.background, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, focusedBorderColor = MaterialTheme.appColors.primary, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.primary, - leadingIconColor = MaterialTheme.appColors.textPrimary + focusedLeadingIconColor = MaterialTheme.appColors.textPrimary, + unfocusedLeadingIconColor = MaterialTheme.appColors.textPrimary ), placeholder = { Text( @@ -387,17 +352,16 @@ fun SearchBarStateless( onValueChanged(it) } }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = MaterialTheme.appColors.textPrimary, - backgroundColor = if (isFocused) { - MaterialTheme.appColors.background - } else { - MaterialTheme.appColors.textFieldBackground - }, + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textPrimary, + unfocusedTextColor = MaterialTheme.appColors.textPrimary, + focusedContainerColor = MaterialTheme.appColors.background, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, focusedBorderColor = MaterialTheme.appColors.primary, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.primary, - leadingIconColor = MaterialTheme.appColors.textPrimary + focusedLeadingIconColor = MaterialTheme.appColors.textPrimary, + unfocusedLeadingIconColor = MaterialTheme.appColors.textPrimary ), placeholder = { Text( @@ -443,13 +407,13 @@ fun SearchBarStateless( @NonRestartableComposable fun HandleUIMessage( uiMessage: UIMessage?, - scaffoldState: ScaffoldState, + snackbarHostState: SnackbarHostState, ) { val context = LocalContext.current LaunchedEffect(uiMessage) { when (uiMessage) { is UIMessage.SnackBarMessage -> { - scaffoldState.snackbarHostState.showSnackbar( + snackbarHostState.showSnackbar( message = uiMessage.message, duration = uiMessage.duration ) @@ -601,7 +565,7 @@ fun SheetContent( style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center ) - Divider(modifier = Modifier.padding(horizontal = 16.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } } } @@ -667,7 +631,7 @@ fun SheetContent( style = MaterialTheme.appTypography.bodyLarge, textAlign = TextAlign.Center ) - Divider(modifier = Modifier.padding(horizontal = 16.dp)) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) } } } @@ -718,10 +682,13 @@ fun OpenEdXOutlinedTextField( inputFieldValue = it onValueChanged(it.text) }, - colors = TextFieldDefaults.outlinedTextFieldColors( + colors = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, + focusedBorderColor = MaterialTheme.appColors.primary, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - textColor = MaterialTheme.appColors.textFieldText, - backgroundColor = MaterialTheme.appColors.textFieldBackground, errorBorderColor = MaterialTheme.appColors.error, ), shape = MaterialTheme.appShapes.textFieldShape, @@ -982,6 +949,15 @@ fun OfflineModeDialog( } } +@Composable +fun HorizontalLine() { + HorizontalDivider( + color = Color.LightGray.copy(alpha = 0.5f), // Set the color of the line + thickness = 1.dp, // Set the thickness of the line + modifier = Modifier.fillMaxWidth() // Make it span the entire width + ) +} + @Composable fun OpenEdXButton( modifier: Modifier = Modifier @@ -1000,7 +976,7 @@ fun OpenEdXButton( .then(modifier), shape = MaterialTheme.appShapes.buttonShape, colors = ButtonDefaults.buttonColors( - backgroundColor = backgroundColor + containerColor = backgroundColor ), enabled = enabled, onClick = onClick @@ -1038,7 +1014,7 @@ fun OpenEdXOutlinedButton( enabled = enabled, border = BorderStroke(1.dp, borderColor), shape = MaterialTheme.appShapes.buttonShape, - colors = ButtonDefaults.outlinedButtonColors(backgroundColor = backgroundColor) + colors = ButtonDefaults.outlinedButtonColors(containerColor = backgroundColor) ) { if (content == null) { Text( @@ -1170,14 +1146,16 @@ fun AuthButtonsPanel( onSignInClick: () -> Unit, showRegisterButton: Boolean, ) { - Row { + Row( + verticalAlignment = Alignment.CenterVertically + ) { OpenEdXOutlinedButton( modifier = Modifier .testTag("btn_sign_in") .then( if (showRegisterButton) { Modifier - .width(100.dp) + .width(120.dp) .padding(end = 16.dp) } else { Modifier.weight(1f) @@ -1310,6 +1288,51 @@ private fun RoundTab( } } +@Composable +fun MainScreenToolbar( + modifier: Modifier = Modifier, + label: String, + onSettingsClick: () -> Unit, +) { + Box( + modifier = modifier.fillMaxWidth() + ) { + Text( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp), + text = label, + color = MaterialTheme.appColors.textDark, + style = MaterialTheme.appTypography.headlineBold + ) + IconButton( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 12.dp), + onClick = { + onSettingsClick() + } + ) { + Icon( + imageVector = Icons.Default.ManageAccounts, + tint = MaterialTheme.appColors.textAccent, + contentDescription = stringResource(id = R.string.core_accessibility_settings) + ) + } + } +} + +@Preview +@Composable +private fun MainScreenTitlePreview() { + OpenEdXTheme { + MainScreenToolbar( + label = "Title", + onSettingsClick = {} + ) + } +} + @Composable fun OpenEdXDropdownMenuItem( modifier: Modifier = Modifier, diff --git a/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt b/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt index 0105e2cff..34980354c 100644 --- a/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt +++ b/core/src/main/java/org/openedx/core/ui/HTMLRenderer.kt @@ -14,8 +14,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/core/src/main/java/org/openedx/core/ui/PageIndicator.kt b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt index 8e9f4f40b..411de5736 100644 --- a/core/src/main/java/org/openedx/core/ui/PageIndicator.kt +++ b/core/src/main/java/org/openedx/core/ui/PageIndicator.kt @@ -7,7 +7,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment diff --git a/core/src/main/java/org/openedx/core/ui/SSOWebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/SSOWebContentScreen.kt new file mode 100644 index 000000000..c9585b4d7 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/SSOWebContentScreen.kt @@ -0,0 +1,127 @@ +package org.openedx.core.ui +import android.webkit.CookieManager +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.zIndex +import org.openedx.core.ui.theme.appColors +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.windowSizeValue + +@Composable +fun SSOWebContentScreen( + windowSize: WindowSize, + url: String, + title: String, + ssoFinishedUrl: String, + onBackClick: () -> Unit, + onWebPageLoaded: () -> Unit, + onWebPageUpdated: (String) -> Unit = {}, +) { + val context = LocalContext.current + val webView = remember { + WebView(context).apply { + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, pageUrl: String?) { + super.onPageFinished(view, pageUrl) + if (pageUrl == null) return + if (pageUrl.contains(ssoFinishedUrl)) { + val header = getCookie(pageUrl, "edx-jwt-cookie-header-payload") + val signature = getCookie(pageUrl, "edx-jwt-cookie-signature") + if (!header.isNullOrEmpty() && !signature.isNullOrEmpty()) { + onWebPageUpdated("$header.$signature") + } else { + // Handle error logic here if needed + } + } + } + override fun onPageCommitVisible(view: WebView?, url: String?) { + super.onPageCommitVisible(view, url) + onWebPageLoaded() + } + } + with(settings) { + javaScriptEnabled = true + domStorageEnabled = true + } + loadUrl(url) + } + } + + val screenWidthModifier = windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding(), // Fix 2: Changed from statusBarsInset() + contentAlignment = Alignment.TopCenter + ) { + Column(screenWidthModifier) { + Box( + Modifier + .fillMaxWidth() + .zIndex(1f), + contentAlignment = Alignment.CenterStart + ) { + Toolbar( + label = title, + canShowBackBtn = true, + onBackClick = onBackClick + ) + } + Surface( + Modifier.fillMaxSize(), + color = MaterialTheme.appColors.background + ) { + val webViewAlpha by rememberSaveable { mutableFloatStateOf(1f) } + Surface( + Modifier.alpha(webViewAlpha), + color = MaterialTheme.appColors.background + ) { + AndroidView( + modifier = Modifier + .background(MaterialTheme.appColors.background), + factory = { webView } + ) + } + } + } + } +} + +fun getCookie(siteName: String?, cookieName: String?): String? { + val cookieValue = siteName + ?.takeIf { it.isNotEmpty() } + ?.let { sn -> + cookieName?.takeIf { it.isNotEmpty() }?.let { cn -> + CookieManager.getInstance().getCookie(sn) + ?.split(";") + ?.firstOrNull { it.trim().startsWith("$cn=") } + ?.substringAfter("=") + } + } + return cookieValue +} diff --git a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt index 70f320368..7bec499c7 100644 --- a/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt +++ b/core/src/main/java/org/openedx/core/ui/WebContentScreen.kt @@ -9,14 +9,14 @@ import android.webkit.WebViewClient import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.rememberScaffoldState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -54,7 +54,6 @@ fun WebContentScreen( htmlBody: String? = null, contentUrl: String? = null, ) { - val scaffoldState = rememberScaffoldState() Scaffold( modifier = Modifier .fillMaxSize() @@ -62,8 +61,8 @@ fun WebContentScreen( .semantics { testTagsAsResourceId = true }, - scaffoldState = scaffoldState, - backgroundColor = MaterialTheme.appColors.background + containerColor = MaterialTheme.appColors.background, + contentWindowInsets = WindowInsets() ) { val screenWidth by remember(key1 = windowSize) { mutableStateOf( diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt index bf20366d9..daf1e649e 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppColors.kt @@ -1,10 +1,10 @@ package org.openedx.core.ui.theme -import androidx.compose.material.Colors +import androidx.compose.material3.ColorScheme import androidx.compose.ui.graphics.Color data class AppColors( - val material: Colors, + val material3: ColorScheme, val textPrimary: Color, val textPrimaryVariant: Color, @@ -83,17 +83,52 @@ data class AppColors( val gradeProgressBarBackground: Color, val assignmentCardBorder: Color, ) { - val primary: Color get() = material.primary - val primaryVariant: Color get() = material.primaryVariant - val secondary: Color get() = material.secondary - val secondaryVariant: Color get() = material.secondaryVariant - val background: Color get() = material.background - val surface: Color get() = material.surface - val error: Color get() = material.error - val onPrimary: Color get() = material.onPrimary - val onSecondary: Color get() = material.onSecondary - val onBackground: Color get() = material.onBackground - val onSurface: Color get() = material.onSurface - val onError: Color get() = material.onError - val isLight: Boolean get() = material.isLight + // Material 3 ColorScheme accessors + val primary: Color get() = material3.primary + val onPrimary: Color get() = material3.onPrimary + val primaryContainer: Color get() = material3.primaryContainer + val onPrimaryContainer: Color get() = material3.onPrimaryContainer + val secondary: Color get() = material3.secondary + val onSecondary: Color get() = material3.onSecondary + val secondaryContainer: Color get() = material3.secondaryContainer + val onSecondaryContainer: Color get() = material3.onSecondaryContainer + val tertiary: Color get() = material3.tertiary + val onTertiary: Color get() = material3.onTertiary + val tertiaryContainer: Color get() = material3.tertiaryContainer + val onTertiaryContainer: Color get() = material3.onTertiaryContainer + val background: Color get() = material3.background + val onBackground: Color get() = material3.onBackground + val surface: Color get() = material3.surface + val onSurface: Color get() = material3.onSurface + val surfaceVariant: Color get() = material3.surfaceVariant + val onSurfaceVariant: Color get() = material3.onSurfaceVariant + val error: Color get() = material3.error + val onError: Color get() = material3.onError + val errorContainer: Color get() = material3.errorContainer + val onErrorContainer: Color get() = material3.onErrorContainer + val outline: Color get() = material3.outline + val outlineVariant: Color get() = material3.outlineVariant + val inverseSurface: Color get() = material3.inverseSurface + val inverseOnSurface: Color get() = material3.inverseOnSurface + val inversePrimary: Color get() = material3.inversePrimary + val surfaceTint: Color get() = material3.surfaceTint + val scrim: Color get() = material3.scrim + + // Backward compatibility accessors for M1 color names + @Deprecated("Use primary instead", ReplaceWith("primary")) + val primaryVariant: Color get() = material3.primaryContainer + + @Deprecated("Use secondary instead", ReplaceWith("secondary")) + val secondaryVariant: Color get() = material3.secondaryContainer + + // Helper to determine if this is a light theme + val isLight: Boolean get() = material3.background.luminance() > 0.5f + + private fun Color.luminance(): Float { + val r = red + val g = green + val b = blue + @Suppress("MagicNumber") + return 0.299f * r + 0.587f * g + 0.114f * b + } } diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt index 1a45681f9..214ebdf5b 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppShapes.kt @@ -1,13 +1,13 @@ package org.openedx.core.ui.theme import androidx.compose.foundation.shape.CornerBasedShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Shapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable data class AppShapes( - val material: Shapes, + val material3: Shapes, val buttonShape: CornerBasedShape, val navigationButtonShape: CornerBasedShape, val textFieldShape: CornerBasedShape, diff --git a/core/src/main/java/org/openedx/core/ui/theme/AppTypography.kt b/core/src/main/java/org/openedx/core/ui/theme/AppTypography.kt index 52d9adebb..5fc36d480 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/AppTypography.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/AppTypography.kt @@ -1,6 +1,6 @@ package org.openedx.core.ui.theme -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index 9b42c90ac..ec7997c72 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -3,27 +3,40 @@ package org.openedx.core.ui.theme import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollFactory import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material.MaterialTheme -import androidx.compose.material.darkColors -import androidx.compose.material.lightColors +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf + +internal val LocalAppColors = staticCompositionLocalOf { + error("No AppColors provided") +} private val DarkColorPalette = AppColors( - material = darkColors( + material3 = darkColorScheme( primary = dark_primary, - primaryVariant = dark_primary_variant, - secondary = dark_secondary, - secondaryVariant = dark_secondary_variant, - background = dark_background, - surface = dark_surface, - error = dark_error, onPrimary = dark_onPrimary, + primaryContainer = dark_primary_variant, + onPrimaryContainer = dark_onPrimary, + secondary = dark_secondary, onSecondary = dark_onSecondary, + secondaryContainer = dark_secondary_variant, + onSecondaryContainer = dark_onSecondary, + tertiary = dark_secondary, + onTertiary = dark_onSecondary, + background = dark_background, onBackground = dark_onBackground, + surface = dark_surface, onSurface = dark_onSurface, - onError = dark_onError + surfaceVariant = dark_surface, + onSurfaceVariant = dark_onSurface, + error = dark_error, + onError = dark_onError, + outline = dark_text_field_border, + outlineVariant = dark_divider, ), textPrimary = dark_text_primary, textPrimaryVariant = dark_text_primary_variant, @@ -103,19 +116,27 @@ private val DarkColorPalette = AppColors( ) private val LightColorPalette = AppColors( - material = lightColors( + material3 = lightColorScheme( primary = light_primary, - primaryVariant = light_primary_variant, - secondary = light_secondary, - secondaryVariant = light_secondary_variant, - background = light_background, - surface = light_surface, - error = light_error, onPrimary = light_onPrimary, + primaryContainer = light_primary_variant, + onPrimaryContainer = light_onPrimary, + secondary = light_secondary, onSecondary = light_onSecondary, + secondaryContainer = light_secondary_variant, + onSecondaryContainer = light_onSecondary, + tertiary = light_secondary, + onTertiary = light_onSecondary, + background = light_background, onBackground = light_onBackground, + surface = light_surface, onSurface = light_onSurface, - onError = light_onError + surfaceVariant = light_surface, + onSurfaceVariant = light_onSurface, + error = light_error, + onError = light_onError, + outline = light_text_field_border, + outlineVariant = light_divider, ), textPrimary = light_text_primary, textPrimaryVariant = light_text_primary_variant, @@ -197,7 +218,7 @@ private val LightColorPalette = AppColors( val MaterialTheme.appColors: AppColors @Composable @ReadOnlyComposable - get() = if (colors.isLight) LightColorPalette else DarkColorPalette + get() = LocalAppColors.current @OptIn(ExperimentalFoundationApi::class) @Composable @@ -209,11 +230,11 @@ fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl } MaterialTheme( - colors = colors.material, - // typography = LocalTypography.current.material, - shapes = LocalShapes.current.material, + colorScheme = colors.material3, + shapes = LocalShapes.current.material3, ) { CompositionLocalProvider( + LocalAppColors provides colors, LocalOverscrollFactory provides null, content = content ) diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index f4fabd553..11c12dc42 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -217,4 +217,8 @@ Explore other parts of this course or download this when you reconnect. Authorization Please enter the system to continue with course enrollment. + Sign in with SSO + Start today to build your career with confidence + Sign in + Sign in through the national unified sign-on service diff --git a/core/src/main/res/values/themes.xml b/core/src/main/res/values/themes.xml index e43010475..a55cddceb 100644 --- a/core/src/main/res/values/themes.xml +++ b/core/src/main/res/values/themes.xml @@ -1,6 +1,6 @@ -