Skip to content

Commit f3343df

Browse files
committed
Fixed announcement refresh.
1 parent 1a7655c commit f3343df

6 files changed

Lines changed: 292 additions & 2 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (C) 2026 - present Instructure, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.instructure.pandautils.domain.usecase.courses
18+
19+
import com.instructure.canvasapi2.managers.graphql.DashboardCoursesManager
20+
import com.instructure.canvasapi2.models.DiscussionTopicHeader
21+
import com.instructure.pandautils.domain.usecase.BaseUseCase
22+
import javax.inject.Inject
23+
24+
class LoadCourseAnnouncementsUseCase @Inject constructor(
25+
private val dashboardCoursesManager: DashboardCoursesManager
26+
) : BaseUseCase<LoadCourseAnnouncementsUseCase.Params, List<DiscussionTopicHeader>>() {
27+
28+
override suspend fun execute(params: Params): List<DiscussionTopicHeader> {
29+
val data = dashboardCoursesManager.getCourseAnnouncements(params.courseId, cursor = null, forceNetwork = params.forceNetwork)
30+
val nodes = data.course?.onCourse?.announcements?.nodes ?: return emptyList()
31+
return nodes.mapNotNull { node ->
32+
node ?: return@mapNotNull null
33+
val isUnread = node.participant?.read != true
34+
val hasUnreadEntries = (node.entryCounts?.unreadCount ?: 0) > 0
35+
if (!isUnread && !hasUnreadEntries) return@mapNotNull null
36+
DiscussionTopicHeader(
37+
id = node._id.toLongOrNull() ?: return@mapNotNull null,
38+
title = node.title,
39+
message = node.message,
40+
postedDate = node.postedAt,
41+
unreadCount = node.entryCounts?.unreadCount ?: 0,
42+
announcement = true
43+
)
44+
}
45+
}
46+
47+
data class Params(val courseId: Long, val forceNetwork: Boolean = true)
48+
}

libs/pandautils/src/main/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModel.kt

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.instructure.canvasapi2.models.DashboardPositions
3030
import com.instructure.canvasapi2.models.DiscussionTopicHeader
3131
import com.instructure.canvasapi2.models.Group
3232
import com.instructure.pandautils.data.repository.user.UserRepository
33+
import com.instructure.pandautils.domain.usecase.courses.LoadCourseAnnouncementsUseCase
3334
import com.instructure.pandautils.domain.usecase.courses.LoadDashboardCardsUseCase
3435
import com.instructure.pandautils.domain.usecase.courses.LoadGroupsParams
3536
import com.instructure.pandautils.domain.usecase.courses.LoadGroupsUseCase
@@ -73,6 +74,7 @@ class CoursesWidgetViewModel @Inject constructor(
7374
private val loadGroupsUseCase: LoadGroupsUseCase,
7475
private val loadSingleCourseUseCase: LoadSingleCourseUseCase,
7576
private val loadDashboardCardsUseCase: LoadDashboardCardsUseCase,
77+
private val loadCourseAnnouncementsUseCase: LoadCourseAnnouncementsUseCase,
7678
private val sectionExpandedStateDataStore: SectionExpandedStateDataStore,
7779
private val courseSyncSettingsDao: CourseSyncSettingsDao,
7880
private val courseDao: CourseDao,
@@ -114,8 +116,11 @@ class CoursesWidgetViewModel @Inject constructor(
114116
override fun onReceive(context: Context, intent: Intent?) {
115117
val courseId = intent?.getLongExtra(Const.COURSE_ID, -1L)
116118
if (courseId != null && courseId != -1L) {
117-
// Reload specific course
118-
reloadCourse(courseId)
119+
if (intent.extras?.getBoolean(Const.RELOAD_ANNOUNCEMENTS) == true) {
120+
reloadCourseAnnouncements(courseId)
121+
} else {
122+
reloadCourse(courseId)
123+
}
119124
} else if (intent?.extras?.getBoolean(Const.COURSE_FAVORITES) == true) {
120125
// Full refresh for favorites changes
121126
refresh()
@@ -459,6 +464,22 @@ class CoursesWidgetViewModel @Inject constructor(
459464
}
460465
}
461466

467+
private fun reloadCourseAnnouncements(courseId: Long) {
468+
viewModelScope.launch {
469+
try {
470+
val announcements = loadCourseAnnouncementsUseCase(LoadCourseAnnouncementsUseCase.Params(courseId))
471+
val updatedAnnouncementsMap = _uiState.value.courses
472+
.associate { it.id to it.announcements }
473+
.toMutableMap()
474+
updatedAnnouncementsMap[courseId] = announcements
475+
val courseCards = mapCoursesToCardItems(visibleCourses, updatedAnnouncementsMap)
476+
_uiState.update { it.copy(courses = courseCards) }
477+
} catch (e: Exception) {
478+
crashlytics.recordException(e)
479+
}
480+
}
481+
}
482+
462483
private fun observeConfig() {
463484
viewModelScope.launch {
464485
observeGlobalConfigUseCase(Unit)

libs/pandautils/src/main/java/com/instructure/pandautils/features/discussion/details/DiscussionDetailsWebViewFragment.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import android.webkit.WebView
2525
import android.widget.Toast
2626
import androidx.core.net.toUri
2727
import androidx.core.view.ViewCompat
28+
import androidx.localbroadcastmanager.content.LocalBroadcastManager
2829
import androidx.core.view.WindowInsetsCompat
2930
import androidx.fragment.app.viewModels
3031
import androidx.lifecycle.lifecycleScope
@@ -80,6 +81,9 @@ class DiscussionDetailsWebViewFragment : BaseCanvasFragment() {
8081
@Inject
8182
lateinit var discussionDetailsWebViewFragmentBehavior: DiscussionDetailsWebViewFragmentBehavior
8283

84+
@Inject
85+
lateinit var localBroadcastManager: LocalBroadcastManager
86+
8387
@get:PageViewUrlParam("canvasContext")
8488
var canvasContext: CanvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT)
8589
private var discussionTopicHeader: DiscussionTopicHeader? by NullableParcelableArg(key = DISCUSSION_TOPIC_HEADER)
@@ -107,6 +111,13 @@ class DiscussionDetailsWebViewFragment : BaseCanvasFragment() {
107111
override fun onStop() {
108112
super.onStop()
109113
discussionSharedEvents.sendEvent(lifecycleScope, DiscussionSharedAction.RefreshListScreen)
114+
if (discussionTopicHeader?.announcement == true) {
115+
val intent = Intent(Const.COURSE_THING_CHANGED).apply {
116+
putExtra(Const.COURSE_ID, canvasContext.id)
117+
putExtra(Const.RELOAD_ANNOUNCEMENTS, true)
118+
}
119+
localBroadcastManager.sendBroadcast(intent)
120+
}
110121
}
111122

112123
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

libs/pandautils/src/main/java/com/instructure/pandautils/utils/Const.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ object Const {
143143
const val FILENAME = "fileName"
144144
const val COURSE_ID = "courseId"
145145
const val COURSE_THING_CHANGED = "courseTHINGChangedBroadcast"
146+
const val RELOAD_ANNOUNCEMENTS = "reloadAnnouncements"
146147
const val BOOKMARK = "bookmark"
147148
const val ITEM = "item"
148149
const val OPEN_OUTSIDE = "isOpenOutside"
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/*
2+
* Copyright (C) 2026 - present Instructure, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.instructure.pandautils.domain.usecase.courses
18+
19+
import com.instructure.canvasapi2.CourseAnnouncementsQuery
20+
import com.instructure.canvasapi2.managers.graphql.DashboardCoursesManager
21+
import io.mockk.coEvery
22+
import io.mockk.coVerify
23+
import io.mockk.mockk
24+
import kotlinx.coroutines.test.runTest
25+
import org.junit.Assert.assertEquals
26+
import org.junit.Assert.assertTrue
27+
import org.junit.Before
28+
import org.junit.Test
29+
import java.util.Date
30+
31+
class LoadCourseAnnouncementsUseCaseTest {
32+
33+
private val dashboardCoursesManager: DashboardCoursesManager = mockk()
34+
35+
private lateinit var useCase: LoadCourseAnnouncementsUseCase
36+
37+
@Before
38+
fun setup() {
39+
useCase = LoadCourseAnnouncementsUseCase(dashboardCoursesManager)
40+
}
41+
42+
@Test
43+
fun `unread announcement is returned`() = runTest {
44+
val node = node(id = "10", title = "Unread", read = false, unreadCount = 0)
45+
coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(listOf(node))
46+
47+
val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L))
48+
49+
assertEquals(1, result.size)
50+
assertEquals(10L, result[0].id)
51+
assertEquals("Unread", result[0].title)
52+
assertTrue(result[0].announcement)
53+
}
54+
55+
@Test
56+
fun `read announcement with unread entries is returned`() = runTest {
57+
val node = node(id = "20", title = "Has Replies", read = true, unreadCount = 3)
58+
coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(listOf(node))
59+
60+
val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L))
61+
62+
assertEquals(1, result.size)
63+
assertEquals(20L, result[0].id)
64+
assertEquals(3, result[0].unreadCount)
65+
}
66+
67+
@Test
68+
fun `fully read announcement with no unread entries is filtered out`() = runTest {
69+
val node = node(id = "30", title = "Already Read", read = true, unreadCount = 0)
70+
coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(listOf(node))
71+
72+
val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L))
73+
74+
assertTrue(result.isEmpty())
75+
}
76+
77+
@Test
78+
fun `null nodes returns empty list`() = runTest {
79+
coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(null)
80+
81+
val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L))
82+
83+
assertTrue(result.isEmpty())
84+
}
85+
86+
@Test
87+
fun `null course response returns empty list`() = runTest {
88+
coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns CourseAnnouncementsQuery.Data(course = null)
89+
90+
val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L))
91+
92+
assertTrue(result.isEmpty())
93+
}
94+
95+
@Test
96+
fun `multiple announcements are all mapped correctly`() = runTest {
97+
val nodes = listOf(
98+
node(id = "1", title = "First", read = false, unreadCount = 0),
99+
node(id = "2", title = "Second", read = true, unreadCount = 1),
100+
node(id = "3", title = "Third", read = true, unreadCount = 0)
101+
)
102+
coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(nodes)
103+
104+
val result = useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L))
105+
106+
assertEquals(2, result.size)
107+
assertEquals(1L, result[0].id)
108+
assertEquals(2L, result[1].id)
109+
}
110+
111+
@Test
112+
fun `courseId is passed to manager`() = runTest {
113+
coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(emptyList())
114+
115+
useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 42L))
116+
117+
coVerify { dashboardCoursesManager.getCourseAnnouncements(42L, null, any()) }
118+
}
119+
120+
@Test
121+
fun `forceNetwork param is propagated to manager`() = runTest {
122+
coEvery { dashboardCoursesManager.getCourseAnnouncements(any(), any(), any()) } returns dataWithNodes(emptyList())
123+
124+
useCase(LoadCourseAnnouncementsUseCase.Params(courseId = 1L, forceNetwork = false))
125+
126+
coVerify { dashboardCoursesManager.getCourseAnnouncements(1L, null, forceNetwork = false) }
127+
}
128+
129+
private fun node(
130+
id: String,
131+
title: String,
132+
read: Boolean,
133+
unreadCount: Int
134+
) = CourseAnnouncementsQuery.Node(
135+
_id = id,
136+
title = title,
137+
message = null,
138+
postedAt = Date(),
139+
participant = CourseAnnouncementsQuery.Participant(read = read),
140+
entryCounts = CourseAnnouncementsQuery.EntryCounts(unreadCount = unreadCount)
141+
)
142+
143+
private fun dataWithNodes(nodes: List<CourseAnnouncementsQuery.Node?>?): CourseAnnouncementsQuery.Data {
144+
val announcements = CourseAnnouncementsQuery.Announcements(
145+
pageInfo = CourseAnnouncementsQuery.PageInfo(hasNextPage = false, endCursor = null),
146+
nodes = nodes
147+
)
148+
val onCourse = CourseAnnouncementsQuery.OnCourse(_id = "1", announcements = announcements)
149+
val course = CourseAnnouncementsQuery.Course(__typename = "Course", onCourse = onCourse)
150+
return CourseAnnouncementsQuery.Data(course = course)
151+
}
152+
}

libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/widget/courses/CoursesWidgetViewModelTest.kt

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.instructure.canvasapi2.models.DiscussionTopicHeader
2929
import com.instructure.canvasapi2.models.Enrollment
3030
import com.instructure.canvasapi2.models.Group
3131
import com.instructure.pandautils.data.repository.user.UserRepository
32+
import com.instructure.pandautils.domain.usecase.courses.LoadCourseAnnouncementsUseCase
3233
import com.instructure.pandautils.domain.usecase.courses.LoadGroupsParams
3334
import com.instructure.pandautils.domain.usecase.courses.LoadGroupsUseCase
3435
import com.instructure.pandautils.domain.usecase.courses.LoadDashboardCardsUseCase
@@ -87,6 +88,7 @@ class CoursesWidgetViewModelTest {
8788
private val loadGroupsUseCase: LoadGroupsUseCase = mockk()
8889
private val loadSingleCourseUseCase: LoadSingleCourseUseCase = mockk()
8990
private val loadDashboardCardsUseCase: LoadDashboardCardsUseCase = mockk()
91+
private val loadCourseAnnouncementsUseCase: LoadCourseAnnouncementsUseCase = mockk()
9092
private val sectionExpandedStateDataStore: SectionExpandedStateDataStore = mockk(relaxed = true)
9193
private val courseSyncSettingsDao: CourseSyncSettingsDao = mockk()
9294
private val courseDao: CourseDao = mockk()
@@ -143,6 +145,7 @@ class CoursesWidgetViewModelTest {
143145
every { networkStateProvider.isOnlineLiveData } returns MutableLiveData(true)
144146
every { localBroadcastManager.registerReceiver(any(), any()) } returns Unit
145147
every { localBroadcastManager.unregisterReceiver(any()) } returns Unit
148+
coEvery { loadCourseAnnouncementsUseCase(any()) } returns emptyList()
146149
coEvery { courseSyncSettingsDao.findAll() } returns emptyList()
147150
coEvery { courseDao.findByIds(any()) } returns emptyList()
148151
every { observeOfflineSyncUpdatesUseCase(Unit) } returns flowOf()
@@ -908,6 +911,59 @@ class CoursesWidgetViewModelTest {
908911
assertEquals("My Nickname", state.courses.find { it.id == 1L }?.name)
909912
}
910913

914+
@Test
915+
fun `RELOAD_ANNOUNCEMENTS broadcast reloads announcements for the specified course`() {
916+
setupDefaultMocks()
917+
val courses = listOf(
918+
Course(id = 1, name = "Course 1", isFavorite = true)
919+
)
920+
val initialAnnouncements = listOf(DiscussionTopicHeader(id = 10, announcement = true))
921+
val refreshedAnnouncements = listOf(DiscussionTopicHeader(id = 11, announcement = true))
922+
923+
coEvery { loadVisibleCoursesUseCase(any()) } returns visibleCoursesResult(courses, announcementsMap = mapOf(1L to initialAnnouncements))
924+
coEvery { loadCourseAnnouncementsUseCase(LoadCourseAnnouncementsUseCase.Params(1L)) } returns refreshedAnnouncements
925+
926+
viewModel = createViewModel()
927+
928+
val receiverSlot = slot<BroadcastReceiver>()
929+
verify { localBroadcastManager.registerReceiver(capture(receiverSlot), any()) }
930+
931+
val bundle: android.os.Bundle = mockk(relaxed = true)
932+
every { bundle.getBoolean(Const.RELOAD_ANNOUNCEMENTS) } returns true
933+
val intent: Intent = mockk(relaxed = true)
934+
every { intent.getLongExtra(Const.COURSE_ID, -1L) } returns 1L
935+
every { intent.extras } returns bundle
936+
937+
receiverSlot.captured.onReceive(mockk(), intent)
938+
939+
val state = viewModel.uiState.value
940+
val announcements = state.courses.find { it.id == 1L }?.announcements
941+
assertEquals(1, announcements?.size)
942+
assertEquals(11L, announcements?.first()?.id)
943+
}
944+
945+
@Test
946+
fun `RELOAD_ANNOUNCEMENTS broadcast does not call reloadCourse`() {
947+
setupDefaultMocks()
948+
val courses = listOf(Course(id = 1, name = "Course 1", isFavorite = true))
949+
coEvery { loadVisibleCoursesUseCase(any()) } returns visibleCoursesResult(courses)
950+
951+
viewModel = createViewModel()
952+
953+
val receiverSlot = slot<BroadcastReceiver>()
954+
verify { localBroadcastManager.registerReceiver(capture(receiverSlot), any()) }
955+
956+
val bundle: android.os.Bundle = mockk(relaxed = true)
957+
every { bundle.getBoolean(Const.RELOAD_ANNOUNCEMENTS) } returns true
958+
val intent: Intent = mockk(relaxed = true)
959+
every { intent.getLongExtra(Const.COURSE_ID, -1L) } returns 1L
960+
every { intent.extras } returns bundle
961+
962+
receiverSlot.captured.onReceive(mockk(), intent)
963+
964+
coVerify(exactly = 0) { loadSingleCourseUseCase(any()) }
965+
}
966+
911967
@Test
912968
fun `onCourseMoved does nothing when fromIndex equals toIndex`() {
913969
setupDefaultMocks()
@@ -1186,6 +1242,7 @@ class CoursesWidgetViewModelTest {
11861242
loadGroupsUseCase = loadGroupsUseCase,
11871243
loadSingleCourseUseCase = loadSingleCourseUseCase,
11881244
loadDashboardCardsUseCase = loadDashboardCardsUseCase,
1245+
loadCourseAnnouncementsUseCase = loadCourseAnnouncementsUseCase,
11891246
sectionExpandedStateDataStore = sectionExpandedStateDataStore,
11901247
courseSyncSettingsDao = courseSyncSettingsDao,
11911248
courseDao = courseDao,

0 commit comments

Comments
 (0)