Skip to content

[CLXR-476][Horizon] Offline sync#3674

Open
domonkosadam wants to merge 16 commits intofeature/horizon-offlinefrom
CLXR-476-Offline-sync
Open

[CLXR-476][Horizon] Offline sync#3674
domonkosadam wants to merge 16 commits intofeature/horizon-offlinefrom
CLXR-476-Offline-sync

Conversation

@domonkosadam
Copy link
Copy Markdown
Contributor

refs: CLXR-476
affects: Student
release note: none

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

🧪 Unit Test Results

✅ 📱 Student App

  • Tests: 1210 total, 0 failed, 0 skipped
  • Duration: 0.000s
  • Success Rate: 100%

✅ 🌅 Horizon

  • Tests: 753 total, 0 failed, 0 skipped
  • Duration: 32.926s
  • Success Rate: 100%

❌ 📦 Submodules

  • Tests: 3381 total, 2 failed, 0 skipped
  • Duration: 63.364s
  • Success Rate: 100%
❌ Failed Tests (2)
  • com.instructure.pandautils.utils.FeatureFlagProviderTest.Offline is disabled when feature flag is disabled
  • com.instructure.pandautils.utils.FeatureFlagProviderTest.Offline is disabled when feature flag is enabled and user is an elementary user

📊 Summary

  • Total Tests: 5344
  • Failed: 2
  • Skipped: 0
  • Status: ❌ 2 test(s) failed

Last updated: Mon, 04 May 2026 11:58:37 GMT

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Review Summary

This PR delivers the offline sync infrastructure for the Horizon app — a substantial and well-structured addition covering WorkManager-based sync, per-course sync plans, image caching, and offline-aware UI across multiple screens. The overall design is solid. A few issues worth addressing before merge:

Issues Found

  • Debug feature flag override (FeatureFlagProvider.kt) — offlineEnabled() is hardcoded to true, bypassing the environment flag check entirely. Needs to be reverted before merging to avoid enabling offline for all users/environments.
  • Missing Room migration scripts (HorizonDatabase.kt) — Version jumps from 15 → 18 with 5 new tables but no migration objects visible. Existing installs will crash without explicit migrations or a declared fallbackToDestructiveMigration.
  • clearAllTables() clears user settings (DeleteSyncedContentUseCase.kt) — Deleting synced content also wipes horizon_sync_settings, resetting the user's sync frequency and wifi-only preference to defaults.
  • Unstructured coroutine scope in Worker (HorizonOfflineSyncWorker.kt) — CoroutineScope(Dispatchers.Default) in observeProgress() is not bound to the worker's lifecycle and won't be cancelled by WorkManager cancellation. Use coroutineContext from the CoroutineWorker.
  • Navigation in composable body (DeleteContentScreen.kt) — navController.popBackStack/navigate calls are inside the composition directly (not in a LaunchedEffect), which will re-trigger on every recomposition while isComplete = true.
  • Hash collision risk in image cache filenames (ImageSyncer.kt) — url.hashCode() is 32-bit; two different URLs can produce the same filename, silently serving the wrong cached image.
  • Direct DAO access in ViewModel (ManageOfflineContentViewModel.kt) — HorizonCourseSyncPlanDao and HorizonFileSyncPlanDao are injected directly; consider wrapping in a repository/use case for consistency and testability.
  • Null user ID path in file storage (HorizonFileSyncRepository.downloadFileByUrl) — apiPrefs.user?.id.toString() evaluates to "null" when user is absent, writing files to an untracked directory that cleanup helpers won't find.

Positive Highlights

  • NET_CAPABILITY_VALIDATED + onCapabilitiesChanged in NetworkStateProvider is a meaningful accuracy improvement over NET_CAPABILITY_INTERNET + onAvailable — correctly handles captive portals.
  • Mutex-guarded file ID accumulation in HorizonCourseSync.syncCourse is the right approach for concurrent coroutine launches modifying shared sets.
  • @Volatile var isStopped on HorizonCourseSync is correct for safe cross-thread visibility.
  • OfflineCardStateHelper is a clean abstraction that centralises the "am I offline + is this course synced + resolve image URL" logic rather than duplicating it across three ViewModels.
  • ImageSyncer chunking (6 concurrent downloads) is a sensible rate-limit.
  • Test coverage is properly updated for all changed ViewModels and repositories.


