[CLXR-455][Horizon] Implement offline POC#3620
[CLXR-455][Horizon] Implement offline POC#3620domonkosadam wants to merge 10 commits intofeature/horizon-offlinefrom
Conversation
🧪 Unit Test Results📊 Summary
Last updated: Thu, 02 Apr 2026 11:57:12 GMT |
There was a problem hiding this comment.
Review: Offline-First Dashboard Architecture
This PR introduces a well-structured offline-first layer for the Horizon dashboard — Room database persistence, repository/datasource pattern, use cases, and an offline banner UI. The separation of concerns is clear and the MVVM pattern is consistently followed. A few things worth addressing before this ships:
Issues
-
sync()not implemented in all three repositories (CourseEnrollmentRepository,ModuleItemRepository,ProgramRepository) —TODO("Not yet implemented")throwsNotImplementedErrorat runtime if called. See inline comments. -
Silent
-1user ID fallback inCourseEnrollmentNetworkDataSource(and duplicated inHorizonOfflineModule) — whenapiPrefs.useris null the GraphQL query receives-1, which will produce a silent bad request rather than a clear error. See inline comment. -
LearningObjectType.valueOf()can crash inModuleItemLocalDataSourceif the database contains a string that doesn't match an enum constant.ProgramLocalDataSourceusessafeValueOffor the same pattern — this should match. See inline comment. -
fallbackToDestructiveMigration()inHorizonDatabaseProvider— this silently deletes all cached data on any schema version bump. There is already a version 2 defined inHorizonDatabase. For an offline-first feature this is high-impact data loss. See inline comment. -
Typo in
OfflineBanner.kt— preview function namedOfflineBannerNoDDatePreviewshould beOfflineBannerNoDatePreview. See inline comment. -
SyncPolicyenum (SyncPolicy.kt) is defined but never referenced anywhere in this PR. If it's placeholder for future work, a tracking reference would help; otherwise it's dead code. -
Missing unit tests for the new offline logic:
OfflineSyncRepository.shouldFetchFromNetwork()decision branches,CourseEnrollmentRepositoryreturning cached data when offline,GetDashboardCoursesUseCaseorchestration (especially the invitation acceptance flow), andDashboardMapperchanges forDashboardEnrollment.
Minor
- The auto-invite-acceptance behaviour is carried over from the old
DashboardCourseViewModel— noted in an inline comment purely for documentary purposes, no change required. HorizonOfflineModulealso usesapiPrefs.user?.id ?: -1Lwhen providing theHorizonDatabaseinstance — same concern as the datasource fallback.- No newline at end of file in
HorizonTypeConverters.ktandHorizonSyncMetadataEntity.kt.
Overall the architecture is solid and the refactor from ViewModel-level data fetching to proper use cases is a good step forward. The main blockers are the unimplemented sync() methods and the enum crash risk in ModuleItemLocalDataSource.
| } | ||
|
|
||
| override suspend fun sync() { | ||
| TODO("Not yet implemented") |
There was a problem hiding this comment.
This TODO will throw a NotImplementedError at runtime if sync() is ever called. The base class OfflineSyncRepository defines this as an abstract method that is part of the offline sync contract. Either implement it or, if intentionally deferred, throw a more informative exception or leave a tracked issue reference here so it doesn't silently ship as dead code.
override suspend fun sync() {
// TODO: Implement full enrollment sync - tracked in ISSUE-XXXX
throw UnsupportedOperationException("Enrollment sync not yet implemented")
}| } | ||
|
|
||
| override suspend fun sync() { | ||
| TODO("Not yet implemented") |
There was a problem hiding this comment.
Same concern as in CourseEnrollmentRepository — TODO("Not yet implemented") will crash at runtime with a NotImplementedError if sync() is invoked. Please either implement or explicitly mark as deferred with a tracking reference.
| } | ||
|
|
||
| override suspend fun sync() { | ||
| TODO("Not yet implemented") |
There was a problem hiding this comment.
Same concern as other repositories — TODO("Not yet implemented") will crash at runtime. Please implement or add a tracked issue reference.
|
|
||
| suspend fun getEnrollments(): List<DashboardEnrollment> { | ||
| return horizonGetCoursesManager.getDashboardEnrollments( | ||
| userId = apiPrefs.user?.id ?: -1, |
There was a problem hiding this comment.
apiPrefs.user?.id ?: -1 will silently pass -1 as a user ID to the GraphQL query when the user is not logged in, likely producing an empty or error response that is hard to debug. Consider throwing instead so callers can handle the unauthenticated case explicitly:
userId = apiPrefs.user?.id ?: error("User must be logged in to fetch enrollments"),| moduleItemId = entity.moduleItemId, | ||
| courseId = entity.courseId, | ||
| title = entity.moduleItemTitle, | ||
| type = LearningObjectType.valueOf(entity.moduleItemType), |
There was a problem hiding this comment.
LearningObjectType.valueOf(entity.moduleItemType) will throw IllegalArgumentException if the stored string doesn't match an enum constant — for example after a future enum rename or if the database already contains a value from a schema that predates this one. ProgramLocalDataSource uses safeValueOf for the same pattern; please apply the same defensive approach here:
type = LearningObjectType.safeValueOf(entity.moduleItemType),(or filter out rows with unknown types before mapping them)
| fun getDatabase(userId: Long): HorizonDatabase { | ||
| return dbMap.getOrPut(userId) { | ||
| Room.databaseBuilder(context, HorizonDatabase::class.java, "horizon-db-$userId") | ||
| .fallbackToDestructiveMigration() |
There was a problem hiding this comment.
fallbackToDestructiveMigration() will silently wipe all cached offline data when the schema version is bumped (e.g. version 1 → 2 already exists in HorizonDatabase). For an offline-first feature this means users lose their cached content on every schema change without warning. If full migration scripts aren't ready yet, at a minimum document this decision and track the migration work. A lightweight migration that just drops and recreates tables can also be written to make the data-loss intent explicit rather than accidental.
|
|
||
| @Preview | ||
| @Composable | ||
| private fun OfflineBannerNoDDatePreview() { |
There was a problem hiding this comment.
Typo in preview function name: NoDDate should be NoDate.
private fun OfflineBannerNoDatePreview() {|
|
||
| val invitations = enrollments.filter { it.enrollmentState == DashboardEnrollment.STATE_INVITED } | ||
| if (invitations.isNotEmpty()) { | ||
| invitations.forEach { enrollment -> |
There was a problem hiding this comment.
Invitations are being accepted automatically without user confirmation. This was already the behaviour in DashboardCourseViewModel before this refactor, so the behaviour isn't changing — but it's worth flagging since this is now more prominently a domain-layer concern. If silent auto-acceptance is intentional per product spec, a brief comment here would help future readers understand why (e.g. "Canvas Career automatically accepts all pending invitations on dashboard load").
| ): ViewModel() { | ||
| private val getDashboardCoursesUseCase: GetDashboardCoursesUseCase, | ||
| private val dashboardEventHandler: DashboardEventHandler, | ||
| networkStateProvider: NetworkStateProvider, |
There was a problem hiding this comment.
You can use composition instead. Creating a class that encapsulates the behavior of the HorizonOfflineViewModel, in that case you won't need to pass all the parameters to the ViewModels instead just pass that new class.
| private val networkStateProvider: NetworkStateProvider, | ||
| private val featureFlagProvider: FeatureFlagProvider, | ||
| private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase, | ||
| ) : ViewModel() { |
There was a problem hiding this comment.
Shouldn't we use HorizonOfflineViewModel here as well?
| private val syncMetadataDao: HorizonSyncMetadataDao, | ||
| ) : BaseUseCase<SyncDataType, Long?>() { | ||
|
|
||
| override suspend fun execute(params: SyncDataType): Long? = syncMetadataDao.getLastSyncedAt(params) |
There was a problem hiding this comment.
If I remember correctly we agreed with Ákos to have the params as an inner class of the Use Case as a convention. I am not sure if this is applicable where we have a single parameter only, but I see some occurrences that suggest this, for example GetAuthenticatedSessionUseCase. What do you think @hermannakos ?
There was a problem hiding this comment.
Yeah, I would go with the inner class for consistency at least. We didn't follow this convention at first since we didn't have that, so there might be some where it is not followed
|
|
||
| class ProgramRepository @Inject constructor( | ||
| private val networkDataSource: ProgramNetworkDataSource, | ||
| private val localDataSource: ProgramLocalDataSource, |
There was a problem hiding this comment.
Why don't we have a data source interface like in the current offline implementation?
refs: CLXR-455
affects: Student
release note: none