Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager {
courseName = courses[0].name,
courseImageUrl = null,
courseSyllabus = "Syllabus for Course 1",
progress = 0.25
progress = 25.0
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The progress values changed from fractions (0.25, 1.0) to percentages (25.0, 100.0). If the real API contract uses a 0–1 scale this mismatch will cause test assertions to diverge from production behaviour (and vice-versa). Please confirm the expected unit and ensure both the mock data and the display logic use the same scale consistently.

)
} else { null }
val completedCourse = if (courses.size > 1) {
Expand All @@ -153,7 +153,7 @@ class FakeGetHorizonCourseManager(): HorizonGetCoursesManager {
courseName = courses[1].name,
courseImageUrl = null,
courseSyllabus = "Syllabus for Course 2",
progress = 1.0
progress = 100.0
)
} else { null }
val invitedCourse = if (courses.size > 2) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ enum class CollectionItemType {
EXTERNAL_URL,
EXTERNAL_TOOL,
FILE,
PROGRAM
PROGRAM;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

safeValueOf returning null for unknown types is good defensive practice, but callers that use mapNotNull { it.toModel() } will silently drop collection items the server sends with a type the client doesn't recognise yet. This can make future API additions invisible to users without any indication something was skipped.

Consider adding a Crashlytics log (non-fatal) or an UNKNOWN fallback entry so unknown items surface during QA rather than disappearing silently in production.


companion object {
fun safeValueOf(name: String): CollectionItemType? = entries.find { it.name == name }
}
}