suspend fun offlineEnabled(): Boolean {
return checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary
return true//checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Debug override left in production code. The real feature flag check is commented out — offlineEnabled() now unconditionally returns true for all users and environments. This should be reverted before merging:

return checkEnvironmentFeatureFlag(FEATURE_FLAG_OFFLINE) && !apiPrefs.canvasForElementary

HorizonAssignmentCommentAttachmentEntity::class,
HorizonSubmissionEntity::class,
HorizonSubmissionAttachmentEntity::class,
HorizonSyncSettingsEntity::class,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing Room migration scripts. The version jumps from 15 → 18 (adding 5 new tables), but no migration objects are visible in this diff. Without addMigrations(...) or fallbackToDestructiveMigration() declared in the dagger/Room builder, existing installs will crash with IllegalStateException: A migration from 15 to 18 was required but not found.

If destructive migration is acceptable here (e.g. this is still pre-release), please make it explicit. If users need their data preserved, the migration scripts need to be added.

syncHelper.cancelPeriodicSync()

withContext(Dispatchers.IO) {
database.clearAllTables()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

clearAllTables() wipes every table in the database, including horizon_sync_settings and horizon_user. After the user deletes their synced content, they will lose their configured sync frequency and wifi-only preference — those will reset to defaults on next open.

Consider only clearing the tables that hold synced content (course data, files, images, module items, sync plans, sync metadata) while preserving horizon_sync_settings.


private fun observeProgress() {
observerJob = CoroutineScope(Dispatchers.Default).launch {
aggregateProgressObserver.progressData.collect { data ->
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Creating a new CoroutineScope(Dispatchers.Default) here is unstructured concurrency — this scope is not tied to the worker's lifecycle and will not be cancelled if the worker is cancelled via the normal WorkManager path.

Use the worker's own coroutine context instead:

observerJob = CoroutineScope(coroutineContext + Dispatchers.Default).launch {

Or rely on the CoroutineWorker's built-in structured coroutine scope by keeping the observer as part of the doWork suspending call rather than a side-launched job.

Comment on lines +48 to +51
fun DeleteContentScreen(
navController: NavHostController,
viewModel: DeleteContentViewModel = hiltViewModel(),
) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Navigation calls in the composable body (outside a LaunchedEffect) will execute on every recomposition while isComplete is true, which can trigger multiple redundant popBackStack / navigate calls and cause a crash or an unexpected back-stack state.

Wrap this in a LaunchedEffect:

LaunchedEffect(isComplete) {
    if (isComplete) {
        navController.popBackStack(AccountRoute.ManageOfflineContent.route, inclusive = true)
        navController.navigate(AccountRoute.ManageOfflineContent.route)
    }
}

.take(4)
.ifEmpty { "jpg" }
val destFile = File(dir, "${url.hashCode()}.$ext")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

url.hashCode() is a 32-bit signed integer, so different URLs can produce the same hash code. When two URLs collide, the second image will silently re-use the first image's cached file. Consider using a more collision-resistant key, e.g. a URL-safe Base64 of the URL's SHA-256, or simply url.replace(Regex("[^a-zA-Z0-9]"), "_").takeLast(100).

@ApplicationContext private val context: Context,
private val getCoursesWithFilesUseCase: GetCoursesWithFilesUseCase,
private val getDeviceStorageUseCase: GetDeviceStorageUseCase,
private val courseSyncPlanDao: HorizonCourseSyncPlanDao,
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 ViewModel is injecting HorizonCourseSyncPlanDao and HorizonFileSyncPlanDao directly. This bypasses the repository layer that the rest of the codebase uses for data access. Consider wrapping these DAO operations in a repository or use case so the ViewModel isn't coupled directly to the database layer — and to make this unit-testable without Room artifacts.

Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CourseSyncState.SYNCING and CourseSyncState.PENDING render identically (both show a Spinner). If this is intentional it's fine, but consider whether a distinct visual indicator for "queued/waiting" vs "actively downloading" would help the user understand the overall progress.

suspend fun downloadFileByUrl(fileId: Long, courseId: Long, url: String, displayName: String) {
if (localFileDao.findById(fileId) != null) return
val dir = File(context.filesDir, apiPrefs.user?.id.toString()).also { it.mkdirs() }
val destFile = File(dir, "${fileId}_$displayName")
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If apiPrefs.user is null, apiPrefs.user?.id.toString() evaluates to the string "null", and files will be placed in <filesDir>/null/. This directory won't be cleaned up correctly by CourseCleanupHelper or DeleteSyncedContentUseCase (which looks for <filesDir>/<userId>). Guard against this:

val userId = apiPrefs.user?.id ?: return
val dir = File(context.filesDir, userId.toString()).also { it.mkdirs() }

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

Student Install Page

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant