Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
@@ -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<LoadCourseAnnouncementsUseCase.Params, List<DiscussionTopicHeader>>() {

override suspend fun execute(params: Params): List<DiscussionTopicHeader> {
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -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.Node?>?): 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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<BroadcastReceiver>()
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<BroadcastReceiver>()
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()
Expand Down Expand Up @@ -1186,6 +1242,7 @@ class CoursesWidgetViewModelTest {
loadGroupsUseCase = loadGroupsUseCase,
loadSingleCourseUseCase = loadSingleCourseUseCase,
loadDashboardCardsUseCase = loadDashboardCardsUseCase,
loadCourseAnnouncementsUseCase = loadCourseAnnouncementsUseCase,
sectionExpandedStateDataStore = sectionExpandedStateDataStore,
courseSyncSettingsDao = courseSyncSettingsDao,
courseDao = courseDao,
Expand Down
Loading