fun ApolloCollectionItemType.toModel(): CollectionItemType {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.espresso

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.HorizonCourseDao
import com.instructure.horizon.database.dao.HorizonCourseModuleDao
import com.instructure.horizon.database.dao.HorizonCourseScoreDao
import com.instructure.horizon.database.dao.HorizonDashboardEnrollmentDao
import com.instructure.horizon.database.dao.HorizonDashboardModuleItemDao
import com.instructure.horizon.database.dao.HorizonFileFolderDao
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.HorizonProgramDao
import com.instructure.horizon.database.dao.HorizonSyncMetadataDao
import com.instructure.horizon.di.HorizonHtmlParserQualifier
import com.instructure.horizon.di.HorizonOfflineModule
import com.instructure.horizon.offline.HorizonHtmlParserFileSource
import com.instructure.pandautils.features.offline.sync.HtmlParser
import dagger.Module
import dagger.Provides
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton

@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [HorizonOfflineModule::class])
object HorizonOfflineTestModule {

@Provides
@Singleton
fun provideHorizonDatabase(@ApplicationContext context: Context): HorizonDatabase {
return Room.inMemoryDatabaseBuilder(context, HorizonDatabase::class.java)
.allowMainThreadQueries()
.build()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.allowMainThreadQueries() disables Room's main-thread safety guard. This is fine for keeping tests simple, but it means any accidental main-thread DB access in production code won't be caught here. If threading correctness is important for offline sync, consider running at least a subset of the new local datasource tests without this flag so regressions are caught earlier.

}

@Provides
fun provideHorizonDashboardEnrollmentDao(db: HorizonDatabase): HorizonDashboardEnrollmentDao = db.dashboardEnrollmentDao()

@Provides
fun provideHorizonProgramDao(db: HorizonDatabase): HorizonProgramDao = db.programDao()

@Provides
fun provideHorizonDashboardModuleItemDao(db: HorizonDatabase): HorizonDashboardModuleItemDao = db.dashboardModuleItemDao()

@Provides
fun provideHorizonSyncMetadataDao(db: HorizonDatabase): HorizonSyncMetadataDao = db.syncMetadataDao()

@Provides
fun provideHorizonLearnItemDao(db: HorizonDatabase): HorizonLearnItemDao = db.learnItemDao()

@Provides
fun provideHorizonLearnCollectionDao(db: HorizonDatabase): HorizonLearnCollectionDao = db.learnCollectionDao()

@Provides
fun provideHorizonLearnSavedItemDao(db: HorizonDatabase): HorizonLearnSavedItemDao = db.learnSavedItemDao()

@Provides
fun provideHorizonCourseDao(db: HorizonDatabase): HorizonCourseDao = db.courseDao()

@Provides
fun provideHorizonCourseModuleDao(db: HorizonDatabase): HorizonCourseModuleDao = db.courseModuleDao()

@Provides
fun provideHorizonCourseScoreDao(db: HorizonDatabase): HorizonCourseScoreDao = db.courseScoreDao()

@Provides
fun provideHorizonLocalFileDao(db: HorizonDatabase): HorizonLocalFileDao = db.localFileDao()

@Provides
fun provideHorizonFileFolderDao(db: HorizonDatabase): HorizonFileFolderDao = db.fileFolderDao()

@Provides
@HorizonHtmlParserQualifier
fun provideHorizonHtmlParser(
fileSource: HorizonHtmlParserFileSource,
apiPrefs: ApiPrefs,
@ApplicationContext context: Context,
): HtmlParser = HtmlParser(fileSource, apiPrefs, context)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.Intent
import androidx.room.Room
import com.instructure.canvasapi2.LoginRouter
import com.instructure.canvasapi2.utils.pageview.PandataInfo
import dagger.hilt.android.qualifiers.ApplicationContext
import com.instructure.pandautils.features.about.AboutRepository
import com.instructure.pandautils.features.assignments.details.AssignmentDetailsBehaviour
import com.instructure.pandautils.features.assignments.details.AssignmentDetailsColorProvider
Expand Down Expand Up @@ -70,8 +71,8 @@ import com.instructure.pandautils.utils.LogoutHelper
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
Expand Down Expand Up @@ -101,6 +102,7 @@ object HorizonTestModule {
}

@Provides
@Singleton
fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
.allowMainThreadQueries()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*
* 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.CourseWithProgress
import com.instructure.canvasapi2.managers.graphql.horizon.journey.Program
import com.instructure.canvasapi2.managers.graphql.horizon.journey.ProgramRequirement
import com.instructure.horizon.database.dao.HorizonCourseDao
import com.instructure.horizon.database.dao.HorizonProgramDao
import com.instructure.horizon.database.dao.HorizonSyncMetadataDao
import com.instructure.horizon.database.entity.HorizonCourseEntity
import com.instructure.horizon.database.entity.HorizonProgramCourseRef
import com.instructure.horizon.database.entity.HorizonProgramEntity
import com.instructure.horizon.database.entity.HorizonSyncMetadataEntity
import com.instructure.horizon.database.entity.SyncDataType
import com.instructure.journey.type.ProgramProgressCourseEnrollmentStatus
import com.instructure.journey.type.ProgramVariantType
import java.util.Date
import javax.inject.Inject

class CourseDetailsLocalDataSource @Inject constructor(
private val courseDao: HorizonCourseDao,
private val programDao: HorizonProgramDao,
private val syncMetadataDao: HorizonSyncMetadataDao,
) {

suspend fun getCourse(courseId: Long): CourseWithProgress {
val entity = courseDao.getByCourseId(courseId)
?: throw IllegalStateException("Course $courseId not found in cache")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Throwing IllegalStateException on a cache miss will crash the app for first-time users or after a fresh install where the local DB is empty. Consider returning null (or a Result) and letting the caller decide how to handle the missing data — e.g. by triggering a network fetch rather than crashing:

suspend fun getCourse(courseId: Long): CourseWithProgress? {
    return courseDao.getByCourseId(courseId)?.toModel()
}

return entity.toCourseWithProgress()
}

suspend fun getProgramsForCourse(courseId: Long): List<Program> {
val allPrograms = programDao.getAll()
return allPrograms.mapNotNull { programEntity ->
val refs = programDao.getRefsForProgram(programEntity.programId)
val program = programEntity.toProgram(refs)
if (program.sortedRequirements.any { it.courseId == courseId }) program else null
}
}

suspend fun saveCourseDetails(course: CourseWithProgress, programs: List<Program>) {
courseDao.insertIfAbsent(listOf(course.toDefaultEntity()))
courseDao.updateCourseDetailsFields(
courseId = course.courseId,
name = course.courseName,
progress = course.progress,
imageUrl = course.courseImageUrl,
courseSyllabus = course.courseSyllabus,
)
val programEntities = programs.map { it.toEntity() }
val refs = programs.flatMap { program ->
program.sortedRequirements.mapIndexed { index, req ->
HorizonProgramCourseRef(
programId = program.id,
courseId = req.courseId,
requirementId = req.id,
progressId = req.progressId,
required = req.required,
progress = req.progress,
enrollmentStatus = req.enrollmentStatus?.rawValue,
sortOrder = index,
)
}
}
programDao.insertAll(programEntities)
programDao.insertAllRefs(refs)
syncMetadataDao.upsert(
HorizonSyncMetadataEntity(
dataType = SyncDataType.COURSE_DETAILS,
lastSyncedAtMs = System.currentTimeMillis(),
)
)
}

private fun HorizonCourseEntity.toCourseWithProgress(): CourseWithProgress {
return CourseWithProgress(
courseId = courseId,
courseName = name,
courseImageUrl = imageUrl,
courseSyllabus = courseSyllabus,
progress = progress,
)
}

private fun CourseWithProgress.toDefaultEntity(): HorizonCourseEntity {
return HorizonCourseEntity(
courseId = courseId,
name = courseName,
progress = progress,
imageUrl = courseImageUrl,
startAtMs = null,
endAtMs = null,
requirementCount = null,
requirementCompletedCount = null,
completedAtMs = null,
grade = null,
workflowState = null,
lastActivityAtMs = null,
enrolledAtMs = null,
courseSyllabus = courseSyllabus,
moduleItemsDurations = "",
)
}

private fun Program.toEntity(): HorizonProgramEntity {
return HorizonProgramEntity(
programId = id,
name = name,
description = description,
startDateMs = startDate?.time,
endDateMs = endDate?.time,
variant = variant.rawValue,
estimatedDurationMinutes = null,
courseCount = sortedRequirements.size,
courseCompletionCount = courseCompletionCount,
enrolledAtMs = null,
completionPercentage = null,
enrollmentStatus = null,
)
}

private fun HorizonProgramEntity.toProgram(refs: List<HorizonProgramCourseRef>): Program {
val requirements = refs.sortedBy { it.sortOrder }.map { ref ->
ProgramRequirement(
id = ref.requirementId,
progressId = ref.progressId,
courseId = ref.courseId,
required = ref.required,
progress = ref.progress,
enrollmentStatus = ref.enrollmentStatus?.let {
runCatching { ProgramProgressCourseEnrollmentStatus.valueOf(it) }.getOrNull()
},
)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling here is asymmetric: an unknown variant silently defaults to LINEAR, while an unknown enrollmentStatus silently becomes null. Defaulting to LINEAR is a potentially wrong assumption — if the server adds a new variant type, all affected courses will appear as linear.

Consider logging the unrecognised value (or recording it to Crashlytics) so it surfaces during development rather than silently corrupting displayed data.

return Program(
id = programId,
name = name,
description = description,
startDate = startDateMs?.let { Date(it) },
endDate = endDateMs?.let { Date(it) },
variant = runCatching { ProgramVariantType.valueOf(variant) }.getOrDefault(ProgramVariantType.LINEAR),
courseCompletionCount = courseCompletionCount,
sortedRequirements = requirements,
)
}
}
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.instructure.horizon.features.learn.course.details
package com.instructure.horizon.data.datasource

import com.instructure.canvasapi2.apis.ExternalToolAPI
import com.instructure.canvasapi2.builders.RestParams
Expand All @@ -25,24 +25,27 @@ import com.instructure.canvasapi2.models.CanvasContext
import com.instructure.canvasapi2.utils.ApiPrefs
import javax.inject.Inject

class CourseDetailsRepository @Inject constructor(
class CourseDetailsNetworkDataSource @Inject constructor(
private val getCoursesManager: HorizonGetCoursesManager,
private val getProgramsManager: GetProgramsManager,
private val externalToolApi: ExternalToolAPI.ExternalToolInterface,
private val apiPrefs: ApiPrefs
private val apiPrefs: ApiPrefs,
) {
suspend fun getCourse(courseId: Long, forceNetwork: Boolean): CourseWithProgress {
return getCoursesManager.getCourseWithProgressById(courseId, apiPrefs.user?.id ?: -1L, forceNetwork).dataOrThrow

suspend fun getCourse(courseId: Long, forceRefresh: Boolean): CourseWithProgress {
return getCoursesManager.getCourseWithProgressById(courseId, apiPrefs.user?.id ?: -1L, forceRefresh).dataOrThrow
}

suspend fun getProgramsForCourse(courseId: Long, forceNetwork: Boolean): List<Program> {
return getProgramsManager.getPrograms(forceNetwork).filter { it.sortedRequirements.firstOrNull()?.courseId == courseId }
suspend fun getProgramsForCourse(courseId: Long, forceRefresh: Boolean): List<Program> {
return getProgramsManager.getPrograms(forceRefresh).filter {
it.sortedRequirements.firstOrNull()?.courseId == courseId
}
}

suspend fun hasExternalTools(courseId: Long, forceNetwork: Boolean): Boolean {
suspend fun hasExternalTools(courseId: Long, forceRefresh: Boolean): Boolean {
return externalToolApi.getExternalToolsForCourses(
listOf(CanvasContext.emptyCourseContext(courseId).contextId),
RestParams(isForceReadFromNetwork = forceNetwork)
RestParams(isForceReadFromNetwork = forceRefresh)
).dataOrNull.orEmpty().isNotEmpty()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@ class CourseEnrollmentNetworkDataSource @Inject constructor(
private val enrollmentApi: EnrollmentAPI.EnrollmentInterface,
) {

suspend fun getEnrollments(): List<DashboardEnrollment> {
suspend fun getEnrollments(
forceRefresh: Boolean,
): List<DashboardEnrollment> {
return horizonGetCoursesManager.getDashboardEnrollments(
userId = apiPrefs.user?.id ?: -1,
forceNetwork = true,
forceNetwork = forceRefresh,
).dataOrThrow
}

Expand Down
Loading
Loading