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 @@
-