diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseAnnouncementsUseCase.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseAnnouncementsUseCase.kt new file mode 100644 index 0000000000..d4bb5dc436 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseAnnouncementsUseCase.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.pandautils.domain.usecase.courses + +import com.instructure.canvasapi2.managers.graphql.DashboardCoursesManager +import com.instructure.canvasapi2.models.DiscussionTopicHeader +import com.instructure.pandautils.domain.usecase.BaseUseCase +import javax.inject.Inject + +class LoadCourseAnnouncementsUseCase @Inject constructor( + private val dashboardCoursesManager: DashboardCoursesManager +) : BaseUseCase>() { + + override suspend fun execute(params: Params): List { + val data = dashboardCoursesManager.getCourseAnnouncements(params.courseId, cursor = null, forceNetwork = params.forceNetwork) + val nodes = data.course?.onCourse?.announcements?.nodes ?: return emptyList() + return nodes.mapNotNull { node -> + node ?: return@mapNotNull null + val isUnread = node.participant?.read != true + val hasUnreadEntries = (node.entryCounts?.unreadCount ?: 0) > 0 + if (!isUnread && !hasUnreadEntries) return@mapNotNull null + DiscussionTopicHeader( + id = node._id.toLongOrNull() ?: return@mapNotNull null, + title = node.title, + message = node.message, + postedDate = node.postedAt, + unreadCount = node.entryCounts?.unreadCount ?: 0, + announcement = true + ) + } + } + + data class Params(val courseId: Long, val forceNetwork: Boolean = true) +} diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModel.kt index ea5cdb9947..5fcb1656b2 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModel.kt @@ -30,6 +30,7 @@ import com.instructure.canvasapi2.models.DashboardPositions import com.instructure.canvasapi2.models.DiscussionTopicHeader import com.instructure.canvasapi2.models.Group import com.instructure.pandautils.data.repository.user.UserRepository +import com.instructure.pandautils.domain.usecase.courses.LoadCourseAnnouncementsUseCase import com.instructure.pandautils.domain.usecase.courses.LoadDashboardCardsUseCase import com.instructure.pandautils.domain.usecase.courses.LoadGroupsParams import com.instructure.pandautils.domain.usecase.courses.LoadGroupsUseCase @@ -73,6 +74,7 @@ class CoursesWidgetViewModel @Inject constructor( private val loadGroupsUseCase: LoadGroupsUseCase, private val loadSingleCourseUseCase: LoadSingleCourseUseCase, private val loadDashboardCardsUseCase: LoadDashboardCardsUseCase, + private val loadCourseAnnouncementsUseCase: LoadCourseAnnouncementsUseCase, private val sectionExpandedStateDataStore: SectionExpandedStateDataStore, private val courseSyncSettingsDao: CourseSyncSettingsDao, private val courseDao: CourseDao, @@ -114,8 +116,11 @@ class CoursesWidgetViewModel @Inject constructor( override fun onReceive(context: Context, intent: Intent?) { val courseId = intent?.getLongExtra(Const.COURSE_ID, -1L) if (courseId != null && courseId != -1L) { - // Reload specific course - reloadCourse(courseId) + if (intent.extras?.getBoolean(Const.RELOAD_ANNOUNCEMENTS) == true) { + reloadCourseAnnouncements(courseId) + } else { + reloadCourse(courseId) + } } else if (intent?.extras?.getBoolean(Const.COURSE_FAVORITES) == true) { // Full refresh for favorites changes refresh() @@ -459,6 +464,22 @@ class CoursesWidgetViewModel @Inject constructor( } } + private fun reloadCourseAnnouncements(courseId: Long) { + viewModelScope.launch { + try { + val announcements = loadCourseAnnouncementsUseCase(LoadCourseAnnouncementsUseCase.Params(courseId)) + val updatedAnnouncementsMap = _uiState.value.courses + .associate { it.id to it.announcements } + .toMutableMap() + updatedAnnouncementsMap[courseId] = announcements + val courseCards = mapCoursesToCardItems(visibleCourses, updatedAnnouncementsMap) + _uiState.update { it.copy(courses = courseCards) } + } catch (e: Exception) { + crashlytics.recordException(e) + } + } + } + private fun observeConfig() { viewModelScope.launch { observeGlobalConfigUseCase(Unit) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt index 8556c6e01b..97bdd0f153 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt @@ -25,6 +25,7 @@ import android.webkit.WebView import android.widget.Toast import androidx.core.net.toUri import androidx.core.view.ViewCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope @@ -80,6 +81,9 @@ class DiscussionDetailsWebViewFragment : BaseCanvasFragment() { @Inject lateinit var discussionDetailsWebViewFragmentBehavior: DiscussionDetailsWebViewFragmentBehavior + @Inject + lateinit var localBroadcastManager: LocalBroadcastManager + @get:PageViewUrlParam("canvasContext") var canvasContext: CanvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) private var discussionTopicHeader: DiscussionTopicHeader? by NullableParcelableArg(key = DISCUSSION_TOPIC_HEADER) @@ -107,6 +111,13 @@ class DiscussionDetailsWebViewFragment : BaseCanvasFragment() { override fun onStop() { super.onStop() discussionSharedEvents.sendEvent(lifecycleScope, DiscussionSharedAction.RefreshListScreen) + if (discussionTopicHeader?.announcement == true) { + val intent = Intent(Const.COURSE_THING_CHANGED).apply { + putExtra(Const.COURSE_ID, canvasContext.id) + putExtra(Const.RELOAD_ANNOUNCEMENTS, true) + } + localBroadcastManager.sendBroadcast(intent) + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Const.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Const.kt index 51f4806e0d..65145f3de8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Const.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/Const.kt @@ -143,6 +143,7 @@ object Const { const val FILENAME = "fileName" const val COURSE_ID = "courseId" const val COURSE_THING_CHANGED = "courseTHINGChangedBroadcast" + const val RELOAD_ANNOUNCEMENTS = "reloadAnnouncements" const val BOOKMARK = "bookmark" const val ITEM = "item" const val OPEN_OUTSIDE = "isOpenOutside" diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseAnnouncementsUseCaseTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseAnnouncementsUseCaseTest.kt new file mode 100644 index 0000000000..67a96e6f20 --- /dev/null +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/domain/usecase/courses/LoadCourseAnnouncementsUseCaseTest.kt @@ -0,0 +1,152 @@ +/* + * 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.pandautils.domain.usecase.courses + +import com.instructure.canvasapi2.CourseAnnouncementsQuery +import com.instructure.canvasapi2.managers.graphql.DashboardCoursesManager +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.Date + +class LoadCourseAnnouncementsUseCaseTest { + + private val dashboardCoursesManager: DashboardCoursesManager = mockk() + + private lateinit var useCase: LoadCourseAnnouncementsUseCase + + @Before + fun setup() { + useCase = LoadCourseAnnouncementsUseCase(dashboardCoursesManager) + } + + @Test + fun `unread announcement is returned`() = runTest { + val node = node(id = "10", title = "Unread", read = false, unreadCount = 0) + coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(listOf(node)) + + val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L)) + + assertEquals(1, result.size) + assertEquals(10L, result[0].id) + assertEquals("Unread", result[0].title) + assertTrue(result[0].announcement) + } + + @Test + fun `read announcement with unread entries is returned`() = runTest { + val node = node(id = "20", title = "Has Replies", read = true, unreadCount = 3) + coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(listOf(node)) + + val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L)) + + assertEquals(1, result.size) + assertEquals(20L, result[0].id) + assertEquals(3, result[0].unreadCount) + } + + @Test + fun `fully read announcement with no unread entries is filtered out`() = runTest { + val node = node(id = "30", title = "Already Read", read = true, unreadCount = 0) + coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(listOf(node)) + + val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L)) + + assertTrue(result.isEmpty()) + } + + @Test + fun `null nodes returns empty list`() = runTest { + coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(null) + + val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L)) + + assertTrue(result.isEmpty()) + } + + @Test + fun `null course response returns empty list`() = runTest { + coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns CourseAnnouncementsQuery.Data(course = null) + + val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L)) + + assertTrue(result.isEmpty()) + } + + @Test + fun `multiple announcements are all mapped correctly`() = runTest { + val nodes = listOf( + node(id = "1", title = "First", read = false, unreadCount = 0), + node(id = "2", title = "Second", read = true, unreadCount = 1), + node(id = "3", title = "Third", read = true, unreadCount = 0) + ) + coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(nodes) + + val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L)) + + assertEquals(2, result.size) + assertEquals(1L, result[0].id) + assertEquals(2L, result[1].id) + } + + @Test + fun `courseId is passed to manager`() = runTest { + coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(emptyList()) + + useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 42L)) + + coVerify { dashboardCoursesManager.getCourseAnnouncements(42L, null, any()) } + } + + @Test + fun `forceNetwork param is propagated to manager`() = runTest { + coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(emptyList()) + + useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L, forceNetwork = false)) + + coVerify { dashboardCoursesManager.getCourseAnnouncements(1L, null, forceNetwork = false) } + } + + private fun node( + id: String, + title: String, + read: Boolean, + unreadCount: Int + ) = CourseAnnouncementsQuery.Node( + _id = id, + title = title, + message = null, + postedAt = Date(), + participant = CourseAnnouncementsQuery.Participant(read = read), + entryCounts = CourseAnnouncementsQuery.EntryCounts(unreadCount = unreadCount) + ) + + private fun dataWithNodes(nodes: List?): CourseAnnouncementsQuery.Data { + val announcements = CourseAnnouncementsQuery.Announcements( + pageInfo = CourseAnnouncementsQuery.PageInfo(hasNextPage = false, endCursor = null), + nodes = nodes + ) + val onCourse = CourseAnnouncementsQuery.OnCourse(_id = "1", announcements = announcements) + val course = CourseAnnouncementsQuery.Course(__typename = "Course", onCourse = onCourse) + return CourseAnnouncementsQuery.Data(course = course) + } +} diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModelTest.kt index fb7a5eb102..ce6e4a0012 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModelTest.kt @@ -29,6 +29,7 @@ import com.instructure.canvasapi2.models.DiscussionTopicHeader import com.instructure.canvasapi2.models.Enrollment import com.instructure.canvasapi2.models.Group import com.instructure.pandautils.data.repository.user.UserRepository +import com.instructure.pandautils.domain.usecase.courses.LoadCourseAnnouncementsUseCase import com.instructure.pandautils.domain.usecase.courses.LoadGroupsParams import com.instructure.pandautils.domain.usecase.courses.LoadGroupsUseCase import com.instructure.pandautils.domain.usecase.courses.LoadDashboardCardsUseCase @@ -87,6 +88,7 @@ class CoursesWidgetViewModelTest { private val loadGroupsUseCase: LoadGroupsUseCase = mockk() private val loadSingleCourseUseCase: LoadSingleCourseUseCase = mockk() private val loadDashboardCardsUseCase: LoadDashboardCardsUseCase = mockk() + private val loadCourseAnnouncementsUseCase: LoadCourseAnnouncementsUseCase = mockk() private val sectionExpandedStateDataStore: SectionExpandedStateDataStore = mockk(relaxed = true) private val courseSyncSettingsDao: CourseSyncSettingsDao = mockk() private val courseDao: CourseDao = mockk() @@ -143,6 +145,7 @@ class CoursesWidgetViewModelTest { every { networkStateProvider.isOnlineLiveData } returns MutableLiveData(true) every { localBroadcastManager.registerReceiver(any(), any()) } returns Unit every { localBroadcastManager.unregisterReceiver(any()) } returns Unit + coEvery { loadCourseAnnouncementsUseCase(any()) } returns emptyList() coEvery { courseSyncSettingsDao.findAll() } returns emptyList() coEvery { courseDao.findByIds(any()) } returns emptyList() every { observeOfflineSyncUpdatesUseCase(Unit) } returns flowOf() @@ -908,6 +911,59 @@ class CoursesWidgetViewModelTest { assertEquals("My Nickname", state.courses.find { it.id == 1L }?.name) } + @Test + fun `RELOAD_ANNOUNCEMENTS broadcast reloads announcements for the specified course`() { + setupDefaultMocks() + val courses = listOf( + Course(id = 1, name = "Course 1", isFavorite = true) + ) + val initialAnnouncements = listOf(DiscussionTopicHeader(id = 10, announcement = true)) + val refreshedAnnouncements = listOf(DiscussionTopicHeader(id = 11, announcement = true)) + + coEvery { loadVisibleCoursesUseCase(any()) } returns visibleCoursesResult(courses, announcementsMap = mapOf(1L to initialAnnouncements)) + coEvery { loadCourseAnnouncementsUseCase(LoadCourseAnnouncementsUseCase.Params(1L)) } returns refreshedAnnouncements + + viewModel = createViewModel() + + val receiverSlot = slot() + verify { localBroadcastManager.registerReceiver(capture(receiverSlot), any()) } + + val bundle: android.os.Bundle = mockk(relaxed = true) + every { bundle.getBoolean(Const.RELOAD_ANNOUNCEMENTS) } returns true + val intent: Intent = mockk(relaxed = true) + every { intent.getLongExtra(Const.COURSE_ID, -1L) } returns 1L + every { intent.extras } returns bundle + + receiverSlot.captured.onReceive(mockk(), intent) + + val state = viewModel.uiState.value + val announcements = state.courses.find { it.id == 1L }?.announcements + assertEquals(1, announcements?.size) + assertEquals(11L, announcements?.first()?.id) + } + + @Test + fun `RELOAD_ANNOUNCEMENTS broadcast does not call reloadCourse`() { + setupDefaultMocks() + val courses = listOf(Course(id = 1, name = "Course 1", isFavorite = true)) + coEvery { loadVisibleCoursesUseCase(any()) } returns visibleCoursesResult(courses) + + viewModel = createViewModel() + + val receiverSlot = slot() + verify { localBroadcastManager.registerReceiver(capture(receiverSlot), any()) } + + val bundle: android.os.Bundle = mockk(relaxed = true) + every { bundle.getBoolean(Const.RELOAD_ANNOUNCEMENTS) } returns true + val intent: Intent = mockk(relaxed = true) + every { intent.getLongExtra(Const.COURSE_ID, -1L) } returns 1L + every { intent.extras } returns bundle + + receiverSlot.captured.onReceive(mockk(), intent) + + coVerify(exactly = 0) { loadSingleCourseUseCase(any()) } + } + @Test fun `onCourseMoved does nothing when fromIndex equals toIndex`() { setupDefaultMocks() @@ -1186,6 +1242,7 @@ class CoursesWidgetViewModelTest { loadGroupsUseCase = loadGroupsUseCase, loadSingleCourseUseCase = loadSingleCourseUseCase, loadDashboardCardsUseCase = loadDashboardCardsUseCase, + loadCourseAnnouncementsUseCase = loadCourseAnnouncementsUseCase, sectionExpandedStateDataStore = sectionExpandedStateDataStore, courseSyncSettingsDao = courseSyncSettingsDao, courseDao = courseDao,