diff --git a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt index 37d71c03a2..c3eaa98f96 100644 --- a/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt +++ b/libs/horizon/src/androidTest/java/com/instructure/horizon/espresso/HorizonOfflineTestModule.kt @@ -19,6 +19,8 @@ import android.content.Context import androidx.room.Room import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.horizon.database.HorizonDatabase +import com.instructure.horizon.database.dao.HorizonAssignmentCommentDao +import com.instructure.horizon.database.dao.HorizonAssignmentDetailsDao import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao @@ -29,7 +31,9 @@ import com.instructure.horizon.database.dao.HorizonLearnCollectionDao import com.instructure.horizon.database.dao.HorizonLearnItemDao import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.dao.HorizonPageDao import com.instructure.horizon.database.dao.HorizonProgramDao +import com.instructure.horizon.database.dao.HorizonSubmissionDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao import com.instructure.horizon.di.HorizonHtmlParserQualifier import com.instructure.horizon.di.HorizonOfflineModule @@ -90,6 +94,18 @@ object HorizonOfflineTestModule { @Provides fun provideHorizonFileFolderDao(db: HorizonDatabase): HorizonFileFolderDao = db.fileFolderDao() + @Provides + fun provideHorizonPageDao(db: HorizonDatabase): HorizonPageDao = db.pageDao() + + @Provides + fun provideHorizonAssignmentDetailsDao(db: HorizonDatabase): HorizonAssignmentDetailsDao = db.assignmentDetailsDao() + + @Provides + fun provideHorizonAssignmentCommentDao(db: HorizonDatabase): HorizonAssignmentCommentDao = db.assignmentCommentDao() + + @Provides + fun provideHorizonSubmissionDao(db: HorizonDatabase): HorizonSubmissionDao = db.submissionDao() + @Provides @HorizonHtmlParserQualifier fun provideHorizonHtmlParser( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentCommentsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentCommentsLocalDataSource.kt new file mode 100644 index 0000000000..d7398a2c56 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentCommentsLocalDataSource.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.managers.graphql.horizon.Comment +import com.instructure.canvasapi2.managers.graphql.horizon.CommentAttachment +import com.instructure.canvasapi2.managers.graphql.horizon.CommentsData +import com.instructure.horizon.database.dao.HorizonAssignmentCommentDao +import com.instructure.horizon.database.entity.HorizonAssignmentCommentAttachmentEntity +import com.instructure.horizon.database.entity.HorizonAssignmentCommentEntity +import java.util.Date +import javax.inject.Inject + +class AssignmentCommentsLocalDataSource @Inject constructor( + private val commentDao: HorizonAssignmentCommentDao, +) { + + suspend fun getComments(assignmentId: Long, attempt: Int): CommentsData { + val commentEntities = commentDao.getComments(assignmentId, attempt) + val commentIds = commentEntities.map { it.id } + val attachmentEntities = if (commentIds.isNotEmpty()) { + commentDao.getAttachments(commentIds) + } else { + emptyList() + } + val attachmentsByCommentId = attachmentEntities.groupBy { it.commentId } + val comments = commentEntities.map { entity -> + entity.toComment(attachmentsByCommentId[entity.id] ?: emptyList()) + } + return CommentsData( + comments = comments, + hasNextPage = false, + hasPreviousPage = false, + ) + } + + suspend fun saveComments(assignmentId: Long, attempt: Int, commentsData: CommentsData) { + val commentWithAttachments = commentsData.comments.map { comment -> + val commentEntity = HorizonAssignmentCommentEntity( + assignmentId = assignmentId, + attempt = attempt, + authorId = comment.authorId, + authorName = comment.authorName, + commentText = comment.commentText, + createdAtMs = comment.createdAt.time, + read = comment.read, + ) + val attachmentEntities = comment.attachments.map { attachment -> + HorizonAssignmentCommentAttachmentEntity( + attachmentId = attachment.attachmentId, + commentId = 0, + fileName = attachment.fileName, + fileUrl = attachment.fileUrl, + fileType = attachment.fileType, + ) + } + commentEntity to attachmentEntities + } + commentDao.replaceCommentsForAttempt(assignmentId, attempt, commentWithAttachments) + } + + suspend fun getUnreadCommentCount(assignmentId: Long): Int { + return commentDao.getUnreadCommentCount(assignmentId) + } + + private fun HorizonAssignmentCommentEntity.toComment( + attachments: List, + ): Comment { + return Comment( + authorId = authorId, + authorName = authorName, + commentText = commentText, + createdAt = Date(createdAtMs), + attachments = attachments.map { it.toCommentAttachment() }, + read = read, + ) + } + + private fun HorizonAssignmentCommentAttachmentEntity.toCommentAttachment(): CommentAttachment { + return CommentAttachment( + attachmentId = attachmentId, + fileName = fileName, + fileUrl = fileUrl, + fileType = fileType, + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsLocalDataSource.kt new file mode 100644 index 0000000000..e91e9f232f --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsLocalDataSource.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.ExternalToolAttributes +import com.instructure.horizon.database.dao.HorizonAssignmentDetailsDao +import com.instructure.horizon.database.entity.HorizonAssignmentDetailsEntity +import javax.inject.Inject + +class AssignmentDetailsLocalDataSource @Inject constructor( + private val assignmentDetailsDao: HorizonAssignmentDetailsDao, +) { + + suspend fun getAssignment(assignmentId: Long): Assignment? { + return assignmentDetailsDao.getAssignment(assignmentId)?.toAssignment() + } + + suspend fun saveAssignment(assignment: Assignment, courseId: Long, parsedDescription: String?) { + assignmentDetailsDao.saveAssignment( + HorizonAssignmentDetailsEntity( + assignmentId = assignment.id, + courseId = courseId, + name = assignment.name, + description = parsedDescription, + pointsPossible = assignment.pointsPossible, + allowedAttempts = assignment.allowedAttempts, + dueAt = assignment.dueAt, + submissionTypes = assignment.submissionTypesRaw.joinToString(","), + gradingType = assignment.gradingType, + lockedForUser = assignment.lockedForUser, + lockExplanation = assignment.lockExplanation, + quizId = assignment.quizId, + url = assignment.url, + ltiToolUrl = assignment.externalToolAttributes?.url, + ) + ) + } + + private fun HorizonAssignmentDetailsEntity.toAssignment(): Assignment { + return Assignment( + id = assignmentId, + name = name, + description = description, + pointsPossible = pointsPossible, + allowedAttempts = allowedAttempts, + dueAt = dueAt, + submissionTypesRaw = submissionTypes.split(",").filter { it.isNotEmpty() }, + gradingType = gradingType, + lockedForUser = lockedForUser, + lockExplanation = lockExplanation, + quizId = quizId, + url = url, + externalToolAttributes = ltiToolUrl?.let { ExternalToolAttributes(url = it) }, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsNetworkDataSource.kt new file mode 100644 index 0000000000..827011ade1 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/AssignmentDetailsNetworkDataSource.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.apis.AssignmentAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Assignment +import javax.inject.Inject + +class AssignmentDetailsNetworkDataSource @Inject constructor( + private val assignmentApi: AssignmentAPI.AssignmentInterface, +) { + + suspend fun getAssignment(courseId: Long, assignmentId: Long, forceRefresh: Boolean): Assignment { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + return assignmentApi.getAssignmentWithHistory(courseId, assignmentId, params).dataOrThrow + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt index ac594a316e..3b9795d954 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressLocalDataSource.kt @@ -76,7 +76,10 @@ class CourseProgressLocalDataSource @Inject constructor( completed = completionRequirementCompleted, ) } - val moduleDetails = if (pointsPossible != null || dueAt != null || lockedForUser || lockExplanation != null) { + val moduleDetails = if ( + pointsPossible != null || dueAt != null || lockedForUser || lockExplanation != null || + hidden != null || locked != null + ) { ModuleContentDetails( pointsPossible = pointsPossible, dueAt = dueAt, @@ -84,16 +87,24 @@ class CourseProgressLocalDataSource @Inject constructor( lockExplanation = lockExplanation, lockAt = lockAt, unlockAt = unlockAt, + hidden = hidden, + locked = locked, ) } else null return ModuleItem( id = itemId, moduleId = moduleId, position = position, + indent = indent, title = title, type = type, htmlUrl = htmlUrl, url = url, + contentId = contentId, + externalUrl = externalUrl, + pageUrl = pageUrl, + published = published, + unpublishable = unpublishable, completionRequirement = completionRequirement, moduleDetails = moduleDetails, quizLti = quizLti, @@ -120,9 +131,15 @@ class CourseProgressLocalDataSource @Inject constructor( courseId = courseId, title = title, position = position, + indent = indent, type = type, htmlUrl = htmlUrl, url = url, + contentId = contentId, + externalUrl = externalUrl, + pageUrl = pageUrl, + published = published, + unpublishable = unpublishable, completionRequirementType = completionRequirement?.type, completionRequirementMinScore = completionRequirement?.minScore ?: 0.0, completionRequirementCompleted = completionRequirement?.completed ?: false, @@ -132,6 +149,8 @@ class CourseProgressLocalDataSource @Inject constructor( lockExplanation = moduleDetails?.lockExplanation, lockAt = moduleDetails?.lockAt, unlockAt = moduleDetails?.unlockAt, + hidden = moduleDetails?.hidden, + locked = moduleDetails?.locked, quizLti = quizLti, estimatedDuration = estimatedDuration, ) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt index 43810a416d..eb1e23bd9e 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/CourseProgressNetworkDataSource.kt @@ -18,6 +18,7 @@ package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.apis.ModuleAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.utils.depaginate import javax.inject.Inject @@ -28,7 +29,7 @@ class CourseProgressNetworkDataSource @Inject constructor( suspend fun getModuleItems(courseId: Long, forceRefresh: Boolean): List { val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = forceRefresh) - return moduleApi.getFirstPageModulesWithItems( + val modules = moduleApi.getFirstPageModulesWithItems( CanvasContext.Type.COURSE.apiString, courseId, params, @@ -36,5 +37,24 @@ class CourseProgressNetworkDataSource @Inject constructor( ) .depaginate { moduleApi.getNextPageModuleObjectList(it, params) } .dataOrThrow + + return modules.map { module -> + if (module.itemCount != module.items.size) { + module.copy(items = getAllModuleItems(courseId, module.id)) + } else { + module + } + } + } + + private suspend fun getAllModuleItems(courseId: Long, moduleId: Long): List { + val params = RestParams() + return moduleApi.getFirstPageModuleItems( + CanvasContext.Type.COURSE.apiString, + courseId, + moduleId, + params, + includes = listOf("estimated_durations") + ).depaginate { moduleApi.getNextPageModuleItemList(it, params) }.dataOrThrow } } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentLocalDataSource.kt new file mode 100644 index 0000000000..8692a9b5c4 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentLocalDataSource.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.FileFolder +import com.instructure.horizon.database.dao.HorizonFileFolderDao +import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.entity.HorizonFileFolderEntity +import javax.inject.Inject + +class FileContentLocalDataSource @Inject constructor( + private val localFileDao: HorizonLocalFileDao, + private val fileFolderDao: HorizonFileFolderDao, +) { + + suspend fun getLocalFilePath(fileId: Long): String? { + return localFileDao.findById(fileId)?.path + } + + suspend fun getFileFolder(fileId: Long): FileFolder? { + return fileFolderDao.findById(fileId)?.toFileFolder() + } + + suspend fun saveFileFolder(fileFolder: FileFolder) { + fileFolderDao.insert( + HorizonFileFolderEntity( + id = fileFolder.id, + url = fileFolder.url.orEmpty(), + displayName = fileFolder.displayName.orEmpty(), + contentType = fileFolder.contentType, + thumbnailUrl = fileFolder.thumbnailUrl, + ) + ) + } + + private fun HorizonFileFolderEntity.toFileFolder(): FileFolder { + return FileFolder( + id = id, + url = url, + displayName = displayName, + contentType = contentType, + thumbnailUrl = thumbnailUrl, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentNetworkDataSource.kt similarity index 63% rename from libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsRepository.kt rename to libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentNetworkDataSource.kt index d3e663f4d4..3dfa75546f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/FileContentNetworkDataSource.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 - present Instructure, Inc. + * Copyright (C) 2026 - present Instructure, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,23 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.instructure.horizon.features.moduleitemsequence.content.file +package com.instructure.horizon.data.datasource import com.instructure.canvasapi2.apis.FileFolderAPI -import com.instructure.canvasapi2.apis.OAuthAPI import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.FileFolder +import com.instructure.horizon.data.repository.HorizonFileSyncRepository import javax.inject.Inject -class FileDetailsRepository @Inject constructor( +class FileContentNetworkDataSource @Inject constructor( private val fileFolderApi: FileFolderAPI.FilesFoldersInterface, - private val oAuthApi: OAuthAPI.OAuthInterface + private val fileSyncRepository: HorizonFileSyncRepository, ) { - suspend fun getFileFolderFromURL(url: String): FileFolder? { + + suspend fun getFileDetails(url: String): FileFolder? { return fileFolderApi.getFileFolderFromURL(url, RestParams()).dataOrNull } - suspend fun getAuthenticatedFileUrl(fileUrl: String): String { - return oAuthApi.getAuthenticatedSession("$fileUrl?display=borderless", RestParams(isForceReadFromNetwork = true)).dataOrThrow.sessionUrl + suspend fun downloadFile(fileId: Long, courseId: Long) { + fileSyncRepository.downloadFile(fileId, courseId) } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageLocalDataSource.kt new file mode 100644 index 0000000000..e200fa1117 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageLocalDataSource.kt @@ -0,0 +1,51 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.Page +import com.instructure.horizon.database.dao.HorizonPageDao +import com.instructure.horizon.database.entity.HorizonPageEntity +import javax.inject.Inject + +class PageLocalDataSource @Inject constructor( + private val pageDao: HorizonPageDao, +) { + + suspend fun getPage(courseId: Long, pageUrl: String): Page? { + return pageDao.getPage(courseId, pageUrl)?.toPage() + } + + suspend fun savePage(page: Page, courseId: Long, parsedBody: String?) { + pageDao.savePage( + HorizonPageEntity( + pageId = page.id, + courseId = courseId, + pageUrl = page.url.orEmpty(), + title = page.title, + body = parsedBody, + ) + ) + } + + private fun HorizonPageEntity.toPage(): Page { + return Page( + id = pageId, + url = pageUrl, + title = title, + body = body, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageNetworkDataSource.kt new file mode 100644 index 0000000000..8f7bf96e83 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/PageNetworkDataSource.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.apis.PageAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Page +import javax.inject.Inject + +class PageNetworkDataSource @Inject constructor( + private val pageApi: PageAPI.PagesInterface, +) { + + suspend fun getPage(courseId: Long, pageUrl: String, forceRefresh: Boolean): Page { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + return pageApi.getDetailedPage(CanvasContext.Type.COURSE.apiString, courseId, pageUrl, params).dataOrThrow + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionLocalDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionLocalDataSource.kt new file mode 100644 index 0000000000..8c35da4530 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionLocalDataSource.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.models.Attachment +import com.instructure.canvasapi2.models.Submission +import com.instructure.horizon.database.dao.HorizonSubmissionDao +import com.instructure.horizon.database.entity.HorizonSubmissionAttachmentEntity +import com.instructure.horizon.database.entity.HorizonSubmissionEntity +import java.util.Date +import javax.inject.Inject + +class SubmissionLocalDataSource @Inject constructor( + private val submissionDao: HorizonSubmissionDao, +) { + + suspend fun getSubmissions(assignmentId: Long): List { + val submissionEntities = submissionDao.getSubmissions(assignmentId) + if (submissionEntities.isEmpty()) return emptyList() + val attachmentsByAttempt = submissionDao.getAttachments(assignmentId).groupBy { it.attempt } + return submissionEntities.map { entity -> + entity.toSubmission(attachmentsByAttempt[entity.attempt] ?: emptyList()) + } + } + + suspend fun saveSubmissions(assignmentId: Long, submissions: List) { + val submissionEntities = submissions.map { it.toEntity(assignmentId) } + val attachmentEntities = submissions.flatMap { submission -> + submission.attachments.map { it.toEntity(assignmentId, submission.attempt) } + } + submissionDao.replaceForAssignment(assignmentId, submissionEntities, attachmentEntities) + } + + private fun HorizonSubmissionEntity.toSubmission( + attachments: List, + ): Submission { + return Submission( + id = submissionId, + assignmentId = assignmentId, + attempt = attempt, + grade = grade, + score = score, + submittedAt = submittedAtMs?.let { Date(it) }, + workflowState = workflowState, + submissionType = submissionType, + body = body, + url = url, + late = late, + excused = excused, + missing = missing, + customGradeStatusId = customGradeStatusId, + userId = userId, + attachments = ArrayList(attachments.map { it.toAttachment() }), + ) + } + + private fun HorizonSubmissionAttachmentEntity.toAttachment(): Attachment { + return Attachment( + id = id, + displayName = displayName, + url = url, + contentType = contentType, + thumbnailUrl = thumbnailUrl, + ) + } + + private fun Submission.toEntity(assignmentId: Long): HorizonSubmissionEntity { + return HorizonSubmissionEntity( + assignmentId = assignmentId, + attempt = attempt, + submissionId = id, + grade = grade, + score = score, + submittedAtMs = submittedAt?.time, + workflowState = workflowState, + submissionType = submissionType, + body = body, + url = url, + late = late, + excused = excused, + missing = missing, + customGradeStatusId = customGradeStatusId, + userId = userId, + ) + } + + private fun Attachment.toEntity(assignmentId: Long, attempt: Long): HorizonSubmissionAttachmentEntity { + return HorizonSubmissionAttachmentEntity( + id = id, + assignmentId = assignmentId, + attempt = attempt, + displayName = displayName, + url = url, + contentType = contentType, + thumbnailUrl = thumbnailUrl, + ) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionNetworkDataSource.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionNetworkDataSource.kt new file mode 100644 index 0000000000..7dbeebb219 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/datasource/SubmissionNetworkDataSource.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.datasource + +import com.instructure.canvasapi2.apis.SubmissionAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.Submission +import javax.inject.Inject + +class SubmissionNetworkDataSource @Inject constructor( + private val submissionApi: SubmissionAPI.SubmissionInterface, +) { + + suspend fun getSubmissions(courseId: Long, assignmentId: Long, userId: Long, forceRefresh: Boolean): List { + val params = RestParams(isForceReadFromNetwork = forceRefresh) + val submission = submissionApi.getSingleSubmission(courseId, assignmentId, userId, params).dataOrThrow + return submission.submissionHistory.filterNotNull() + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt new file mode 100644 index 0000000000..0a12bdad31 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/AssignmentDetailsRepository.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.Assignment +import com.instructure.horizon.data.datasource.AssignmentDetailsLocalDataSource +import com.instructure.horizon.data.datasource.AssignmentDetailsNetworkDataSource +import com.instructure.horizon.data.datasource.SubmissionLocalDataSource +import com.instructure.horizon.di.HorizonHtmlParserQualifier +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class AssignmentDetailsRepository @Inject constructor( + private val networkDataSource: AssignmentDetailsNetworkDataSource, + private val localDataSource: AssignmentDetailsLocalDataSource, + private val submissionLocalDataSource: SubmissionLocalDataSource, + @HorizonHtmlParserQualifier private val htmlParser: HtmlParser, + private val fileSyncRepository: HorizonFileSyncRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getAssignment(courseId: Long, assignmentId: Long, forceRefresh: Boolean): Assignment { + return if (shouldFetchFromNetwork()) { + networkDataSource.getAssignment(courseId, assignmentId, forceRefresh).also { assignment -> + if (shouldSync()) { + val parsingResult = htmlParser.createHtmlStringWithLocalFiles(assignment.description, courseId) + localDataSource.saveAssignment(assignment, courseId, parsingResult.htmlWithLocalFileLinks) + fileSyncRepository.syncHtmlFiles(courseId, parsingResult) + val submissionHistory = assignment.submission?.submissionHistory?.filterNotNull() ?: emptyList() + submissionLocalDataSource.saveSubmissions(assignment.id, submissionHistory) + } + } + } else { + localDataSource.getAssignment(assignmentId) + ?: throw IllegalStateException("Assignment $assignmentId not available offline") + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/FileContentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/FileContentRepository.kt new file mode 100644 index 0000000000..efe3dd9c3a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/FileContentRepository.kt @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.horizon.data.datasource.FileContentLocalDataSource +import com.instructure.horizon.data.datasource.FileContentNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +data class FileDetails( + val id: Long, + val url: String, + val displayName: String, + val contentType: String?, + val thumbnailUrl: String?, + val localPath: String?, +) + +class FileContentRepository @Inject constructor( + private val networkDataSource: FileContentNetworkDataSource, + private val localDataSource: FileContentLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getFileDetails(url: String, courseId: Long): FileDetails { + return if (shouldFetchFromNetwork()) { + val fileFolder = networkDataSource.getFileDetails(url) + ?: throw IllegalStateException("File not found: $url") + if (shouldSync()) { + localDataSource.saveFileFolder(fileFolder) + networkDataSource.downloadFile(fileFolder.id, courseId) + } + val localPath = localDataSource.getLocalFilePath(fileFolder.id) + FileDetails( + id = fileFolder.id, + url = fileFolder.url.orEmpty(), + displayName = fileFolder.displayName.orEmpty(), + contentType = fileFolder.contentType, + thumbnailUrl = fileFolder.thumbnailUrl, + localPath = localPath, + ) + } else { + val fileId = extractFileId(url) + ?: throw IllegalStateException("Cannot determine file ID from URL: $url") + val fileFolder = localDataSource.getFileFolder(fileId) + ?: throw IllegalStateException("File $fileId not available offline") + val localPath = localDataSource.getLocalFilePath(fileId) + FileDetails( + id = fileFolder.id, + url = fileFolder.url.orEmpty(), + displayName = fileFolder.displayName.orEmpty(), + contentType = fileFolder.contentType, + thumbnailUrl = fileFolder.thumbnailUrl, + localPath = localPath, + ) + } + } + + private fun extractFileId(url: String): Long? { + return Regex("files/(\\d+)").find(url)?.groupValues?.get(1)?.toLongOrNull() + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt index 5a702c1306..10ab43df6c 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/HorizonFileSyncRepository.kt @@ -37,6 +37,10 @@ class HorizonFileSyncRepository @Inject constructor( featureFlagProvider: FeatureFlagProvider, ) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + suspend fun downloadFile(fileId: Long, courseId: Long) { + downloadInternalFile(fileId, courseId) + } + suspend fun syncHtmlFiles(courseId: Long, parsingResult: HtmlParsingResult) = withContext(Dispatchers.IO) { val alreadyDownloadedIds = localFileDao.findByCourseId(courseId).map { it.id }.toSet() val internalFileIdsToSync = parsingResult.internalFileIds.filterNot { alreadyDownloadedIds.contains(it) } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/PageRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/PageRepository.kt new file mode 100644 index 0000000000..cb27687caa --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/PageRepository.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.Page +import com.instructure.horizon.data.datasource.PageLocalDataSource +import com.instructure.horizon.data.datasource.PageNetworkDataSource +import com.instructure.horizon.di.HorizonHtmlParserQualifier +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class PageRepository @Inject constructor( + private val networkDataSource: PageNetworkDataSource, + private val localDataSource: PageLocalDataSource, + @HorizonHtmlParserQualifier private val htmlParser: HtmlParser, + private val fileSyncRepository: HorizonFileSyncRepository, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getPage(courseId: Long, pageUrl: String, forceRefresh: Boolean): Page { + return if (shouldFetchFromNetwork()) { + networkDataSource.getPage(courseId, pageUrl, forceRefresh).also { page -> + if (shouldSync()) { + val parsingResult = htmlParser.createHtmlStringWithLocalFiles(page.body, courseId) + localDataSource.savePage(page, courseId, parsingResult.htmlWithLocalFileLinks) + fileSyncRepository.syncHtmlFiles(courseId, parsingResult) + } + } + } else { + localDataSource.getPage(courseId, pageUrl) + ?: throw IllegalStateException("Page '$pageUrl' not available offline") + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/data/repository/SubmissionRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/SubmissionRepository.kt new file mode 100644 index 0000000000..1b71575958 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/data/repository/SubmissionRepository.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.data.repository + +import com.instructure.canvasapi2.models.Submission +import com.instructure.horizon.data.datasource.SubmissionLocalDataSource +import com.instructure.horizon.data.datasource.SubmissionNetworkDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import javax.inject.Inject + +class SubmissionRepository @Inject constructor( + private val networkDataSource: SubmissionNetworkDataSource, + private val localDataSource: SubmissionLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + + suspend fun getSubmissions(courseId: Long, assignmentId: Long, userId: Long, forceRefresh: Boolean = false): List { + return if (shouldFetchFromNetwork()) { + networkDataSource.getSubmissions(courseId, assignmentId, userId, forceRefresh).also { submissions -> + if (shouldSync()) { + localDataSource.saveSubmissions(assignmentId, submissions) + } + } + } else { + localDataSource.getSubmissions(assignmentId) + } + } + + override suspend fun sync() { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt index 715a2523f9..14a9db0023 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/HorizonDatabase.kt @@ -18,6 +18,9 @@ package com.instructure.horizon.database import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters +import com.instructure.horizon.database.dao.HorizonAssignmentCommentDao +import com.instructure.horizon.database.dao.HorizonAssignmentDetailsDao +import com.instructure.horizon.database.dao.HorizonSubmissionDao import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao @@ -28,8 +31,14 @@ import com.instructure.horizon.database.dao.HorizonLearnCollectionDao import com.instructure.horizon.database.dao.HorizonLearnItemDao import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.dao.HorizonPageDao import com.instructure.horizon.database.dao.HorizonProgramDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.database.entity.HorizonAssignmentCommentAttachmentEntity +import com.instructure.horizon.database.entity.HorizonAssignmentCommentEntity +import com.instructure.horizon.database.entity.HorizonAssignmentDetailsEntity +import com.instructure.horizon.database.entity.HorizonSubmissionAttachmentEntity +import com.instructure.horizon.database.entity.HorizonSubmissionEntity import com.instructure.horizon.database.entity.HorizonCourseAssignmentEntity import com.instructure.horizon.database.entity.HorizonCourseAssignmentGroupEntity import com.instructure.horizon.database.entity.HorizonCourseEntity @@ -44,6 +53,7 @@ import com.instructure.horizon.database.entity.HorizonLearnCollectionItemEntity import com.instructure.horizon.database.entity.HorizonLearnItemEntity import com.instructure.horizon.database.entity.HorizonLearnSavedItemEntity import com.instructure.horizon.database.entity.HorizonLocalFileEntity +import com.instructure.horizon.database.entity.HorizonPageEntity import com.instructure.horizon.database.entity.HorizonProgramCourseRef import com.instructure.horizon.database.entity.HorizonProgramEntity import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity @@ -68,8 +78,14 @@ import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity HorizonCourseGradeEntity::class, HorizonLocalFileEntity::class, HorizonFileFolderEntity::class, + HorizonPageEntity::class, + HorizonAssignmentDetailsEntity::class, + HorizonAssignmentCommentEntity::class, + HorizonAssignmentCommentAttachmentEntity::class, + HorizonSubmissionEntity::class, + HorizonSubmissionAttachmentEntity::class, ], - version = 7, + version = 13, ) abstract class HorizonDatabase : RoomDatabase() { abstract fun dashboardEnrollmentDao(): HorizonDashboardEnrollmentDao @@ -84,4 +100,8 @@ abstract class HorizonDatabase : RoomDatabase() { abstract fun courseScoreDao(): HorizonCourseScoreDao abstract fun localFileDao(): HorizonLocalFileDao abstract fun fileFolderDao(): HorizonFileFolderDao + abstract fun pageDao(): HorizonPageDao + abstract fun assignmentDetailsDao(): HorizonAssignmentDetailsDao + abstract fun assignmentCommentDao(): HorizonAssignmentCommentDao + abstract fun submissionDao(): HorizonSubmissionDao } diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentCommentDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentCommentDao.kt new file mode 100644 index 0000000000..5877b8283e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentCommentDao.kt @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonAssignmentCommentAttachmentEntity +import com.instructure.horizon.database.entity.HorizonAssignmentCommentEntity + +@Dao +interface HorizonAssignmentCommentDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertComment(comment: HorizonAssignmentCommentEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAttachments(attachments: List) + + @Query("SELECT * FROM horizon_assignment_comments WHERE assignmentId = :assignmentId AND attempt = :attempt ORDER BY createdAtMs ASC") + suspend fun getComments(assignmentId: Long, attempt: Int): List + + @Query("SELECT * FROM horizon_assignment_comment_attachments WHERE commentId IN (:commentIds)") + suspend fun getAttachments(commentIds: List): List + + @Query("SELECT COUNT(*) FROM horizon_assignment_comments WHERE assignmentId = :assignmentId AND read = 0") + suspend fun getUnreadCommentCount(assignmentId: Long): Int + + @Query("DELETE FROM horizon_assignment_comments WHERE assignmentId = :assignmentId AND attempt = :attempt") + suspend fun deleteCommentsForAttempt(assignmentId: Long, attempt: Int) + + @Transaction + suspend fun replaceCommentsForAttempt( + assignmentId: Long, + attempt: Int, + commentWithAttachments: List>>, + ) { + deleteCommentsForAttempt(assignmentId, attempt) + for ((comment, attachments) in commentWithAttachments) { + val commentId = insertComment(comment) + if (attachments.isNotEmpty()) { + insertAttachments(attachments.map { it.copy(commentId = commentId) }) + } + } + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentDetailsDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentDetailsDao.kt new file mode 100644 index 0000000000..e9104c2894 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonAssignmentDetailsDao.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonAssignmentDetailsEntity + +@Dao +interface HorizonAssignmentDetailsDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun saveAssignment(assignment: HorizonAssignmentDetailsEntity) + + @Query("SELECT * FROM horizon_assignment_details WHERE assignmentId = :assignmentId LIMIT 1") + suspend fun getAssignment(assignmentId: Long): HorizonAssignmentDetailsEntity? +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt index c97bcd9337..1e57d5e406 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonCourseModuleDao.kt @@ -38,6 +38,12 @@ interface HorizonCourseModuleDao { @Query("SELECT * FROM horizon_course_module_items WHERE moduleId = :moduleId ORDER BY position") suspend fun getItemsForModule(moduleId: Long): List + @Query("SELECT * FROM horizon_course_module_items WHERE courseId = :courseId ORDER BY moduleId, position") + suspend fun getItemsForCourse(courseId: Long): List + + @Query("SELECT * FROM horizon_course_module_items WHERE itemId = :itemId LIMIT 1") + suspend fun getItemById(itemId: Long): HorizonCourseModuleItemEntity? + @Query("DELETE FROM horizon_course_modules WHERE courseId = :courseId") suspend fun deleteModulesForCourse(courseId: Long) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonPageDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonPageDao.kt new file mode 100644 index 0000000000..f532a3a47e --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonPageDao.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.instructure.horizon.database.entity.HorizonPageEntity + +@Dao +interface HorizonPageDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun savePage(page: HorizonPageEntity) + + @Query("SELECT * FROM horizon_pages WHERE courseId = :courseId AND pageUrl = :pageUrl LIMIT 1") + suspend fun getPage(courseId: Long, pageUrl: String): HorizonPageEntity? +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSubmissionDao.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSubmissionDao.kt new file mode 100644 index 0000000000..0fff796d66 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/dao/HorizonSubmissionDao.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.instructure.horizon.database.entity.HorizonSubmissionAttachmentEntity +import com.instructure.horizon.database.entity.HorizonSubmissionEntity + +@Dao +interface HorizonSubmissionDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSubmissions(submissions: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAttachments(attachments: List) + + @Query("SELECT * FROM horizon_submissions WHERE assignmentId = :assignmentId ORDER BY attempt ASC") + suspend fun getSubmissions(assignmentId: Long): List + + @Query("SELECT * FROM horizon_submission_attachments WHERE assignmentId = :assignmentId") + suspend fun getAttachments(assignmentId: Long): List + + @Query("DELETE FROM horizon_submissions WHERE assignmentId = :assignmentId") + suspend fun deleteSubmissionsForAssignment(assignmentId: Long) + + @Query("DELETE FROM horizon_submission_attachments WHERE assignmentId = :assignmentId") + suspend fun deleteAttachmentsForAssignment(assignmentId: Long) + + @Transaction + suspend fun replaceForAssignment( + assignmentId: Long, + submissions: List, + attachments: List, + ) { + deleteAttachmentsForAssignment(assignmentId) + deleteSubmissionsForAssignment(assignmentId) + insertSubmissions(submissions) + if (attachments.isNotEmpty()) { + insertAttachments(attachments) + } + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentCommentEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentCommentEntity.kt new file mode 100644 index 0000000000..4c64e4b707 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentCommentEntity.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Stores a single submission comment for offline access. + * Comments are grouped by [assignmentId] and [attempt]. + * [createdAtMs] stores [java.util.Date.getTime] for sorting. + */ +@Entity( + tableName = "horizon_assignment_comments", + indices = [Index("assignmentId", "attempt")] +) +data class HorizonAssignmentCommentEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val assignmentId: Long, + val attempt: Int, + val authorId: Long, + val authorName: String, + val commentText: String, + val createdAtMs: Long, + val read: Boolean, +) + +/** + * Stores attachments for a [HorizonAssignmentCommentEntity]. + */ +@Entity( + tableName = "horizon_assignment_comment_attachments", + indices = [Index("commentId")], + foreignKeys = [ + ForeignKey( + entity = HorizonAssignmentCommentEntity::class, + parentColumns = ["id"], + childColumns = ["commentId"], + onDelete = ForeignKey.CASCADE, + ) + ] +) +data class HorizonAssignmentCommentAttachmentEntity( + @PrimaryKey val attachmentId: Long, + val commentId: Long, + val fileName: String, + val fileUrl: String, + val fileType: String, +) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentDetailsEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentDetailsEntity.kt new file mode 100644 index 0000000000..c5f23a63b0 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonAssignmentDetailsEntity.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Stores assignment/quiz content for offline access. + * [description] contains the parsed HTML with local file references replacing remote URLs. + * [submissionTypes] is a comma-separated list of submission type API strings. + * [url] is the assignment URL (used by quizzes/assessments to launch the quiz). + * [ltiToolUrl] is from [com.instructure.canvasapi2.models.ExternalToolAttributes.url], used for LTI button. + * Submission history is not stored; offline view shows description only. + */ +@Entity( + tableName = "horizon_assignment_details", + indices = [Index("courseId")] +) +data class HorizonAssignmentDetailsEntity( + @PrimaryKey val assignmentId: Long, + val courseId: Long, + val name: String?, + val description: String?, + val pointsPossible: Double, + val allowedAttempts: Long, + val dueAt: String?, + val submissionTypes: String, + val gradingType: String?, + val lockedForUser: Boolean, + val lockExplanation: String?, + val quizId: Long, + val url: String?, + val ltiToolUrl: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt index 941694d6b1..e5741a80d5 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonCourseModuleItemEntity.kt @@ -37,9 +37,15 @@ data class HorizonCourseModuleItemEntity( val courseId: Long, val title: String?, val position: Int, + val indent: Int, val type: String?, val htmlUrl: String?, val url: String?, + val contentId: Long, + val externalUrl: String?, + val pageUrl: String?, + val published: Boolean?, + val unpublishable: Boolean, // ModuleCompletionRequirement flattened val completionRequirementType: String?, val completionRequirementMinScore: Double, @@ -51,6 +57,8 @@ data class HorizonCourseModuleItemEntity( val lockExplanation: String?, val lockAt: String?, val unlockAt: String?, + val hidden: Boolean?, + val locked: Boolean?, // Other val quizLti: Boolean, val estimatedDuration: String?, diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt index 674e199da9..cbe77153f7 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonFileFolderEntity.kt @@ -24,4 +24,6 @@ data class HorizonFileFolderEntity( val id: Long, val url: String, val displayName: String, + val contentType: String? = null, + val thumbnailUrl: String? = null, ) \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonPageEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonPageEntity.kt new file mode 100644 index 0000000000..667781c384 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonPageEntity.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * Stores page content for offline access. + * [body] contains the parsed HTML with local file references replacing remote URLs. + * [pageUrl] is the slug used by the Pages API (e.g. "introduction-to-kotlin"). + */ +@Entity( + tableName = "horizon_pages", + indices = [Index("courseId"), Index("pageUrl")] +) +data class HorizonPageEntity( + @PrimaryKey val pageId: Long, + val courseId: Long, + val pageUrl: String, + val title: String?, + val body: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSubmissionEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSubmissionEntity.kt new file mode 100644 index 0000000000..08ca992ea7 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSubmissionEntity.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.database.entity + +import androidx.room.Entity +import androidx.room.Index + +/** + * Stores a single submission attempt for offline access. + * Primary key is (assignmentId, attempt) because all history entries for the same + * assignment share the same Canvas submission ID — only [attempt] differs. + * [submissionId] is the Canvas submission ID, kept as a regular field. + * File attachments are stored in [HorizonSubmissionAttachmentEntity]. + */ +@Entity( + tableName = "horizon_submissions", + primaryKeys = ["assignmentId", "attempt"], + indices = [Index("assignmentId")] +) +data class HorizonSubmissionEntity( + val assignmentId: Long, + val attempt: Long, + val submissionId: Long, + val grade: String?, + val score: Double, + val submittedAtMs: Long?, + val workflowState: String?, + val submissionType: String?, + val body: String?, + val url: String?, + val late: Boolean, + val excused: Boolean, + val missing: Boolean, + val customGradeStatusId: Long?, + val userId: Long, +) + +/** + * Stores file attachments for a submission attempt. + * Linked via [assignmentId] + [attempt] to [HorizonSubmissionEntity]. + * No FK constraint because [replaceForAssignment] handles cleanup via explicit DELETE. + */ +@Entity( + tableName = "horizon_submission_attachments", + primaryKeys = ["id"], + indices = [Index("assignmentId", "attempt")] +) +data class HorizonSubmissionAttachmentEntity( + val id: Long, + val assignmentId: Long, + val attempt: Long, + val displayName: String?, + val url: String?, + val contentType: String?, + val thumbnailUrl: String?, +) diff --git a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt index d1b21c3dbc..8e1ab15923 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/database/entity/HorizonSyncMetadataEntity.kt @@ -29,6 +29,7 @@ enum class SyncDataType { COURSE_DETAILS, COURSE_MODULES, COURSE_SCORES, + ASSIGNMENT_COMMENTS, } @Entity(tableName = "horizon_sync_metadata") diff --git a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt index 5d12b56932..09de327f8b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/di/HorizonOfflineModule.kt @@ -19,9 +19,8 @@ import android.content.Context import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.horizon.database.HorizonDatabase import com.instructure.horizon.database.HorizonDatabaseProvider -import com.instructure.horizon.offline.HorizonHtmlParserFileSource -import com.instructure.pandautils.features.offline.sync.HtmlParser -import dagger.hilt.android.qualifiers.ApplicationContext +import com.instructure.horizon.database.dao.HorizonAssignmentCommentDao +import com.instructure.horizon.database.dao.HorizonAssignmentDetailsDao import com.instructure.horizon.database.dao.HorizonCourseDao import com.instructure.horizon.database.dao.HorizonCourseModuleDao import com.instructure.horizon.database.dao.HorizonCourseScoreDao @@ -32,11 +31,16 @@ import com.instructure.horizon.database.dao.HorizonLearnCollectionDao import com.instructure.horizon.database.dao.HorizonLearnItemDao import com.instructure.horizon.database.dao.HorizonLearnSavedItemDao import com.instructure.horizon.database.dao.HorizonLocalFileDao +import com.instructure.horizon.database.dao.HorizonPageDao import com.instructure.horizon.database.dao.HorizonProgramDao +import com.instructure.horizon.database.dao.HorizonSubmissionDao import com.instructure.horizon.database.dao.HorizonSyncMetadataDao +import com.instructure.horizon.offline.HorizonHtmlParserFileSource +import com.instructure.pandautils.features.offline.sync.HtmlParser import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent @Module @@ -111,6 +115,26 @@ class HorizonOfflineModule { return db.fileFolderDao() } + @Provides + fun provideHorizonPageDao(db: HorizonDatabase): HorizonPageDao { + return db.pageDao() + } + + @Provides + fun provideHorizonAssignmentDetailsDao(db: HorizonDatabase): HorizonAssignmentDetailsDao { + return db.assignmentDetailsDao() + } + + @Provides + fun provideHorizonAssignmentCommentDao(db: HorizonDatabase): HorizonAssignmentCommentDao { + return db.assignmentCommentDao() + } + + @Provides + fun provideHorizonSubmissionDao(db: HorizonDatabase): HorizonSubmissionDao { + return db.submissionDao() + } + @Provides @HorizonHtmlParserQualifier fun provideHorizonHtmlParser( diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetAssignmentDetailsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetAssignmentDetailsUseCase.kt new file mode 100644 index 0000000000..8791faec08 --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetAssignmentDetailsUseCase.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.Assignment +import com.instructure.horizon.data.repository.AssignmentDetailsRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetAssignmentDetailsUseCase @Inject constructor( + private val repository: AssignmentDetailsRepository, +) : BaseUseCase() { + + data class Params( + val courseId: Long, + val assignmentId: Long, + val forceRefresh: Boolean = false, + ) + + override suspend fun execute(params: Params): Assignment { + return repository.getAssignment(params.courseId, params.assignmentId, params.forceRefresh) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetCommentsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetCommentsUseCase.kt new file mode 100644 index 0000000000..cd25cd53ea --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetCommentsUseCase.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.managers.graphql.horizon.CommentsData +import com.instructure.horizon.features.moduleitemsequence.content.assignment.comments.CommentsRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetCommentsUseCase @Inject constructor( + private val repository: CommentsRepository, +) : BaseUseCase() { + + data class Params( + val assignmentId: Long, + val userId: Long, + val attempt: Int, + val forceNetwork: Boolean = false, + val startCursor: String? = null, + val endCursor: String? = null, + val nextPage: Boolean = false, + ) + + override suspend fun execute(params: Params): CommentsData { + return repository.getComments( + assignmentId = params.assignmentId, + userId = params.userId, + attempt = params.attempt, + forceNetwork = params.forceNetwork, + startCursor = params.startCursor, + endCursor = params.endCursor, + nextPage = params.nextPage, + ) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetFileDetailsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetFileDetailsUseCase.kt new file mode 100644 index 0000000000..6001f483ee --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetFileDetailsUseCase.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.horizon.data.repository.FileContentRepository +import com.instructure.horizon.data.repository.FileDetails +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetFileDetailsUseCase @Inject constructor( + private val repository: FileContentRepository, +) : BaseUseCase() { + + data class Params( + val url: String, + val courseId: Long, + ) + + override suspend fun execute(params: Params): FileDetails { + return repository.getFileDetails(params.url, params.courseId) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModulesWithItemsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModulesWithItemsUseCase.kt new file mode 100644 index 0000000000..4b36d9a33f --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetModulesWithItemsUseCase.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetModulesWithItemsUseCase @Inject constructor( + private val repository: CourseRepository, +) : BaseUseCase>() { + + data class Params( + val courseId: Long, + val forceRefresh: Boolean = false, + ) + + override suspend fun execute(params: Params): List { + return repository.getModuleItems(params.courseId, params.forceRefresh) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetPageDetailsUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetPageDetailsUseCase.kt new file mode 100644 index 0000000000..f41c78ae0d --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetPageDetailsUseCase.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.Page +import com.instructure.horizon.data.repository.PageRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetPageDetailsUseCase @Inject constructor( + private val repository: PageRepository, +) : BaseUseCase() { + + data class Params( + val courseId: Long, + val pageUrl: String, + val forceRefresh: Boolean = false, + ) + + override suspend fun execute(params: Params): Page { + return repository.getPage(params.courseId, params.pageUrl, params.forceRefresh) + } +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetSubmissionHistoryUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetSubmissionHistoryUseCase.kt new file mode 100644 index 0000000000..51308c932a --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetSubmissionHistoryUseCase.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.canvasapi2.models.Submission +import com.instructure.horizon.data.repository.SubmissionRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetSubmissionHistoryUseCase @Inject constructor( + private val repository: SubmissionRepository, +) : BaseUseCase>() { + + data class Params( + val courseId: Long, + val assignmentId: Long, + val userId: Long, + val forceRefresh: Boolean = false, + ) + + override suspend fun execute(params: Params): List { + return repository.getSubmissions(params.courseId, params.assignmentId, params.userId, params.forceRefresh) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetUnreadCommentsCountUseCase.kt b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetUnreadCommentsCountUseCase.kt new file mode 100644 index 0000000000..479ca6ecfe --- /dev/null +++ b/libs/horizon/src/main/java/com/instructure/horizon/domain/usecase/GetUnreadCommentsCountUseCase.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 - present Instructure, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.instructure.horizon.domain.usecase + +import com.instructure.horizon.features.moduleitemsequence.content.assignment.comments.CommentsRepository +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class GetUnreadCommentsCountUseCase @Inject constructor( + private val repository: CommentsRepository, +) : BaseUseCase() { + + data class Params( + val assignmentId: Long, + val userId: Long, + val forceNetwork: Boolean = false, + ) + + override suspend fun execute(params: Params): Int { + return repository.getUnreadCommentCount(params.assignmentId, params.userId, params.forceNetwork) + } +} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt index 0423c2d35e..0c6b13e7ff 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepository.kt @@ -15,82 +15,86 @@ */ package com.instructure.horizon.features.moduleitemsequence -import com.instructure.canvasapi2.apis.AssignmentAPI import com.instructure.canvasapi2.apis.ModuleAPI import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager -import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.ModuleCompletionRequirement +import com.instructure.canvasapi2.models.ModuleContentDetails import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleItemSequence +import com.instructure.canvasapi2.models.ModuleItemWrapper import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult -import com.instructure.canvasapi2.utils.depaginate +import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.database.entity.HorizonCourseModuleItemEntity +import com.instructure.horizon.domain.usecase.GetUnreadCommentsCountUseCase +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.orDefault import okhttp3.ResponseBody import javax.inject.Inject class ModuleItemSequenceRepository @Inject constructor( private val moduleApi: ModuleAPI.ModuleInterface, - private val assignmentApi: AssignmentAPI.AssignmentInterface, - private val horizonGetCommentsManager: HorizonGetCommentsManager, - private val apiPrefs: ApiPrefs -) { + private val courseRepository: CourseRepository, + private val courseModuleDao: HorizonCourseModuleDao, + private val getUnreadCommentsCountUseCase: GetUnreadCommentsCountUseCase, + private val apiPrefs: ApiPrefs, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { suspend fun getModuleItemSequence(courseId: Long, assetType: String, assetId: String): ModuleItemSequence { - val params = RestParams(isForceReadFromNetwork = true) - return moduleApi.getModuleItemSequence( - CanvasContext.Type.COURSE.apiString, - courseId, - assetType, - assetId, - params - ).dataOrThrow + return if (shouldFetchFromNetwork()) { + val params = RestParams(isForceReadFromNetwork = true) + moduleApi.getModuleItemSequence( + CanvasContext.Type.COURSE.apiString, + courseId, + assetType, + assetId, + params + ).dataOrThrow + } else { + val itemId = findItemIdLocally(courseId, assetType, assetId) + ModuleItemSequence( + items = arrayOf(ModuleItemWrapper(current = ModuleItem(id = itemId))) + ) + } } - suspend fun getModulesWithItems(courseId: Long, forceNetwork: Boolean): List { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - val modules = moduleApi.getFirstPageModulesWithItems( - CanvasContext.Type.COURSE.apiString, - courseId, - params, - includes = listOf("estimated_durations") - ).depaginate { - moduleApi.getNextPageModuleObjectList(it, params) - }.dataOrThrow - - return modules.map { - if (it.itemCount != it.items.size) { - it.copy(items = getAllModuleItems(courseId, it.id)) - } else { - it + private suspend fun findItemIdLocally(courseId: Long, assetType: String, assetId: String): Long { + val items = courseModuleDao.getItemsForCourse(courseId) + return items.find { item -> + when (assetType) { + "Assignment", "Quiz" -> item.url?.endsWith("/$assetId") == true + "Page" -> item.url?.contains("/pages/$assetId") == true + "File" -> item.url?.endsWith("/$assetId") == true + else -> false } - } + }?.itemId ?: -1L } - private suspend fun getAllModuleItems(courseId: Long, moduleId: Long): List { - val params = RestParams() - return moduleApi.getFirstPageModuleItems( - CanvasContext.Type.COURSE.apiString, - courseId, - moduleId, - params, - includes = listOf("estimated_durations") - ).depaginate { - moduleApi.getNextPageModuleItemList(it, params) - }.dataOrThrow + suspend fun getModulesWithItems(courseId: Long, forceNetwork: Boolean): List { + return courseRepository.getModuleItems(courseId, forceNetwork) } suspend fun getModuleItem(courseId: Long, moduleId: Long, moduleItemId: Long): ModuleItem { - val params = RestParams(isForceReadFromNetwork = true) - return moduleApi.getModuleItem( - CanvasContext.Type.COURSE.apiString, - courseId, - moduleId, - moduleItemId, - params - ).dataOrThrow + return if (shouldFetchFromNetwork()) { + val params = RestParams(isForceReadFromNetwork = true) + moduleApi.getModuleItem( + CanvasContext.Type.COURSE.apiString, + courseId, + moduleId, + moduleItemId, + params + ).dataOrThrow + } else { + courseModuleDao.getItemById(moduleItemId)?.toModuleItem() + ?: throw IllegalStateException("Module item $moduleItemId not available offline") + } } suspend fun markAsNotDone(courseId: Long, moduleItem: ModuleItem): DataResult { @@ -118,16 +122,58 @@ class ModuleItemSequenceRepository @Inject constructor( moduleApi.markModuleItemRead(CanvasContext.Type.COURSE.apiString, courseId, moduleId, itemId, restParams) } - suspend fun getAssignment(assignmentId: Long, courseId: Long, forceNetwork: Boolean): Assignment { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - return assignmentApi.getAssignmentWithHistory(courseId, assignmentId, params).dataOrThrow + suspend fun hasUnreadComments(assignmentId: Long?, forceNetwork: Boolean = false): Boolean { + if (assignmentId == null) return false + return getUnreadCommentsCountUseCase( + GetUnreadCommentsCountUseCase.Params(assignmentId, apiPrefs.user?.id.orDefault(), forceNetwork) + ) > 0 + } + + private fun HorizonCourseModuleItemEntity.toModuleItem(): ModuleItem { + val completionRequirement = completionRequirementType?.let { + ModuleCompletionRequirement( + type = it, + minScore = completionRequirementMinScore, + completed = completionRequirementCompleted, + ) + } + val moduleDetails = if ( + pointsPossible != null || dueAt != null || lockedForUser || lockExplanation != null || + hidden != null || locked != null + ) { + ModuleContentDetails( + pointsPossible = pointsPossible, + dueAt = dueAt, + lockedForUser = lockedForUser, + lockExplanation = lockExplanation, + lockAt = lockAt, + unlockAt = unlockAt, + hidden = hidden, + locked = locked, + ) + } else null + return ModuleItem( + id = itemId, + moduleId = moduleId, + position = position, + indent = indent, + title = title, + type = type, + htmlUrl = htmlUrl, + url = url, + contentId = contentId, + externalUrl = externalUrl, + pageUrl = pageUrl, + published = published, + unpublishable = unpublishable, + completionRequirement = completionRequirement, + moduleDetails = moduleDetails, + quizLti = quizLti, + estimatedDuration = estimatedDuration, + ) } - suspend fun hasUnreadComments( - assignmentId: Long?, - forceNetwork: Boolean = false - ): Boolean { - if (assignmentId == null) return false - return horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, apiPrefs.user?.id.orDefault(), forceNetwork) > 0 + override suspend fun sync() { + TODO("Not yet implemented") } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt index 12a6b833b0..1bc6a4ed02 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModel.kt @@ -28,6 +28,7 @@ import com.instructure.canvasapi2.utils.isLocked import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.aiassistant.common.model.AiAssistContext import com.instructure.horizon.features.aiassistant.common.model.AiAssistContextSource @@ -56,6 +57,7 @@ import javax.inject.Inject class ModuleItemSequenceViewModel @Inject constructor( @ApplicationContext private val context: Context, private val repository: ModuleItemSequenceRepository, + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase, private val moduleItemCardStateMapper: ModuleItemCardStateMapper, private val aiAssistContextProvider: AiAssistContextProvider, savedStateHandle: SavedStateHandle, @@ -295,6 +297,7 @@ class ModuleItemSequenceViewModel @Inject constructor( private fun loadModuleItem(position: Int, moduleItemId: Long) { _uiState.update { it.copy( + loadingState = it.loadingState.copy(isError = false), notebookButtonEnabled = false, aiAssistButtonEnabled = false ) @@ -522,7 +525,7 @@ class ModuleItemSequenceViewModel @Inject constructor( private suspend fun getAssignment(item: ModuleItem?, forceNetwork: Boolean): Assignment? { if (item?.type != Type.Assignment.name) return null - return repository.getAssignment(item.contentId, courseId, forceNetwork = forceNetwork) + return getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, item.contentId, forceNetwork)) } private fun getAttemptCount(assignment: Assignment?): String? { diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepository.kt deleted file mode 100644 index 46e7717bb5..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepository.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.features.moduleitemsequence.content.assessment - -import com.instructure.canvasapi2.apis.AssignmentAPI -import com.instructure.canvasapi2.apis.LaunchDefinitionsAPI -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.models.Assignment -import javax.inject.Inject - -class AssessmentRepository @Inject constructor( - private val assignmentApi: AssignmentAPI.AssignmentInterface, - private val oAuthInterface: OAuthAPI.OAuthInterface, - private val launchDefinitionsApi: LaunchDefinitionsAPI.LaunchDefinitionsInterface, -) { - - suspend fun getAssignment(assignmentId: Long, courseId: Long, forceNetwork: Boolean): Assignment { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - return assignmentApi.getAssignmentWithHistory(courseId, assignmentId, params).dataOrThrow - } - - suspend fun authenticateUrl(url: String): String { - val ltiTool = launchDefinitionsApi.getLtiFromAuthenticationUrl(url, RestParams(isForceReadFromNetwork = true)).dataOrThrow - return ltiTool.url?.let { - oAuthInterface.getAuthenticatedSession( - it, - RestParams(isForceReadFromNetwork = true) - ).dataOrNull?.sessionUrl ?: url - } ?: url - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModel.kt index eeedc0de33..168be11f8f 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModel.kt @@ -18,8 +18,12 @@ package com.instructure.horizon.features.moduleitemsequence.content.assessment import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.apis.LaunchDefinitionsAPI +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.horizon.horizonui.platform.LoadingState import com.instructure.pandautils.utils.Const @@ -33,7 +37,9 @@ import javax.inject.Inject @HiltViewModel class AssessmentViewModel @Inject constructor( - val repository: AssessmentRepository, + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase, + private val launchDefinitionsApi: LaunchDefinitionsAPI.LaunchDefinitionsInterface, + private val oAuthApi: OAuthAPI.OAuthInterface, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -54,7 +60,7 @@ class AssessmentViewModel @Inject constructor( private fun onAssessmentCompletion() { _uiState.update { it.copy(assessmentCompletionLoading = true) } viewModelScope.launch { - delay(15000) // This is based on the iOS app, we need to add a loading delay so the quiz result would be processed correctly + delay(15000) // Loading delay so the quiz result is processed correctly before we refresh _uiState.update { it.copy(assessmentCompletionLoading = false) } } } @@ -70,7 +76,7 @@ class AssessmentViewModel @Inject constructor( it.copy(loadingState = LoadingState(isLoading = true)) } viewModelScope.tryLaunch { - val assignment = repository.getAssignment(assignmentId, courseId, false) + val assignment = getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId)) assessmentUrl = assignment.url _uiState.update { it.copy(loadingState = LoadingState(isLoading = false), assessmentName = assignment.name.orEmpty()) @@ -86,7 +92,11 @@ class AssessmentViewModel @Inject constructor( _uiState.update { it.copy(showAssessmentDialog = true, assessmentLoading = true) } viewModelScope.tryLaunch { assessmentUrl?.let { url -> - val authenticatedUrl = repository.authenticateUrl(url) + val ltiTool = launchDefinitionsApi.getLtiFromAuthenticationUrl(url, RestParams(isForceReadFromNetwork = true)).dataOrThrow + val authenticatedUrl = ltiTool.url?.let { ltiUrl -> + oAuthApi.getAuthenticatedSession(ltiUrl, RestParams(isForceReadFromNetwork = true)) + .dataOrNull?.sessionUrl ?: url + } ?: url _uiState.update { it.copy(urlToLoad = authenticatedUrl) } } ?: run { _uiState.update { it.copy(assessmentLoading = false) } @@ -103,4 +113,4 @@ class AssessmentViewModel @Inject constructor( private fun onAssessmentLoaded() { _uiState.update { it.copy(assessmentLoading = false) } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepository.kt deleted file mode 100644 index 21bdfdb775..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepository.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.features.moduleitemsequence.content.assignment - -import com.instructure.canvasapi2.apis.AssignmentAPI -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager -import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.pandautils.utils.orDefault -import javax.inject.Inject - -class AssignmentDetailsRepository @Inject constructor( - private val assignmentApi: AssignmentAPI.AssignmentInterface, - private val oAuthInterface: OAuthAPI.OAuthInterface, - private val horizonGetCommentsManager: HorizonGetCommentsManager, - private val apiPrefs: ApiPrefs, -) { - suspend fun getAssignment(assignmentId: Long, courseId: Long, forceNetwork: Boolean): Assignment { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - return assignmentApi.getAssignmentWithHistory(courseId, assignmentId, params).dataOrThrow - } - - suspend fun authenticateUrl(url: String): String { - return oAuthInterface.getAuthenticatedSession( - url, - RestParams(isForceReadFromNetwork = true) - ).dataOrNull?.sessionUrl ?: url - } - - suspend fun hasUnreadComments( - assignmentId: Long, - forceNetwork: Boolean = false - ): Boolean { - return horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, apiPrefs.user?.id.orDefault(), forceNetwork) > 0 - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt index 36aa4729dc..50e48d5970 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModel.kt @@ -19,11 +19,17 @@ import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Submission +import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch import com.instructure.horizon.R +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase +import com.instructure.horizon.domain.usecase.GetSubmissionHistoryUseCase +import com.instructure.horizon.domain.usecase.GetUnreadCommentsCountUseCase import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.aiassistant.common.model.AiAssistContext import com.instructure.horizon.features.aiassistant.common.model.AiAssistContextSource @@ -32,6 +38,7 @@ import com.instructure.horizon.horizonui.organisms.cards.AttemptCardState import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.HtmlContentFormatter import com.instructure.pandautils.utils.localisedFormat +import com.instructure.pandautils.utils.orDefault import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow @@ -44,8 +51,12 @@ import javax.inject.Inject @HiltViewModel class AssignmentDetailsViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val assignmentDetailsRepository: AssignmentDetailsRepository, + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase, + private val getSubmissionHistoryUseCase: GetSubmissionHistoryUseCase, + private val getUnreadCommentsCountUseCase: GetUnreadCommentsCountUseCase, private val htmlContentFormatter: HtmlContentFormatter, + private val oAuthApi: OAuthAPI.OAuthInterface, + private val apiPrefs: ApiPrefs, private val aiAssistContextProvider: AiAssistContextProvider, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -83,10 +94,10 @@ class AssignmentDetailsViewModel @Inject constructor( private fun loadData() { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = true)) } viewModelScope.tryLaunch { - val assignment = assignmentDetailsRepository.getAssignment(assignmentId, courseId, forceNetwork = false) + val assignment = getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId, forceRefresh = false)) _assignmentFlow.value = assignment - val lastActualSubmission = assignment.lastGradedOrSubmittedSubmission - val attempts = assignment.submission?.submissionHistory?.filterNotNull() ?: emptyList() + val attempts = getSubmissionHistoryUseCase(GetSubmissionHistoryUseCase.Params(courseId, assignmentId, apiPrefs.user?.id.orDefault())) + val lastActualSubmission = attempts.lastGradedOrSubmitted() val submissions = if (lastActualSubmission != null) { mapSubmissions(attempts) } else { @@ -98,7 +109,9 @@ class AssignmentDetailsViewModel @Inject constructor( val attemptsUiState = createAttemptCardsState(attempts, assignment, initialAttempt) val showAttemptSelector = assignment.allowedAttempts != 1L - val hasUnreadComments = assignmentDetailsRepository.hasUnreadComments(assignmentId) + val hasUnreadComments = getUnreadCommentsCountUseCase( + GetUnreadCommentsCountUseCase.Params(assignmentId, apiPrefs.user?.id.orDefault()) + ) > 0 aiAssistContextProvider.aiAssistContext = AiAssistContext( contextString = assignment.description.orEmpty(), @@ -215,9 +228,10 @@ class AssignmentDetailsViewModel @Inject constructor( private fun ltiButtonPressed(ltiUrl: String) { viewModelScope.launch { try { - val authenticatedSessionURL = - assignmentDetailsRepository.authenticateUrl(ltiUrl) - + val authenticatedSessionURL = oAuthApi.getAuthenticatedSession( + ltiUrl, + RestParams(isForceReadFromNetwork = true) + ).dataOrNull?.sessionUrl ?: ltiUrl _uiState.update { it.copy(urlToOpen = authenticatedSessionURL) } } catch (e: Exception) { _uiState.update { it.copy(urlToOpen = ltiUrl) } @@ -242,10 +256,10 @@ class AssignmentDetailsViewModel @Inject constructor( } private suspend fun updateAssignment() { - val assignment = assignmentDetailsRepository.getAssignment(assignmentId, courseId, forceNetwork = true) + val assignment = getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId, forceRefresh = true)) _assignmentFlow.value = assignment - val lastActualSubmission = assignment.lastGradedOrSubmittedSubmission - val attempts = assignment.submission?.submissionHistory?.filterNotNull() ?: emptyList() + val attempts = getSubmissionHistoryUseCase(GetSubmissionHistoryUseCase.Params(courseId, assignmentId, apiPrefs.user?.id.orDefault(), forceRefresh = true)) + val lastActualSubmission = attempts.lastGradedOrSubmitted() val submissions = if (lastActualSubmission != null) { mapSubmissions(attempts) } else { @@ -348,6 +362,11 @@ class AssignmentDetailsViewModel @Inject constructor( } } + private fun List.lastGradedOrSubmitted(): Submission? { + return filter { it.workflowState == "graded" || it.workflowState == "submitted" } + .maxByOrNull { it.attempt } + } + private fun formatScore(value: Double): String { val formatter = NumberFormat.getNumberInstance().apply { maximumFractionDigits = 2 diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepository.kt index 7cd9f376f5..5a19b9e974 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepository.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepository.kt @@ -21,12 +21,20 @@ import com.instructure.canvasapi2.managers.graphql.horizon.CommentsData import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.data.datasource.AssignmentCommentsLocalDataSource +import com.instructure.horizon.offline.OfflineSyncRepository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import javax.inject.Inject class CommentsRepository @Inject constructor( private val getCommentsManager: HorizonGetCommentsManager, - private val submissionApi: SubmissionAPI.SubmissionInterface -) { + private val submissionApi: SubmissionAPI.SubmissionInterface, + private val localDataSource: AssignmentCommentsLocalDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider, +) : OfflineSyncRepository(networkStateProvider, featureFlagProvider) { + suspend fun getComments( assignmentId: Long, userId: Long, @@ -36,15 +44,31 @@ class CommentsRepository @Inject constructor( endCursor: String? = null, nextPage: Boolean = false ): CommentsData { - return getCommentsManager.getComments( - assignmentId = assignmentId, - userId = userId, - attempt = attempt, - forceNetwork = forceNetwork, - startCursor = startCursor, - endCursor = endCursor, - nextPage = nextPage - ) + return if (shouldFetchFromNetwork()) { + val commentsData = getCommentsManager.getComments( + assignmentId = assignmentId, + userId = userId, + attempt = attempt, + forceNetwork = forceNetwork, + startCursor = startCursor, + endCursor = endCursor, + nextPage = nextPage + ) + if (shouldSync() && startCursor == null && endCursor == null && !nextPage) { + localDataSource.saveComments(assignmentId, attempt, commentsData) + } + commentsData + } else { + localDataSource.getComments(assignmentId, attempt) + } + } + + suspend fun getUnreadCommentCount(assignmentId: Long, userId: Long, forceNetwork: Boolean): Int { + return if (shouldFetchFromNetwork()) { + getCommentsManager.getUnreadCommentsCount(assignmentId, userId, forceNetwork) + } else { + localDataSource.getUnreadCommentCount(assignmentId) + } } suspend fun postComment( @@ -65,4 +89,8 @@ class CommentsRepository @Inject constructor( RestParams() ) } + + override suspend fun sync() { + TODO("Not yet implemented") + } } \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsViewModel.kt index 6a6ce74138..1a71694d88 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/file/FileDetailsViewModel.kt @@ -21,15 +21,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.work.WorkManager import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.instructure.canvasapi2.models.FileFolder +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetFileDetailsUseCase import com.instructure.horizon.features.account.filepreview.FilePreviewUiState import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.pandautils.features.file.download.FileDownloadWorker import com.instructure.pandautils.room.appdatabase.daos.FileDownloadProgressDao import com.instructure.pandautils.room.appdatabase.entities.FileDownloadProgressEntity import com.instructure.pandautils.room.appdatabase.entities.FileDownloadProgressState +import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.filecache.FileCache import com.instructure.pandautils.utils.filecache.awaitFileDownload import dagger.hilt.android.lifecycle.HiltViewModel @@ -43,7 +46,8 @@ import javax.inject.Inject @HiltViewModel class FileDetailsViewModel @Inject constructor( - private val fileDetailsRepository: FileDetailsRepository, + private val getFileDetailsUseCase: GetFileDetailsUseCase, + private val oAuthApi: OAuthAPI.OAuthInterface, private val workManager: WorkManager, private val fileDownloadProgressDao: FileDownloadProgressDao, private val fileCache: FileCache, @@ -52,6 +56,7 @@ class FileDetailsViewModel @Inject constructor( ) : ViewModel() { private val fileUrl = savedStateHandle[ModuleItemContent.File.FILE_URL] ?: "" + private val courseId: Long = savedStateHandle[Const.COURSE_ID] ?: -1L private val _uiState = MutableStateFlow( @@ -67,7 +72,8 @@ class FileDetailsViewModel @Inject constructor( private var runningWorkerId: UUID? = null - private var fileFolder: FileFolder? = null + private var fileUrl_: String = "" + private var displayName_: String = "" init { loadData() @@ -76,18 +82,32 @@ class FileDetailsViewModel @Inject constructor( private fun loadData() { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = true)) } viewModelScope.tryLaunch { - fileFolder = fileDetailsRepository.getFileFolderFromURL(fileUrl) - val authUrl = fileDetailsRepository.getAuthenticatedFileUrl(fileUrl.replace("api/v1/", "")) - fileFolder?.let { file -> - _uiState.update { - it.copy( - fileId = file.id, - loadingState = it.loadingState.copy(isLoading = false), - fileName = file.displayName.orEmpty(), - filePreview = getFilePreview(file, authUrl), - mimeType = file.contentType ?: "*/*", - ) - } + val fileDetails = getFileDetailsUseCase(GetFileDetailsUseCase.Params(fileUrl, courseId)) + fileUrl_ = fileDetails.url + displayName_ = fileDetails.displayName + val authUrl = if (fileDetails.localPath == null) { + oAuthApi.getAuthenticatedSession( + "${fileDetails.url.replace("api/v1/", "")}?display=borderless", + RestParams(isForceReadFromNetwork = true) + ).dataOrNull?.sessionUrl + } else { + null + } + _uiState.update { + it.copy( + fileId = fileDetails.id, + loadingState = it.loadingState.copy(isLoading = false), + fileName = fileDetails.displayName, + filePreview = getFilePreview( + url = fileDetails.url, + displayName = fileDetails.displayName, + contentType = fileDetails.contentType.orEmpty(), + thumbnailUrl = fileDetails.thumbnailUrl.orEmpty(), + authUrl = authUrl, + localPath = fileDetails.localPath, + ), + mimeType = fileDetails.contentType ?: "*/*", + ) } } catch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false, isError = true)) } @@ -95,21 +115,19 @@ class FileDetailsViewModel @Inject constructor( } private fun onDownloadClicked() { - fileFolder?.let { file -> - _uiState.update { it.copy(downloadState = FileDownloadProgressState.STARTING) } - val workRequest = FileDownloadWorker.createOneTimeWorkRequest(file.displayName.orEmpty(), file.url.orEmpty()) - workManager.enqueue(workRequest) - runningWorkerId = workRequest.id - val workerId = workRequest.id.toString() - viewModelScope.tryLaunch { - fileDownloadProgressDao.findByWorkerIdFlow(workerId) - .collect { progress -> - updateProgress(progress) - } - } catch { - _uiState.update { - it.copy(downloadState = FileDownloadProgressState.ERROR) + _uiState.update { it.copy(downloadState = FileDownloadProgressState.STARTING) } + val workRequest = FileDownloadWorker.createOneTimeWorkRequest(displayName_, fileUrl_) + workManager.enqueue(workRequest) + runningWorkerId = workRequest.id + val workerId = workRequest.id.toString() + viewModelScope.tryLaunch { + fileDownloadProgressDao.findByWorkerIdFlow(workerId) + .collect { progress -> + updateProgress(progress) } + } catch { + _uiState.update { + it.copy(downloadState = FileDownloadProgressState.ERROR) } } } @@ -137,44 +155,48 @@ class FileDetailsViewModel @Inject constructor( _uiState.update { it.copy(filePathToOpen = null) } } - private suspend fun getFilePreview(file: FileFolder, authUrl: String): FilePreviewUiState { - val url = file.url.orEmpty() - val displayName = file.displayName.orEmpty() - val contentType = file.contentType.orEmpty() - val thumbnailUrl = file.thumbnailUrl.orEmpty() - + private suspend fun getFilePreview( + url: String, + displayName: String, + contentType: String, + thumbnailUrl: String, + authUrl: String?, + localPath: String?, + ): FilePreviewUiState { try { return when { contentType == "application/pdf" -> { - val tempFile: File? = fileCache.awaitFileDownload(url) - tempFile?.let { - FilePreviewUiState.Pdf(Uri.fromFile(it)) - } ?: FilePreviewUiState.NoPreview + val uri = if (localPath != null) { + Uri.fromFile(File(localPath)) + } else { + Uri.fromFile(fileCache.awaitFileDownload(url) ?: return FilePreviewUiState.NoPreview) + } + FilePreviewUiState.Pdf(uri) } contentType.startsWith("video") || contentType.startsWith("audio") -> { - val tempFile: File? = fileCache.awaitFileDownload(url) - tempFile?.let { - FilePreviewUiState.Media( - Uri.fromFile(it), - thumbnailUrl, - contentType, - displayName - ) - } ?: FilePreviewUiState.NoPreview + val uri = if (localPath != null) { + Uri.fromFile(File(localPath)) + } else { + Uri.fromFile(fileCache.awaitFileDownload(url) ?: return FilePreviewUiState.NoPreview) + } + FilePreviewUiState.Media(uri, thumbnailUrl, contentType, displayName) } contentType.startsWith("image") -> { - val tempFile: File? = fileCache.awaitFileDownload(url) - tempFile?.let { - FilePreviewUiState.Image( - displayName = displayName, - uri = Uri.fromFile(it) - ) - } ?: FilePreviewUiState.NoPreview + val uri = if (localPath != null) { + Uri.fromFile(File(localPath)) + } else { + Uri.fromFile(fileCache.awaitFileDownload(url) ?: return FilePreviewUiState.NoPreview) + } + FilePreviewUiState.Image(displayName = displayName, uri = uri) } - else -> FilePreviewUiState.WebView("$authUrl&preview=1") + else -> if (authUrl != null) { + FilePreviewUiState.WebView("$authUrl&preview=1") + } else { + FilePreviewUiState.NoPreview + } } } catch (e: Exception) { crashlytics.recordException(e) @@ -191,4 +213,4 @@ class FileDetailsViewModel @Inject constructor( } catch {} } } -} \ No newline at end of file +} diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepository.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepository.kt deleted file mode 100644 index 5399a28e5d..0000000000 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepository.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.instructure.horizon.features.moduleitemsequence.content.page - -import com.apollographql.apollo.api.Optional -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.apis.PageAPI -import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager -import com.instructure.canvasapi2.models.CanvasContext -import com.instructure.canvasapi2.models.Page -import com.instructure.horizon.features.notebook.common.model.Note -import com.instructure.horizon.features.notebook.common.model.mapToNotes -import com.instructure.redwood.type.LearningObjectFilter -import com.instructure.redwood.type.NoteFilterInput -import javax.inject.Inject - -class PageDetailsRepository @Inject constructor( - private val pageApi: PageAPI.PagesInterface, - private val oAuthInterface: OAuthAPI.OAuthInterface, - private val redwoodApi: RedwoodApiManager, -) { - suspend fun getPageDetails(courseId: Long, pageId: String, forceNetwork: Boolean = false): Page { - val params = RestParams(isForceReadFromNetwork = forceNetwork) - return pageApi.getDetailedPage(CanvasContext.Type.COURSE.apiString, courseId, pageId, params).dataOrThrow - } - - suspend fun authenticateUrl(url: String): String { - return oAuthInterface.getAuthenticatedSession( - url, - RestParams(isForceReadFromNetwork = true) - ).dataOrNull?.sessionUrl ?: url - } - - suspend fun getNotes(courseId: Long, pageId: Long): List { - return redwoodApi.getNotes( - filter = NoteFilterInput( - courseId = Optional.present(courseId.toString()), - learningObject = Optional.present(LearningObjectFilter( - type = "Page", - id = pageId.toString() - )), - ), - firstN = null, - after = null, - ).mapToNotes() - } -} \ No newline at end of file diff --git a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModel.kt b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModel.kt index 99740bd22d..a82795b07b 100644 --- a/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModel.kt +++ b/libs/horizon/src/main/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModel.kt @@ -18,14 +18,22 @@ package com.instructure.horizon.features.moduleitemsequence.content.page import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.apollographql.apollo.api.Optional +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryLaunch +import com.instructure.horizon.domain.usecase.GetPageDetailsUseCase import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.horizon.features.notebook.addedit.add.AddNoteRepository import com.instructure.horizon.features.notebook.common.model.NotebookType +import com.instructure.horizon.features.notebook.common.model.mapToNotes import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.HtmlContentFormatter +import com.instructure.redwood.type.LearningObjectFilter +import com.instructure.redwood.type.NoteFilterInput import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,8 +43,10 @@ import javax.inject.Inject @HiltViewModel class PageDetailsViewModel @Inject constructor( - private val pageDetailsRepository: PageDetailsRepository, + private val getPageDetailsUseCase: GetPageDetailsUseCase, private val htmlContentFormatter: HtmlContentFormatter, + private val oAuthApi: OAuthAPI.OAuthInterface, + private val redwoodApi: RedwoodApiManager, private val addNoteRepository: AddNoteRepository, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -63,10 +73,20 @@ class PageDetailsViewModel @Inject constructor( _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = true)) } - val pageDetails = pageDetailsRepository.getPageDetails(courseId, pageUrl) + val pageDetails = getPageDetailsUseCase(GetPageDetailsUseCase.Params(courseId, pageUrl)) val html = htmlContentFormatter.formatHtmlWithIframes(pageDetails.body.orEmpty(), courseId) - val notes = try { // We don't want to fail the page load if fetching notes fails - pageDetailsRepository.getNotes(courseId, pageDetails.id) + val notes = try { + redwoodApi.getNotes( + filter = NoteFilterInput( + courseId = Optional.present(courseId.toString()), + learningObject = Optional.present(LearningObjectFilter( + type = "Page", + id = pageDetails.id.toString() + )), + ), + firstN = null, + after = null, + ).mapToNotes() } catch (e: Exception) { emptyList() } @@ -79,9 +99,6 @@ class PageDetailsViewModel @Inject constructor( pageUrl = pageUrl ) } - _uiState.update { - it.copy(loadingState = it.loadingState.copy(isLoading = false)) - } } catch { _uiState.update { it.copy(loadingState = it.loadingState.copy(isLoading = false, isError = true)) @@ -91,17 +108,28 @@ class PageDetailsViewModel @Inject constructor( fun refreshNotes() { viewModelScope.tryLaunch { - val notes = pageDetailsRepository.getNotes(uiState.value.courseId, uiState.value.pageId) + val notes = redwoodApi.getNotes( + filter = NoteFilterInput( + courseId = Optional.present(uiState.value.courseId.toString()), + learningObject = Optional.present(LearningObjectFilter( + type = "Page", + id = uiState.value.pageId.toString() + )), + ), + firstN = null, + after = null, + ).mapToNotes() _uiState.update { it.copy(notes = notes) } - } catch { } + } catch { } } private fun ltiButtonPressed(ltiUrl: String) { viewModelScope.launch { try { - val authenticatedSessionURL = - pageDetailsRepository.authenticateUrl(ltiUrl) - + val authenticatedSessionURL = oAuthApi.getAuthenticatedSession( + ltiUrl, + RestParams(isForceReadFromNetwork = true) + ).dataOrNull?.sessionUrl ?: ltiUrl _uiState.update { it.copy(urlToOpen = authenticatedSessionURL) } } catch (e: Exception) { _uiState.update { it.copy(urlToOpen = ltiUrl) } @@ -126,4 +154,4 @@ class PageDetailsViewModel @Inject constructor( refreshNotes() } catch {} } -} \ No newline at end of file +} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt index de0f7890dc..c266f066cd 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceRepositoryTest.kt @@ -16,16 +16,18 @@ */ package com.instructure.horizon.features.moduleitemsequence -import com.instructure.canvasapi2.apis.AssignmentAPI import com.instructure.canvasapi2.apis.ModuleAPI -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager -import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleItemSequence import com.instructure.canvasapi2.models.ModuleObject import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.data.repository.CourseRepository +import com.instructure.horizon.database.dao.HorizonCourseModuleDao +import com.instructure.horizon.domain.usecase.GetUnreadCommentsCountUseCase +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -40,9 +42,12 @@ import org.junit.Test class ModuleItemSequenceRepositoryTest { private val moduleApi: ModuleAPI.ModuleInterface = mockk(relaxed = true) - private val assignmentApi: AssignmentAPI.AssignmentInterface = mockk(relaxed = true) - private val horizonGetCommentsManager: HorizonGetCommentsManager = mockk(relaxed = true) + private val courseRepository: CourseRepository = mockk(relaxed = true) + private val courseModuleDao: HorizonCourseModuleDao = mockk(relaxed = true) + private val getUnreadCommentsCountUseCase: GetUnreadCommentsCountUseCase = mockk(relaxed = true) private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private val userId = 1L private val courseId = 1L @@ -50,6 +55,8 @@ class ModuleItemSequenceRepositoryTest { @Before fun setup() { every { apiPrefs.user } returns User(id = userId, name = "Test User") + // featureFlagProvider.offlineEnabled() returns false by default (relaxed mock), + // so shouldFetchFromNetwork() = isOnline() || !offlineEnabled() = false || true = true } @Test @@ -76,8 +83,7 @@ class ModuleItemSequenceRepositoryTest { ModuleItem(id = 1L, title = "Item 1"), ModuleItem(id = 2L, title = "Item 2") )) - coEvery { moduleApi.getFirstPageModulesWithItems(any(), any(), any(), any()) } returns - DataResult.Success(listOf(module)) + coEvery { courseRepository.getModuleItems(any(), any()) } returns listOf(module) val result = getRepository().getModulesWithItems(courseId, false) @@ -85,28 +91,6 @@ class ModuleItemSequenceRepositoryTest { assertEquals(module, result[0]) } - @Test - fun `Test modules with incomplete items fetches all items`() = runTest { - val incompleteModule = ModuleObject(id = 1L, name = "Module 1", itemCount = 3, items = listOf( - ModuleItem(id = 1L, title = "Item 1") - )) - val allItems = listOf( - ModuleItem(id = 1L, title = "Item 1"), - ModuleItem(id = 2L, title = "Item 2"), - ModuleItem(id = 3L, title = "Item 3") - ) - - coEvery { moduleApi.getFirstPageModulesWithItems(any(), any(), any(), any()) } returns - DataResult.Success(listOf(incompleteModule)) - coEvery { moduleApi.getFirstPageModuleItems(any(), any(), any(), any(), any()) } returns - DataResult.Success(allItems) - - val result = getRepository().getModulesWithItems(courseId, false) - - assertEquals(1, result.size) - assertEquals(3, result[0].items.size) - } - @Test fun `Test successful module item retrieval`() = runTest { val moduleItem = ModuleItem(id = 1L, title = "Item 1", moduleId = 1L) @@ -150,20 +134,9 @@ class ModuleItemSequenceRepositoryTest { coVerify { moduleApi.markModuleItemRead(any(), courseId, 1L, 1L, any()) } } - @Test - fun `Test successful assignment retrieval`() = runTest { - val assignment = Assignment(id = 1L, name = "Assignment 1") - coEvery { assignmentApi.getAssignmentWithHistory(any(), any(), any()) } returns - DataResult.Success(assignment) - - val result = getRepository().getAssignment(1L, courseId, false) - - assertEquals(assignment, result) - } - @Test fun `Test has unread comments returns true when count greater than zero`() = runTest { - coEvery { horizonGetCommentsManager.getUnreadCommentsCount(any(), any(), any()) } returns 5 + coEvery { getUnreadCommentsCountUseCase(any()) } returns 5 val result = getRepository().hasUnreadComments(1L, false) @@ -172,7 +145,7 @@ class ModuleItemSequenceRepositoryTest { @Test fun `Test has unread comments returns false when count is zero`() = runTest { - coEvery { horizonGetCommentsManager.getUnreadCommentsCount(any(), any(), any()) } returns 0 + coEvery { getUnreadCommentsCountUseCase(any()) } returns 0 val result = getRepository().hasUnreadComments(1L, false) @@ -187,6 +160,14 @@ class ModuleItemSequenceRepositoryTest { } private fun getRepository(): ModuleItemSequenceRepository { - return ModuleItemSequenceRepository(moduleApi, assignmentApi, horizonGetCommentsManager, apiPrefs) + return ModuleItemSequenceRepository( + moduleApi, + courseRepository, + courseModuleDao, + getUnreadCommentsCountUseCase, + apiPrefs, + networkStateProvider, + featureFlagProvider, + ) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt index 46785f31b6..87013c0b85 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/ModuleItemSequenceViewModelTest.kt @@ -23,6 +23,7 @@ import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.ModuleItem import com.instructure.canvasapi2.models.ModuleItemSequence import com.instructure.canvasapi2.models.ModuleObject +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.dashboard.DashboardEventHandler import com.instructure.horizon.features.learn.LearnEventHandler @@ -50,6 +51,7 @@ import org.junit.Test class ModuleItemSequenceViewModelTest { private val context: Context = mockk(relaxed = true) private val repository: ModuleItemSequenceRepository = mockk(relaxed = true) + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase = mockk(relaxed = true) private val moduleItemCardStateMapper: ModuleItemCardStateMapper = mockk(relaxed = true) private val aiAssistContextProvider: AiAssistContextProvider = mockk(relaxed = true) private val dashboardEventHandler: DashboardEventHandler = DashboardEventHandler() @@ -86,7 +88,7 @@ class ModuleItemSequenceViewModelTest { coEvery { repository.getModuleItemSequence(any(), any(), any()) } returns ModuleItemSequence() coEvery { repository.getModulesWithItems(any(), any()) } returns listOf(testModule) coEvery { repository.getModuleItem(any(), any(), any()) } returns testModuleItem - coEvery { repository.getAssignment(any(), any(), any()) } returns Assignment(id = 1L) + coEvery { getAssignmentDetailsUseCase(any()) } returns Assignment(id = 1L) coEvery { repository.hasUnreadComments(any(), any()) } returns false coEvery { moduleItemCardStateMapper.mapModuleItemToCardState(any(), any()) } returns mockk(relaxed = true) } @@ -124,17 +126,16 @@ class ModuleItemSequenceViewModelTest { @Test fun `Test assignment is fetched for module item`() = runTest { - coEvery { repository.getAssignment(any(), any(), any()) } returns Assignment(id = 123L, name = "Test Assignment") + coEvery { getAssignmentDetailsUseCase(any()) } returns Assignment(id = 123L, name = "Test Assignment") val viewModel = getViewModel(savedStateHandle) - coVerify { repository.getAssignment(any(), courseId, any()) } + coVerify { getAssignmentDetailsUseCase(any()) } } @Test fun `Test unread comments check is performed`() = runTest { - val assignment = Assignment(id = 123L) - coEvery { repository.getAssignment(any(), any(), any()) } returns assignment + coEvery { getAssignmentDetailsUseCase(any()) } returns Assignment(id = 123L) val viewModel = getViewModel(savedStateHandle) @@ -145,6 +146,7 @@ class ModuleItemSequenceViewModelTest { return ModuleItemSequenceViewModel( context, repository, + getAssignmentDetailsUseCase, moduleItemCardStateMapper, aiAssistContextProvider, savedStateHandle, diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepositoryTest.kt deleted file mode 100644 index f4c665e3af..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentRepositoryTest.kt +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.moduleitemsequence.content.assessment - -import com.instructure.canvasapi2.apis.AssignmentAPI -import com.instructure.canvasapi2.apis.LaunchDefinitionsAPI -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.models.AuthenticatedSession -import com.instructure.canvasapi2.models.LTITool -import com.instructure.canvasapi2.utils.DataResult -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import io.mockk.unmockkAll -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test - -class AssessmentRepositoryTest { - private val assignmentApi: AssignmentAPI.AssignmentInterface = mockk(relaxed = true) - private val oAuthInterface: OAuthAPI.OAuthInterface = mockk(relaxed = true) - private val launchDefinitionsApi: LaunchDefinitionsAPI.LaunchDefinitionsInterface = mockk(relaxed = true) - - private lateinit var repository: AssessmentRepository - - private val testAssignment = Assignment( - id = 1L, - name = "Test Quiz", - courseId = 100L, - url = "https://example.com/quiz/1" - ) - - private val testLTITool = LTITool( - url = "https://lti.example.com/tool", - id = 1 - ) - - @Before - fun setup() { - repository = AssessmentRepository(assignmentApi, oAuthInterface, launchDefinitionsApi) - } - - @After - fun tearDown() { - unmockkAll() - } - - @Test - fun `getAssignment returns assignment successfully`() = runTest { - coEvery { assignmentApi.getAssignmentWithHistory(any(), any(), any()) } returns DataResult.Success(testAssignment) - - val result = repository.getAssignment(assignmentId = 1L, courseId = 100L, forceNetwork = false) - - assertEquals("Test Quiz", result.name) - assertEquals(1L, result.id) - coVerify { assignmentApi.getAssignmentWithHistory(100L, 1L, any()) } - } - - @Test - fun `getAssignment with forceNetwork true`() = runTest { - coEvery { assignmentApi.getAssignmentWithHistory(any(), any(), any()) } returns DataResult.Success(testAssignment) - - repository.getAssignment(assignmentId = 1L, courseId = 100L, forceNetwork = true) - - coVerify { assignmentApi.getAssignmentWithHistory(any(), any(), match { it.isForceReadFromNetwork }) } - } - - @Test - fun `getAssignment with forceNetwork false`() = runTest { - coEvery { assignmentApi.getAssignmentWithHistory(any(), any(), any()) } returns DataResult.Success(testAssignment) - - repository.getAssignment(assignmentId = 1L, courseId = 100L, forceNetwork = false) - - coVerify { assignmentApi.getAssignmentWithHistory(any(), any(), match { !it.isForceReadFromNetwork }) } - } - - @Test - fun `authenticateUrl returns authenticated URL for LTI tool`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://authenticated.lti.url") - coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(testLTITool) - coEvery { oAuthInterface.getAuthenticatedSession(any(), any(), any()) } returns DataResult.Success(session) - - val result = repository.authenticateUrl("https://example.com/quiz") - - assertEquals("https://authenticated.lti.url", result) - coVerify { launchDefinitionsApi.getLtiFromAuthenticationUrl("https://example.com/quiz", any()) } - coVerify { oAuthInterface.getAuthenticatedSession("https://lti.example.com/tool", any(), any()) } - } - - @Test - fun `authenticateUrl returns original URL when LTI tool URL is null`() = runTest { - val ltiToolWithoutUrl = LTITool(url = null, id = 1) - coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(ltiToolWithoutUrl) - - val result = repository.authenticateUrl("https://example.com/quiz") - - assertEquals("https://example.com/quiz", result) - } - - @Test - fun `authenticateUrl returns original URL when session URL is null`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://example.com/quiz/authenticated") - coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(testLTITool) - coEvery { oAuthInterface.getAuthenticatedSession(any(), any(), any()) } returns DataResult.Success(session) - - val result = repository.authenticateUrl("https://example.com/quiz") - - assertEquals("https://example.com/quiz/authenticated", result) - } - - @Test - fun `authenticateUrl returns original URL when authentication fails`() = runTest { - coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(testLTITool) - coEvery { oAuthInterface.getAuthenticatedSession(any(), any(), any()) } returns DataResult.Fail() - - val result = repository.authenticateUrl("https://example.com/quiz") - - assertEquals("https://example.com/quiz", result) - } - - @Test - fun `authenticateUrl always uses forceNetwork`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://authenticated.url") - coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns DataResult.Success(testLTITool) - coEvery { oAuthInterface.getAuthenticatedSession(any(), any(), any()) } returns DataResult.Success(session) - - repository.authenticateUrl("https://example.com") - - coVerify { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), match { it.isForceReadFromNetwork }) } - coVerify { oAuthInterface.getAuthenticatedSession(any(), match { it.isForceReadFromNetwork }, any()) } - } - - @Test - fun `getAssignment with different course and assignment IDs`() = runTest { - coEvery { assignmentApi.getAssignmentWithHistory(any(), any(), any()) } returns DataResult.Success(testAssignment) - - repository.getAssignment(assignmentId = 99L, courseId = 200L, forceNetwork = false) - - coVerify { assignmentApi.getAssignmentWithHistory(200L, 99L, any()) } - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt index 2ededc5f96..75213e27ef 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assessment/AssessmentViewModelTest.kt @@ -17,7 +17,13 @@ package com.instructure.horizon.features.moduleitemsequence.content.assessment import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.apis.LaunchDefinitionsAPI +import com.instructure.canvasapi2.apis.OAuthAPI import com.instructure.canvasapi2.models.Assignment +import com.instructure.canvasapi2.models.AuthenticatedSession +import com.instructure.canvasapi2.models.LTITool +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.pandautils.utils.Const import io.mockk.coEvery @@ -43,7 +49,9 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class AssessmentViewModelTest { - private val repository: AssessmentRepository = mockk(relaxed = true) + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase = mockk(relaxed = true) + private val launchDefinitionsApi: LaunchDefinitionsAPI.LaunchDefinitionsInterface = mockk(relaxed = true) + private val oAuthApi: OAuthAPI.OAuthInterface = mockk(relaxed = true) private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() @@ -60,8 +68,11 @@ class AssessmentViewModelTest { Dispatchers.setMain(testDispatcher) every { savedStateHandle.get(ModuleItemContent.Assignment.ASSIGNMENT_ID) } returns assignmentId every { savedStateHandle.get(Const.COURSE_ID) } returns courseId - coEvery { repository.getAssignment(any(), any(), any()) } returns testAssignment - coEvery { repository.authenticateUrl(any()) } returns "https://authenticated.url" + coEvery { getAssignmentDetailsUseCase(any()) } returns testAssignment + coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } returns + DataResult.Success(LTITool(url = "https://lti.url")) + coEvery { oAuthApi.getAuthenticatedSession(any(), any(), any()) } returns + DataResult.Success(AuthenticatedSession(sessionUrl = "https://authenticated.url")) } @After @@ -76,7 +87,7 @@ class AssessmentViewModelTest { assertFalse(viewModel.uiState.value.loadingState.isLoading) assertEquals("Test Quiz", viewModel.uiState.value.assessmentName) - coVerify { repository.getAssignment(assignmentId, courseId, false) } + coVerify { getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId)) } } @Test @@ -89,12 +100,11 @@ class AssessmentViewModelTest { assertEquals("https://authenticated.url", viewModel.uiState.value.urlToLoad) viewModel.uiState.value.onAssessmentLoaded() assertFalse(viewModel.uiState.value.assessmentLoading) - coVerify { repository.authenticateUrl("https://example.com/quiz/1") } } @Test fun `Test start quiz with authentication error`() = runTest { - coEvery { repository.authenticateUrl(any()) } throws Exception("Auth error") + coEvery { launchDefinitionsApi.getLtiFromAuthenticationUrl(any(), any()) } throws Exception("Auth error") val viewModel = getViewModel() @@ -148,7 +158,7 @@ class AssessmentViewModelTest { @Test fun `Test load error sets error state`() = runTest { - coEvery { repository.getAssignment(any(), any(), any()) } throws Exception("Error") + coEvery { getAssignmentDetailsUseCase(any()) } throws Exception("Error") val viewModel = getViewModel() @@ -157,7 +167,7 @@ class AssessmentViewModelTest { @Test fun `Test start quiz with null assessment URL`() = runTest { - coEvery { repository.getAssignment(any(), any(), any()) } returns testAssignment.copy(url = null) + coEvery { getAssignmentDetailsUseCase(any()) } returns testAssignment.copy(url = null) val viewModel = getViewModel() @@ -179,6 +189,6 @@ class AssessmentViewModelTest { } private fun getViewModel(): AssessmentViewModel { - return AssessmentViewModel(repository, savedStateHandle) + return AssessmentViewModel(getAssignmentDetailsUseCase, launchDefinitionsApi, oAuthApi, savedStateHandle) } } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepositoryTest.kt index b7f71ddea0..9685555d94 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsRepositoryTest.kt @@ -16,122 +16,121 @@ */ package com.instructure.horizon.features.moduleitemsequence.content.assignment -import com.instructure.canvasapi2.apis.AssignmentAPI -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager import com.instructure.canvasapi2.models.Assignment -import com.instructure.canvasapi2.models.AuthenticatedSession -import com.instructure.canvasapi2.models.User -import com.instructure.canvasapi2.utils.ApiPrefs -import com.instructure.canvasapi2.utils.DataResult +import com.instructure.canvasapi2.models.Submission +import com.instructure.horizon.data.datasource.AssignmentDetailsLocalDataSource +import com.instructure.horizon.data.datasource.AssignmentDetailsNetworkDataSource +import com.instructure.horizon.data.datasource.SubmissionLocalDataSource +import com.instructure.horizon.data.repository.AssignmentDetailsRepository +import com.instructure.horizon.data.repository.HorizonFileSyncRepository +import com.instructure.pandautils.features.offline.sync.HtmlParser +import com.instructure.pandautils.features.offline.sync.HtmlParsingResult +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery -import io.mockk.every +import io.mockk.coVerify import io.mockk.mockk +import io.mockk.unmockkAll import junit.framework.TestCase.assertEquals -import junit.framework.TestCase.assertFalse -import junit.framework.TestCase.assertTrue import kotlinx.coroutines.test.runTest -import org.junit.Before +import org.junit.After import org.junit.Test class AssignmentDetailsRepositoryTest { - private val assignmentApi: AssignmentAPI.AssignmentInterface = mockk(relaxed = true) - private val oAuthInterface: OAuthAPI.OAuthInterface = mockk(relaxed = true) - private val horizonGetCommentsManager: HorizonGetCommentsManager = mockk(relaxed = true) - private val apiPrefs: ApiPrefs = mockk(relaxed = true) + private val networkDataSource: AssignmentDetailsNetworkDataSource = mockk(relaxed = true) + private val localDataSource: AssignmentDetailsLocalDataSource = mockk(relaxed = true) + private val submissionLocalDataSource: SubmissionLocalDataSource = mockk(relaxed = true) + private val htmlParser: HtmlParser = mockk(relaxed = true) + private val fileSyncRepository: HorizonFileSyncRepository = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) - private val userId = 1L private val courseId = 1L - private val assignmentId = 1L + private val assignmentId = 10L + private val testAssignment = Assignment(id = assignmentId, name = "Test Assignment", pointsPossible = 100.0) - @Before - fun setup() { - every { apiPrefs.user } returns User(id = userId, name = "Test User") + @After + fun tearDown() { + unmockkAll() } @Test - fun `Test successful assignment retrieval`() = runTest { - val assignment = Assignment(id = assignmentId, name = "Test Assignment", pointsPossible = 100.0) - coEvery { assignmentApi.getAssignmentWithHistory(courseId, assignmentId, any()) } returns - DataResult.Success(assignment) + fun `getAssignment fetches from network when online`() = runTest { + coEvery { networkDataSource.getAssignment(courseId, assignmentId, false) } returns testAssignment + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult("", emptySet(), emptySet(), emptySet()) - val result = getRepository().getAssignment(assignmentId, courseId, false) + val result = getRepository().getAssignment(courseId, assignmentId, false) - assertEquals(assignment, result) + assertEquals(testAssignment, result) + coVerify { networkDataSource.getAssignment(courseId, assignmentId, false) } } @Test(expected = IllegalStateException::class) - fun `Test failed assignment retrieval throws exception`() = runTest { - coEvery { assignmentApi.getAssignmentWithHistory(courseId, assignmentId, any()) } returns - DataResult.Fail() + fun `getAssignment throws when offline and no cached data`() = runTest { + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { networkStateProvider.isOnline() } returns false + coEvery { localDataSource.getAssignment(assignmentId) } returns null - getRepository().getAssignment(assignmentId, courseId, false) + getRepository().getAssignment(courseId, assignmentId, false) } @Test - fun `Test successful URL authentication`() = runTest { - val originalUrl = "https://example.com/file" - val authenticatedUrl = "https://example.com/file?session=xyz" - val session = AuthenticatedSession(sessionUrl = authenticatedUrl) + fun `getAssignment returns cached data when offline`() = runTest { + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { networkStateProvider.isOnline() } returns false + coEvery { localDataSource.getAssignment(assignmentId) } returns testAssignment - coEvery { oAuthInterface.getAuthenticatedSession(originalUrl, any(), any()) } returns - DataResult.Success(session) + val result = getRepository().getAssignment(courseId, assignmentId, false) - val result = getRepository().authenticateUrl(originalUrl) - - assertEquals(authenticatedUrl, result) - } - - @Test - fun `Test URL authentication fallback on failure`() = runTest { - val originalUrl = "https://example.com/file" - coEvery { oAuthInterface.getAuthenticatedSession(originalUrl, any(), any()) } returns DataResult.Fail() - - val result = getRepository().authenticateUrl(originalUrl) - - assertEquals(originalUrl, result) - } - - @Test - fun `Test URL authentication fallback on null session`() = runTest { - val originalUrl = "https://example.com/file" - coEvery { oAuthInterface.getAuthenticatedSession(originalUrl, any(), any()) } returns DataResult.Fail() - - val result = getRepository().authenticateUrl(originalUrl) - - assertEquals(originalUrl, result) + assertEquals(testAssignment, result) + coVerify { localDataSource.getAssignment(assignmentId) } } @Test - fun `Test has unread comments returns true when count greater than zero`() = runTest { - coEvery { horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, userId, false) } returns 3 + fun `getAssignment saves to local when online and sync enabled`() = runTest { + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { networkStateProvider.isOnline() } returns true + coEvery { networkDataSource.getAssignment(courseId, assignmentId, false) } returns testAssignment + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult("parsed", emptySet(), emptySet(), emptySet()) - val result = getRepository().hasUnreadComments(assignmentId, false) + getRepository().getAssignment(courseId, assignmentId, false) - assertTrue(result) + coVerify { localDataSource.saveAssignment(testAssignment, courseId, "parsed") } } @Test - fun `Test has unread comments returns false when count is zero`() = runTest { - coEvery { horizonGetCommentsManager.getUnreadCommentsCount(assignmentId, userId, false) } returns 0 - - val result = getRepository().hasUnreadComments(assignmentId, false) - - assertFalse(result) + fun `getAssignment saves submission history when online and sync enabled`() = runTest { + val submission = Submission(id = 1L, attempt = 1L, workflowState = "submitted") + val assignmentWithSubmission = testAssignment.copy( + submission = Submission(submissionHistory = listOf(submission)) + ) + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { networkStateProvider.isOnline() } returns true + coEvery { networkDataSource.getAssignment(courseId, assignmentId, false) } returns assignmentWithSubmission + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult("", emptySet(), emptySet(), emptySet()) + + getRepository().getAssignment(courseId, assignmentId, false) + + coVerify { submissionLocalDataSource.saveSubmissions(assignmentId, listOf(submission)) } } @Test - fun `Test force network parameter is passed correctly`() = runTest { - val assignment = Assignment(id = assignmentId, name = "Test Assignment") - coEvery { assignmentApi.getAssignmentWithHistory(courseId, assignmentId, any()) } returns - DataResult.Success(assignment) + fun `getAssignment passes forceRefresh to network data source`() = runTest { + coEvery { networkDataSource.getAssignment(courseId, assignmentId, true) } returns testAssignment + coEvery { htmlParser.createHtmlStringWithLocalFiles(any(), any()) } returns HtmlParsingResult("", emptySet(), emptySet(), emptySet()) - getRepository().getAssignment(assignmentId, courseId, true) + getRepository().getAssignment(courseId, assignmentId, true) - coEvery { assignmentApi.getAssignmentWithHistory(courseId, assignmentId, match { it.isForceReadFromNetwork }) } + coVerify { networkDataSource.getAssignment(courseId, assignmentId, true) } } - private fun getRepository(): AssignmentDetailsRepository { - return AssignmentDetailsRepository(assignmentApi, oAuthInterface, horizonGetCommentsManager, apiPrefs) - } + private fun getRepository() = AssignmentDetailsRepository( + networkDataSource, + localDataSource, + submissionLocalDataSource, + htmlParser, + fileSyncRepository, + networkStateProvider, + featureFlagProvider, + ) } diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt index a9cdd1eb34..bba5d3bc82 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/AssignmentDetailsViewModelTest.kt @@ -18,8 +18,13 @@ package com.instructure.horizon.features.moduleitemsequence.content.assignment import android.content.Context import androidx.lifecycle.SavedStateHandle +import com.instructure.canvasapi2.apis.OAuthAPI import com.instructure.canvasapi2.models.Assignment import com.instructure.canvasapi2.models.Submission +import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.horizon.domain.usecase.GetAssignmentDetailsUseCase +import com.instructure.horizon.domain.usecase.GetSubmissionHistoryUseCase +import com.instructure.horizon.domain.usecase.GetUnreadCommentsCountUseCase import com.instructure.horizon.features.aiassistant.common.AiAssistContextProvider import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.pandautils.utils.Const @@ -45,8 +50,12 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class AssignmentDetailsViewModelTest { private val context: Context = mockk(relaxed = true) - private val repository: AssignmentDetailsRepository = mockk(relaxed = true) + private val getAssignmentDetailsUseCase: GetAssignmentDetailsUseCase = mockk(relaxed = true) + private val getSubmissionHistoryUseCase: GetSubmissionHistoryUseCase = mockk(relaxed = true) + private val getUnreadCommentsCountUseCase: GetUnreadCommentsCountUseCase = mockk(relaxed = true) private val htmlContentFormatter: HtmlContentFormatter = mockk(relaxed = true) + private val oAuthApi: OAuthAPI.OAuthInterface = mockk(relaxed = true) + private val apiPrefs: ApiPrefs = mockk(relaxed = true) private val aiAssistContextProvider: AiAssistContextProvider = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() @@ -64,9 +73,9 @@ class AssignmentDetailsViewModelTest { @Before fun setup() { Dispatchers.setMain(testDispatcher) - coEvery { repository.getAssignment(any(), any(), any()) } returns testAssignment - coEvery { repository.hasUnreadComments(any(), any()) } returns false - coEvery { repository.authenticateUrl(any()) } returns "https://authenticated.url" + coEvery { getAssignmentDetailsUseCase(any()) } returns testAssignment + coEvery { getSubmissionHistoryUseCase(any()) } returns emptyList() + coEvery { getUnreadCommentsCountUseCase(any()) } returns 0 coEvery { htmlContentFormatter.formatHtmlWithIframes(any(), any()) } returns "Formatted content" coEvery { aiAssistContextProvider.aiAssistContext } returns mockk(relaxed = true) coEvery { aiAssistContextProvider.aiAssistContext = any() } returns Unit @@ -88,7 +97,7 @@ class AssignmentDetailsViewModelTest { val viewModel = getViewModel(savedStateHandle) assertFalse(viewModel.uiState.value.loadingState.isLoading) - coVerify { repository.getAssignment(assignmentId, courseId, false) } + coVerify { getAssignmentDetailsUseCase(GetAssignmentDetailsUseCase.Params(courseId, assignmentId, false)) } } @Test @@ -106,7 +115,7 @@ class AssignmentDetailsViewModelTest { @Test fun `Test failed data load sets error state`() = runTest { - coEvery { repository.getAssignment(any(), any(), any()) } throws Exception("Network error") + coEvery { getAssignmentDetailsUseCase(any()) } throws Exception("Network error") val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -127,12 +136,12 @@ class AssignmentDetailsViewModelTest { val viewModel = getViewModel(savedStateHandle) - coVerify { repository.hasUnreadComments(assignmentId, false) } + coVerify { getUnreadCommentsCountUseCase(any()) } } @Test fun `Test unread comments flag is set correctly`() = runTest { - coEvery { repository.hasUnreadComments(any(), any()) } returns true + coEvery { getUnreadCommentsCountUseCase(any()) } returns 1 val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -147,7 +156,7 @@ class AssignmentDetailsViewModelTest { @Test fun `Test assignment with no submission shows add submission`() = runTest { val assignmentWithoutSubmission = testAssignment.copy(submission = null) - coEvery { repository.getAssignment(any(), any(), any()) } returns assignmentWithoutSubmission + coEvery { getAssignmentDetailsUseCase(any()) } returns assignmentWithoutSubmission val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -163,10 +172,7 @@ class AssignmentDetailsViewModelTest { @Test fun `Test assignment with submission shows submission details`() = runTest { val submission = Submission(attempt = 1L, workflowState = "submitted") - val assignmentWithSubmission = testAssignment.copy( - submission = submission - ) - coEvery { repository.getAssignment(any(), any(), any()) } returns assignmentWithSubmission + coEvery { getSubmissionHistoryUseCase(any()) } returns listOf(submission) val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -178,26 +184,6 @@ class AssignmentDetailsViewModelTest { assertTrue(viewModel.uiState.value.showSubmissionDetails) } - @Test - fun `Test LTI URL authentication is performed`() = runTest { - val ltiUrl = "https://lti.example.com/launch" - val testAssignmentWithLti = testAssignment.copy( - externalToolAttributes = mockk { - coEvery { url } returns ltiUrl - } - ) - coEvery { repository.getAssignment(any(), any(), any()) } returns testAssignmentWithLti - - val savedStateHandle = SavedStateHandle(mapOf( - ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, - Const.COURSE_ID to courseId - )) - - val viewModel = getViewModel(savedStateHandle) - - assertEquals(ltiUrl, viewModel.uiState.value.ltiUrl) - } - @Test fun `Test HTML content formatting is applied to description`() = runTest { val savedStateHandle = SavedStateHandle(mapOf( @@ -214,7 +200,7 @@ class AssignmentDetailsViewModelTest { @Test fun `Test attempt selector visibility for multiple attempts`() = runTest { val assignmentWithMultipleAttempts = testAssignment.copy(allowedAttempts = 3L) - coEvery { repository.getAssignment(any(), any(), any()) } returns assignmentWithMultipleAttempts + coEvery { getAssignmentDetailsUseCase(any()) } returns assignmentWithMultipleAttempts val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -229,7 +215,7 @@ class AssignmentDetailsViewModelTest { @Test fun `Test attempt selector is hidden for single attempt assignment`() = runTest { val assignmentWithSingleAttempt = testAssignment.copy(allowedAttempts = 1L) - coEvery { repository.getAssignment(any(), any(), any()) } returns assignmentWithSingleAttempt + coEvery { getAssignmentDetailsUseCase(any()) } returns assignmentWithSingleAttempt val savedStateHandle = SavedStateHandle(mapOf( ModuleItemContent.Assignment.ASSIGNMENT_ID to assignmentId, @@ -258,10 +244,14 @@ class AssignmentDetailsViewModelTest { private fun getViewModel(savedStateHandle: SavedStateHandle): AssignmentDetailsViewModel { return AssignmentDetailsViewModel( context, - repository, + getAssignmentDetailsUseCase, + getSubmissionHistoryUseCase, + getUnreadCommentsCountUseCase, htmlContentFormatter, + oAuthApi, + apiPrefs, aiAssistContextProvider, savedStateHandle ) } -} +} \ No newline at end of file diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepositoryTest.kt index bbb8147381..470e9c86fb 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepositoryTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/assignment/comments/CommentsRepositoryTest.kt @@ -23,6 +23,9 @@ import com.instructure.canvasapi2.managers.graphql.horizon.CommentsData import com.instructure.canvasapi2.managers.graphql.horizon.HorizonGetCommentsManager import com.instructure.canvasapi2.models.Submission import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.data.datasource.AssignmentCommentsLocalDataSource +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -38,6 +41,9 @@ import java.util.Date class CommentsRepositoryTest { private val getCommentsManager: HorizonGetCommentsManager = mockk(relaxed = true) private val submissionApi: SubmissionAPI.SubmissionInterface = mockk(relaxed = true) + private val localDataSource: AssignmentCommentsLocalDataSource = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) private lateinit var repository: CommentsRepository @@ -66,7 +72,7 @@ class CommentsRepositoryTest { @Before fun setup() { - repository = CommentsRepository(getCommentsManager, submissionApi) + repository = CommentsRepository(getCommentsManager, submissionApi, localDataSource, networkStateProvider, featureFlagProvider) } @After diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepositoryTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepositoryTest.kt deleted file mode 100644 index 304098d217..0000000000 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsRepositoryTest.kt +++ /dev/null @@ -1,209 +0,0 @@ -/* - * Copyright (C) 2025 - present Instructure, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - */ -package com.instructure.horizon.features.moduleitemsequence.content.page - -import com.instructure.canvasapi2.apis.OAuthAPI -import com.instructure.canvasapi2.apis.PageAPI -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager -import com.instructure.canvasapi2.models.AuthenticatedSession -import com.instructure.canvasapi2.models.Page -import com.instructure.canvasapi2.utils.DataResult -import com.instructure.redwood.QueryNotesQuery -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk -import io.mockk.unmockkAll -import junit.framework.TestCase.assertEquals -import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before -import org.junit.Test -import java.util.Date - -class PageDetailsRepositoryTest { - private val pageApi: PageAPI.PagesInterface = mockk(relaxed = true) - private val oAuthInterface: OAuthAPI.OAuthInterface = mockk(relaxed = true) - private val redwoodApi: RedwoodApiManager = mockk(relaxed = true) - - private lateinit var repository: PageDetailsRepository - - private val testPage = Page( - id = 1L, - url = "test-page", - title = "Test Page", - body = "

Page content

" - ) - - private val testNotes = QueryNotesQuery.Notes( - pageInfo = QueryNotesQuery.PageInfo( - hasNextPage = false, - hasPreviousPage = false, - startCursor = null, - endCursor = null - ), - edges = listOf( - QueryNotesQuery.Edge( - cursor = "", - node = QueryNotesQuery.Node( - id = "1", - objectId = "1", - objectType = "Page", - userText = "comment 1", - rootAccountUuid = "1", - userId = "1", - courseId = "1", - reaction = listOf("Important"), - highlightData = "", - createdAt = Date(), - updatedAt = Date(), - ) - ), - QueryNotesQuery.Edge( - cursor = "", - node = QueryNotesQuery.Node( - id = "2", - objectId = "1", - objectType = "Page", - userText = "comment 2", - rootAccountUuid = "1", - userId = "1", - courseId = "1", - reaction = listOf("Important"), - highlightData = "", - createdAt = Date(), - updatedAt = Date(), - ) - ) - ) - ) - - @Before - fun setup() { - repository = PageDetailsRepository(pageApi, oAuthInterface, redwoodApi) - } - - @After - fun tearDown() { - unmockkAll() - } - - @Test - fun `getPageDetails returns page successfully`() = runTest { - coEvery { pageApi.getDetailedPage(any(), any(), any(), any()) } returns DataResult.Success(testPage) - - val result = repository.getPageDetails(courseId = 1L, pageId = "test-page", forceNetwork = false) - - assertEquals("Test Page", result.title) - assertEquals("

Page content

", result.body) - coVerify { pageApi.getDetailedPage("courses", 1L, "test-page", any()) } - } - - @Test - fun `getPageDetails with forceNetwork true`() = runTest { - coEvery { pageApi.getDetailedPage(any(), any(), any(), any()) } returns DataResult.Success(testPage) - - repository.getPageDetails(courseId = 1L, pageId = "test-page", forceNetwork = true) - - coVerify { pageApi.getDetailedPage(any(), any(), any(), match { it.isForceReadFromNetwork }) } - } - - @Test - fun `getPageDetails with forceNetwork false`() = runTest { - coEvery { pageApi.getDetailedPage(any(), any(), any(), any()) } returns DataResult.Success(testPage) - - repository.getPageDetails(courseId = 1L, pageId = "test-page", forceNetwork = false) - - coVerify { pageApi.getDetailedPage(any(), any(), any(), match { !it.isForceReadFromNetwork }) } - } - - @Test - fun `authenticateUrl returns authenticated URL`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://authenticated.url") - coEvery { oAuthInterface.getAuthenticatedSession(any(), any(), any()) } returns DataResult.Success(session) - - val result = repository.authenticateUrl("https://example.com/page") - - assertEquals("https://authenticated.url", result) - coVerify { oAuthInterface.getAuthenticatedSession("https://example.com/page", any(), any()) } - } - - @Test - fun `authenticateUrl returns original URL on failure`() = runTest { - coEvery { oAuthInterface.getAuthenticatedSession(any(), any(), any()) } returns DataResult.Fail() - - val result = repository.authenticateUrl("https://example.com/page") - - assertEquals("https://example.com/page", result) - } - - @Test - fun `authenticateUrl returns original URL when session URL is null`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://example.com/page/authenticated") - coEvery { oAuthInterface.getAuthenticatedSession(any(), any(), any()) } returns DataResult.Success(session) - - val result = repository.authenticateUrl("https://example.com/page") - - assertEquals("https://example.com/page/authenticated", result) - } - - @Test - fun `getNotes returns notes list`() = runTest { - coEvery { redwoodApi.getNotes(any(), any(), any()) } returns testNotes - - val result = repository.getNotes(courseId = 1L, pageId = 100L) - - assertEquals(2, result.size) - assertEquals("comment 1", result.first().userText) - coVerify { redwoodApi.getNotes(any(), null, null) } - } - - @Test - fun `getNotes with different page ID`() = runTest { - coEvery { redwoodApi.getNotes(any(), any(), any()) } returns testNotes - - repository.getNotes(courseId = 5L, pageId = 200L) - - coVerify { redwoodApi.getNotes(any(), null, null) } - } - - @Test - fun `getNotes returns empty list`() = runTest { - coEvery { redwoodApi.getNotes(any(), any(), any()) } returns QueryNotesQuery.Notes( - pageInfo = QueryNotesQuery.PageInfo( - hasNextPage = false, - hasPreviousPage = false, - startCursor = null, - endCursor = null - ), - edges = emptyList() - ) - - val result = repository.getNotes(courseId = 1L, pageId = 100L) - - assertEquals(0, result.size) - } - - @Test - fun `authenticateUrl always uses forceNetwork`() = runTest { - val session = AuthenticatedSession(sessionUrl = "https://authenticated.url") - coEvery { oAuthInterface.getAuthenticatedSession(any(), any(), any()) } returns DataResult.Success(session) - - repository.authenticateUrl("https://example.com") - - coVerify { oAuthInterface.getAuthenticatedSession(any(), match { it.isForceReadFromNetwork }, any()) } - } -} diff --git a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt index 2c697376d7..db52cb8d0a 100644 --- a/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt +++ b/libs/horizon/src/test/java/com/instructure/horizon/features/moduleitemsequence/content/page/PageDetailsViewModelTest.kt @@ -17,17 +17,17 @@ package com.instructure.horizon.features.moduleitemsequence.content.page import androidx.lifecycle.SavedStateHandle -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition -import com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteObjectType +import com.instructure.canvasapi2.apis.OAuthAPI +import com.instructure.canvasapi2.managers.graphql.horizon.redwood.RedwoodApiManager +import com.instructure.canvasapi2.models.AuthenticatedSession import com.instructure.canvasapi2.models.Page +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.horizon.domain.usecase.GetPageDetailsUseCase import com.instructure.horizon.features.moduleitemsequence.ModuleItemContent import com.instructure.horizon.features.notebook.addedit.add.AddNoteRepository -import com.instructure.horizon.features.notebook.common.model.Note -import com.instructure.horizon.features.notebook.common.model.NotebookType import com.instructure.pandautils.utils.Const import com.instructure.pandautils.utils.HtmlContentFormatter +import com.instructure.redwood.QueryNotesQuery import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -51,8 +51,10 @@ import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class PageDetailsViewModelTest { - private val repository: PageDetailsRepository = mockk(relaxed = true) + private val getPageDetailsUseCase: GetPageDetailsUseCase = mockk(relaxed = true) private val htmlContentFormatter: HtmlContentFormatter = mockk(relaxed = true) + private val oAuthApi: OAuthAPI.OAuthInterface = mockk(relaxed = true) + private val redwoodApi: RedwoodApiManager = mockk(relaxed = true) private val addNoteRepository: AddNoteRepository = mockk(relaxed = true) private val savedStateHandle: SavedStateHandle = mockk(relaxed = true) private val testDispatcher = UnconfinedTestDispatcher() @@ -66,34 +68,27 @@ class PageDetailsViewModelTest { body = "

Test content

" ) - private val testNotes = listOf( - Note( - id = "1", - objectId = "1", - objectType = NoteObjectType.PAGE, - userText = "comment 1", - highlightedText = NoteHighlightedData( - selectedText = "highlighted text 1", - range = NoteHighlightedDataRange(1, 5, "start", "end"), - textPosition = NoteHighlightedDataTextPosition(1, 5) - ), - type = NotebookType.Important, - updatedAt = Date(), - courseId = 1, + private fun makeNoteNode(id: String, userText: String) = QueryNotesQuery.Node( + id = id, + userText = userText, + createdAt = Date(), + updatedAt = Date(), + rootAccountUuid = "", + userId = "1", + courseId = courseId.toString(), + objectId = testPage.id.toString(), + objectType = "Page", + reaction = listOf("Important"), + highlightData = "" + ) + + private val testNotesResponse = QueryNotesQuery.Notes( + edges = listOf( + QueryNotesQuery.Edge(cursor = "cursor1", node = makeNoteNode("1", "comment 1")), + QueryNotesQuery.Edge(cursor = "cursor2", node = makeNoteNode("2", "comment 2")), ), - Note( - id = "2", - objectId = "1", - objectType = NoteObjectType.PAGE, - userText = "comment 2", - highlightedText = NoteHighlightedData( - selectedText = "highlighted text 2", - range = NoteHighlightedDataRange(10, 15, "start", "end"), - textPosition = NoteHighlightedDataTextPosition(10, 15) - ), - type = NotebookType.Confusing, - updatedAt = Date(), - courseId = 1, + pageInfo = QueryNotesQuery.PageInfo( + hasNextPage = false, hasPreviousPage = false, endCursor = null, startCursor = null ) ) @@ -102,9 +97,10 @@ class PageDetailsViewModelTest { Dispatchers.setMain(testDispatcher) every { savedStateHandle.get(Const.COURSE_ID) } returns courseId every { savedStateHandle.get(ModuleItemContent.Page.PAGE_URL) } returns pageUrl - coEvery { repository.getPageDetails(any(), any()) } returns testPage - coEvery { repository.getNotes(any(), any()) } returns testNotes - coEvery { repository.authenticateUrl(any()) } returns "https://authenticated.url" + coEvery { getPageDetailsUseCase(any()) } returns testPage + coEvery { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } returns testNotesResponse + coEvery { oAuthApi.getAuthenticatedSession(any(), any(), any()) } returns + DataResult.Success(AuthenticatedSession(sessionUrl = "https://authenticated.url")) coEvery { htmlContentFormatter.formatHtmlWithIframes(any(), any()) } answers { firstArg() } coEvery { addNoteRepository.addNote(any(), any(), any(), any(), any(), any()) } returns Unit } @@ -123,7 +119,7 @@ class PageDetailsViewModelTest { assertEquals("

Test content

", viewModel.uiState.value.pageHtmlContent) assertEquals(100L, viewModel.uiState.value.pageId) assertEquals(pageUrl, viewModel.uiState.value.pageUrl) - coVerify { repository.getPageDetails(courseId, pageUrl) } + coVerify { getPageDetailsUseCase(GetPageDetailsUseCase.Params(courseId, pageUrl)) } } @Test @@ -142,12 +138,12 @@ class PageDetailsViewModelTest { assertEquals(2, viewModel.uiState.value.notes.size) assertEquals("comment 1", viewModel.uiState.value.notes.first().userText) - coVerify { repository.getNotes(courseId, 100L) } + coVerify { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } } @Test fun `Test notes loading failure does not fail page load`() = runTest { - coEvery { repository.getNotes(any(), any()) } throws Exception("Notes error") + coEvery { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } throws Exception("Notes error") val viewModel = getViewModel() @@ -163,12 +159,12 @@ class PageDetailsViewModelTest { viewModel.uiState.value.ltiButtonPressed?.invoke("https://lti.url") assertEquals("https://authenticated.url", viewModel.uiState.value.urlToOpen) - coVerify { repository.authenticateUrl("https://lti.url") } + coVerify { oAuthApi.getAuthenticatedSession("https://lti.url", any(), any()) } } @Test fun `Test LTI authentication failure returns original URL`() = runTest { - coEvery { repository.authenticateUrl(any()) } throws Exception("Auth error") + coEvery { oAuthApi.getAuthenticatedSession(any(), any(), any()) } throws Exception("Auth error") val viewModel = getViewModel() @@ -190,10 +186,10 @@ class PageDetailsViewModelTest { @Test fun `Test add note creates note and refreshes`() = runTest { val viewModel = getViewModel() - val highlightedData = NoteHighlightedData( + val highlightedData = com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedData( selectedText = "highlighted text", - range = NoteHighlightedDataRange(1, 5, "start", "end"), - textPosition = NoteHighlightedDataTextPosition(1, 5) + range = com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataRange(1, 5, "start", "end"), + textPosition = com.instructure.canvasapi2.managers.graphql.horizon.redwood.NoteHighlightedDataTextPosition(1, 5) ) viewModel.uiState.value.addNote(highlightedData, "Important") @@ -204,16 +200,25 @@ class PageDetailsViewModelTest { objectType = "Page", highlightedData = highlightedData, userComment = "", - type = NotebookType.Important + type = com.instructure.horizon.features.notebook.common.model.NotebookType.Important ) } - coVerify(atLeast = 2) { repository.getNotes(courseId, 100L) } + coVerify(atLeast = 2) { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } } @Test fun `Test refresh notes updates state`() = runTest { - val updatedNotes = testNotes + testNotes.last().copy(userText = "New note") - coEvery { repository.getNotes(any(), any()) } returns testNotes andThen updatedNotes + val updatedNotesResponse = QueryNotesQuery.Notes( + edges = testNotesResponse.edges.orEmpty() + QueryNotesQuery.Edge( + cursor = "cursor3", + node = makeNoteNode("3", "New note") + ), + pageInfo = QueryNotesQuery.PageInfo( + hasNextPage = false, hasPreviousPage = false, endCursor = null, startCursor = null + ) + ) + coEvery { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } returnsMany + listOf(testNotesResponse, updatedNotesResponse) val viewModel = getViewModel() assertEquals(2, viewModel.uiState.value.notes.size) @@ -227,15 +232,14 @@ class PageDetailsViewModelTest { @Test fun `Test refresh notes handles error`() = runTest { val viewModel = getViewModel() - coEvery { repository.getNotes(any(), any()) } throws Exception("Error") + coEvery { redwoodApi.getNotes(any(), any(), any(), any(), any(), any(), any()) } throws Exception("Error") - // Should not crash viewModel.refreshNotes() } @Test fun `Test page load error sets error state`() = runTest { - coEvery { repository.getPageDetails(any(), any()) } throws Exception("Error") + coEvery { getPageDetailsUseCase(any()) } throws Exception("Error") val viewModel = getViewModel() @@ -252,8 +256,10 @@ class PageDetailsViewModelTest { private fun getViewModel(): PageDetailsViewModel { return PageDetailsViewModel( - repository, + getPageDetailsUseCase, htmlContentFormatter, + oAuthApi, + redwoodApi, addNoteRepository, savedStateHandle )