From 12d5e2807665770ab936aac004aeee439957fcfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Wed, 8 Nov 2023 11:10:54 +0100 Subject: [PATCH 01/49] Release Student 7.0.0 (256) --- apps/student/build.gradle | 4 ++-- .../offline/offlinecontent/OfflineContentViewModel.kt | 3 --- .../offlinecontent/itemviewmodels/CourseItemViewModel.kt | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/student/build.gradle b/apps/student/build.gradle index eb671160b8..9aa4e3023d 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 255 - versionName = '6.26.1' + versionCode = 256 + versionName = '7.0.0' vectorDrawables.useSupportLibrary = true multiDexEnabled = true diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/OfflineContentViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/OfflineContentViewModel.kt index 18323b2c61..9a4fcbcd92 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/OfflineContentViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/OfflineContentViewModel.kt @@ -177,8 +177,6 @@ class OfflineContentViewModel @Inject constructor( val tabs = course.tabs?.filter { it.tabId in ALLOWED_TAB_IDS }.orEmpty() val size = "~${Formatter.formatShortFileSize(context, files.sumOf { it.size } + tabs.filter { it.tabId != Tab.FILES_ID }.size * TAB_SIZE)}" - val collapsed = _data.value?.courseItems?.find { it.courseId == courseId }?.collapsed ?: (this.course == null) - return CourseItemViewModel( data = CourseItemViewData( fullContentSync = courseSyncSettingsWithFiles.courseSyncSettings.fullContentSync, @@ -192,7 +190,6 @@ class OfflineContentViewModel @Inject constructor( } ), courseId = course.id, - collapsed = collapsed, onCheckedChanged = this::onCourseCheckedChanged ) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/itemviewmodels/CourseItemViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/itemviewmodels/CourseItemViewModel.kt index 7fa6a016d6..f57ffa3a00 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/itemviewmodels/CourseItemViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/itemviewmodels/CourseItemViewModel.kt @@ -28,7 +28,6 @@ import com.instructure.pandautils.mvvm.ItemViewModel data class CourseItemViewModel( val data: CourseItemViewData, val courseId: Long, - @get:Bindable override var collapsed: Boolean, val onCheckedChanged: (Boolean, CourseItemViewModel) -> Unit ) : GroupItemViewModel(collapsable = true, items = data.tabs.ifEmpty { listOf(EmptyCourseContentViewModel()) }) { override val layoutId = R.layout.item_offline_course From 15c24c6aa34f63eb1e0c5de8e573afddc4b1f32b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Wed, 8 Nov 2023 12:06:01 +0100 Subject: [PATCH 02/49] fix tests --- .../student/ui/interaction/OfflineContentInteractionTest.kt | 4 ++-- .../offline/offlinecontent/OfflineContentViewModel.kt | 3 +++ .../offlinecontent/itemviewmodels/CourseItemViewModel.kt | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/OfflineContentInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/OfflineContentInteractionTest.kt index 2be3ec69ac..eaf9d6cd9a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/OfflineContentInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/OfflineContentInteractionTest.kt @@ -73,10 +73,10 @@ class OfflineContentInteractionTest : StudentTest() { @Test @TestMetaData(Priority.IMPORTANT, FeatureCategory.OFFLINE_CONTENT, TestCategory.INTERACTION, false) - fun displaysCourseExpandedIfCourseOfflineContent() { + fun displaysCourseCollapsedIfCourseOfflineContent() { val data = createMockCanvas() goToOfflineContentByCourse(data) - manageOfflineContentPage.assertDisplaysItemWithExpandedState(data.courses.values.first().name, true) + manageOfflineContentPage.assertDisplaysItemWithExpandedState(data.courses.values.first().name, false) } @Test diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/OfflineContentViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/OfflineContentViewModel.kt index 9a4fcbcd92..f91571e6b1 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/OfflineContentViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/OfflineContentViewModel.kt @@ -177,6 +177,8 @@ class OfflineContentViewModel @Inject constructor( val tabs = course.tabs?.filter { it.tabId in ALLOWED_TAB_IDS }.orEmpty() val size = "~${Formatter.formatShortFileSize(context, files.sumOf { it.size } + tabs.filter { it.tabId != Tab.FILES_ID }.size * TAB_SIZE)}" + val collapsed = _data.value?.courseItems?.find { it.courseId == courseId }?.collapsed ?: true + return CourseItemViewModel( data = CourseItemViewData( fullContentSync = courseSyncSettingsWithFiles.courseSyncSettings.fullContentSync, @@ -190,6 +192,7 @@ class OfflineContentViewModel @Inject constructor( } ), courseId = course.id, + collapsed = collapsed, onCheckedChanged = this::onCourseCheckedChanged ) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/itemviewmodels/CourseItemViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/itemviewmodels/CourseItemViewModel.kt index f57ffa3a00..7fa6a016d6 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/itemviewmodels/CourseItemViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/offlinecontent/itemviewmodels/CourseItemViewModel.kt @@ -28,6 +28,7 @@ import com.instructure.pandautils.mvvm.ItemViewModel data class CourseItemViewModel( val data: CourseItemViewData, val courseId: Long, + @get:Bindable override var collapsed: Boolean, val onCheckedChanged: (Boolean, CourseItemViewModel) -> Unit ) : GroupItemViewModel(collapsable = true, items = data.tabs.ifEmpty { listOf(EmptyCourseContentViewModel()) }) { override val layoutId = R.layout.item_offline_course From deac2c9b9b74e1ab475028eed4475b6890d37b2a Mon Sep 17 00:00:00 2001 From: "kristof.nemere" Date: Thu, 9 Nov 2023 14:19:26 +0100 Subject: [PATCH 03/49] fixed landscape tests --- .../OfflineContentInteractionTest.kt | 3 +- .../pages/offline/ManageOfflineContentPage.kt | 30 ++++++++++++------- .../res/layout/fragment_offline_content.xml | 1 + 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/OfflineContentInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/OfflineContentInteractionTest.kt index eaf9d6cd9a..57590587ec 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/OfflineContentInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/OfflineContentInteractionTest.kt @@ -98,7 +98,6 @@ class OfflineContentInteractionTest : StudentTest() { manageOfflineContentPage.assertDisplaysItemWithExpandedState(course.name, false) manageOfflineContentPage.expandCollapseItem(course.name) manageOfflineContentPage.assertDisplaysItemWithExpandedState(course.name, true) - manageOfflineContentPage.assertItemDisplayed(data.courseTabs[course.id]!!.first().label!!) getCourseItemNames(data, course).forEach { manageOfflineContentPage.assertItemDisplayed(it) } } @@ -353,7 +352,7 @@ class OfflineContentInteractionTest : StudentTest() { } private fun getCourseItemNames(data: MockCanvas, course: Course): List { - return data.courseTabs[course.id]!!.map { it.label!! } + course.name + + return listOf(course.name) + data.courseTabs[course.id]!!.map { it.label!! } + data.folderFiles[data.courseRootFolders[course.id]!!.id]!!.map { it.displayName!! } } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/ManageOfflineContentPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/ManageOfflineContentPage.kt index 87d65a84ae..80887424eb 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/ManageOfflineContentPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/ManageOfflineContentPage.kt @@ -17,9 +17,8 @@ package com.instructure.student.ui.pages.offline -import androidx.test.espresso.matcher.ViewMatchers -import androidx.test.espresso.matcher.ViewMatchers.hasSibling -import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvas.espresso.containsTextCaseInsensitive import com.instructure.canvas.espresso.hasCheckedState import com.instructure.canvas.espresso.withRotation @@ -27,6 +26,7 @@ import com.instructure.espresso.* import com.instructure.espresso.actions.ForceClick import com.instructure.espresso.page.* import com.instructure.pandautils.R +import com.instructure.pandautils.binding.BindableViewHolder import org.hamcrest.CoreMatchers.allOf class ManageOfflineContentPage : BasePage(R.id.manageOfflineContentPage) { @@ -36,12 +36,14 @@ class ManageOfflineContentPage : BasePage(R.id.manageOfflineContentPage) { //OfflineMethod fun changeItemSelectionState(itemName: String) { + onView(withId(R.id.offlineContentRecyclerView)) + .perform(RecyclerViewActions.scrollTo(hasDescendant(withText(itemName)))) onView(withId(R.id.checkbox) + hasSibling(withId(R.id.title) + withText(itemName))).scrollTo().click() } //OfflineMethod fun expandCollapseItem(itemName: String) { - onView(withId(R.id.arrow) + withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE) + hasSibling(withId(R.id.title) + withText(itemName))).scrollTo().perform(ForceClick()) + onView(withId(R.id.arrow) + withEffectiveVisibility(Visibility.VISIBLE) + hasSibling(withId(R.id.title) + withText(itemName))).scrollTo().perform(ForceClick()) } //OfflineMethod @@ -82,7 +84,7 @@ class ManageOfflineContentPage : BasePage(R.id.manageOfflineContentPage) { //OfflineMethod fun assertSelectButtonText(selectAll: Boolean) { - if(selectAll) waitForView(withId(R.id.menu_select_all) + withText(R.string.offline_content_select_all)).assertDisplayed() + if (selectAll) waitForView(withId(R.id.menu_select_all) + withText(R.string.offline_content_select_all)).assertDisplayed() else waitForView(withId(R.id.menu_select_all) + withText(R.string.offline_content_deselect_all)).assertDisplayed() } @@ -98,12 +100,12 @@ class ManageOfflineContentPage : BasePage(R.id.manageOfflineContentPage) { //OfflineMethod fun assertCourseCountWithMatcher(expectedCount: Int) { - ConstraintLayoutItemCountAssertionWithMatcher((allOf(withId(R.id.arrow), withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))), expectedCount) + ConstraintLayoutItemCountAssertionWithMatcher((allOf(withId(R.id.arrow), withEffectiveVisibility(Visibility.VISIBLE))), expectedCount) } //OfflineMethod fun assertCourseCount(expectedCount: Int) { - onView((allOf(withId(R.id.arrow), withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))).check(ConstraintLayoutItemCountAssertion(expectedCount)) + onView((allOf(withId(R.id.arrow), withEffectiveVisibility(Visibility.VISIBLE)))).check(ConstraintLayoutItemCountAssertion(expectedCount)) } //OfflineMethod @@ -114,7 +116,10 @@ class ManageOfflineContentPage : BasePage(R.id.manageOfflineContentPage) { //OfflineMethod fun assertCheckedStateOfItem(itemName: String, state: Int) { - onView(withId(R.id.checkbox) + hasSibling(withId(R.id.title) + withText(itemName)) + hasCheckedState(state)).scrollTo().assertDisplayed() + val matcher = withId(R.id.checkbox) + hasSibling(withId(R.id.title) + withText(itemName)) + hasCheckedState(state) + onView(withId(R.id.offlineContentRecyclerView)) + .perform(RecyclerViewActions.scrollTo(hasDescendant(withText(itemName)))) + onView(matcher).scrollTo().assertDisplayed() } //OfflineMethod @@ -129,21 +134,24 @@ class ManageOfflineContentPage : BasePage(R.id.manageOfflineContentPage) { //OfflineMethod fun assertDisplaysEmptyCourse() { - onView(withText(R.string.offline_content_empty_course_message)).assertDisplayed() + onView(withText(R.string.offline_content_empty_course_message)).scrollTo().assertDisplayed() } //OfflineMethod fun assertDisplaysItemWithExpandedState(title: String, expanded: Boolean) { onView(withId(R.id.arrow) + withRotation(if (expanded) 180f else 0f) - + withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE) + + withEffectiveVisibility(Visibility.VISIBLE) + hasSibling(withId(R.id.title) + withText(title)) ).scrollTo().assertDisplayed() } //OfflineMethod fun assertItemDisplayed(title: String) { - onView(withId(R.id.title) + withText(title)).scrollTo().assertDisplayed() + val matcher = withId(R.id.title) + withText(title) + onView(withId(R.id.offlineContentRecyclerView)) + .perform(RecyclerViewActions.scrollTo(hasDescendant(matcher))) + onView(matcher).scrollTo().assertDisplayed() } //OfflineMethod diff --git a/libs/pandautils/src/main/res/layout/fragment_offline_content.xml b/libs/pandautils/src/main/res/layout/fragment_offline_content.xml index 903a4ee3e3..5948dac458 100644 --- a/libs/pandautils/src/main/res/layout/fragment_offline_content.xml +++ b/libs/pandautils/src/main/res/layout/fragment_offline_content.xml @@ -195,6 +195,7 @@ app:refreshState="@{viewModel.state}"> Date: Thu, 9 Nov 2023 16:13:49 +0100 Subject: [PATCH 04/49] move fetching feature flags --- .../instructure/student/activity/CallbackActivity.kt | 10 ++++++++++ .../student/activity/NavigationActivity.kt | 11 ----------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt index 988a81b30b..4eb72c7801 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/CallbackActivity.kt @@ -18,6 +18,7 @@ package com.instructure.student.activity import android.os.Bundle +import androidx.lifecycle.lifecycleScope import com.google.firebase.crashlytics.FirebaseCrashlytics import com.heapanalytics.android.Heap import com.instructure.canvasapi2.StatusCallback @@ -39,12 +40,19 @@ import com.instructure.student.flutterChannels.FlutterComm import com.instructure.student.fragment.NotificationListFragment import com.instructure.student.service.StudentPageViewService import com.instructure.student.util.StudentPrefs +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import retrofit2.Call import retrofit2.Response +import javax.inject.Inject +@AndroidEntryPoint abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, NotificationListFragment.OnNotificationCountInvalidated { + @Inject + lateinit var featureFlagProvider: FeatureFlagProvider + private var loadInitialDataJob: Job? = null abstract fun gotLaunchDefinitions(launchDefinitions: List?) @@ -130,6 +138,8 @@ abstract class CallbackActivity : ParentActivity(), OnUnreadCountInvalidated, No getUnreadNotificationCount() + featureFlagProvider.fetchEnvironmentFeatureFlags() + initialCoreDataLoadingComplete() } catch { initialCoreDataLoadingComplete() diff --git a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt index 674981de32..9187905881 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt @@ -143,9 +143,6 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. @Inject lateinit var databaseProvider: DatabaseProvider - @Inject - lateinit var featureFlagProvider: FeatureFlagProvider - @Inject lateinit var repository: NavigationRepository @@ -305,8 +302,6 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. setupNavDrawerItems() - loadFeatureFlags() - checkAppUpdates() val savedBottomScreens = savedInstanceState?.getStringArrayList(BOTTOM_SCREENS_BUNDLE_KEY) @@ -345,12 +340,6 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. } } - private fun loadFeatureFlags() { - lifecycleScope.launch { - featureFlagProvider.fetchEnvironmentFeatureFlags() - } - } - private fun requestNotificationsPermission() { if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { notificationsPermissionContract.launch(Manifest.permission.POST_NOTIFICATIONS) From 7bd6726b67c991b1eb2190c680cca68ee4ecf5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Fri, 10 Nov 2023 12:08:38 +0100 Subject: [PATCH 05/49] remove lateinit recycler adapters --- .../list/AssignmentListFragment.kt | 41 ++++----- .../features/grades/GradesListFragment.kt | 84 +++++++++++-------- .../modules/list/ModuleListFragment.kt | 28 ++++--- .../features/pages/list/PageListFragment.kt | 16 ++-- .../fragment/NotificationListFragment.kt | 52 ++++++------ .../student/fragment/ToDoListFragment.kt | 62 +++++++------- 6 files changed, 156 insertions(+), 127 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/list/AssignmentListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/list/AssignmentListFragment.kt index e198678fb9..97c13658cc 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/list/AssignmentListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/list/AssignmentListFragment.kt @@ -75,7 +75,7 @@ class AssignmentListFragment : ParentFragment(), Bookmarkable { private var canvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) - private lateinit var recyclerAdapter: AssignmentListRecyclerAdapter + private var recyclerAdapter: AssignmentListRecyclerAdapter? = null private var termAdapter: TermSpinnerAdapter? = null private var filterPosition = 0 @@ -118,7 +118,7 @@ class AssignmentListFragment : ParentFragment(), Bookmarkable { override fun onRefreshFinished() { if (!isAdded) return // Refresh can finish after user has left screen, causing emptyView to be null setRefreshing(false) - if (recyclerAdapter.size() == 0) { + if (recyclerAdapter?.size() == 0) { setEmptyView(binding.emptyView, R.drawable.ic_panda_space, R.string.noAssignments, R.string.noAssignmentsSubtext) } } @@ -140,14 +140,16 @@ class AssignmentListFragment : ParentFragment(), Bookmarkable { binding.sortByTextView.setText(sortOrder.buttonTextRes) binding.sortByButton.contentDescription = getString(sortOrder.contentDescriptionRes) - configureRecyclerView( + recyclerAdapter?.let { + configureRecyclerView( view, requireContext(), - recyclerAdapter, + it, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView - ) + ) + } binding.appbar.addOnOffsetChangedListener(AppBarLayout.OnOffsetChangedListener { _, i -> // Workaround for Toolbar not showing with swipe to refresh @@ -230,7 +232,7 @@ class AssignmentListFragment : ParentFragment(), Bookmarkable { dialog.dismiss() filterPosition = index filter = AssignmentListFilter.values()[index] - recyclerAdapter.filter = filter + recyclerAdapter?.filter = filter updateBadge() } @@ -262,7 +264,7 @@ class AssignmentListFragment : ParentFragment(), Bookmarkable { } else { emptyView.emptyViewText(getString(R.string.noItemsMatchingQuery, query)) } - recyclerAdapter.searchQuery = query + recyclerAdapter?.searchQuery = query } ViewStyler.themeToolbarColored(requireActivity(), toolbar, canvasContext) } @@ -290,22 +292,22 @@ class AssignmentListFragment : ParentFragment(), Bookmarkable { termSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected(adapterView: AdapterView<*>, view: View?, i: Int, l: Long) { if (adapter.getItem(i)!!.title == getString(R.string.assignmentsListAllGradingPeriods)) { - recyclerAdapter.loadAssignment() + recyclerAdapter?.loadAssignment() } else { - recyclerAdapter.loadAssignmentsForGradingPeriod(adapter.getItem(i)!!.id, true) + recyclerAdapter?.loadAssignmentsForGradingPeriod(adapter.getItem(i)!!.id, true) termSpinner.isEnabled = false adapter.isLoading = true adapter.notifyDataSetChanged() } - recyclerAdapter.currentGradingPeriod = adapter.getItem(i) + recyclerAdapter?.currentGradingPeriod = adapter.getItem(i) } override fun onNothingSelected(adapterView: AdapterView<*>) {} } // If we have a "current" grading period select it - if (hasGradingPeriods && recyclerAdapter.currentGradingPeriod != null) { - val position = adapter.getPositionForId(recyclerAdapter.currentGradingPeriod?.id ?: 0) + if (hasGradingPeriods && recyclerAdapter?.currentGradingPeriod != null) { + val position = adapter.getPositionForId(recyclerAdapter?.currentGradingPeriod?.id ?: 0) if (position != -1) { termSpinner.setSelection(position) } else { @@ -318,16 +320,17 @@ class AssignmentListFragment : ParentFragment(), Bookmarkable { override fun onConfigurationChanged(newConfig: Configuration) = with(binding) { super.onConfigurationChanged(newConfig) - configureRecyclerView( + recyclerAdapter?.let { + configureRecyclerView( requireView(), requireContext(), - recyclerAdapter, + it, R.id.swipeRefreshLayout, R.id.emptyView, - R.id.listView, - R.string.noAssignments - ) - if (recyclerAdapter.size() == 0) { + R.id.listView + ) + } + if (recyclerAdapter?.size() == 0) { emptyView.changeTextSize() if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { if (isTablet) { @@ -348,7 +351,7 @@ class AssignmentListFragment : ParentFragment(), Bookmarkable { override fun onDestroy() { super.onDestroy() - recyclerAdapter.cancel() + recyclerAdapter?.cancel() } companion object { diff --git a/apps/student/src/main/java/com/instructure/student/features/grades/GradesListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/grades/GradesListFragment.kt index d5b10ebda6..eb8dfb874d 100644 --- a/apps/student/src/main/java/com/instructure/student/features/grades/GradesListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/grades/GradesListFragment.kt @@ -74,7 +74,7 @@ class GradesListFragment : ParentFragment(), Bookmarkable { private var gradingScheme = emptyList() private lateinit var allTermsGradingPeriod: GradingPeriod - private lateinit var recyclerAdapter: GradesListRecyclerAdapter + private var recyclerAdapter: GradesListRecyclerAdapter? = null private val course: Course get() = canvasContext as Course @@ -106,9 +106,9 @@ class GradesListFragment : ParentFragment(), Bookmarkable { //check to see if grade is empty for reset if (whatIf == null) { assignment.submission = null - recyclerAdapter.assignmentsHash[assignment.id]?.submission = null + recyclerAdapter?.assignmentsHash?.get(assignment.id)?.submission = null } else { - recyclerAdapter.assignmentsHash[assignment.id]?.submission = Submission( + recyclerAdapter?.assignmentsHash?.get(assignment.id)?.submission = Submission( score = whatIf, grade = whatIf.toString() ) @@ -122,14 +122,17 @@ class GradesListFragment : ParentFragment(), Bookmarkable { ) view.let { configureViews(it) - configureRecyclerView(it, requireContext(), recyclerAdapter, R.id.swipeRefreshLayout, R.id.gradesEmptyView, R.id.listView) + recyclerAdapter?.let {recyclerAdapter -> + configureRecyclerView(it, requireContext(), recyclerAdapter, R.id.swipeRefreshLayout, R.id.gradesEmptyView, R.id.listView) + } + } } override fun onDestroyView() { super.onDestroyView() computeGradesJob?.cancel() - recyclerAdapter.cancel() + recyclerAdapter?.cancel() } override fun applyTheme() { @@ -143,7 +146,10 @@ class GradesListFragment : ParentFragment(), Bookmarkable { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - view?.let { configureRecyclerView(it, requireContext(), recyclerAdapter, R.id.swipeRefreshLayout, R.id.gradesEmptyView, R.id.listView) } + view?.let { + recyclerAdapter?.let { recyclerAdapter -> + configureRecyclerView(it, requireContext(), recyclerAdapter, R.id.swipeRefreshLayout, R.id.gradesEmptyView, R.id.listView) } + } } private fun configureViews(rootView: View) { @@ -169,7 +175,7 @@ class GradesListFragment : ParentFragment(), Bookmarkable { computeGrades(showTotalCheckBox.isChecked, -1) } else { val gradeString = getGradeString( - recyclerAdapter.courseGrade, + recyclerAdapter?.courseGrade, !isChecked ) txtOverallGrade.text = gradeString @@ -182,24 +188,26 @@ class GradesListFragment : ParentFragment(), Bookmarkable { whatIfView.setOnClickListener { showWhatIfCheckBox.toggle() } showWhatIfCheckBox.setOnCheckedChangeListener { _, _ -> - val currentScoreVal = recyclerAdapter.courseGrade?.currentScore ?: 0.0 + val currentScoreVal = recyclerAdapter?.courseGrade?.currentScore ?: 0.0 val currentScore = NumberHelper.doubleToPercentage(currentScoreVal) if (!showWhatIfCheckBox.isChecked) { txtOverallGrade.text = currentScore - } else if (recyclerAdapter.whatIfGrade != null) { - txtOverallGrade.text = NumberHelper.doubleToPercentage(recyclerAdapter.whatIfGrade) + } else if (recyclerAdapter?.whatIfGrade != null) { + recyclerAdapter?.let { + txtOverallGrade.text = NumberHelper.doubleToPercentage(it.whatIfGrade) + } } // If the user is turning off what if grades we need to do a full refresh, should be // cached data, so fast. if (!showWhatIfCheckBox.isChecked) { - recyclerAdapter.whatIfGrade = null - recyclerAdapter.loadCachedData() + recyclerAdapter?.whatIfGrade = null + recyclerAdapter?.loadCachedData() } else { // Only log when what if grades is checked on Analytics.logEvent(AnalyticsEventConstants.WHAT_IF_GRADES) - recyclerAdapter.notifyDataSetChanged() + recyclerAdapter?.notifyDataSetChanged() } } } @@ -270,12 +278,12 @@ class GradesListFragment : ParentFragment(), Bookmarkable { override fun onNothingSelected(parent: AdapterView<*>?) {} override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { // The current item must always be set first - recyclerAdapter.currentGradingPeriod = termAdapter?.getItem(position) + recyclerAdapter?.currentGradingPeriod = termAdapter?.getItem(position) if (termAdapter?.getItem(position)?.title == getString(R.string.allGradingPeriods)) { - recyclerAdapter.loadData() + recyclerAdapter?.loadData() } else { if (termAdapter?.isEmpty == false) { - recyclerAdapter.loadAssignmentsForGradingPeriod( + recyclerAdapter?.loadAssignmentsForGradingPeriod( gradingPeriodID = termAdapter?.getItem(position)?.id.orDefault(), refreshFirst = true, forceNetwork = true @@ -290,8 +298,10 @@ class GradesListFragment : ParentFragment(), Bookmarkable { } // If we have a "current" grading period select it - if (recyclerAdapter.currentGradingPeriod != null) { - val position = termAdapter?.getPositionForId(recyclerAdapter.currentGradingPeriod?.id ?: -1) ?: -1 + if (recyclerAdapter?.currentGradingPeriod != null) { + val position = recyclerAdapter?.let { + termAdapter?.getPositionForId(it.currentGradingPeriod?.id ?: -1) ?: -1 + } ?: -1 if (position != -1) { termSpinner.setSelection(position) } else { @@ -335,7 +345,7 @@ class GradesListFragment : ParentFragment(), Bookmarkable { } private fun lockGrade(isLocked: Boolean) { - if (isLocked || recyclerAdapter.isAllGradingPeriodsSelected && !course.isTotalsForAllGradingPeriodsEnabled) { + if (isLocked || recyclerAdapter?.isAllGradingPeriodsSelected == true && !course.isTotalsForAllGradingPeriodsEnabled) { binding.txtOverallGrade.setInvisible() binding.lockedGradeImage.setVisible() binding.gradeToggleView.setGone() @@ -351,25 +361,27 @@ class GradesListFragment : ParentFragment(), Bookmarkable { private fun computeGrades(isShowTotalGrade: Boolean, lastPositionChanged: Int) { computeGradesJob = weave { val result = inBackground { - if (!isShowTotalGrade) { - if (course.isApplyAssignmentGroupWeights) { - calcGradesTotal(recyclerAdapter.assignmentGroups) - } else { - calcGradesTotalNoWeight(recyclerAdapter.assignmentGroups) - } - } else { //Calculates grade based on only graded assignments - if (course.isApplyAssignmentGroupWeights) { - calcGradesGraded(recyclerAdapter.assignmentGroups) - } else { - calcGradesGradedNoWeight(recyclerAdapter.assignmentGroups) + recyclerAdapter?.let { recyclerAdapter -> + if (!isShowTotalGrade) { + if (course.isApplyAssignmentGroupWeights) { + calcGradesTotal(recyclerAdapter.assignmentGroups) + } else { + calcGradesTotalNoWeight(recyclerAdapter.assignmentGroups) + } + } else { //Calculates grade based on only graded assignments + if (course.isApplyAssignmentGroupWeights) { + calcGradesGraded(recyclerAdapter.assignmentGroups) + } else { + calcGradesGradedNoWeight(recyclerAdapter.assignmentGroups) + } } } } - recyclerAdapter.whatIfGrade = result + recyclerAdapter?.whatIfGrade = result binding.txtOverallGrade.text = NumberHelper.doubleToPercentage(result) - if(lastPositionChanged >= 0) recyclerAdapter.notifyItemChanged(lastPositionChanged) + if(lastPositionChanged >= 0) recyclerAdapter?.notifyItemChanged(lastPositionChanged) } } @@ -392,7 +404,7 @@ class GradesListFragment : ParentFragment(), Bookmarkable { var totalPoints = 0.0 val weight = g.groupWeight for (a in g.assignments) { - val tempAssignment = recyclerAdapter.assignmentsHash[a.id].takeIf { !it?.omitFromFinalGrade.orDefault() } + val tempAssignment = recyclerAdapter?.assignmentsHash?.get(a.id).takeIf { !it?.omitFromFinalGrade.orDefault() } val tempSub = tempAssignment?.submission if (tempSub?.grade != null && tempAssignment.submissionTypesRaw.isNotEmpty()) { earnedPoints += tempSub.score @@ -427,7 +439,7 @@ class GradesListFragment : ParentFragment(), Bookmarkable { val weight = g.groupWeight var assignCount = 0 for (a in g.assignments) { - val tempAssignment = recyclerAdapter.assignmentsHash[a.id].takeIf { !it?.omitFromFinalGrade.orDefault() } + val tempAssignment = recyclerAdapter?.assignmentsHash?.get(a.id).takeIf { !it?.omitFromFinalGrade.orDefault() } val tempSub = tempAssignment?.submission if (tempSub?.grade != null && tempAssignment.submissionTypesRaw.isNotEmpty() && Const.PENDING_REVIEW != tempSub.workflowState) { assignCount++ // Determines if a group contains assignments @@ -476,7 +488,7 @@ class GradesListFragment : ParentFragment(), Bookmarkable { var totalPoints = 0.0 for (g in groups) { for (a in g.assignments) { - val tempAssignment = recyclerAdapter.assignmentsHash[a.id].takeIf { !it?.omitFromFinalGrade.orDefault() } + val tempAssignment = recyclerAdapter?.assignmentsHash?.get(a.id).takeIf { !it?.omitFromFinalGrade.orDefault() } val tempSub = tempAssignment?.submission if (tempSub?.grade != null && tempAssignment.submissionTypesRaw.isNotEmpty() && Const.PENDING_REVIEW != tempSub.workflowState) { earnedPoints += tempSub.score @@ -510,7 +522,7 @@ class GradesListFragment : ParentFragment(), Bookmarkable { var earnedPoints = 0.0 for (g in groups) { for (a in g.assignments) { - val tempAssignment = recyclerAdapter.assignmentsHash[a.id].takeIf { !it?.omitFromFinalGrade.orDefault() } + val tempAssignment = recyclerAdapter?.assignmentsHash?.get(a.id).takeIf { !it?.omitFromFinalGrade.orDefault() } val tempSub = tempAssignment?.submission if (tempSub?.grade != null && tempAssignment.submissionTypesRaw.isNotEmpty()) { totalPoints += tempAssignment.pointsPossible diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/list/ModuleListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/modules/list/ModuleListFragment.kt index 134ea38aea..1d9e96862c 100644 --- a/apps/student/src/main/java/com/instructure/student/features/modules/list/ModuleListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/modules/list/ModuleListFragment.kt @@ -64,7 +64,7 @@ class ModuleListFragment : ParentFragment(), Bookmarkable { private lateinit var recyclerBinding: PandaRecyclerRefreshLayoutBinding private var canvasContext: CanvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) - private lateinit var recyclerAdapter: ModuleListRecyclerAdapter + private var recyclerAdapter: ModuleListRecyclerAdapter? = null @Inject lateinit var repository: ModuleListRepository @@ -85,7 +85,7 @@ class ModuleListFragment : ParentFragment(), Bookmarkable { } override fun onDestroy() { - recyclerAdapter.cancel() + recyclerAdapter?.cancel() super.onDestroy() } @@ -105,7 +105,7 @@ class ModuleListFragment : ParentFragment(), Bookmarkable { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - if (recyclerAdapter.size() == 0) { + if (recyclerAdapter?.size() == 0) { recyclerBinding.emptyView.changeTextSize() if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { if (isTablet) { @@ -160,9 +160,9 @@ class ModuleListFragment : ParentFragment(), Bookmarkable { if (isLocked) return // Remove all the subheaders and stuff. - val groups = recyclerAdapter.groups + val groups = recyclerAdapter?.groups ?: arrayListOf() - val moduleItemsArray = groups.indices.mapTo(ArrayList()) { recyclerAdapter.getItems(groups[it]) } + val moduleItemsArray = groups.indices.mapTo(ArrayList()) { recyclerAdapter?.getItems(groups[it]) ?: arrayListOf() } val moduleHelper = ModuleProgressionUtility.prepareModulesForCourseProgression( requireContext(), moduleItem.id, groups, moduleItemsArray ) @@ -184,16 +184,16 @@ class ModuleListFragment : ParentFragment(), Bookmarkable { // We need to force the empty view to be visible to use it for errors on refresh recyclerBinding.emptyView.setVisible() setEmptyView(recyclerBinding.emptyView, R.drawable.ic_panda_nomodules, R.string.modulesLocked, R.string.modulesLockedSubtext) - } else if (recyclerAdapter.size() == 0) { + } else if (recyclerAdapter?.size() == 0) { setEmptyView(recyclerBinding.emptyView, R.drawable.ic_panda_nomodules, R.string.noModules, R.string.noModulesSubtext) } else if (!arguments?.getString(MODULE_ID).isNullOrEmpty()) { // We need to delay scrolling until the expand animation has completed, otherwise modules // that appear near the end of the list will not have the extra 'expanded' space needed // to scroll as far as possible toward the top recyclerBinding.listView.postDelayed({ - val groupPosition = recyclerAdapter.getGroupItemPosition(arguments!!.getString( + val groupPosition = recyclerAdapter?.getGroupItemPosition(arguments!!.getString( MODULE_ID - )!!.toLong()) + )!!.toLong()) ?: -1 if (groupPosition >= 0) { val lm = recyclerBinding.listView.layoutManager as? LinearLayoutManager lm?.scrollToPositionWithOffset(groupPosition, 0) @@ -203,22 +203,24 @@ class ModuleListFragment : ParentFragment(), Bookmarkable { } } }) - configureRecyclerView(requireView(), requireContext(), recyclerAdapter, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView) + recyclerAdapter?.let { + configureRecyclerView(requireView(), requireContext(), it, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView) + } } fun notifyOfItemChanged(`object`: ModuleObject?, item: ModuleItem?) { if (item == null || `object` == null) return - recyclerAdapter.addOrUpdateItem(`object`, item) + recyclerAdapter?.addOrUpdateItem(`object`, item) } - fun refreshModuleList() = recyclerAdapter.updateMasteryPathItems() + fun refreshModuleList() = recyclerAdapter?.updateMasteryPathItems() /** * Update the list without clearing the data or collapsing headers. Used to update possibly updated * items (like a page that has now been viewed) */ - private fun updateList(moduleObject: ModuleObject) = recyclerAdapter.updateWithoutResettingViews(moduleObject) + private fun updateList(moduleObject: ModuleObject) = recyclerAdapter?.updateWithoutResettingViews(moduleObject) // region Bus Events @@ -227,7 +229,7 @@ class ModuleListFragment : ParentFragment(), Bookmarkable { fun onModuleUpdated(event: ModuleUpdatedEvent) { event.once(javaClass.simpleName) { updateList(it) - recyclerAdapter.notifyDataSetChanged() + recyclerAdapter?.notifyDataSetChanged() } } // endregion diff --git a/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt index 811db00ae2..740d0c4816 100644 --- a/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/pages/list/PageListFragment.kt @@ -62,7 +62,7 @@ class PageListFragment : ParentFragment(), Bookmarkable { private var canvasContext: CanvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) - private lateinit var recyclerAdapter: PageListRecyclerAdapter + private var recyclerAdapter: PageListRecyclerAdapter? = null private var defaultSelectedPageTitle = PageListRecyclerAdapter.FRONT_PAGE_DETERMINER // blank string is used to determine front page private var isShowFrontPage by BooleanArg(key = SHOW_FRONT_PAGE) @@ -73,7 +73,7 @@ class PageListFragment : ParentFragment(), Bookmarkable { @Subscribe fun onUpdatePage(event: PageUpdatedEvent) { event.once(javaClass.simpleName) { - recyclerAdapter.refresh() + recyclerAdapter?.refresh() } } @@ -88,7 +88,7 @@ class PageListFragment : ParentFragment(), Bookmarkable { } override fun onDestroyView() { - recyclerAdapter.cancel() + recyclerAdapter?.cancel() super.onDestroyView() } @@ -120,7 +120,9 @@ class PageListFragment : ParentFragment(), Bookmarkable { } }, defaultSelectedPageTitle) - configureRecyclerView(rootView!!, requireContext(), recyclerAdapter, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView) + recyclerAdapter?.let { + configureRecyclerView(rootView!!, requireContext(), it, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView) + } } override fun onActivityCreated(savedInstanceState: Bundle?) { @@ -137,7 +139,9 @@ class PageListFragment : ParentFragment(), Bookmarkable { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - configureRecyclerView(rootView!!, requireContext(), recyclerAdapter, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView) + recyclerAdapter?.let { + configureRecyclerView(rootView!!, requireContext(), it, R.id.swipeRefreshLayout, R.id.emptyView, R.id.listView) + } recyclerBinding.emptyView.changeTextSize() if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { if (isTablet) { @@ -172,7 +176,7 @@ class PageListFragment : ParentFragment(), Bookmarkable { } else { recyclerBinding.emptyView.emptyViewText(getString(R.string.noItemsMatchingQuery, query)) } - recyclerAdapter.searchQuery = query + recyclerAdapter?.searchQuery = query } ViewStyler.themeToolbarColored(requireActivity(), toolbar, canvasContext) } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/NotificationListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/NotificationListFragment.kt index cdb1024802..d1adbbfd09 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/NotificationListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/NotificationListFragment.kt @@ -65,19 +65,19 @@ class NotificationListFragment : ParentFragment(), Bookmarkable, FragmentManager private var canvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) - private lateinit var recyclerAdapter: NotificationListRecyclerAdapter + private var recyclerAdapter: NotificationListRecyclerAdapter? = null private var adapterToFragmentCallback: NotificationAdapterToFragmentCallback = object : NotificationAdapterToFragmentCallback { override fun onRowClicked(streamItem: StreamItem, position: Int, isOpenDetail: Boolean) { - recyclerAdapter.setSelectedPosition(position) + recyclerAdapter?.setSelectedPosition(position) onRowClick(streamItem) } override fun onRefreshFinished() { setRefreshing(false) binding.editOptions.setGone() - if (recyclerAdapter.size() == 0) { + if (recyclerAdapter?.size() == 0) { setEmptyView(recyclerBinding.emptyView, R.drawable.ic_panda_noalerts, R.string.noNotifications, R.string.noNotificationsSubtext) if (resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) { recyclerBinding.emptyView.setGuidelines(.2f, .7f, .74f, .15f, .85f) @@ -112,21 +112,23 @@ class NotificationListFragment : ParentFragment(), Bookmarkable, FragmentManager override fun onViewCreated(view: View, savedInstanceState: Bundle?) { recyclerBinding = PandaRecyclerRefreshLayoutBinding.bind(binding.root) recyclerAdapter = NotificationListRecyclerAdapter(requireContext(), canvasContext, adapterToFragmentCallback) - configureRecyclerView( - view, - requireContext(), - recyclerAdapter, - R.id.swipeRefreshLayout, - R.id.emptyView, - R.id.listView - ) + recyclerAdapter?.let { + configureRecyclerView( + view, + requireContext(), + it, + R.id.swipeRefreshLayout, + R.id.emptyView, + R.id.listView + ) + } recyclerBinding.listView.isSelectionEnabled = false binding.confirmButton.text = getString(R.string.delete) - binding.confirmButton.setOnClickListener { recyclerAdapter.confirmButtonClicked() } + binding.confirmButton.setOnClickListener { recyclerAdapter?.confirmButtonClicked() } binding.cancelButton.text = getString(R.string.cancel) - binding.cancelButton.setOnClickListener { recyclerAdapter.cancelButtonClicked() } + binding.cancelButton.setOnClickListener { recyclerAdapter?.cancelButtonClicked() } applyTheme() @@ -139,14 +141,14 @@ class NotificationListFragment : ParentFragment(), Bookmarkable, FragmentManager if (activity?.supportFragmentManager?.fragments?.lastOrNull()?.javaClass == this.javaClass) { if (shouldRefreshOnResume) { recyclerBinding.swipeRefreshLayout.isRefreshing = true - recyclerAdapter.refresh() + recyclerAdapter?.refresh() shouldRefreshOnResume = false } } } override fun onDestroyView() { - recyclerAdapter.cancel() + recyclerAdapter?.cancel() activity?.supportFragmentManager?.removeOnBackStackChangedListener(this) super.onDestroyView() } @@ -170,16 +172,18 @@ class NotificationListFragment : ParentFragment(), Bookmarkable, FragmentManager override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - configureRecyclerView( - requireView(), - requireContext(), - recyclerAdapter, - R.id.swipeRefreshLayout, - R.id.emptyView, - R.id.listView, + recyclerAdapter?.let { + configureRecyclerView( + requireView(), + requireContext(), + it, + R.id.swipeRefreshLayout, + R.id.emptyView, + R.id.listView, R.string.noNotifications - ) - if (recyclerAdapter.size() == 0) { + ) + } + if (recyclerAdapter?.size() == 0) { recyclerBinding.emptyView.changeTextSize() if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { if (isTablet) { diff --git a/apps/student/src/main/java/com/instructure/student/fragment/ToDoListFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/ToDoListFragment.kt index dc9b95e04f..0ffb0d1a1d 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/ToDoListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/ToDoListFragment.kt @@ -51,11 +51,11 @@ class ToDoListFragment : ParentFragment() { private var canvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) - private lateinit var recyclerAdapter: TodoListRecyclerAdapter + private var recyclerAdapter: TodoListRecyclerAdapter? = null private var adapterToFragmentCallback: NotificationAdapterToFragmentCallback = object : NotificationAdapterToFragmentCallback { override fun onRowClicked(todo: ToDo, position: Int, isOpenDetail: Boolean) { - recyclerAdapter.setSelectedPosition(position) + recyclerAdapter?.setSelectedPosition(position) onRowClick(todo) } @@ -63,7 +63,7 @@ class ToDoListFragment : ParentFragment() { if (!isAdded) return setRefreshing(false) binding.editOptions.setGone() - if (recyclerAdapter.size() == 0) { + if (recyclerAdapter?.size() == 0) { setEmptyView(recyclerViewBinding.emptyView, R.drawable.ic_panda_sleeping, R.string.noTodos, R.string.noTodosSubtext) } } @@ -91,26 +91,28 @@ class ToDoListFragment : ParentFragment() { } } recyclerAdapter = TodoListRecyclerAdapter(requireContext(), canvasContext, adapterToFragmentCallback) - configureRecyclerView( - view, - requireContext(), - recyclerAdapter, - R.id.swipeRefreshLayout, - R.id.emptyView, - R.id.listView - ) + recyclerAdapter?.let { + configureRecyclerView( + view, + requireContext(), + it, + R.id.swipeRefreshLayout, + R.id.emptyView, + R.id.listView + ) + } recyclerViewBinding.listView.isSelectionEnabled = false binding.confirmButton.text = getString(R.string.markAsDone) - binding.confirmButton.setOnClickListener { recyclerAdapter.confirmButtonClicked() } + binding.confirmButton.setOnClickListener { recyclerAdapter?.confirmButtonClicked() } binding.cancelButton.setText(R.string.cancel) - binding.cancelButton.setOnClickListener { recyclerAdapter.cancelButtonClicked() } + binding.cancelButton.setOnClickListener { recyclerAdapter?.cancelButtonClicked() } - updateFilterTitle(recyclerAdapter.getFilterMode()) + updateFilterTitle(recyclerAdapter?.getFilterMode() ?: NoFilter) binding.clearFilterTextView.setOnClickListener { - recyclerAdapter.loadDataWithFilter(NoFilter) - updateFilterTitle(recyclerAdapter.getFilterMode()) + recyclerAdapter?.loadDataWithFilter(NoFilter) + updateFilterTitle(recyclerAdapter?.getFilterMode() ?: NoFilter) } } @@ -132,16 +134,18 @@ class ToDoListFragment : ParentFragment() { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - configureRecyclerView( - requireView(), - requireContext(), - recyclerAdapter, - R.id.swipeRefreshLayout, - R.id.emptyView, - R.id.listView, + recyclerAdapter?.let { + configureRecyclerView( + requireView(), + requireContext(), + it, + R.id.swipeRefreshLayout, + R.id.emptyView, + R.id.listView, R.string.noTodos - ) - if (recyclerAdapter.size() == 0) { + ) + } + if (recyclerAdapter?.size() == 0) { recyclerViewBinding.emptyView.changeTextSize() if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { if (isTablet) { @@ -184,15 +188,15 @@ class ToDoListFragment : ParentFragment() { private fun showCourseFilterDialog() { val choices = arrayOf(getString(R.string.favoritedCoursesLabel)) - var checkedItem = choices.indexOf(getString(recyclerAdapter.getFilterMode().titleId)) + var checkedItem = choices.indexOf(getString(recyclerAdapter?.getFilterMode()?.titleId ?: NoFilter.titleId)) val dialog = AlertDialog.Builder(requireContext()) .setTitle(R.string.filterByEllipsis) .setSingleChoiceItems(choices, checkedItem) { _, index -> checkedItem = index }.setPositiveButton(android.R.string.ok) { _, _ -> - if (checkedItem >= 0) recyclerAdapter.loadDataWithFilter(convertFilterChoiceToMode(choices[checkedItem])) - updateFilterTitle(recyclerAdapter.getFilterMode()) + if (checkedItem >= 0) recyclerAdapter?.loadDataWithFilter(convertFilterChoiceToMode(choices[checkedItem])) + updateFilterTitle(recyclerAdapter?.getFilterMode() ?: NoFilter) }.setNegativeButton(android.R.string.cancel, null) .create() @@ -216,7 +220,7 @@ class ToDoListFragment : ParentFragment() { override fun onDestroyView() { super.onDestroyView() - recyclerAdapter.cancel() + recyclerAdapter?.cancel() } companion object { From 2899517753b3b22474444d31af9eea6386899fa4 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Tue, 14 Nov 2023 16:10:56 +0100 Subject: [PATCH 06/49] [MBL-17180][Student] Sync fixes (#2247) Test plan: Test the sync. Check the background progresses after cancel, restart, etc. refs: MBL-17180 affects: Student release note: none --- .../student/tasks/StudentLogoutTask.kt | 5 +- .../1.json | 12 +- .../offline/daos/CourseSyncProgressDaoTest.kt | 17 +- .../offline/daos/FileSyncProgressDaoTest.kt | 5 +- .../pandautils/di/OfflineSyncModule.kt | 105 ++++++++++++ .../offline/sync/AggregateProgressObserver.kt | 4 +- .../{CourseSyncWorker.kt => CourseSync.kt} | 157 ++++++++++-------- .../features/offline/sync/FileSync.kt | 1 + .../offline/sync/OfflineSyncHelper.kt | 87 +++++++--- .../offline/sync/OfflineSyncWorker.kt | 68 +++----- .../sync/progress/SyncProgressViewData.kt | 7 +- .../sync/progress/SyncProgressViewModel.kt | 13 +- .../AdditionalFilesProgressItemViewModel.kt | 2 +- .../CourseProgressItemViewModel.kt | 8 +- .../FilesTabProgressItemViewModel.kt | 2 +- .../TabProgressItemViewModel.kt | 2 +- .../offline/daos/CourseSyncProgressDao.kt | 4 +- .../entities/CourseSyncProgressEntity.kt | 1 - .../offline/sync/OfflineSyncHelperTest.kt | 120 ++++++++++++- .../progress/AggregateProgressObserverTest.kt | 27 +-- .../progress/SyncProgressViewModelTest.kt | 36 ++-- ...dditionalFilesProgressItemViewModelTest.kt | 32 ++-- .../CourseProgressItemViewModelTest.kt | 44 ++--- .../FilesTabProgressItemViewModelTest.kt | 29 ++-- .../TabProgressItemViewModelTest.kt | 10 +- 25 files changed, 489 insertions(+), 309 deletions(-) rename libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/{CourseSyncWorker.kt => CourseSync.kt} (82%) diff --git a/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt b/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt index ec829ffbc1..59360f590b 100644 --- a/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt +++ b/apps/student/src/main/java/com/instructure/student/tasks/StudentLogoutTask.kt @@ -25,7 +25,6 @@ import com.heapanalytics.android.Heap import com.instructure.canvasapi2.utils.ContextKeeper import com.instructure.canvasapi2.utils.tryOrNull import com.instructure.loginapi.login.tasks.LogoutTask -import com.instructure.pandautils.features.offline.sync.CourseSyncWorker import com.instructure.pandautils.features.offline.sync.OfflineSyncWorker import com.instructure.pandautils.room.offline.DatabaseProvider import com.instructure.pandautils.typeface.TypefaceBehavior @@ -79,8 +78,8 @@ class StudentLogoutTask( override fun stopOfflineSync() { val workManager = WorkManager.getInstance(ContextKeeper.appContext) workManager.apply { - cancelAllWorkByTag(CourseSyncWorker.TAG) - cancelAllWorkByTag(OfflineSyncWorker.TAG) + cancelAllWorkByTag(OfflineSyncWorker.PERIODIC_TAG) + cancelAllWorkByTag(OfflineSyncWorker.ONE_TIME_TAG) } } } diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json b/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json index 31ef763ae0..7c8f1b4539 100644 --- a/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "8fa86dcf6c28df2a7e77dee7a0e3c7c6", + "identityHash": "a5fc40aae4d227313a98d9a89f41cfdd", "entities": [ { "tableName": "AssignmentDueDateEntity", @@ -5393,7 +5393,7 @@ }, { "tableName": "CourseSyncProgressEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `workerId` TEXT NOT NULL, `courseName` TEXT NOT NULL, `tabs` TEXT NOT NULL, `additionalFilesStarted` INTEGER NOT NULL, `progressState` TEXT NOT NULL, PRIMARY KEY(`courseId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`courseId` INTEGER NOT NULL, `courseName` TEXT NOT NULL, `tabs` TEXT NOT NULL, `additionalFilesStarted` INTEGER NOT NULL, `progressState` TEXT NOT NULL, PRIMARY KEY(`courseId`))", "fields": [ { "fieldPath": "courseId", @@ -5401,12 +5401,6 @@ "affinity": "INTEGER", "notNull": true }, - { - "fieldPath": "workerId", - "columnName": "workerId", - "affinity": "TEXT", - "notNull": true - }, { "fieldPath": "courseName", "columnName": "courseName", @@ -5513,7 +5507,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8fa86dcf6c28df2a7e77dee7a0e3c7c6')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a5fc40aae4d227313a98d9a89f41cfdd')" ] } } \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/CourseSyncProgressDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/CourseSyncProgressDaoTest.kt index 4f6432bacf..f16738ee82 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/CourseSyncProgressDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/CourseSyncProgressDaoTest.kt @@ -66,7 +66,6 @@ class CourseSyncProgressDaoTest { fun testInsertError() = runTest { val entity = CourseSyncProgressEntity( 1L, - UUID.randomUUID().toString(), "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS @@ -82,14 +81,12 @@ class CourseSyncProgressDaoTest { val entities = listOf( CourseSyncProgressEntity( 1L, - UUID.randomUUID().toString(), "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS ), CourseSyncProgressEntity( 2L, - UUID.randomUUID().toString(), "Course 2", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS @@ -110,14 +107,12 @@ class CourseSyncProgressDaoTest { val entities = listOf( CourseSyncProgressEntity( 1L, - UUID.randomUUID().toString(), "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS ), CourseSyncProgressEntity( 2L, - UUID.randomUUID().toString(), "Course 2", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS @@ -135,14 +130,12 @@ class CourseSyncProgressDaoTest { val entities = listOf( CourseSyncProgressEntity( 1L, - UUID.randomUUID().toString(), "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS ), CourseSyncProgressEntity( 2L, - UUID.randomUUID().toString(), "Course 2", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS @@ -161,14 +154,12 @@ class CourseSyncProgressDaoTest { val entities = listOf( CourseSyncProgressEntity( 1L, - UUID.randomUUID().toString(), "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS ), CourseSyncProgressEntity( 2L, - UUID.randomUUID().toString(), "Course 2", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS @@ -188,14 +179,12 @@ class CourseSyncProgressDaoTest { val entities = listOf( CourseSyncProgressEntity( 1L, - UUID.randomUUID().toString(), "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS ), CourseSyncProgressEntity( 2L, - UUID.randomUUID().toString(), "Course 2", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS @@ -211,18 +200,16 @@ class CourseSyncProgressDaoTest { } @Test - fun testFindByWorkerIdLiveData() = runTest { + fun testFindByCourseIdLiveData() = runTest { val entities = listOf( CourseSyncProgressEntity( 1L, - UUID.randomUUID().toString(), "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS ), CourseSyncProgressEntity( 2L, - UUID.randomUUID().toString(), "Course 2", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS @@ -231,7 +218,7 @@ class CourseSyncProgressDaoTest { courseSyncProgressDao.insertAll(entities) - val result = courseSyncProgressDao.findByWorkerIdLiveData(entities[1].workerId) + val result = courseSyncProgressDao.findByCourseIdLiveData(entities[1].courseId) result.observeForever { } assertEquals(entities[1], result.value) diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDaoTest.kt index 6f8f1d177d..6802ae1119 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDaoTest.kt @@ -57,7 +57,6 @@ class FileSyncProgressDaoTest { courseSyncProgressDao.insert( CourseSyncProgressEntity( - workerId = "workerId", courseId = 1L, courseName = "Course 1" ) @@ -192,7 +191,7 @@ class FileSyncProgressDaoTest { @Test fun testFindByCourseIdLiveData() = runTest { - courseSyncProgressDao.insert(CourseSyncProgressEntity(2L, "workerId2", "Course 2")) + courseSyncProgressDao.insert(CourseSyncProgressEntity(2L, "Course 2")) val entities = listOf( FileSyncProgressEntity( courseId = 1L, @@ -229,7 +228,7 @@ class FileSyncProgressDaoTest { @Test fun testFindAllLiveData() = runTest { - courseSyncProgressDao.insert(CourseSyncProgressEntity(2L, "workerId2", "Course 2")) + courseSyncProgressDao.insert(CourseSyncProgressEntity(2L, "Course 2")) val entities = listOf( FileSyncProgressEntity( courseId = 1L, diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/OfflineSyncModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/OfflineSyncModule.kt index 906328a7d6..586864e0ad 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/OfflineSyncModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/OfflineSyncModule.kt @@ -20,18 +20,48 @@ package com.instructure.pandautils.di import android.content.Context import androidx.work.WorkManager import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.instructure.canvasapi2.apis.AnnouncementAPI +import com.instructure.canvasapi2.apis.AssignmentAPI +import com.instructure.canvasapi2.apis.CalendarEventAPI +import com.instructure.canvasapi2.apis.ConferencesApi +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.DiscussionAPI +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.apis.FeaturesAPI import com.instructure.canvasapi2.apis.FileDownloadAPI import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.apis.GroupAPI +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.apis.PageAPI +import com.instructure.canvasapi2.apis.QuizAPI +import com.instructure.canvasapi2.apis.UserAPI import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.pandautils.features.offline.offlinecontent.CourseFileSharedRepository import com.instructure.pandautils.features.offline.sync.AggregateProgressObserver +import com.instructure.pandautils.features.offline.sync.CourseSync import com.instructure.pandautils.features.offline.sync.FileSync +import com.instructure.pandautils.features.offline.sync.HtmlParser import com.instructure.pandautils.features.offline.sync.OfflineSyncHelper +import com.instructure.pandautils.room.offline.daos.CourseFeaturesDao import com.instructure.pandautils.room.offline.daos.CourseSyncProgressDao +import com.instructure.pandautils.room.offline.daos.CourseSyncSettingsDao import com.instructure.pandautils.room.offline.daos.FileFolderDao import com.instructure.pandautils.room.offline.daos.FileSyncProgressDao import com.instructure.pandautils.room.offline.daos.FileSyncSettingsDao import com.instructure.pandautils.room.offline.daos.LocalFileDao +import com.instructure.pandautils.room.offline.daos.PageDao +import com.instructure.pandautils.room.offline.daos.QuizDao +import com.instructure.pandautils.room.offline.facade.AssignmentFacade +import com.instructure.pandautils.room.offline.facade.ConferenceFacade +import com.instructure.pandautils.room.offline.facade.CourseFacade +import com.instructure.pandautils.room.offline.facade.DiscussionTopicFacade +import com.instructure.pandautils.room.offline.facade.DiscussionTopicHeaderFacade +import com.instructure.pandautils.room.offline.facade.GroupFacade +import com.instructure.pandautils.room.offline.facade.ModuleFacade +import com.instructure.pandautils.room.offline.facade.PageFacade +import com.instructure.pandautils.room.offline.facade.ScheduleItemFacade import com.instructure.pandautils.room.offline.facade.SyncSettingsFacade +import com.instructure.pandautils.room.offline.facade.UserFacade import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -76,4 +106,79 @@ class OfflineSyncModule { fileFolderApi ) } + + @Provides + fun provideCourseSync( + courseApi: CourseAPI.CoursesInterface, + pageApi: PageAPI.PagesInterface, + userApi: UserAPI.UsersInterface, + assignmentApi: AssignmentAPI.AssignmentInterface, + calendarEventApi: CalendarEventAPI.CalendarEventInterface, + courseSyncSettingsDao: CourseSyncSettingsDao, + pageFacade: PageFacade, + userFacade: UserFacade, + courseFacade: CourseFacade, + assignmentFacade: AssignmentFacade, + quizDao: QuizDao, + quizApi: QuizAPI.QuizInterface, + scheduleItemFacade: ScheduleItemFacade, + conferencesApi: ConferencesApi.ConferencesInterface, + conferenceFacade: ConferenceFacade, + discussionApi: DiscussionAPI.DiscussionInterface, + discussionTopicHeaderFacade: DiscussionTopicHeaderFacade, + announcementApi: AnnouncementAPI.AnnouncementInterface, + moduleApi: ModuleAPI.ModuleInterface, + moduleFacade: ModuleFacade, + featuresApi: FeaturesAPI.FeaturesInterface, + courseFeaturesDao: CourseFeaturesDao, + courseFileSharedRepository: CourseFileSharedRepository, + fileFolderDao: FileFolderDao, + discussionTopicFacade: DiscussionTopicFacade, + groupApi: GroupAPI.GroupInterface, + groupFacade: GroupFacade, + enrollmentsApi: EnrollmentAPI.EnrollmentInterface, + courseSyncProgressDao: CourseSyncProgressDao, + htmlParser: HtmlParser, + fileFolderApi: FileFolderAPI.FilesFoldersInterface, + pageDao: PageDao, + firebaseCrashlytics: FirebaseCrashlytics, + fileSync: FileSync + ): CourseSync { + return CourseSync( + courseApi, + pageApi, + userApi, + assignmentApi, + calendarEventApi, + courseSyncSettingsDao, + pageFacade, + userFacade, + courseFacade, + assignmentFacade, + quizDao, + quizApi, + scheduleItemFacade, + conferencesApi, + conferenceFacade, + discussionApi, + discussionTopicHeaderFacade, + announcementApi, + moduleApi, + moduleFacade, + featuresApi, + courseFeaturesDao, + courseFileSharedRepository, + fileFolderDao, + discussionTopicFacade, + groupApi, + groupFacade, + enrollmentsApi, + courseSyncProgressDao, + htmlParser, + fileFolderApi, + pageDao, + firebaseCrashlytics, + fileSync + ) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/AggregateProgressObserver.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/AggregateProgressObserver.kt index dcd38aaf6d..a1e8acaca3 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/AggregateProgressObserver.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/AggregateProgressObserver.kt @@ -42,11 +42,11 @@ class AggregateProgressObserver( private var courseProgressLiveData: LiveData>? = null private var fileProgressLiveData: LiveData>? = null - private var courseProgresses = mutableMapOf() + private var courseProgresses = mutableMapOf() private var fileProgresses = mutableMapOf() private val courseProgressObserver = Observer> { - courseProgresses = it.associateBy { it.workerId }.toMutableMap() + courseProgresses = it.associateBy { it.courseId }.toMutableMap() calculateProgress() } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/CourseSyncWorker.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/CourseSync.kt similarity index 82% rename from libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/CourseSyncWorker.kt rename to libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/CourseSync.kt index a58c657948..2cfe45356d 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/CourseSyncWorker.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/CourseSync.kt @@ -18,35 +18,63 @@ package com.instructure.pandautils.features.offline.sync -import android.content.Context -import android.net.Uri -import androidx.hilt.work.HiltWorker -import androidx.work.* import com.google.firebase.crashlytics.FirebaseCrashlytics -import com.instructure.canvasapi2.apis.* +import com.instructure.canvasapi2.apis.AnnouncementAPI +import com.instructure.canvasapi2.apis.AssignmentAPI +import com.instructure.canvasapi2.apis.CalendarEventAPI +import com.instructure.canvasapi2.apis.ConferencesApi +import com.instructure.canvasapi2.apis.CourseAPI +import com.instructure.canvasapi2.apis.DiscussionAPI +import com.instructure.canvasapi2.apis.EnrollmentAPI +import com.instructure.canvasapi2.apis.FeaturesAPI +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.apis.GroupAPI +import com.instructure.canvasapi2.apis.ModuleAPI +import com.instructure.canvasapi2.apis.PageAPI +import com.instructure.canvasapi2.apis.QuizAPI +import com.instructure.canvasapi2.apis.UserAPI import com.instructure.canvasapi2.builders.RestParams -import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.AssignmentGroup +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.Conference +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.DiscussionEntry +import com.instructure.canvasapi2.models.DiscussionTopic +import com.instructure.canvasapi2.models.DiscussionTopicHeader +import com.instructure.canvasapi2.models.ModuleItem +import com.instructure.canvasapi2.models.ScheduleItem +import com.instructure.canvasapi2.models.Tab import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.canvasapi2.utils.DataResult import com.instructure.canvasapi2.utils.depaginate import com.instructure.pandautils.features.offline.offlinecontent.CourseFileSharedRepository -import com.instructure.pandautils.room.offline.daos.* -import com.instructure.pandautils.room.offline.entities.* -import com.instructure.pandautils.room.offline.facade.* +import com.instructure.pandautils.room.offline.daos.CourseFeaturesDao +import com.instructure.pandautils.room.offline.daos.CourseSyncProgressDao +import com.instructure.pandautils.room.offline.daos.CourseSyncSettingsDao +import com.instructure.pandautils.room.offline.daos.FileFolderDao +import com.instructure.pandautils.room.offline.daos.PageDao +import com.instructure.pandautils.room.offline.daos.QuizDao +import com.instructure.pandautils.room.offline.entities.CourseFeaturesEntity +import com.instructure.pandautils.room.offline.entities.CourseSyncProgressEntity +import com.instructure.pandautils.room.offline.entities.CourseSyncSettingsEntity +import com.instructure.pandautils.room.offline.entities.FileFolderEntity +import com.instructure.pandautils.room.offline.entities.QuizEntity +import com.instructure.pandautils.room.offline.facade.AssignmentFacade +import com.instructure.pandautils.room.offline.facade.ConferenceFacade +import com.instructure.pandautils.room.offline.facade.CourseFacade +import com.instructure.pandautils.room.offline.facade.DiscussionTopicFacade +import com.instructure.pandautils.room.offline.facade.DiscussionTopicHeaderFacade +import com.instructure.pandautils.room.offline.facade.GroupFacade +import com.instructure.pandautils.room.offline.facade.ModuleFacade +import com.instructure.pandautils.room.offline.facade.PageFacade +import com.instructure.pandautils.room.offline.facade.ScheduleItemFacade +import com.instructure.pandautils.room.offline.facade.UserFacade import com.instructure.pandautils.room.offline.model.CourseSyncSettingsWithFiles -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import java.io.File -import java.lang.IllegalStateException -import java.util.Date - -@HiltWorker -class CourseSyncWorker @AssistedInject constructor( - @Assisted private val context: Context, - @Assisted private val workerParameters: WorkerParameters, + +class CourseSync( private val courseApi: CourseAPI.CoursesInterface, private val pageApi: PageAPI.PagesInterface, private val userApi: UserAPI.UsersInterface, @@ -81,20 +109,33 @@ class CourseSyncWorker @AssistedInject constructor( private val pageDao: PageDao, private val firebaseCrashlytics: FirebaseCrashlytics, private val fileSync: FileSync -) : CoroutineWorker(context, workerParameters) { +) { - private val additionalFileIdsToSync = mutableSetOf() - private val externalFilesToSync = mutableSetOf() + private val additionalFileIdsToSync = mutableMapOf>() + private val externalFilesToSync = mutableMapOf>() - private var courseId = -1L + private var isStopped = false + set(value) = synchronized(this) { + field = value + } - override suspend fun doWork(): Result { + suspend fun syncCourses(courseIds: List) { + coroutineScope { + courseIds.map { + async { syncCourse(it) } + }.awaitAll() + } + } + + private suspend fun syncCourse(courseId: Long) { val courseSettingsWithFiles = - courseSyncSettingsDao.findWithFilesById(inputData.getLong(COURSE_ID, -1)) ?: return Result.failure() + courseSyncSettingsDao.findWithFilesById(courseId) ?: return val courseSettings = courseSettingsWithFiles.courseSyncSettings - courseId = courseSettings.courseId val course = fetchCourseDetails(courseId) + additionalFileIdsToSync[courseId] = emptySet() + externalFilesToSync[courseId] = emptySet() + initProgress(courseSettings, course) if (courseSettings.fullFileSync || courseSettingsWithFiles.files.isNotEmpty()) { @@ -108,7 +149,7 @@ class CourseSyncWorker @AssistedInject constructor( listOf(contentDeferred, filesDeferred).awaitAll() } - fileSync.syncAdditionalFiles(courseSettings, additionalFileIdsToSync, externalFilesToSync) + fileSync.syncAdditionalFiles(courseSettings, additionalFileIdsToSync[courseId].orEmpty(), externalFilesToSync[courseId].orEmpty()) val progress = courseSyncProgressDao.findByCourseId(courseId) progress @@ -116,8 +157,6 @@ class CourseSyncWorker @AssistedInject constructor( ?.let { courseSyncProgressDao.update(it) } - - return Result.success() } private suspend fun fetchCourseContent(courseSettingsWithFiles: CourseSyncSettingsWithFiles, course: Course) { @@ -182,7 +221,7 @@ class CourseSyncWorker @AssistedInject constructor( } private suspend fun fetchSyllabus(courseId: Long) { - fetchTab(Tab.SYLLABUS_ID) { + fetchTab(courseId, Tab.SYLLABUS_ID) { val calendarEvents = fetchCalendarEvents(courseId) val assignmentEvents = fetchCalendarAssignments(courseId) val scheduleItems = mutableListOf() @@ -231,7 +270,7 @@ class CourseSyncWorker @AssistedInject constructor( } private suspend fun fetchPages(courseId: Long) { - fetchTab(Tab.PAGES_ID) { + fetchTab(courseId, Tab.PAGES_ID) { val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = true) val pages = pageApi.getFirstPagePagesWithBody(courseId, CanvasContext.Type.COURSE.apiString, params) .depaginate { nextUrl -> @@ -263,7 +302,7 @@ class CourseSyncWorker @AssistedInject constructor( } private suspend fun fetchAssignments(courseId: Long) { - fetchTab(Tab.ASSIGNMENTS_ID, Tab.GRADES_ID) { + fetchTab(courseId, Tab.ASSIGNMENTS_ID, Tab.GRADES_ID) { val restParams = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = true) val assignmentGroups = assignmentApi.getFirstPageAssignmentGroupListWithAssignments(courseId, restParams) .depaginate { nextUrl -> @@ -303,7 +342,7 @@ class CourseSyncWorker @AssistedInject constructor( } private suspend fun fetchUsers(courseId: Long) { - fetchTab(Tab.PEOPLE_ID) { + fetchTab(courseId, Tab.PEOPLE_ID) { val restParams = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = true) val users = userApi.getFirstPagePeopleList(courseId, CanvasContext.Type.COURSE.apiString, restParams) .depaginate { userApi.getNextPagePeopleList(it, restParams) }.dataOrThrow @@ -328,7 +367,7 @@ class CourseSyncWorker @AssistedInject constructor( } private suspend fun fetchAllQuizzes(contextType: String, courseId: Long) { - fetchTab(Tab.QUIZZES_ID) { + fetchTab(courseId, Tab.QUIZZES_ID) { val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = true) val quizzes = quizApi.getFirstPageQuizzesList(contextType, courseId, params).depaginate { nextUrl -> quizApi.getNextPageQuizzesList(nextUrl, params) @@ -343,7 +382,7 @@ class CourseSyncWorker @AssistedInject constructor( } private suspend fun fetchConferences(courseId: Long) { - fetchTab(Tab.CONFERENCES_ID) { + fetchTab(courseId, Tab.CONFERENCES_ID) { val conferences = getConferencesForContext(CanvasContext.emptyCourseContext(courseId), true).dataOrThrow conferenceFacade.insertConferences(conferences, courseId) @@ -363,7 +402,7 @@ class CourseSyncWorker @AssistedInject constructor( } private suspend fun fetchDiscussions(courseId: Long) { - fetchTab(Tab.DISCUSSIONS_ID) { + fetchTab(courseId, Tab.DISCUSSIONS_ID) { val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = true) val discussions = discussionApi.getFirstPageDiscussionTopicHeaders(CanvasContext.Type.COURSE.apiString, courseId, params) @@ -380,7 +419,7 @@ class CourseSyncWorker @AssistedInject constructor( } private suspend fun fetchAnnouncements(courseId: Long) { - fetchTab(Tab.ANNOUNCEMENTS_ID) { + fetchTab(courseId, Tab.ANNOUNCEMENTS_ID) { val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = true) val announcements = announcementApi.getFirstPageAnnouncementsList(CanvasContext.Type.COURSE.apiString, courseId, params) @@ -440,7 +479,7 @@ class CourseSyncWorker @AssistedInject constructor( } private suspend fun fetchModules(courseId: Long, courseSettings: CourseSyncSettingsWithFiles) { - fetchTab(Tab.MODULES_ID) { + fetchTab(courseId, Tab.MODULES_ID) { val params = RestParams(usePerPageQueryParam = true, isForceReadFromNetwork = true) val moduleObjects = moduleApi.getFirstPageModuleObjects( CanvasContext.Type.COURSE.apiString, courseId, params @@ -471,14 +510,14 @@ class CourseSyncWorker @AssistedInject constructor( } } - private suspend fun fetchTab(vararg tabIds: String, fetchBlock: suspend () -> Unit) { + private suspend fun fetchTab(courseId: Long, vararg tabIds: String, fetchBlock: suspend () -> Unit) { if (isStopped) return try { fetchBlock() - updateTabSuccess(*tabIds) + updateTabSuccess(courseId, *tabIds) } catch (e: Exception) { e.printStackTrace() - updateTabError(*tabIds) + updateTabError(courseId, *tabIds) firebaseCrashlytics.recordException(e) } } @@ -506,7 +545,9 @@ class CourseSyncWorker @AssistedInject constructor( val file = fileFolderApi.getCourseFile(courseId, it.contentId, params).dataOrNull if (file?.id != null) { - additionalFileIdsToSync.add(file.id) + additionalFileIdsToSync[courseId]?.let { + additionalFileIdsToSync[courseId] = it + file.id + } } } @@ -524,8 +565,12 @@ class CourseSyncWorker @AssistedInject constructor( private suspend fun parseHtmlContent(htmlContent: String?, courseId: Long): String? { val htmlParsingResult = htmlParser.createHtmlStringWithLocalFiles(htmlContent, courseId) - additionalFileIdsToSync.addAll(htmlParsingResult.internalFileIds) - externalFilesToSync.addAll(htmlParsingResult.externalFileUrls) + additionalFileIdsToSync[courseId]?.let { + additionalFileIdsToSync[courseId] = it + htmlParsingResult.internalFileIds + } + externalFilesToSync[courseId]?.let { + externalFilesToSync[courseId] = it + htmlParsingResult.externalFileUrls + } return htmlParsingResult.htmlWithLocalFileLinks } @@ -557,7 +602,6 @@ class CourseSyncWorker @AssistedInject constructor( private suspend fun createNewProgress(courseSettings: CourseSyncSettingsEntity): CourseSyncProgressEntity { val newProgress = CourseSyncProgressEntity( - workerId = workerParameters.id.toString(), courseId = courseSettings.courseId, courseName = courseSettings.courseName, progressState = ProgressState.STARTING, @@ -566,7 +610,7 @@ class CourseSyncWorker @AssistedInject constructor( return newProgress } - private suspend fun updateTabError(vararg tabIds: String) { + private suspend fun updateTabError(courseId: Long, vararg tabIds: String) { val progress = courseSyncProgressDao.findByCourseId(courseId) progress?.copy( tabs = progress.tabs.toMutableMap().apply { @@ -582,7 +626,7 @@ class CourseSyncWorker @AssistedInject constructor( } } - private suspend fun updateTabSuccess(vararg tabIds: String) { + private suspend fun updateTabSuccess(courseId: Long, vararg tabIds: String) { val progress = courseSyncProgressDao.findByCourseId(courseId) progress?.copy( tabs = progress.tabs.toMutableMap().apply { @@ -596,23 +640,4 @@ class CourseSyncWorker @AssistedInject constructor( courseSyncProgressDao.update(it) } } - - companion object { - const val COURSE_ID = "course_id" - const val TAG = "CourseSyncWorker" - - fun createOnTimeWork(courseId: Long, wifiOnly: Boolean): OneTimeWorkRequest { - val data = workDataOf(COURSE_ID to courseId) - return OneTimeWorkRequestBuilder() - .addTag(TAG) - .setInputData(data) - .setConstraints( - Constraints.Builder() - .setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) - .setRequiresBatteryNotLow(true) - .build() - ) - .build() - } - } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt index 307264fc05..f67aa666f0 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt @@ -20,6 +20,7 @@ package com.instructure.pandautils.features.offline.sync import android.content.Context import android.net.Uri +import android.util.Log import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.canvasapi2.apis.DownloadState import com.instructure.canvasapi2.apis.FileDownloadAPI diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelper.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelper.kt index 7fdf30f45a..eb80412b8b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelper.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelper.kt @@ -17,11 +17,18 @@ package com.instructure.pandautils.features.offline.sync -import androidx.work.* +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.await import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.pandautils.features.offline.sync.settings.SyncFrequency import com.instructure.pandautils.room.offline.facade.SyncSettingsFacade -import java.util.* import java.util.concurrent.TimeUnit class OfflineSyncHelper( @@ -31,11 +38,22 @@ class OfflineSyncHelper( ) { suspend fun syncCourses(courseIds: List) { - cancelRunningWorkers() - if (isWorkScheduled() || !syncSettingsFacade.getSyncSettings().autoSyncEnabled) { - syncOnce(courseIds) - } else { - scheduleWork() + when { + isPeriodicWorkRunning() -> { + scheduleWork(ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE) + } + + isWorkScheduled() || !syncSettingsFacade.getSyncSettings().autoSyncEnabled -> { + val runningInfo = getRunningOneTimeWorkInfo() + if (runningInfo != null) { + workManager.cancelWorkById(runningInfo.id) + } + syncOnce(courseIds) + } + + else -> { + scheduleWork() + } } } @@ -44,58 +62,79 @@ class OfflineSyncHelper( } suspend fun updateWork() { - val id = workManager.getWorkInfosForUniqueWork(apiPrefs.user?.id.toString()).await().firstOrNull()?.id - val workRequest = createWorkRequest(id) + val workRequest = createPeriodicWorkRequest() workManager.updateWork(workRequest) } - suspend fun scheduleWork() { - val workRequest = createWorkRequest() + suspend fun scheduleWork(policy: ExistingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, addDelay: Boolean = false) { + val workRequest = createPeriodicWorkRequest(addDelay) workManager.enqueueUniquePeriodicWork( apiPrefs.user?.id.toString(), - ExistingPeriodicWorkPolicy.UPDATE, + policy, workRequest ) } - fun syncOnce(courseIds: List) { + suspend fun syncOnce(courseIds: List) { + val syncSettings = syncSettingsFacade.getSyncSettings() + val constraints = Constraints.Builder() + .setRequiredNetworkType(if (syncSettings.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build() val inputData = Data.Builder() .putLongArray(COURSE_IDS, courseIds.toLongArray()) .build() val workRequest = OneTimeWorkRequest.Builder(OfflineSyncWorker::class.java) - .addTag(OfflineSyncWorker.TAG) + .addTag(OfflineSyncWorker.ONE_TIME_TAG) .setInputData(inputData) + .setConstraints(constraints) .build() workManager.enqueue(workRequest) } - fun cancelRunningWorkers() { - workManager.cancelAllWorkByTag(CourseSyncWorker.TAG) + suspend fun cancelRunningWorkers() { + if (isPeriodicWorkRunning()) { + scheduleWork(ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, true) + } + + val runningWorkInfo = getRunningOneTimeWorkInfo() + if (runningWorkInfo != null) { + workManager.cancelWorkById(runningWorkInfo.id) + } } private suspend fun isWorkScheduled(): Boolean { return workManager.getWorkInfosForUniqueWork(apiPrefs.user?.id.toString()).await() - .filter { it.state != WorkInfo.State.CANCELLED } - .isNotEmpty() + .any { it.state != WorkInfo.State.CANCELLED } + } + + private suspend fun isPeriodicWorkRunning(): Boolean { + return workManager.getWorkInfosForUniqueWork(apiPrefs.user?.id.toString()).await() + .any { it.state == WorkInfo.State.RUNNING } } - private suspend fun createWorkRequest(id: UUID? = null): PeriodicWorkRequest { + private suspend fun getRunningOneTimeWorkInfo(): WorkInfo? { + return workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG).await() + .firstOrNull { it.state == WorkInfo.State.RUNNING } + } + + private suspend fun createPeriodicWorkRequest(setDelay: Boolean = false): PeriodicWorkRequest { val syncSettings = syncSettingsFacade.getSyncSettings() val constraints = Constraints.Builder() .setRequiredNetworkType(if (syncSettings.wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) .build() + val frequency: Long = if (syncSettings.syncFrequency == SyncFrequency.DAILY) 1 else 7 + val workRequestBuilder = PeriodicWorkRequest.Builder( OfflineSyncWorker::class.java, - if (syncSettings.syncFrequency == SyncFrequency.DAILY) 1 else 7, TimeUnit.DAYS + frequency, TimeUnit.DAYS ) - .addTag(OfflineSyncWorker.TAG) + .addTag(OfflineSyncWorker.PERIODIC_TAG) .setConstraints(constraints) - id?.let { - workRequestBuilder.setId(it) - } + if (setDelay) workRequestBuilder.setInitialDelay(frequency, TimeUnit.DAYS) return workRequestBuilder.build() } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncWorker.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncWorker.kt index 86365c3986..c659a6f86b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncWorker.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncWorker.kt @@ -23,9 +23,6 @@ import android.content.Context import androidx.core.app.NotificationCompat import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.WorkQuery import androidx.work.WorkerParameters import com.instructure.canvasapi2.apis.CourseAPI import com.instructure.canvasapi2.builders.RestParams @@ -45,13 +42,10 @@ import com.instructure.pandautils.room.offline.entities.CourseSyncProgressEntity import com.instructure.pandautils.room.offline.entities.DashboardCardEntity import com.instructure.pandautils.room.offline.entities.EditDashboardItemEntity import com.instructure.pandautils.room.offline.entities.EnrollmentState -import com.instructure.pandautils.room.offline.facade.SyncSettingsFacade import com.instructure.pandautils.utils.FeatureFlagProvider import dagger.assisted.Assisted import dagger.assisted.AssistedInject -import kotlinx.coroutines.delay import java.io.File -import java.util.UUID import kotlin.random.Random const val COURSE_IDS = "course-ids" @@ -60,12 +54,10 @@ const val COURSE_IDS = "course-ids" class OfflineSyncWorker @AssistedInject constructor( @Assisted private val context: Context, @Assisted workerParameters: WorkerParameters, - private val workManager: WorkManager, private val featureFlagProvider: FeatureFlagProvider, private val courseApi: CourseAPI.CoursesInterface, private val dashboardCardDao: DashboardCardDao, private val courseSyncSettingsDao: CourseSyncSettingsDao, - private val syncSettingsFacade: SyncSettingsFacade, private val editDashboardItemDao: EditDashboardItemDao, private val courseDao: CourseDao, private val courseSyncProgressDao: CourseSyncProgressDao, @@ -74,13 +66,17 @@ class OfflineSyncWorker @AssistedInject constructor( private val fileFolderDao: FileFolderDao, private val localFileDao: LocalFileDao, private val syncRouter: SyncRouter, + private val courseSync: CourseSync ) : CoroutineWorker(context, workerParameters) { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationId = Random.nextInt() override suspend fun doWork(): Result { - if (!featureFlagProvider.offlineEnabled() && apiPrefs.user == null) return Result.success() + courseSyncProgressDao.deleteAll() + fileSyncProgressDao.deleteAll() + + if (!featureFlagProvider.offlineEnabled() || apiPrefs.user == null) return Result.success() val dashboardCards = courseApi.getDashboardCourses(RestParams(isForceReadFromNetwork = true)).dataOrNull.orEmpty() @@ -119,52 +115,33 @@ class OfflineSyncWorker @AssistedInject constructor( } editDashboardItemDao.updateEntities(allCourses) - val courseIds = inputData.getLongArray(COURSE_IDS) - val courses = courseIds?.let { - courseSyncSettingsDao.findByIds(courseIds.toList()) - } ?: courseSyncSettingsDao.findAll() + val courseIds = inputData.getLongArray(COURSE_IDS)?.toSet() + val courses = courseSyncSettingsDao.findAll() - val courseIdsToRemove = courseSyncSettingsDao.findAll().filter { !it.anySyncEnabled }.map { it.courseId } + var (coursesToSync, coursesToDelete) = courses.partition { it.anySyncEnabled } + + val courseIdsToRemove = coursesToDelete.map { it.courseId } courseDao.deleteByIds(courseIdsToRemove) courseIdsToRemove.forEach { cleanupFiles(it) } - val settingsMap = courses.associateBy { it.courseId } - - val courseWorkers = courses.filter { it.anySyncEnabled } - .map { CourseSyncWorker.createOnTimeWork(it.courseId, syncSettingsFacade.getSyncSettings().wifiOnly) } + coursesToSync = coursesToSync.filter { courseIds?.contains(it.courseId) ?: true } - val courseProgresses = courseWorkers.map { - val courseId = it.workSpec.input.getLong(CourseSyncWorker.COURSE_ID, 0) - CourseSyncProgressEntity( - workerId = it.id.toString(), - courseId = courseId, - courseName = settingsMap[courseId]?.courseName.orEmpty(), - ) + coursesToSync.map { CourseSyncProgressEntity(it.courseId, it.courseName) }.let { + courseSyncProgressDao.insertAll(it) } - courseSyncProgressDao.deleteAll() - fileSyncProgressDao.deleteAll() - courseSyncProgressDao.insertAll(courseProgresses) - - workManager.beginWith(courseWorkers) - .enqueue() - - val workerIds = courseWorkers.map { it.id } - - while (true) { - delay(1000) - val infos = workManager.getWorkInfos(WorkQuery.fromIds(workerIds)).get() - - if (infos.isEmpty()) break + courseSync.syncCourses(coursesToSync.map { it.courseId }) - if (infos.any { it.state == WorkInfo.State.CANCELLED }) break + val courseProgresses = courseSyncProgressDao.findAll() + val fileProgresses = fileSyncProgressDao.findAll() - if (infos.all { it.state.isFinished }) { - showNotification(infos.size, infos.all { it.state == WorkInfo.State.SUCCEEDED }) - break - } + if (courseProgresses.isNotEmpty() && fileProgresses.isNotEmpty()) { + showNotification( + courseProgresses.size, + courseProgresses.all { it.progressState == ProgressState.COMPLETED } + && fileProgresses.all { it.progressState == ProgressState.COMPLETED }) } return Result.success() @@ -224,6 +201,7 @@ class OfflineSyncWorker @AssistedInject constructor( companion object { const val CHANNEL_ID = "syncChannel" - const val TAG = "OfflineSyncWorker" + const val PERIODIC_TAG = "OfflineSyncWorkerPeriodic" + const val ONE_TIME_TAG = "OfflineSyncWorkerOneTime" } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewData.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewData.kt index d725359345..c67df78fd6 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewData.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewData.kt @@ -34,7 +34,6 @@ data class SyncProgressViewData(val items: List) data class CourseProgressViewData( val courseName: String, val courseId: Long, - val workerId: String, val files: FilesTabProgressItemViewModel?, val additionalFiles: AdditionalFilesProgressItemViewModel, @Bindable var tabs: List? = null, @@ -62,7 +61,7 @@ data class CourseProgressViewData( data class TabProgressViewData( val tabId: String, val tabName: String, - val workerId: String, + val courseId: Long, @Bindable var state: ProgressState = ProgressState.IN_PROGRESS ) : BaseObservable() { @@ -92,7 +91,7 @@ data class FileSyncProgressViewData( } data class FileTabProgressViewData( - val courseWorkerId: String, + val courseId: Long, var items: List, @Bindable var totalSize: String = "", @Bindable var progress: Int = 0, @@ -112,7 +111,7 @@ data class FileTabProgressViewData( } data class AdditionalFilesProgressViewData( - val courseWorkerId: String, + val courseId: Long, @Bindable var totalSize: String = "", @Bindable var state: ProgressState = ProgressState.IN_PROGRESS ) : BaseObservable() { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewModel.kt index 9354b88e83..8381671c68 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewModel.kt @@ -89,11 +89,10 @@ class SyncProgressViewModel @Inject constructor( val data = CourseProgressViewData( courseName = courseSyncProgressEntity.courseName, courseId = courseSyncProgressEntity.courseId, - workerId = courseSyncProgressEntity.workerId, size = context.getString(R.string.syncProgress_syncQueued), files = if (courseSyncSettings?.files?.isNotEmpty() == true || courseSyncSettings?.courseSyncSettings?.fullFileSync == true) { FilesTabProgressItemViewModel( - data = FileTabProgressViewData(courseWorkerId = courseSyncProgressEntity.workerId, items = emptyList()), + data = FileTabProgressViewData(courseId = courseSyncProgressEntity.courseId, items = emptyList()), context = context, courseSyncProgressDao = courseSyncProgressDao, fileSyncProgressDao = fileSyncProgressDao @@ -103,7 +102,7 @@ class SyncProgressViewModel @Inject constructor( }, additionalFiles = AdditionalFilesProgressItemViewModel( - data = AdditionalFilesProgressViewData(courseWorkerId = courseSyncProgressEntity.workerId), + data = AdditionalFilesProgressViewData(courseId = courseSyncProgressEntity.courseId), fileSyncProgressDao = fileSyncProgressDao, courseSyncProgressDao = courseSyncProgressDao, context = context @@ -114,15 +113,15 @@ class SyncProgressViewModel @Inject constructor( } fun cancel() { - offlineSyncHelper.cancelRunningWorkers() viewModelScope.launch { + offlineSyncHelper.cancelRunningWorkers() courseSyncProgressDao.deleteAll() fileSyncProgressDao.deleteAll() _events.postValue(Event(SyncProgressAction.Back)) } } - private fun retry() { + private suspend fun retry() { offlineSyncHelper.syncOnce(courseIds) } @@ -132,9 +131,9 @@ class SyncProgressViewModel @Inject constructor( viewModelScope.launch { courseSyncProgressDao.deleteAll() fileSyncProgressDao.deleteAll() + retry() + _events.postValue(Event(SyncProgressAction.Back)) } - retry() - _events.postValue(Event(SyncProgressAction.Back)) } ProgressState.IN_PROGRESS -> _events.postValue(Event(SyncProgressAction.CancelConfirmation)) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/AdditionalFilesProgressItemViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/AdditionalFilesProgressItemViewModel.kt index 722ba63f68..7d279ee9f0 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/AdditionalFilesProgressItemViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/AdditionalFilesProgressItemViewModel.kt @@ -61,7 +61,7 @@ data class AdditionalFilesProgressItemViewModel( data.updateTotalSize(NumberHelper.readableFileSize(context, totalSize)) } - private val courseProgressLiveData = courseSyncProgressDao.findByWorkerIdLiveData(data.courseWorkerId) + private val courseProgressLiveData = courseSyncProgressDao.findByCourseIdLiveData(data.courseId) private var fileProgressLiveData: LiveData>? = null private val courseProgressObserver = Observer { progress -> diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/CourseProgressItemViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/CourseProgressItemViewModel.kt index 2948cfb67e..bec20f6462 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/CourseProgressItemViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/CourseProgressItemViewModel.kt @@ -68,27 +68,27 @@ data class CourseProgressItemViewModel( courseSyncProgressEntity = courseProgress if (data.tabs == null && courseProgress.tabs.isNotEmpty()) { - createTabs(courseProgress.tabs, courseProgress.workerId) + createTabs(courseProgress.tabs, courseProgress.courseId) } updateProgress() } init { - courseProgressLiveData = courseSyncProgressDao.findByWorkerIdLiveData(data.workerId) + courseProgressLiveData = courseSyncProgressDao.findByCourseIdLiveData(data.courseId) courseProgressLiveData?.observeForever(progressObserver) fileProgressLiveData = fileSyncProgressDao.findByCourseIdLiveData(data.courseId) fileProgressLiveData?.observeForever(fileProgressObserver) } - private fun createTabs(tabs: Map, courseWorkerId: String) { + private fun createTabs(tabs: Map, courseId: Long) { val tabViewModels = tabs.map { tabEntry -> TabProgressItemViewModel( TabProgressViewData( tabEntry.key, tabEntry.value.tabName, - courseWorkerId, + courseId, tabEntry.value.state ), courseSyncProgressDao diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/FilesTabProgressItemViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/FilesTabProgressItemViewModel.kt index 5309733a31..f0b66a1509 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/FilesTabProgressItemViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/FilesTabProgressItemViewModel.kt @@ -46,7 +46,7 @@ data class FilesTabProgressItemViewModel( override val viewType = ViewType.COURSE_FILE_TAB_PROGRESS.viewType private var fileProgressLiveData: LiveData>? = null - private val courseProgressLiveData = courseSyncProgressDao.findByWorkerIdLiveData(data.courseWorkerId) + private val courseProgressLiveData = courseSyncProgressDao.findByCourseIdLiveData(data.courseId) private val fileProgressObserver = Observer> { progresses -> if (progresses.isEmpty()) { diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/TabProgressItemViewModel.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/TabProgressItemViewModel.kt index 4f68a5ad9c..2a51b90f3b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/TabProgressItemViewModel.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/TabProgressItemViewModel.kt @@ -34,7 +34,7 @@ data class TabProgressItemViewModel( override val viewType = ViewType.COURSE_TAB_PROGRESS.viewType - private val progressLiveData = courseSyncProgressDao.findByWorkerIdLiveData(data.workerId) + private val progressLiveData = courseSyncProgressDao.findByCourseIdLiveData(data.courseId) private val progressObserver = Observer { progress -> progress?.tabs?.get(data.tabId)?.let { tabProgress -> diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/CourseSyncProgressDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/CourseSyncProgressDao.kt index a3ad581587..9026a2722c 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/CourseSyncProgressDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/CourseSyncProgressDao.kt @@ -46,8 +46,8 @@ interface CourseSyncProgressDao { @Query("SELECT * FROM CourseSyncProgressEntity WHERE courseId = :courseId") suspend fun findByCourseId(courseId: Long): CourseSyncProgressEntity? - @Query("SELECT * FROM CourseSyncProgressEntity WHERE workerId = :workerId") - fun findByWorkerIdLiveData(workerId: String): LiveData + @Query("SELECT * FROM CourseSyncProgressEntity WHERE courseId = :courseId") + fun findByCourseIdLiveData(courseId: Long): LiveData @Query("DELETE FROM CourseSyncProgressEntity") suspend fun deleteAll() diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/CourseSyncProgressEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/CourseSyncProgressEntity.kt index c7e4872a84..c30b1522e8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/CourseSyncProgressEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/CourseSyncProgressEntity.kt @@ -29,7 +29,6 @@ const val TAB_PROGRESS_SIZE = 100 * 1000 data class CourseSyncProgressEntity( @PrimaryKey val courseId: Long, - val workerId: String, val courseName: String, val tabs: Map = emptyMap(), val additionalFilesStarted: Boolean = false, diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelperTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelperTest.kt index e4069f017a..d84a44897d 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelperTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelperTest.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.work.Data +import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest @@ -51,6 +52,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import java.time.Duration +import java.util.UUID @ExperimentalCoroutinesApi class OfflineSyncHelperTest { @@ -78,6 +80,13 @@ class OfflineSyncHelperTest { every { workManager.enqueue(any()) } returns OperationImpl() every { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } returns OperationImpl() + coEvery { syncSettingsFacade.getSyncSettings() } returns SyncSettingsEntity( + 1L, + false, + SyncFrequency.DAILY, + true + ) + offlineSyncHelper = OfflineSyncHelper(workManager, syncSettingsFacade, apiPrefs) } @@ -92,6 +101,9 @@ class OfflineSyncHelperTest { true ) every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture(mockk(relaxed = true)) + every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( + emptyList() + ) offlineSyncHelper.syncCourses(courseIds) @@ -123,6 +135,9 @@ class OfflineSyncHelperTest { true ) every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture(emptyList()) + every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( + emptyList() + ) offlineSyncHelper.syncCourses(courseIds) @@ -150,12 +165,14 @@ class OfflineSyncHelperTest { true ) every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture(emptyList()) + every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( + emptyList() + ) offlineSyncHelper.syncCourses(courseIds) val captor = slot() coVerify(exactly = 1) { - workManager.cancelAllWorkByTag(CourseSyncWorker.TAG) workManager.enqueue(capture(captor)) } coVerify(exactly = 0) { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } @@ -259,33 +276,118 @@ class OfflineSyncHelperTest { } @Test - fun `Cancel running workers`() { + fun `Cancel running one time workers`() = runTest { + val uuid = UUID.randomUUID() + every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture(emptyList()) + every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( + listOf( + WorkInfo(uuid, WorkInfo.State.RUNNING, Data.EMPTY, emptyList(), Data.EMPTY, 1, 1) + ) + ) + offlineSyncHelper.cancelRunningWorkers() verify { - workManager.cancelAllWorkByTag(CourseSyncWorker.TAG) + workManager.cancelWorkById(uuid) } } @Test - fun `scheduleWorkAfterLogin should schedule work when auto sync is enabled and no work is already scheduled`() = runTest { - every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture(emptyList()) - coEvery { syncSettingsFacade.getSyncSettings() } returns SyncSettingsEntity(autoSyncEnabled = true, syncFrequency = SyncFrequency.DAILY, wifiOnly = true) + fun `Cancel running periodic workers`() = runTest { + val uuid = UUID.randomUUID() + every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture( + listOf( + WorkInfo(uuid, WorkInfo.State.RUNNING, Data.EMPTY, emptyList(), Data.EMPTY, 1, 1) + ) + ) + every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( + emptyList() + ) - offlineSyncHelper.scheduleWorkAfterLogin() + offlineSyncHelper.cancelRunningWorkers() + + val captor = slot() + + verify { + workManager.enqueueUniquePeriodicWork(any(), any(), capture(captor)) + } - coVerify { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } + val workRequest = captor.captured + + assertEquals(Duration.ofDays(1).toMillis(), workRequest.workSpec.initialDelay) } + @Test + fun `scheduleWorkAfterLogin should schedule work when auto sync is enabled and no work is already scheduled`() = + runTest { + every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture(emptyList()) + coEvery { syncSettingsFacade.getSyncSettings() } returns SyncSettingsEntity( + autoSyncEnabled = true, + syncFrequency = SyncFrequency.DAILY, + wifiOnly = true + ) + + offlineSyncHelper.scheduleWorkAfterLogin() + + coVerify { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } + } + @Test fun `scheduleWorkAfterLogin should not schedule work when auto sync is disabled`() = runTest { - coEvery { syncSettingsFacade.getSyncSettings() } returns SyncSettingsEntity(autoSyncEnabled = false, syncFrequency = SyncFrequency.DAILY, wifiOnly = true) + coEvery { syncSettingsFacade.getSyncSettings() } returns SyncSettingsEntity( + autoSyncEnabled = false, + syncFrequency = SyncFrequency.DAILY, + wifiOnly = true + ) offlineSyncHelper.scheduleWorkAfterLogin() coVerify(exactly = 0) { workManager.enqueueUniquePeriodicWork(any(), any(), any()) } } + @Test + fun `Restart periodic worker`() = runTest { + val uuid = UUID.randomUUID() + every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture( + listOf( + WorkInfo(uuid, WorkInfo.State.RUNNING, Data.EMPTY, emptyList(), Data.EMPTY, 1, 1) + ) + ) + every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( + emptyList() + ) + + offlineSyncHelper.syncCourses(listOf(1L, 2L, 3L)) + + val policyCaptor = slot() + val workRequestCaptor = slot() + verify { + workManager.enqueueUniquePeriodicWork(any(), capture(policyCaptor), capture(workRequestCaptor)) + } + + assertEquals(ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE, policyCaptor.captured) + assertEquals(0, workRequestCaptor.captured.workSpec.initialDelay) + } + + @Test + fun `Restart one time worker`() = runTest { + val uuid = UUID.randomUUID() + every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture( + emptyList() + ) + every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( + listOf( + WorkInfo(uuid, WorkInfo.State.RUNNING, Data.EMPTY, emptyList(), Data.EMPTY, 1, 1) + ) + ) + + offlineSyncHelper.syncCourses(listOf(1L, 2L, 3L)) + + verify { + workManager.cancelWorkById(uuid) + } + } + @Test fun `scheduleWorkAfterLogin should not schedule work when work is already scheduled`() = runTest { every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture(mockk(relaxed = true)) diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/AggregateProgressObserverTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/AggregateProgressObserverTest.kt index ebeab7b378..9e2d976fd2 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/AggregateProgressObserverTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/AggregateProgressObserverTest.kt @@ -30,13 +30,16 @@ import com.instructure.pandautils.room.offline.daos.FileSyncProgressDao import com.instructure.pandautils.room.offline.entities.CourseSyncProgressEntity import com.instructure.pandautils.room.offline.entities.CourseSyncSettingsEntity import com.instructure.pandautils.room.offline.entities.FileSyncProgressEntity -import io.mockk.* +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll import junit.framework.TestCase.assertEquals import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test -import java.util.* class AggregateProgressObserverTest { @@ -65,10 +68,8 @@ class AggregateProgressObserverTest { @Test fun `Course update aggregate progress`() { - val course1UUID = UUID.randomUUID().toString() var courseProgress = CourseSyncProgressEntity( 1L, - course1UUID, "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS @@ -100,22 +101,14 @@ class AggregateProgressObserverTest { @Test fun `Aggregate progress updates`() { - val course1Id = UUID.randomUUID() - val course2Id = UUID.randomUUID() - - val file1Id = UUID.randomUUID() - val file2Id = UUID.randomUUID() - var course1 = CourseSyncProgressEntity( 1L, - course1Id.toString(), "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS ) var course2 = CourseSyncProgressEntity( 2L, - course2Id.toString(), "Course 2", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS @@ -182,15 +175,8 @@ class AggregateProgressObserverTest { @Test fun `Update total size and progress with additional files`() { - val course1Id = UUID.randomUUID() - - val file1Id = UUID.randomUUID() - val file2Id = UUID.randomUUID() - val file3Id = UUID.randomUUID() - var course1Progress = CourseSyncProgressEntity( 1L, - course1Id.toString(), "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, additionalFilesStarted = true, @@ -298,11 +284,8 @@ class AggregateProgressObserverTest { @Test fun `Error state`() { - val course1UUID = UUID.randomUUID().toString() - var course1 = CourseSyncProgressEntity( 1L, - course1UUID, "Course 1", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewModelTest.kt index e778047699..e1b6aa2028 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/SyncProgressViewModelTest.kt @@ -106,12 +106,9 @@ class SyncProgressViewModelTest { @Test fun `Init state`() = runTest { - val course1UUID = UUID.randomUUID().toString() - val course2UUID = UUID.randomUUID().toString() - val courseProgresses = listOf( - CourseSyncProgressEntity(1L, course1UUID, "Course 1", emptyMap()), - CourseSyncProgressEntity(2L, course2UUID, "Course 2", emptyMap()) + CourseSyncProgressEntity(1L, "Course 1", emptyMap()), + CourseSyncProgressEntity(2L, "Course 2", emptyMap()) ) coEvery { courseSyncProgressDao.findAll() } returns courseProgresses @@ -134,22 +131,21 @@ class SyncProgressViewModelTest { courseName = "Course 1", courseId = 1L, files = - FilesTabProgressItemViewModel( - data = FileTabProgressViewData( - courseWorkerId = course1UUID, - items = emptyList(), - ), - context = context, - courseSyncProgressDao = courseSyncProgressDao, - fileSyncProgressDao = fileSyncProgressDao + FilesTabProgressItemViewModel( + data = FileTabProgressViewData( + courseId = 1L, + items = emptyList(), ), + context = context, + courseSyncProgressDao = courseSyncProgressDao, + fileSyncProgressDao = fileSyncProgressDao + ), additionalFiles = AdditionalFilesProgressItemViewModel( - data = AdditionalFilesProgressViewData(course1UUID), + data = AdditionalFilesProgressViewData(1L), context = context, courseSyncProgressDao = courseSyncProgressDao, fileSyncProgressDao = fileSyncProgressDao ), - workerId = course1UUID ), context = context, courseSyncProgressDao = courseSyncProgressDao, @@ -160,12 +156,11 @@ class SyncProgressViewModelTest { courseName = "Course 2", files = null, additionalFiles = AdditionalFilesProgressItemViewModel( - data = AdditionalFilesProgressViewData(course2UUID), + data = AdditionalFilesProgressViewData(2L), context = context, courseSyncProgressDao = courseSyncProgressDao, fileSyncProgressDao = fileSyncProgressDao ), - workerId = course2UUID, courseId = 2L ), context = context, @@ -179,8 +174,7 @@ class SyncProgressViewModelTest { @Test fun `Retry`() = runTest { - val course1UUID = UUID.randomUUID().toString() - val courseProgress = CourseSyncProgressEntity(1L, course1UUID, "Course 1", emptyMap(), progressState = ProgressState.ERROR) + val courseProgress = CourseSyncProgressEntity(1L, "Course 1", emptyMap(), progressState = ProgressState.ERROR) coEvery { courseSyncProgressDao.findAll() } returns listOf(courseProgress) @@ -213,8 +207,8 @@ class SyncProgressViewModelTest { @Test fun `Cancel`() = runTest { - val course1UUID = UUID.randomUUID().toString() - val syncProgress = CourseSyncProgressEntity(1L, course1UUID, "Course 1", emptyMap(), progressState = ProgressState.IN_PROGRESS) + val syncProgress = + CourseSyncProgressEntity(1L, "Course 1", emptyMap(), progressState = ProgressState.IN_PROGRESS) coEvery { courseSyncProgressDao.findAll() } returns listOf(syncProgress) diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/AdditionalFilesProgressItemViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/AdditionalFilesProgressItemViewModelTest.kt index 2e8b112619..fbb2e470f8 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/AdditionalFilesProgressItemViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/AdditionalFilesProgressItemViewModelTest.kt @@ -59,10 +59,8 @@ class AdditionalFilesProgressItemViewModelTest { @Test fun `Success if no files`() { - val courseUUID = UUID.randomUUID() val courseProgress = CourseSyncProgressEntity( 1L, - courseUUID.toString(), "Course", emptyMap(), progressState = ProgressState.COMPLETED, @@ -70,17 +68,16 @@ class AdditionalFilesProgressItemViewModelTest { ) val courseLiveData = MutableLiveData(courseProgress) - every { courseSyncProgressDao.findByWorkerIdLiveData(courseUUID.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData every { fileSyncProgressDao.findAdditionalFilesByCourseIdLiveData(1L) } returns MutableLiveData(emptyList()) - itemViewModel = createItemViewModel(courseUUID) + itemViewModel = createItemViewModel(1L) assertEquals(ProgressState.COMPLETED, itemViewModel.data.state) } @Test fun `Add internal files sizes to total size when progress is starting`() { - val courseUUID = UUID.randomUUID() val additionalFileSyncData = listOf( FileSyncProgressEntity( courseId = 1L, @@ -110,13 +107,13 @@ class AdditionalFilesProgressItemViewModelTest { val fileLiveData = MutableLiveData(additionalFileSyncData) val courseProgress = - CourseSyncProgressEntity(1L, courseUUID.toString(), "Course", emptyMap(), additionalFilesStarted = true) + CourseSyncProgressEntity(1L, "Course", emptyMap(), additionalFilesStarted = true) val courseLiveData = MutableLiveData(courseProgress) - every { courseSyncProgressDao.findByWorkerIdLiveData(courseUUID.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData every { fileSyncProgressDao.findAdditionalFilesByCourseIdLiveData(1L) } returns fileLiveData - itemViewModel = createItemViewModel(courseUUID) + itemViewModel = createItemViewModel(1L) assertEquals(ProgressState.IN_PROGRESS, itemViewModel.data.state) assertEquals("3000 bytes", itemViewModel.data.totalSize) @@ -124,10 +121,6 @@ class AdditionalFilesProgressItemViewModelTest { @Test fun `Update total file size for external files and progress`() { - val courseUUID = UUID.randomUUID() - val file1UUID = UUID.randomUUID() - val file2UUID = UUID.randomUUID() - val file3UUID = UUID.randomUUID() val additionalFileSyncData = listOf( FileSyncProgressEntity( courseId = 1L, @@ -163,16 +156,15 @@ class AdditionalFilesProgressItemViewModelTest { val courseProgress = CourseSyncProgressEntity( 1L, - courseUUID.toString(), "Course", emptyMap(), additionalFilesStarted = true, progressState = ProgressState.IN_PROGRESS ) val courseLiveData = MutableLiveData(courseProgress) - every { courseSyncProgressDao.findByWorkerIdLiveData(courseUUID.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData - itemViewModel = createItemViewModel(courseUUID) + itemViewModel = createItemViewModel(1L) assertEquals(ProgressState.IN_PROGRESS, itemViewModel.data.state) assertEquals("3000 bytes", itemViewModel.data.totalSize) @@ -192,7 +184,6 @@ class AdditionalFilesProgressItemViewModelTest { @Test fun `Error state`() { - val courseUUID = UUID.randomUUID() val additionalFileSyncData = listOf( FileSyncProgressEntity( courseId = 1L, @@ -227,23 +218,22 @@ class AdditionalFilesProgressItemViewModelTest { val courseProgress = CourseSyncProgressEntity( 1L, - courseUUID.toString(), "Course", emptyMap(), additionalFilesStarted = true, progressState = ProgressState.IN_PROGRESS ) val courseLiveData = MutableLiveData(courseProgress) - every { courseSyncProgressDao.findByWorkerIdLiveData(courseUUID.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData - itemViewModel = createItemViewModel(courseUUID) + itemViewModel = createItemViewModel(1L) assertEquals(ProgressState.ERROR, itemViewModel.data.state) } - private fun createItemViewModel(uuid: UUID): AdditionalFilesProgressItemViewModel { + private fun createItemViewModel(courseId: Long): AdditionalFilesProgressItemViewModel { return AdditionalFilesProgressItemViewModel( - data = AdditionalFilesProgressViewData(courseWorkerId = uuid.toString()), + data = AdditionalFilesProgressViewData(courseId = courseId), context = context, fileSyncProgressDao = fileSyncProgressDao, courseSyncProgressDao = courseSyncProgressDao diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/CourseProgressItemViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/CourseProgressItemViewModelTest.kt index aab9e9e88f..bb41aa0124 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/CourseProgressItemViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/CourseProgressItemViewModelTest.kt @@ -31,14 +31,17 @@ import com.instructure.pandautils.room.offline.daos.FileSyncProgressDao import com.instructure.pandautils.room.offline.entities.CourseSyncProgressEntity import com.instructure.pandautils.room.offline.entities.CourseSyncSettingsEntity import com.instructure.pandautils.room.offline.entities.FileSyncProgressEntity -import io.mockk.* +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test -import java.util.* class CourseProgressItemViewModelTest { @@ -65,20 +68,18 @@ class CourseProgressItemViewModelTest { @Test fun `Create tab items`() { - val uuid = UUID.randomUUID() val courseProgress = CourseSyncProgressEntity( 1L, - uuid.toString(), "Course", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, progressState = ProgressState.IN_PROGRESS ) val courseLiveData = MutableLiveData(courseProgress) - every { courseSyncProgressDao.findByWorkerIdLiveData(uuid.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData every { fileSyncProgressDao.findByCourseIdLiveData(1L) } returns MutableLiveData(emptyList()) - courseProgressItemViewModel = createItemViewModel(uuid) + courseProgressItemViewModel = createItemViewModel(1L) assertEquals("1000000 bytes", courseProgressItemViewModel.data.size) @@ -90,10 +91,8 @@ class CourseProgressItemViewModelTest { @Test fun `Failed course sync`() { - val uuid = UUID.randomUUID() val courseProgress = CourseSyncProgressEntity( 1L, - uuid.toString(), "Course", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.ERROR) }, additionalFilesStarted = true, @@ -101,20 +100,18 @@ class CourseProgressItemViewModelTest { ) val courseLiveData = MutableLiveData(courseProgress) - every { courseSyncProgressDao.findByWorkerIdLiveData(uuid.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData every { fileSyncProgressDao.findByCourseIdLiveData(1L) } returns MutableLiveData(emptyList()) - courseProgressItemViewModel = createItemViewModel(uuid) + courseProgressItemViewModel = createItemViewModel(1L) assertEquals(ProgressState.ERROR, courseProgressItemViewModel.data.state) } @Test fun `Failed file sync`() { - val uuid = UUID.randomUUID() val courseProgress = CourseSyncProgressEntity( 1L, - uuid.toString(), "Course", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.COMPLETED) }, progressState = ProgressState.COMPLETED @@ -149,7 +146,7 @@ class CourseProgressItemViewModelTest { val courseLiveData = MutableLiveData(courseProgress) val fileLiveData = MutableLiveData(fileProgresses) - every { courseSyncProgressDao.findByWorkerIdLiveData(uuid.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData every { fileSyncProgressDao.findByCourseIdLiveData(1L) } returns fileLiveData fileProgresses = listOf( @@ -180,7 +177,7 @@ class CourseProgressItemViewModelTest { ) fileLiveData.postValue(fileProgresses) - courseProgressItemViewModel = createItemViewModel(uuid) + courseProgressItemViewModel = createItemViewModel(1L) assertEquals(ProgressState.IN_PROGRESS, courseProgressItemViewModel.data.state) fileProgresses = listOf( @@ -217,10 +214,8 @@ class CourseProgressItemViewModelTest { @Test fun `Sync success`() { - val uuid = UUID.randomUUID() val courseProgress = CourseSyncProgressEntity( 1L, - uuid.toString(), "Course", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.COMPLETED) }, progressState = ProgressState.COMPLETED @@ -255,10 +250,10 @@ class CourseProgressItemViewModelTest { val courseLiveData = MutableLiveData(courseProgress) val fileLiveData = MutableLiveData(fileProgresses) - every { courseSyncProgressDao.findByWorkerIdLiveData(uuid.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData every { fileSyncProgressDao.findByCourseIdLiveData(1L) } returns fileLiveData - courseProgressItemViewModel = createItemViewModel(uuid) + courseProgressItemViewModel = createItemViewModel(1L) assertEquals(ProgressState.COMPLETED, courseProgressItemViewModel.data.state) assertFalse(courseProgressItemViewModel.data.failed) @@ -266,10 +261,8 @@ class CourseProgressItemViewModelTest { @Test fun `Update total size with additional files`() { - val uuid = UUID.randomUUID() val courseProgress = CourseSyncProgressEntity( 1L, - uuid.toString(), "Course", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.COMPLETED) }, additionalFilesStarted = true, @@ -277,7 +270,7 @@ class CourseProgressItemViewModelTest { ) val courseLiveData = MutableLiveData(courseProgress) - every { courseSyncProgressDao.findByWorkerIdLiveData(uuid.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData val files = listOf( FileSyncProgressEntity( @@ -319,7 +312,7 @@ class CourseProgressItemViewModelTest { val filesLiveData = MutableLiveData(files) every { fileSyncProgressDao.findByCourseIdLiveData(1L) } returns filesLiveData - courseProgressItemViewModel = createItemViewModel(uuid) + courseProgressItemViewModel = createItemViewModel(1L) assertEquals(ProgressState.IN_PROGRESS, courseProgressItemViewModel.data.state) // We don't know the size of the external file yet, so we won't add this to the total size @@ -341,15 +334,14 @@ class CourseProgressItemViewModelTest { assertFalse(courseProgressItemViewModel.data.failed) } - private fun createItemViewModel(uuid: UUID): CourseProgressItemViewModel { + private fun createItemViewModel(courseId: Long): CourseProgressItemViewModel { return CourseProgressItemViewModel( data = CourseProgressViewData( courseName = "Course", - courseId = 1L, - workerId = uuid.toString(), + courseId = courseId, files = null, additionalFiles = AdditionalFilesProgressItemViewModel( - data = AdditionalFilesProgressViewData(uuid.toString()), + data = AdditionalFilesProgressViewData(courseId), context = context, fileSyncProgressDao = fileSyncProgressDao, courseSyncProgressDao = courseSyncProgressDao diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/FilesTabProgressItemViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/FilesTabProgressItemViewModelTest.kt index 4da3b3777e..eda530d005 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/FilesTabProgressItemViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/FilesTabProgressItemViewModelTest.kt @@ -28,13 +28,16 @@ import com.instructure.pandautils.room.offline.daos.CourseSyncProgressDao import com.instructure.pandautils.room.offline.daos.FileSyncProgressDao import com.instructure.pandautils.room.offline.entities.CourseSyncProgressEntity import com.instructure.pandautils.room.offline.entities.FileSyncProgressEntity -import io.mockk.* +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkAll import junit.framework.TestCase.assertEquals import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test -import java.util.* class FilesTabProgressItemViewModelTest { @@ -61,11 +64,9 @@ class FilesTabProgressItemViewModelTest { @Test fun `Success if no files`() { - val courseUUID = UUID.randomUUID() val courseProgress = CourseSyncProgressEntity( 1L, - courseUUID.toString(), "Course", emptyMap(), additionalFilesStarted = true, @@ -73,17 +74,16 @@ class FilesTabProgressItemViewModelTest { ) val courseLiveData = MutableLiveData(courseProgress) - every { courseSyncProgressDao.findByWorkerIdLiveData(courseUUID.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData every { fileSyncProgressDao.findCourseFilesByCourseIdLiveData(1L) } returns MutableLiveData(emptyList()) - filesTabProgressItemViewModel = createItemViewModel(courseUUID) + filesTabProgressItemViewModel = createItemViewModel(1L) assertEquals(ProgressState.COMPLETED, filesTabProgressItemViewModel.data.state) } @Test fun `Create file items`() { - val courseUUID = UUID.randomUUID() val fileSyncProgresses = listOf( FileSyncProgressEntity( courseId = 1L, @@ -113,7 +113,6 @@ class FilesTabProgressItemViewModelTest { val courseProgress = CourseSyncProgressEntity( 1L, - courseUUID.toString(), "Course", emptyMap(), additionalFilesStarted = true, @@ -122,10 +121,10 @@ class FilesTabProgressItemViewModelTest { val courseLiveData = MutableLiveData(courseProgress) val fileLiveData = MutableLiveData(fileSyncProgresses) - every { courseSyncProgressDao.findByWorkerIdLiveData(courseUUID.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData every { fileSyncProgressDao.findCourseFilesByCourseIdLiveData(1L) } returns fileLiveData - filesTabProgressItemViewModel = createItemViewModel(courseUUID) + filesTabProgressItemViewModel = createItemViewModel(1L) assertEquals(ProgressState.IN_PROGRESS, filesTabProgressItemViewModel.data.state) assertEquals(3, filesTabProgressItemViewModel.data.items.size) @@ -139,7 +138,6 @@ class FilesTabProgressItemViewModelTest { @Test fun `Progress updates`() { - val courseUUID = UUID.randomUUID() var fileSyncData = listOf( FileSyncProgressEntity( courseId = 1L, @@ -168,7 +166,6 @@ class FilesTabProgressItemViewModelTest { ) val courseProgress = CourseSyncProgressEntity( 1L, - courseUUID.toString(), "Course", emptyMap(), progressState = ProgressState.COMPLETED @@ -176,10 +173,10 @@ class FilesTabProgressItemViewModelTest { val courseLiveData = MutableLiveData(courseProgress) val fileLiveData = MutableLiveData(fileSyncData) - every { courseSyncProgressDao.findByWorkerIdLiveData(courseUUID.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData every { fileSyncProgressDao.findCourseFilesByCourseIdLiveData(1L) } returns fileLiveData - filesTabProgressItemViewModel = createItemViewModel(courseUUID) + filesTabProgressItemViewModel = createItemViewModel(1L) assertEquals(ProgressState.IN_PROGRESS, filesTabProgressItemViewModel.data.state) assertEquals(50, filesTabProgressItemViewModel.data.progress) @@ -248,10 +245,10 @@ class FilesTabProgressItemViewModelTest { } - private fun createItemViewModel(uuid: UUID): FilesTabProgressItemViewModel { + private fun createItemViewModel(courseId: Long): FilesTabProgressItemViewModel { return FilesTabProgressItemViewModel( FileTabProgressViewData( - courseWorkerId = uuid.toString(), + courseId, emptyList(), ), context, diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/TabProgressItemViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/TabProgressItemViewModelTest.kt index 5923b506ba..6241bba1ee 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/TabProgressItemViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/progress/itemviewmodels/TabProgressItemViewModelTest.kt @@ -45,18 +45,16 @@ class TabProgressItemViewModelTest { @Test fun `Progress updates`() { - val uuid = UUID.randomUUID() var courseProgress = CourseSyncProgressEntity( 1L, - uuid.toString(), "Course", CourseSyncSettingsEntity.TABS.associateWith { TabSyncData(it, ProgressState.IN_PROGRESS) }, ) val courseLiveData = MutableLiveData(courseProgress) - every { courseSyncProgressDao.findByWorkerIdLiveData(uuid.toString()) } returns courseLiveData + every { courseSyncProgressDao.findByCourseIdLiveData(1L) } returns courseLiveData - tabProgressItemViewModel = createItemViewModel(uuid) + tabProgressItemViewModel = createItemViewModel(1L) assertEquals(ProgressState.IN_PROGRESS, tabProgressItemViewModel.data.state) @@ -69,12 +67,12 @@ class TabProgressItemViewModelTest { assertEquals(ProgressState.COMPLETED, tabProgressItemViewModel.data.state) } - private fun createItemViewModel(uuid: UUID): TabProgressItemViewModel { + private fun createItemViewModel(courseId: Long): TabProgressItemViewModel { return TabProgressItemViewModel( TabProgressViewData( Tab.ASSIGNMENTS_ID, "Assignments", - uuid.toString(), + courseId, ProgressState.IN_PROGRESS ), courseSyncProgressDao From 0068d0169aba3ae980b31c5c1d84e595bd5689d9 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Thu, 16 Nov 2023 09:29:12 +0100 Subject: [PATCH 07/49] [MBL-17182][Student] Offline content fixes (#2249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refs: MBL-17182 affects: Student release note: none * Fixed page sync issue. * Fixed HTML parsing issue. * Removed network check from edit dashboard button on the emtpy view. * Fixed dashboard cards. * Fixed not available content issue. * Fixed UI glitch with offline settings. * lock info fixes * Fixed new errors in additional files. * Fixed dashboard card updates. * Removed unused import. * Added offline check for the dashboard update. * Fixed tests. * Fixed Dao test. * Do not sync files without url. * Fixed external file size calculation and external file errors. --------- Co-authored-by: Ákos Hermann --- .../student/activity/NavigationActivity.kt | 2 +- .../student/activity/SettingsActivity.kt | 12 ++- .../adapter/DashboardRecyclerAdapter.kt | 2 + .../features/dashboard/DashboardRepository.kt | 7 +- .../features/modules/util/ModuleUtility.kt | 12 +-- .../fragment/ApplicationSettingsFragment.kt | 12 +-- .../student/fragment/DashboardFragment.kt | 42 +++++++-- .../dashboard/DashboardRepositoryTest.kt | 9 +- .../student/test/util/ModuleUtilityTest.kt | 86 ------------------- .../1.json | 28 +++--- .../room/offline/daos/LockInfoDaoTest.kt | 11 +++ .../room/offline/daos/LockedModuleDaoTest.kt | 13 +-- .../room/offline/daos/PageDaoTest.kt | 6 +- .../features/offline/sync/CourseSync.kt | 16 ++-- .../features/offline/sync/FileSync.kt | 31 +++++-- .../features/offline/sync/HtmlParser.kt | 5 +- .../offline/sync/OfflineSyncWorker.kt | 5 +- .../room/offline/daos/LockInfoDao.kt | 3 + .../pandautils/room/offline/daos/PageDao.kt | 4 +- .../room/offline/entities/LockInfoEntity.kt | 3 +- .../offline/entities/LockedModuleEntity.kt | 12 +-- .../room/offline/facade/LockInfoFacade.kt | 5 +- .../features/offline/sync/HtmlParserTest.kt | 5 +- .../room/offline/facade/LockInfoFacadeTest.kt | 24 ++++-- .../pandarecycler/PaginatedRecyclerAdapter.kt | 7 ++ 25 files changed, 186 insertions(+), 176 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt index 9187905881..7c99312709 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/NavigationActivity.kt @@ -227,7 +227,7 @@ class NavigationActivity : BaseRouterActivity(), Navigation, MasqueradingDialog. R.id.navigationDrawerItem_stopMasquerading -> { MasqueradeHelper.stopMasquerading(startActivityClass) } - R.id.navigationDrawerSettings -> startActivity(Intent(applicationContext, SettingsActivity::class.java)) + R.id.navigationDrawerSettings -> startActivity(SettingsActivity.createIntent(applicationContext, featureFlagProvider.offlineEnabled())) } } } diff --git a/apps/student/src/main/java/com/instructure/student/activity/SettingsActivity.kt b/apps/student/src/main/java/com/instructure/student/activity/SettingsActivity.kt index 78a7509f68..746dac953a 100644 --- a/apps/student/src/main/java/com/instructure/student/activity/SettingsActivity.kt +++ b/apps/student/src/main/java/com/instructure/student/activity/SettingsActivity.kt @@ -31,6 +31,8 @@ import com.instructure.student.databinding.ActivitySettingsBinding import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +private const val OFFLINE_ENABLED = "offlineEnabled" + @ScreenView(SCREEN_VIEW_SETTINGS) @AndroidEntryPoint class SettingsActivity : AppCompatActivity(){ @@ -40,10 +42,12 @@ class SettingsActivity : AppCompatActivity(){ private val binding by viewBinding(ActivitySettingsBinding::inflate) + var offlineEnabled: Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + offlineEnabled = intent.getBooleanExtra(OFFLINE_ENABLED, false) setContentView(binding.root) - networkStateProvider.isOnlineLiveData.observe(this) { isOnline -> binding.offlineIndicator.root.setVisible(!isOnline) } @@ -60,8 +64,10 @@ class SettingsActivity : AppCompatActivity(){ } companion object { - fun createIntent(context: Context): Intent { - return Intent(context, SettingsActivity::class.java) + fun createIntent(context: Context, offlineEnabled: Boolean): Intent { + return Intent(context, SettingsActivity::class.java).apply { + putExtra(OFFLINE_ENABLED, offlineEnabled) + } } } } diff --git a/apps/student/src/main/java/com/instructure/student/adapter/DashboardRecyclerAdapter.kt b/apps/student/src/main/java/com/instructure/student/adapter/DashboardRecyclerAdapter.kt index 725f9456d3..7b26e23d3f 100644 --- a/apps/student/src/main/java/com/instructure/student/adapter/DashboardRecyclerAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/adapter/DashboardRecyclerAdapter.kt @@ -135,6 +135,8 @@ class DashboardRecyclerAdapter( val dashboardCards = repository.getDashboardCourses(isRefresh) val syncedCourseIds = repository.getSyncedCourseIds() + resetData() + mCourseMap = courses.associateBy { it.id } // Map not null is needed because the dashboard api can return unpublished courses diff --git a/apps/student/src/main/java/com/instructure/student/features/dashboard/DashboardRepository.kt b/apps/student/src/main/java/com/instructure/student/features/dashboard/DashboardRepository.kt index 4d85ff6b22..760bd0f69c 100644 --- a/apps/student/src/main/java/com/instructure/student/features/dashboard/DashboardRepository.kt +++ b/apps/student/src/main/java/com/instructure/student/features/dashboard/DashboardRepository.kt @@ -45,11 +45,14 @@ class DashboardRepository( } suspend fun getDashboardCourses(forceNetwork: Boolean): List { - val dashboardCards = dataSource().getDashboardCards(forceNetwork).sortedBy { it.position } + var dashboardCards = dataSource().getDashboardCards(forceNetwork) + if (dashboardCards.all { it.position == Int.MAX_VALUE }) { + dashboardCards = dashboardCards.mapIndexed { index, dashboardCard -> dashboardCard.copy(position = index) } + } if (isOnline() && isOfflineEnabled()) { localDataSource.saveDashboardCards(dashboardCards) } - return dashboardCards + return dashboardCards.sortedBy { it.position } } suspend fun getSyncedCourseIds(): Set { diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/util/ModuleUtility.kt b/apps/student/src/main/java/com/instructure/student/features/modules/util/ModuleUtility.kt index 4a3bfe25ac..fdea8d6c99 100644 --- a/apps/student/src/main/java/com/instructure/student/features/modules/util/ModuleUtility.kt +++ b/apps/student/src/main/java/com/instructure/student/features/modules/util/ModuleUtility.kt @@ -56,11 +56,7 @@ object ModuleUtility { syncedFileIds: List, context: Context ): Fragment? = when (item.type) { - "Page" -> { - createFragmentWithOfflineCheck(isOnline, course, item, syncedTabs, context, setOf(Tab.PAGES_ID)) { - PageDetailsFragment.newInstance(PageDetailsFragment.makeRoute(course, item.title, item.pageUrl, navigatedFromModules)) - } - } + "Page" -> PageDetailsFragment.newInstance(PageDetailsFragment.makeRoute(course, item.title, item.pageUrl, navigatedFromModules)) "Assignment" -> { createFragmentWithOfflineCheck(isOnline, course, item, syncedTabs, context, setOf(Tab.ASSIGNMENTS_ID, Tab.GRADES_ID, Tab.SYLLABUS_ID)) { AssignmentDetailsFragment.newInstance(makeRoute(course, getAssignmentId(item))) @@ -78,10 +74,8 @@ object ModuleUtility { "Locked" -> LockedModuleItemFragment.newInstance(LockedModuleItemFragment.makeRoute(course, item.title!!, item.moduleDetails?.lockExplanation ?: "")) "SubHeader" -> null // Don't do anything with headers, they're just dividers so we don't show them here. "Quiz" -> { - createFragmentWithOfflineCheck(isOnline, course, item, syncedTabs, context, setOf(Tab.QUIZZES_ID)) { - val apiURL = removeDomain(item.url) - ModuleQuizDecider.newInstance(ModuleQuizDecider.makeRoute(course, item.htmlUrl!!, apiURL!!, item.contentId)) - } + val apiURL = removeDomain(item.url) + ModuleQuizDecider.newInstance(ModuleQuizDecider.makeRoute(course, item.htmlUrl!!, apiURL!!, item.contentId)) } "ChooseAssignmentGroup" -> { createFragmentWithOfflineCheck(isOnline, course, item, syncedTabs, context) { diff --git a/apps/student/src/main/java/com/instructure/student/fragment/ApplicationSettingsFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/ApplicationSettingsFragment.kt index 594680ba04..3415885946 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/ApplicationSettingsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/ApplicationSettingsFragment.kt @@ -28,7 +28,6 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.instructure.canvasapi2.utils.* import com.instructure.canvasapi2.utils.pageview.PageView -import com.instructure.loginapi.login.dialog.NoInternetConnectionDialog import com.instructure.pandautils.analytics.SCREEN_VIEW_APPLICATION_SETTINGS import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding @@ -182,11 +181,8 @@ class ApplicationSettingsFragment : ParentFragment() { private fun setUpSyncSettings() { lifecycleScope.launch { - if (!featureFlagProvider.offlineEnabled()) { - binding.offlineContentDivider.setGone() - binding.offlineContentTitle.setGone() - binding.offlineSyncSettingsContainer.setGone() - } else { + val offlineEnabled = (activity as? SettingsActivity)?.offlineEnabled ?: false + if (offlineEnabled) { syncSettingsFacade.getSyncSettingsListenable().observe(viewLifecycleOwner) { syncSettings -> if (syncSettings == null) { binding.offlineSyncSettingsContainer.setGone() @@ -202,6 +198,10 @@ class ApplicationSettingsFragment : ParentFragment() { binding.offlineSyncSettingsContainer.onClick { addFragment(SyncSettingsFragment.newInstance()) } + } else { + binding.offlineContentDivider.setGone() + binding.offlineContentTitle.setGone() + binding.offlineSyncSettingsContainer.setGone() } } } diff --git a/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt index 2bfd905eaf..9e55d7c9d3 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/DashboardFragment.kt @@ -32,10 +32,12 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.work.WorkInfo.State +import androidx.work.WorkManager +import androidx.work.WorkQuery import com.instructure.canvasapi2.managers.CourseNicknameManager import com.instructure.canvasapi2.managers.UserManager import com.instructure.canvasapi2.models.* -import com.instructure.canvasapi2.utils.APIHelper import com.instructure.canvasapi2.utils.pageview.PageView import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.catch @@ -47,6 +49,8 @@ import com.instructure.pandautils.binding.viewBinding import com.instructure.pandautils.features.dashboard.edit.EditDashboardFragment import com.instructure.pandautils.features.dashboard.notifications.DashboardNotificationsFragment import com.instructure.pandautils.features.offline.offlinecontent.OfflineContentFragment +import com.instructure.pandautils.features.offline.sync.AggregateProgressObserver +import com.instructure.pandautils.features.offline.sync.OfflineSyncWorker import com.instructure.pandautils.utils.* import com.instructure.student.R import com.instructure.student.adapter.DashboardRecyclerAdapter @@ -86,6 +90,12 @@ class DashboardFragment : ParentFragment() { @Inject lateinit var networkStateProvider: NetworkStateProvider + @Inject + lateinit var aggregateProgressObserver: AggregateProgressObserver + + @Inject + lateinit var workManager: WorkManager + private val binding by viewBinding(FragmentCourseGridBinding::bind) private lateinit var recyclerBinding: CourseGridRecyclerRefreshLayoutBinding @@ -96,6 +106,8 @@ class DashboardFragment : ParentFragment() { private var courseColumns: Int = LIST_SPAN_COUNT private var groupColumns: Int = LIST_SPAN_COUNT + private val runningWorkers = mutableSetOf() + private val somethingChangedReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { if (recyclerAdapter != null && intent?.extras?.getBoolean(Const.COURSE_FAVORITES) == true) { @@ -120,6 +132,28 @@ class DashboardFragment : ParentFragment() { recyclerAdapter?.refresh() if (online) recyclerBinding.swipeRefreshLayout.isRefreshing = true } + + lifecycleScope.launch { + if (featureFlagProvider.offlineEnabled()) { + subscribeToOfflineSyncUpdates() + } + } + } + + private fun subscribeToOfflineSyncUpdates() { + val workQuery = WorkQuery.Builder.fromTags(listOf(OfflineSyncWorker.PERIODIC_TAG, OfflineSyncWorker.ONE_TIME_TAG)).build() + workManager.getWorkInfosLiveData(workQuery).observe(this) { workInfos -> + workInfos.forEach { workInfo -> + if (workInfo.state == State.RUNNING) { + runningWorkers.add(workInfo.id.toString()) + } + } + + if (workInfos?.any { (it.state == State.SUCCEEDED || it.state == State.FAILED) && runningWorkers.contains(it.id.toString()) } == true) { + recyclerAdapter?.silentRefresh() + runningWorkers.clear() + } + } } @@ -307,11 +341,7 @@ class DashboardFragment : ParentFragment() { recyclerBinding.listView.clipToPadding = false emptyCoursesView.onClickAddCourses { - if (!APIHelper.hasNetworkConnection()) { - toast(R.string.notAvailableOffline) - } else { - RouteMatcher.route(requireActivity(), EditDashboardFragment.makeRoute()) - } + RouteMatcher.route(requireActivity(), EditDashboardFragment.makeRoute()) } } diff --git a/apps/student/src/test/java/com/instructure/student/features/dashboard/DashboardRepositoryTest.kt b/apps/student/src/test/java/com/instructure/student/features/dashboard/DashboardRepositoryTest.kt index dd8b0b4f5f..fcf03f3d66 100644 --- a/apps/student/src/test/java/com/instructure/student/features/dashboard/DashboardRepositoryTest.kt +++ b/apps/student/src/test/java/com/instructure/student/features/dashboard/DashboardRepositoryTest.kt @@ -24,7 +24,6 @@ import com.instructure.pandautils.room.offline.daos.CourseDao import com.instructure.pandautils.room.offline.daos.CourseSyncSettingsDao import com.instructure.pandautils.room.offline.entities.CourseEntity import com.instructure.pandautils.room.offline.entities.CourseSyncSettingsEntity -import com.instructure.pandautils.utils.FEATURE_FLAG_OFFLINE import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider import io.mockk.coEvery @@ -120,8 +119,9 @@ class DashboardRepositoryTest { coEvery { localDataSource.getDashboardCards(any()) } returns offlineCards val result = repository.getDashboardCourses(true) + val expected = listOf(DashboardCard(3, position = 0), DashboardCard(4, position = 1)) - Assert.assertEquals(offlineCards, result) + Assert.assertEquals(expected, result) } @Test @@ -134,13 +134,14 @@ class DashboardRepositoryTest { coEvery { localDataSource.getDashboardCards(any()) } returns offlineCards val result = repository.getDashboardCourses(true) + val expected = listOf(DashboardCard(1, position = 0), DashboardCard(2, position = 1)) - Assert.assertEquals(onlineCards, result) + Assert.assertEquals(expected, result) } @Test fun `Returned dashboard courses are saved to the local store`() = runTest { - val onlineCards = listOf(DashboardCard(1), DashboardCard(2)) + val onlineCards = listOf(DashboardCard(1, position = 0), DashboardCard(2, position = 1)) every { networkStateProvider.isOnline() } returns true coEvery { networkDataSource.getDashboardCards(any()) } returns onlineCards diff --git a/apps/student/src/test/java/com/instructure/student/test/util/ModuleUtilityTest.kt b/apps/student/src/test/java/com/instructure/student/test/util/ModuleUtilityTest.kt index cf5fd6ed3c..25e4cae7c8 100644 --- a/apps/student/src/test/java/com/instructure/student/test/util/ModuleUtilityTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/util/ModuleUtilityTest.kt @@ -128,45 +128,6 @@ class ModuleUtilityTest : TestCase() { TestCase.assertEquals(expectedBundle.toString(), parentFragment.arguments!!.toString()) } - @Test - fun testGetFragment_page_offlineSynced() { - val url = "https://mobile.canvas.net/api/v1/courses/222/pages/hello-world" - val moduleItem = ModuleItem( - id = 4567, - type = "Page", - url = url, - title = "hello-world" - ) - - val course = Course() - val expectedBundle = Bundle() - expectedBundle.putParcelable(Const.CANVAS_CONTEXT, course) - expectedBundle.putString(PageDetailsFragment.PAGE_NAME, "hello-world") - expectedBundle.putBoolean(PageDetailsFragment.NAVIGATED_FROM_MODULES, false) - - val parentFragment = callGetFragment(moduleItem, course, null, isOnline = false, tabs = setOf(Tab.PAGES_ID)) - TestCase.assertNotNull(parentFragment) - TestCase.assertEquals(PageDetailsFragment::class.java, parentFragment!!.javaClass) - TestCase.assertEquals(expectedBundle.toString(), parentFragment.arguments!!.toString()) - } - - @Test - fun testGetFragment_page_offlineNotSynced() { - val url = "https://mobile.canvas.net/api/v1/courses/222/pages/hello-world" - val moduleItem = ModuleItem( - id = 4567, - type = "Page", - url = url, - title = "hello-world" - ) - - val course = Course() - - val fragment = callGetFragment(moduleItem, course, null, isOnline = false) - TestCase.assertNotNull(fragment) - TestCase.assertEquals(NotAvailableOfflineFragment::class.java, fragment!!.javaClass) - } - @Test fun testGetFragment_assignment() { val url = "https://mobile.canvas.net/api/v1/courses/222/assignments/123456789" @@ -352,53 +313,6 @@ class ModuleUtilityTest : TestCase() { TestCase.assertEquals(expectedBundle.toString(), parentFragment.arguments!!.toString()) } - @Test - fun testGetFragment_quiz_offlineSynced() { - val url = "https://mobile.canvas.net/api/v1/courses/222/quizzes/123456789" - val htmlUrl = "https://mobile.canvas.net/courses/222/quizzes/123456789" - val apiUrl = "courses/222/quizzes/123456789" - - val moduleItem = ModuleItem( - id = 4567, - type = "Quiz", - url = url, - htmlUrl = htmlUrl, - contentId = 55 - ) - - val course = Course() - val expectedBundle = Bundle() - expectedBundle.putParcelable(Const.CANVAS_CONTEXT, course) - expectedBundle.putString(Const.URL, htmlUrl) - expectedBundle.putString(Const.API_URL, apiUrl) - expectedBundle.putLong(Const.ID, 55) - - val parentFragment = callGetFragment(moduleItem, course, null, isOnline = false, tabs = setOf(Tab.QUIZZES_ID)) - TestCase.assertNotNull(parentFragment) - TestCase.assertEquals(ModuleQuizDecider::class.java, parentFragment!!.javaClass) - TestCase.assertEquals(expectedBundle.toString(), parentFragment.arguments!!.toString()) - } - - @Test - fun testGetFragment_quiz_offlineNotSynced() { - val url = "https://mobile.canvas.net/api/v1/courses/222/quizzes/123456789" - val htmlUrl = "https://mobile.canvas.net/courses/222/quizzes/123456789" - val apiUrl = "courses/222/quizzes/123456789" - - val moduleItem = ModuleItem( - id = 4567, - type = "Quiz", - url = url, - htmlUrl = htmlUrl, - contentId = 55 - ) - - val course = Course() - val fragment = callGetFragment(moduleItem, course, null, isOnline = false) - TestCase.assertNotNull(fragment) - TestCase.assertEquals(NotAvailableOfflineFragment::class.java, fragment!!.javaClass) - } - @Test fun testGetFragment_discussion() { val url = "https://mobile.canvas.net/api/v1/courses/222/discussion_topics/123456789" diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json b/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json index 7c8f1b4539..4a8f8781a2 100644 --- a/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "a5fc40aae4d227313a98d9a89f41cfdd", + "identityHash": "a1cb353c4afbf14831de8752ace07ce9", "entities": [ { "tableName": "AssignmentDueDateEntity", @@ -4554,17 +4554,7 @@ "id" ] }, - "indices": [ - { - "name": "index_LockInfoEntity_lockedModuleId", - "unique": true, - "columnNames": [ - "lockedModuleId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LockInfoEntity_lockedModuleId` ON `${TABLE_NAME}` (`lockedModuleId`)" - } - ], + "indices": [], "foreignKeys": [ { "table": "ModuleContentDetailsEntity", @@ -4603,7 +4593,7 @@ }, { "tableName": "LockedModuleEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contextId` INTEGER NOT NULL, `contextType` TEXT, `name` TEXT, `unlockAt` TEXT, `isRequireSequentialProgress` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `LockInfoEntity`(`lockedModuleId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `contextId` INTEGER NOT NULL, `contextType` TEXT, `name` TEXT, `unlockAt` TEXT, `isRequireSequentialProgress` INTEGER NOT NULL, `lockInfoId` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`lockInfoId`) REFERENCES `LockInfoEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -4640,6 +4630,12 @@ "columnName": "isRequireSequentialProgress", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "lockInfoId", + "columnName": "lockInfoId", + "affinity": "INTEGER", + "notNull": false } ], "primaryKey": { @@ -4655,10 +4651,10 @@ "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ - "id" + "lockInfoId" ], "referencedColumns": [ - "lockedModuleId" + "id" ] } ] @@ -5507,7 +5503,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a5fc40aae4d227313a98d9a89f41cfdd')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a1cb353c4afbf14831de8752ace07ce9')" ] } } \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/LockInfoDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/LockInfoDaoTest.kt index e9107c983f..265760824a 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/LockInfoDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/LockInfoDaoTest.kt @@ -165,4 +165,15 @@ class LockInfoDaoTest { Assert.assertNull(result) } + + @Test + fun testFindByRowId() = runTest { + val expected = LockInfoEntity(LockInfo(unlockAt = "1"), assignmentId = 1) + + val rowId = lockInfoDao.insert(expected) + + val result = lockInfoDao.findByRowId(rowId) + + Assert.assertEquals(expected.assignmentId, result?.assignmentId) + } } \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/LockedModuleDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/LockedModuleDaoTest.kt index aac575c8c5..53cec27be1 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/LockedModuleDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/LockedModuleDaoTest.kt @@ -69,11 +69,11 @@ class LockedModuleDaoTest { @Test fun testFindById() = runTest { - val expected = LockedModuleEntity(LockedModule(id = 1)) + val expected = LockedModuleEntity(LockedModule(id = 1), 1L) lockedModuleDao.insert(expected) - lockedModuleDao.insert(LockedModuleEntity(LockedModule(id = 2))) - lockedModuleDao.insert(LockedModuleEntity(LockedModule(id = 3))) + lockedModuleDao.insert(LockedModuleEntity(LockedModule(id = 2), 2L)) + lockedModuleDao.insert(LockedModuleEntity(LockedModule(id = 3), 3L)) val result = lockedModuleDao.findById(1) @@ -82,14 +82,15 @@ class LockedModuleDaoTest { @Test(expected = SQLiteConstraintException::class) fun testLockInfoForeignKey() = runTest { - lockedModuleDao.insert(LockedModuleEntity(LockedModule(id = 4))) + lockedModuleDao.insert(LockedModuleEntity(LockedModule(id = 4), 4L)) } @Test fun testLockInfoCascade() = runTest { - val id = lockInfoDao.insert(LockInfoEntity(LockInfo(contextModule = LockedModule(1)))) + val rowId = lockInfoDao.insert(LockInfoEntity(LockInfo(contextModule = LockedModule(1)))) + val id = lockInfoDao.findByRowId(rowId)?.id ?: 0 - lockedModuleDao.insert(LockedModuleEntity(LockedModule(id = 1))) + lockedModuleDao.insert(LockedModuleEntity(LockedModule(id = 1), id)) lockInfoDao.delete(LockInfoEntity(id, null, null, null, null, null, null)) diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/PageDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/PageDaoTest.kt index 2749734a27..b29d25edcc 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/PageDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/PageDaoTest.kt @@ -88,14 +88,18 @@ class PageDaoTest { @Test fun testFindByUrl() = runTest { val courseEntity = CourseEntity(Course(id = 1L)) + val courseEntity2 = CourseEntity(Course(id = 2L)) courseDao.insert(courseEntity) + courseDao.insert(courseEntity2) val pageEntity = PageEntity(Page(id = 1, title = "Page1", url = "page-1-url"), courseId = 1L) val pageEntity2 = PageEntity(Page(id = 2, title = "Page2", url = "page-2-url"), courseId = 1L) + val pageEntity3 = PageEntity(Page(id = 3, title = "Page3", url = "page-2-url"), courseId = 2L) pageDao.insert(pageEntity) pageDao.insert(pageEntity2) + pageDao.insert(pageEntity3) - val result = pageDao.findByUrl("page-2-url") + val result = pageDao.findByUrlAndCourse("page-2-url", 1L) assertEquals(pageEntity2, result) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/CourseSync.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/CourseSync.kt index 2cfe45356d..aa2b4fea03 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/CourseSync.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/CourseSync.kt @@ -113,6 +113,7 @@ class CourseSync( private val additionalFileIdsToSync = mutableMapOf>() private val externalFilesToSync = mutableMapOf>() + private val failedTabsPerCourse = mutableMapOf>() private var isStopped = false set(value) = synchronized(this) { @@ -128,14 +129,14 @@ class CourseSync( } private suspend fun syncCourse(courseId: Long) { + additionalFileIdsToSync[courseId] = emptySet() + externalFilesToSync[courseId] = emptySet() + val courseSettingsWithFiles = courseSyncSettingsDao.findWithFilesById(courseId) ?: return val courseSettings = courseSettingsWithFiles.courseSyncSettings val course = fetchCourseDetails(courseId) - additionalFileIdsToSync[courseId] = emptySet() - externalFilesToSync[courseId] = emptySet() - initProgress(courseSettings, course) if (courseSettings.fullFileSync || courseSettingsWithFiles.files.isNotEmpty()) { @@ -519,6 +520,7 @@ class CourseSync( e.printStackTrace() updateTabError(courseId, *tabIds) firebaseCrashlytics.recordException(e) + failedTabsPerCourse[courseId] = failedTabsPerCourse[courseId].orEmpty() + tabIds } } @@ -527,7 +529,9 @@ class CourseSync( it: ModuleItem, params: RestParams ) { - if (it.pageUrl != null && pageDao.findByUrl(it.pageUrl!!) == null) { + // If the pages failed we might already have the page, but we need to get it again to make sure it's up to date + // and download the files inside because those are always deleted + if (it.pageUrl != null && (pageDao.findByUrlAndCourse(it.pageUrl!!, courseId) == null || failedTabsPerCourse[courseId]?.contains(Tab.PAGES_ID) == true)) { val page = pageApi.getDetailedPage(courseId, it.pageUrl!!, params).dataOrNull page?.body = parseHtmlContent(page?.body, courseId) page?.let { pageFacade.insertPage(it, courseId) } @@ -556,7 +560,9 @@ class CourseSync( it: ModuleItem, params: RestParams ) { - if (quizDao.findById(it.contentId) == null) { + // If the pages failed we might already have the page, but we need to get it again to make sure it's up to date + // and download the files inside because those are always deleted + if (quizDao.findById(it.contentId) == null || failedTabsPerCourse[courseId]?.contains(Tab.QUIZZES_ID) == true) { val quiz = quizApi.getQuiz(courseId, it.contentId, params).dataOrNull quiz?.description = parseHtmlContent(quiz?.description, courseId) quiz?.let { quizDao.insert(QuizEntity(it, courseId)) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt index f67aa666f0..a851435233 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt @@ -20,7 +20,6 @@ package com.instructure.pandautils.features.offline.sync import android.content.Context import android.net.Uri -import android.util.Log import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.canvasapi2.apis.DownloadState import com.instructure.canvasapi2.apis.FileDownloadAPI @@ -29,6 +28,7 @@ import com.instructure.canvasapi2.apis.saveFile import com.instructure.canvasapi2.builders.RestParams import com.instructure.canvasapi2.models.FileFolder import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.canvasapi2.utils.DataResult import com.instructure.pandautils.room.offline.daos.CourseSyncProgressDao import com.instructure.pandautils.room.offline.daos.FileFolderDao import com.instructure.pandautils.room.offline.daos.FileSyncProgressDao @@ -103,7 +103,7 @@ class FileSync( fileFolderDao.insertAll(nonPublicFiles.map { FileFolderEntity(it) }) - val additionalFiles = additionalPublicFilesToSync + nonPublicFiles + val additionalFiles = (additionalPublicFilesToSync + nonPublicFiles).filter { !it.url.isNullOrEmpty() } fileSyncProgressDao.insertAll(additionalFiles.map { createProgress(it, courseId, true) }) val syncData = mutableListOf() @@ -149,17 +149,26 @@ class FileSync( var downloadedFile = getDownloadFile(fileName, fileSyncData.externalFile, fileSyncData.courseId) try { - fileDownloadApi.downloadFile( + val downloadResult = fileDownloadApi.downloadFile( fileSyncData.fileUrl, RestParams(shouldIgnoreToken = fileSyncData.externalFile) ) + + // External images can fail for various reasons (for example the file is no longer available), + // so we just mark them as completed in this case. The user wouldn't see those in online mode anyway. + if (fileSyncData.externalFile && downloadResult is DataResult.Fail) { + updateProgress(fileSyncData.fileId, 100, ProgressState.COMPLETED, fileSyncData.externalFile) + return + } + + downloadResult .dataOrThrow .saveFile(downloadedFile) .collect { if (isStopped) throw IllegalStateException("Worker was stopped") when (it) { is DownloadState.InProgress -> { - updateProgress(fileSyncData.fileId, it.progress, ProgressState.IN_PROGRESS) + updateProgress(fileSyncData.fileId, it.progress, ProgressState.IN_PROGRESS, fileSyncData.externalFile, it.totalBytes) } is DownloadState.Success -> { @@ -181,7 +190,7 @@ class FileSync( ) ) } - updateProgress(fileSyncData.fileId, 100, ProgressState.COMPLETED) + updateProgress(fileSyncData.fileId, 100, ProgressState.COMPLETED, fileSyncData.externalFile, it.totalBytes) } is DownloadState.Failure -> { @@ -191,7 +200,7 @@ class FileSync( } } catch (e: Exception) { downloadedFile.delete() - updateProgress(fileSyncData.fileId, 0, ProgressState.ERROR) + updateProgress(fileSyncData.fileId, 0, ProgressState.ERROR, fileSyncData.externalFile) firebaseCrashlytics.recordException(e) } } @@ -275,9 +284,13 @@ class FileSync( return fileSyncProgressDao.findByRowId(rowId)?.fileId ?: -1L } - private suspend fun updateProgress(fileId: Long, progress: Int, progressState: ProgressState) { - fileSyncProgressDao.findByFileId(fileId)?.copy(progress = progress, progressState = progressState) - ?.let { fileSyncProgressDao.update(it) } + private suspend fun updateProgress(fileId: Long, progress: Int, progressState: ProgressState, externalFile: Boolean, size: Long? = null) { + var newProgress = fileSyncProgressDao.findByFileId(fileId)?.copy(progress = progress, progressState = progressState) + if (externalFile && size != null) { + newProgress = newProgress?.copy(fileSize = size) + } + + newProgress?.let { fileSyncProgressDao.update(it) } } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt index f8d288eee7..1079bc7c6f 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/HtmlParser.kt @@ -63,8 +63,9 @@ class HtmlParser( resultHtml = newHtml if (shouldSyncFile) internalFileIds.add(fileId) } else { - val fileName = Uri.parse(imageUrl).lastPathSegment - if (fileName != null) { + val fileUri = Uri.parse(imageUrl) + val fileName = fileUri.lastPathSegment + if (fileName != null && fileUri.scheme == "https") { // We don't allow cleartext traffic in the app. resultHtml = resultHtml.replace(imageUrl, "file://${createLocalFilePathForExternalFile(fileName, courseId)}") externalFileUrls.add(imageUrl) } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncWorker.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncWorker.kt index c659a6f86b..df828ff23b 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncWorker.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/OfflineSyncWorker.kt @@ -78,8 +78,11 @@ class OfflineSyncWorker @AssistedInject constructor( if (!featureFlagProvider.offlineEnabled() || apiPrefs.user == null) return Result.success() - val dashboardCards = + var dashboardCards = courseApi.getDashboardCourses(RestParams(isForceReadFromNetwork = true)).dataOrNull.orEmpty() + if (dashboardCards.all { it.position == Int.MAX_VALUE }) { + dashboardCards = dashboardCards.mapIndexed { index, dashboardCard -> dashboardCard.copy(position = index) } + } dashboardCardDao.updateEntities(dashboardCards.map { DashboardCardEntity(it) }) val params = RestParams(isForceReadFromNetwork = true, usePerPageQueryParam = true) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/LockInfoDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/LockInfoDao.kt index 7da1353da9..9cd96dc238 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/LockInfoDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/LockInfoDao.kt @@ -40,4 +40,7 @@ interface LockInfoDao { @Query("SELECT * FROM LockInfoEntity WHERE pageId = :pageId") suspend fun findByPageId(pageId: Long): LockInfoEntity? + + @Query("SELECT * FROM LockInfoEntity WHERE ROWID = :rowId") + suspend fun findByRowId(rowId: Long): LockInfoEntity? } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/PageDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/PageDao.kt index 53ac401873..aa5a94173e 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/PageDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/PageDao.kt @@ -44,8 +44,8 @@ interface PageDao { @Query("SELECT * FROM PageEntity WHERE id=:id") suspend fun findById(id: Long): PageEntity? - @Query("SELECT * FROM PageEntity WHERE url=:url") - suspend fun findByUrl(url: String): PageEntity? + @Query("SELECT * FROM PageEntity WHERE url=:url AND courseId=:courseId") + suspend fun findByUrlAndCourse(url: String, courseId: Long): PageEntity? @Query("SELECT * FROM PageEntity WHERE frontPage=1 AND courseId=:courseId") suspend fun getFrontPage(courseId: Long): PageEntity? diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/LockInfoEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/LockInfoEntity.kt index b6a6ffc1ea..e1261b66f8 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/LockInfoEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/LockInfoEntity.kt @@ -47,8 +47,7 @@ import com.instructure.canvasapi2.models.LockedModule onDelete = ForeignKey.CASCADE, deferred = true ) - ], - indices = [Index("lockedModuleId", unique = true)] + ] ) data class LockInfoEntity( @PrimaryKey(autoGenerate = true) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/LockedModuleEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/LockedModuleEntity.kt index 4cb4ac8d0a..cc78f39ff6 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/LockedModuleEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/LockedModuleEntity.kt @@ -28,8 +28,8 @@ import com.instructure.canvasapi2.models.ModuleName foreignKeys = [ ForeignKey( entity = LockInfoEntity::class, - parentColumns = ["lockedModuleId"], - childColumns = ["id"], + parentColumns = ["id"], + childColumns = ["lockInfoId"], onDelete = ForeignKey.CASCADE ) ] @@ -41,15 +41,17 @@ data class LockedModuleEntity( val contextType: String?, val name: String?, val unlockAt: String?, - val isRequireSequentialProgress: Boolean + val isRequireSequentialProgress: Boolean, + val lockInfoId: Long? ) { - constructor(lockedModule: LockedModule) : this( + constructor(lockedModule: LockedModule, lockInfoId: Long?) : this( id = lockedModule.id, contextId = lockedModule.contextId, contextType = lockedModule.contextType, name = lockedModule.name, unlockAt = lockedModule.unlockAt, - isRequireSequentialProgress = lockedModule.isRequireSequentialProgress + isRequireSequentialProgress = lockedModule.isRequireSequentialProgress, + lockInfoId = lockInfoId ) fun toApiModel( diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/facade/LockInfoFacade.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/facade/LockInfoFacade.kt index 9078cd9ff7..e81998b332 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/facade/LockInfoFacade.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/facade/LockInfoFacade.kt @@ -47,9 +47,10 @@ class LockInfoFacade( } private suspend fun insertLockInfo(lockInfo: LockInfo, assignmentId: Long? = null, moduleId: Long? = null, pageId: Long? = null) { - lockInfoDao.insert(LockInfoEntity(lockInfo, assignmentId, moduleId, pageId)) + val rowId = lockInfoDao.insert(LockInfoEntity(lockInfo, assignmentId, moduleId, pageId)) + val lockInfoEntity = lockInfoDao.findByRowId(rowId) lockInfo.contextModule?.let { lockedModule -> - lockedModuleDao.insert(LockedModuleEntity(lockedModule)) + lockedModuleDao.insert(LockedModuleEntity(lockedModule, lockInfoEntity?.id)) moduleNameDao.insertAll(lockedModule.prerequisites?.map { ModuleNameEntity(it, lockedModule.id) }.orEmpty()) lockedModule.completionRequirements.forEach { val oldEntity = completionRequirementDao.findById(it.id) diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt index 1cb94dfd1a..d6fac411be 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/HtmlParserTest.kt @@ -67,7 +67,10 @@ class HtmlParserTest { mockkStatic(Uri::class) every { Uri.parse(any()) } answers { val url = it.invocation.args.first() as String - mockk() { every { lastPathSegment } returns url.split("/").last() } + mockk() { + every { lastPathSegment } returns url.split("/").last() + every { scheme } returns "https" + } } } diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/room/offline/facade/LockInfoFacadeTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/room/offline/facade/LockInfoFacadeTest.kt index 726ac3f71b..52a439b713 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/room/offline/facade/LockInfoFacadeTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/room/offline/facade/LockInfoFacadeTest.kt @@ -59,10 +59,13 @@ class LockInfoFacadeTest { coEvery { completionRequirementDao.findById(any()) } returns null + val lockInfoEntity = LockInfoEntity(lockInfo, assignmentId) + coEvery { lockInfoDao.findByRowId(any()) } returns lockInfoEntity + facade.insertLockInfoForAssignment(lockInfo, assignmentId) coVerify { lockInfoDao.insert(LockInfoEntity(lockInfo, assignmentId)) } - coVerify { lockedModuleDao.insert(LockedModuleEntity(lockedModule)) } + coVerify { lockedModuleDao.insert(LockedModuleEntity(lockedModule, lockInfoEntity.id)) } coVerify { moduleNameDao.insertAll(prerequisites.map { ModuleNameEntity(it, 1L) }) } coVerify { completionRequirementDao.insert(ModuleCompletionRequirementEntity(completionRequirements.first(), 1L, 1L)) } } @@ -77,10 +80,13 @@ class LockInfoFacadeTest { coEvery { completionRequirementDao.findById(any()) } returns null + val lockInfoEntity = LockInfoEntity(lockInfo, moduleId = moduleId) + coEvery { lockInfoDao.findByRowId(any()) } returns lockInfoEntity + facade.insertLockInfoForModule(lockInfo, moduleId) - coVerify { lockInfoDao.insert(LockInfoEntity(lockInfo, moduleId = moduleId)) } - coVerify { lockedModuleDao.insert(LockedModuleEntity(lockedModule)) } + coVerify { lockInfoDao.insert(lockInfoEntity) } + coVerify { lockedModuleDao.insert(LockedModuleEntity(lockedModule, lockInfoEntity.id)) } coVerify { moduleNameDao.insertAll(prerequisites.map { ModuleNameEntity(it, 1L) }) } coVerify { completionRequirementDao.insert(ModuleCompletionRequirementEntity(completionRequirements.first(), 1L, 1L)) } } @@ -93,8 +99,10 @@ class LockInfoFacadeTest { val lockedModule = LockedModule(id = 1L, prerequisites = prerequisites, completionRequirements = completionRequirements, contextId = 1) val lockInfo = LockInfo(modulePrerequisiteNames = arrayListOf("1", "2"), contextModule = lockedModule, unlockAt = Date().toApiString()) - coEvery { lockInfoDao.findByAssignmentId(assignmentId) } returns LockInfoEntity(lockInfo, assignmentId) - coEvery { lockedModuleDao.findById(any()) } returns LockedModuleEntity(lockedModule) + val lockInfoEntity = LockInfoEntity(lockInfo, assignmentId) + + coEvery { lockInfoDao.findByAssignmentId(assignmentId) } returns lockInfoEntity + coEvery { lockedModuleDao.findById(any()) } returns LockedModuleEntity(lockedModule, lockInfoEntity.id) coEvery { moduleNameDao.findByLockModuleId(any()) } returns prerequisites.map { ModuleNameEntity(it, 1L) } coEvery { completionRequirementDao.findByModuleId(any()) } returns completionRequirements.map { ModuleCompletionRequirementEntity(it, 1L, 1L) @@ -114,8 +122,10 @@ class LockInfoFacadeTest { val lockedModule = LockedModule(id = 1L, prerequisites = prerequisites, completionRequirements = completionRequirements, contextId = 1) val lockInfo = LockInfo(modulePrerequisiteNames = arrayListOf("1", "2"), contextModule = lockedModule, unlockAt = Date().toApiString()) - coEvery { lockInfoDao.findByModuleId(moduleId) } returns LockInfoEntity(lockInfo, moduleId = moduleId) - coEvery { lockedModuleDao.findById(any()) } returns LockedModuleEntity(lockedModule) + val lockInfoEntity = LockInfoEntity(lockInfo, moduleId = moduleId) + + coEvery { lockInfoDao.findByModuleId(moduleId) } returns lockInfoEntity + coEvery { lockedModuleDao.findById(any()) } returns LockedModuleEntity(lockedModule, lockInfoEntity.id) coEvery { moduleNameDao.findByLockModuleId(any()) } returns prerequisites.map { ModuleNameEntity(it, 1L) } coEvery { completionRequirementDao.findByModuleId(any()) } returns completionRequirements.map { ModuleCompletionRequirementEntity(it, 1L, 1L) diff --git a/libs/recyclerview/src/main/java/com/instructure/pandarecycler/PaginatedRecyclerAdapter.kt b/libs/recyclerview/src/main/java/com/instructure/pandarecycler/PaginatedRecyclerAdapter.kt index 49b8dbdc75..79cf3f66ed 100644 --- a/libs/recyclerview/src/main/java/com/instructure/pandarecycler/PaginatedRecyclerAdapter.kt +++ b/libs/recyclerview/src/main/java/com/instructure/pandarecycler/PaginatedRecyclerAdapter.kt @@ -146,6 +146,13 @@ abstract class PaginatedRecyclerAdapter(c loadData() } + fun silentRefresh() { + nextUrl = null + resetBooleans() + isRefresh = false // We don't care about fresh data here, just want to update the offline state + loadData() + } + override fun resetData() { clear() nextUrl = null From 5d77dc8b46c7c66f73cda291fbe1f7f5d4d74371 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Fri, 17 Nov 2023 16:02:33 +0100 Subject: [PATCH 08/49] Fixed progress id issues. (#2255) --- .../1.json | 14 ++- .../offline/daos/FileSyncProgressDaoTest.kt | 96 ++++++++++++++----- .../features/offline/sync/FileSync.kt | 59 ++++++------ .../room/offline/daos/FileSyncProgressDao.kt | 3 + .../entities/FileSyncProgressEntity.kt | 3 +- 5 files changed, 120 insertions(+), 55 deletions(-) diff --git a/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json b/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json index 4a8f8781a2..644075b091 100644 --- a/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json +++ b/libs/pandautils/schemas/com.instructure.pandautils.room.offline.OfflineDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "a1cb353c4afbf14831de8752ace07ce9", + "identityHash": "1182046fc85ec1b16d7693e1a593081b", "entities": [ { "tableName": "AssignmentDueDateEntity", @@ -5433,7 +5433,7 @@ }, { "tableName": "FileSyncProgressEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `courseId` INTEGER NOT NULL, `fileName` TEXT NOT NULL, `progress` INTEGER NOT NULL, `fileSize` INTEGER NOT NULL, `additionalFile` INTEGER NOT NULL, `progressState` TEXT NOT NULL, FOREIGN KEY(`courseId`) REFERENCES `CourseSyncProgressEntity`(`courseId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fileId` INTEGER NOT NULL, `courseId` INTEGER NOT NULL, `fileName` TEXT NOT NULL, `progress` INTEGER NOT NULL, `fileSize` INTEGER NOT NULL, `additionalFile` INTEGER NOT NULL, `progressState` TEXT NOT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, FOREIGN KEY(`courseId`) REFERENCES `CourseSyncProgressEntity`(`courseId`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "fileId", @@ -5476,12 +5476,18 @@ "columnName": "progressState", "affinity": "TEXT", "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ - "fileId" + "id" ] }, "indices": [], @@ -5503,7 +5509,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a1cb353c4afbf14831de8752ace07ce9')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1182046fc85ec1b16d7693e1a593081b')" ] } } \ No newline at end of file diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDaoTest.kt index 6802ae1119..afa0edd429 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDaoTest.kt @@ -76,7 +76,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 1L + fileId = 1L, + id = 1L ) fileSyncProgressDao.insert(entity) @@ -92,7 +93,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 1L + fileId = 1L, + id = 1L ) fileSyncProgressDao.insertAll(listOf(entity)) @@ -143,7 +145,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 1L + fileId = 1L, + id = 1L ), FileSyncProgressEntity( courseId = 1L, @@ -151,7 +154,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 2L + fileId = 2L, + id = 2L ) ) fileSyncProgressDao.insertAll(entities) @@ -161,6 +165,33 @@ class FileSyncProgressDaoTest { assertEquals(entities[0], result) } + @Test + fun testFindById() = runTest { + val entities = listOf( + FileSyncProgressEntity( + courseId = 1L, + fileName = "File 1", + progress = 0, + fileSize = 1000L, + progressState = ProgressState.IN_PROGRESS, + fileId = 100L + ), + FileSyncProgressEntity( + courseId = 1L, + fileName = "File 2", + progress = 0, + fileSize = 1000L, + progressState = ProgressState.IN_PROGRESS, + fileId = 200L + ) + ) + fileSyncProgressDao.insertAll(entities) + + val result = fileSyncProgressDao.findById(1L) + + assertEquals(entities[0].copy(id = 1L), result) + } + @Test fun testFindByFileIdLiveData() = runTest { val entities = listOf( @@ -170,7 +201,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 1L + fileId = 1L, + id = 1L ), FileSyncProgressEntity( courseId = 1L, @@ -178,7 +210,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 2L + fileId = 2L, + id = 2L ) ) fileSyncProgressDao.insertAll(entities) @@ -199,7 +232,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 1L + fileId = 1L, + id = 1L ), FileSyncProgressEntity( courseId = 1L, @@ -207,7 +241,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 2L + fileId = 2L, + id = 2L ), FileSyncProgressEntity( courseId = 2L, @@ -215,7 +250,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 3L + fileId = 3L, + id = 3L ) ) fileSyncProgressDao.insertAll(entities) @@ -236,7 +272,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 1L + fileId = 1L, + id = 1L ), FileSyncProgressEntity( courseId = 1L, @@ -244,7 +281,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 2L + fileId = 2L, + id = 2L ), FileSyncProgressEntity( courseId = 2L, @@ -252,7 +290,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 3L + fileId = 3L, + id = 3L ) ) fileSyncProgressDao.insertAll(entities) @@ -303,7 +342,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 1L + fileId = 1L, + id = 1L ), FileSyncProgressEntity( courseId = 1L, @@ -311,7 +351,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 2L + fileId = 2L, + id = 2L ), FileSyncProgressEntity( courseId = 1L, @@ -320,7 +361,8 @@ class FileSyncProgressDaoTest { fileSize = 1000L, additionalFile = true, progressState = ProgressState.IN_PROGRESS, - fileId = 3L + fileId = 3L, + id = 3L ), FileSyncProgressEntity( courseId = 1L, @@ -329,7 +371,8 @@ class FileSyncProgressDaoTest { fileSize = 0, additionalFile = true, progressState = ProgressState.IN_PROGRESS, - fileId = 4L + fileId = 4L, + id = 4L ) ) @@ -350,7 +393,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 1L + fileId = 1L, + id = 1L ), FileSyncProgressEntity( courseId = 1L, @@ -358,7 +402,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 2L + fileId = 2L, + id = 2L ), FileSyncProgressEntity( courseId = 1L, @@ -367,7 +412,8 @@ class FileSyncProgressDaoTest { fileSize = 1000L, additionalFile = true, progressState = ProgressState.IN_PROGRESS, - fileId = 3L + fileId = 3L, + id = 3L ), FileSyncProgressEntity( courseId = 1L, @@ -376,7 +422,8 @@ class FileSyncProgressDaoTest { fileSize = 0, additionalFile = true, progressState = ProgressState.IN_PROGRESS, - fileId = 4L + fileId = 4L, + id = 4L ) ) @@ -397,7 +444,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 1L + fileId = 1L, + id = 1L ), FileSyncProgressEntity( courseId = 1L, @@ -405,7 +453,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 2L + fileId = 2L, + id = 2L ) ) @@ -417,7 +466,8 @@ class FileSyncProgressDaoTest { progress = 0, fileSize = 1000L, progressState = ProgressState.IN_PROGRESS, - fileId = 3L + fileId = 3L, + id = 3L ) val rowId = fileSyncProgressDao.insert(entity) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt index a851435233..d24625a705 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/features/offline/sync/FileSync.kt @@ -68,14 +68,18 @@ class FileSync( val fileFolders = fileFolderDao.findFilesToSync(courseId, syncSettings.fullFileSync) .map { it.toApiModel() } - fileSyncProgressDao.insertAll(fileFolders.map { createProgress(it, courseId, false) }) + val syncData = mutableListOf() + + fileFolders.forEach { + val progressId = createAndInsertProgress(it, courseId, false) + syncData.add(FileSyncData(progressId, it.id, it.displayName.orEmpty(), it.url.orEmpty(), courseId, false)) + } courseSyncProgressDao.findByCourseId(courseId)?.copy(progressState = ProgressState.IN_PROGRESS)?.let { courseSyncProgressDao.update(it) } - val chunks = - fileFolders.map { FileSyncData(it.id, it.displayName.orEmpty(), it.url.orEmpty(), courseId) }.chunked(6) + val chunks = syncData.chunked(6) coroutineScope { chunks.forEach { @@ -94,9 +98,12 @@ class FileSync( ) { val courseId = syncSettings.courseId - val additionalPublicFilesToSync = fileFolderDao.findByIds(additionalFileIdsToSync).map { it.toApiModel() } + val localIds = localFileDao.findByCourseId(courseId).map { it.id }.toSet() + val internalFileIdsToSync = additionalFileIdsToSync.filterNot { localIds.contains(it) }.toSet() + + val additionalPublicFilesToSync = fileFolderDao.findByIds(internalFileIdsToSync).map { it.toApiModel() } - val nonPublicFileIds = additionalFileIdsToSync.minus(additionalPublicFilesToSync.map { it.id }.toSet()) + val nonPublicFileIds = internalFileIdsToSync.minus(additionalPublicFilesToSync.map { it.id }.toSet()) val nonPublicFiles = nonPublicFileIds.map { fileFolderApi.getCourseFile(courseId, it, RestParams(isForceReadFromNetwork = false)).dataOrNull }.filterNotNull() @@ -104,23 +111,17 @@ class FileSync( fileFolderDao.insertAll(nonPublicFiles.map { FileFolderEntity(it) }) val additionalFiles = (additionalPublicFilesToSync + nonPublicFiles).filter { !it.url.isNullOrEmpty() } - fileSyncProgressDao.insertAll(additionalFiles.map { createProgress(it, courseId, true) }) val syncData = mutableListOf() - additionalFiles.map { - FileSyncData( - it.id, - it.displayName.orEmpty(), - it.url.orEmpty(), - courseId, - false - ) - }.let { syncData.addAll(it) } + additionalFiles.forEach { + val progressId = createAndInsertProgress(it, courseId, true) + syncData.add(FileSyncData(progressId, it.id, it.displayName.orEmpty(), it.url.orEmpty(), courseId, false)) + } externalFilesToSync.forEach { - val id = createExternalProgress(it, courseId) - syncData.add(FileSyncData(id, Uri.parse(it).lastPathSegment.orEmpty(), it, courseId, true)) + val progressId = createAndInsertExternalProgress(it, courseId) + syncData.add(FileSyncData(progressId, -1, Uri.parse(it).lastPathSegment.orEmpty(), it, courseId, true)) } courseSyncProgressDao.findByCourseId(courseId)?.copy(additionalFilesStarted = true) @@ -157,7 +158,7 @@ class FileSync( // External images can fail for various reasons (for example the file is no longer available), // so we just mark them as completed in this case. The user wouldn't see those in online mode anyway. if (fileSyncData.externalFile && downloadResult is DataResult.Fail) { - updateProgress(fileSyncData.fileId, 100, ProgressState.COMPLETED, fileSyncData.externalFile) + updateProgress(fileSyncData.progressId, 100, ProgressState.COMPLETED, fileSyncData.externalFile) return } @@ -168,7 +169,7 @@ class FileSync( if (isStopped) throw IllegalStateException("Worker was stopped") when (it) { is DownloadState.InProgress -> { - updateProgress(fileSyncData.fileId, it.progress, ProgressState.IN_PROGRESS, fileSyncData.externalFile, it.totalBytes) + updateProgress(fileSyncData.progressId, it.progress, ProgressState.IN_PROGRESS, fileSyncData.externalFile, it.totalBytes) } is DownloadState.Success -> { @@ -190,7 +191,7 @@ class FileSync( ) ) } - updateProgress(fileSyncData.fileId, 100, ProgressState.COMPLETED, fileSyncData.externalFile, it.totalBytes) + updateProgress(fileSyncData.progressId, 100, ProgressState.COMPLETED, fileSyncData.externalFile, it.totalBytes) } is DownloadState.Failure -> { @@ -200,7 +201,7 @@ class FileSync( } } catch (e: Exception) { downloadedFile.delete() - updateProgress(fileSyncData.fileId, 0, ProgressState.ERROR, fileSyncData.externalFile) + updateProgress(fileSyncData.progressId, 0, ProgressState.ERROR, fileSyncData.externalFile) firebaseCrashlytics.recordException(e) } } @@ -258,8 +259,8 @@ class FileSync( return fileFolderDao.findAllFilesByCourseId(courseId) } - private fun createProgress(fileFolder: FileFolder, courseId: Long, additionalFile: Boolean): FileSyncProgressEntity { - return FileSyncProgressEntity( + private suspend fun createAndInsertProgress(fileFolder: FileFolder, courseId: Long, additionalFile: Boolean): Long { + val progress = FileSyncProgressEntity( fileFolder.id, courseId, fileFolder.displayName.orEmpty(), @@ -268,9 +269,12 @@ class FileSync( additionalFile, ProgressState.IN_PROGRESS ) + + val rowId = fileSyncProgressDao.insert(progress) + return fileSyncProgressDao.findByRowId(rowId)?.id ?: -1L } - private suspend fun createExternalProgress(url: String, courseId: Long): Long { + private suspend fun createAndInsertExternalProgress(url: String, courseId: Long): Long { val progress = FileSyncProgressEntity( 0, courseId, @@ -281,11 +285,11 @@ class FileSync( ProgressState.IN_PROGRESS ) val rowId = fileSyncProgressDao.insert(progress) - return fileSyncProgressDao.findByRowId(rowId)?.fileId ?: -1L + return fileSyncProgressDao.findByRowId(rowId)?.id ?: -1L } - private suspend fun updateProgress(fileId: Long, progress: Int, progressState: ProgressState, externalFile: Boolean, size: Long? = null) { - var newProgress = fileSyncProgressDao.findByFileId(fileId)?.copy(progress = progress, progressState = progressState) + private suspend fun updateProgress(progressId: Long, progress: Int, progressState: ProgressState, externalFile: Boolean, size: Long? = null) { + var newProgress = fileSyncProgressDao.findById(progressId)?.copy(progress = progress, progressState = progressState) if (externalFile && size != null) { newProgress = newProgress?.copy(fileSize = size) } @@ -295,6 +299,7 @@ class FileSync( } data class FileSyncData( + val progressId: Long, val fileId: Long, val inputFileName: String, val fileUrl: String, diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDao.kt index d2854419e5..09a8b008e7 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/FileSyncProgressDao.kt @@ -37,6 +37,9 @@ interface FileSyncProgressDao { @Update suspend fun update(fileSyncProgressEntity: FileSyncProgressEntity) + @Query("SELECT * FROM FileSyncProgressEntity WHERE id = :id") + suspend fun findById(id: Long): FileSyncProgressEntity? + @Query("SELECT * FROM FileSyncProgressEntity WHERE courseId = :courseId") fun findByCourseIdLiveData(courseId: Long): LiveData> diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/FileSyncProgressEntity.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/FileSyncProgressEntity.kt index 261d0dd40f..6c768215cd 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/FileSyncProgressEntity.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/entities/FileSyncProgressEntity.kt @@ -33,7 +33,6 @@ import com.instructure.pandautils.features.offline.sync.ProgressState ] ) data class FileSyncProgressEntity( - @PrimaryKey(autoGenerate = true) val fileId: Long, val courseId: Long, val fileName: String, @@ -41,4 +40,6 @@ data class FileSyncProgressEntity( val fileSize: Long, val additionalFile: Boolean = false, val progressState: ProgressState, + @PrimaryKey(autoGenerate = true) + val id: Long = 0 ) \ No newline at end of file From 211128d6b9648c28c007e014416d54af042f1699 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Fri, 17 Nov 2023 16:02:58 +0100 Subject: [PATCH 09/49] [MBL-17201][Student] Fetch feature flags for the correct user (#2253) refs: MBL-17201 affects: Student release note: none test plan: Check the offline features. --- .../main/java/com/instructure/pandautils/di/FeatureFlagModule.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt index 5a38909b2a..5913eb5395 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/FeatureFlagModule.kt @@ -32,7 +32,6 @@ import javax.inject.Singleton class FeatureFlagModule { @Provides - @Singleton fun provideFeatureFlagProvider(userManager: UserManager, apiPrefs: ApiPrefs, featuresApi: FeaturesAPI.FeaturesInterface, environmentFeatureFlagsDao: EnvironmentFeatureFlagsDao): FeatureFlagProvider { return FeatureFlagProvider(userManager, apiPrefs, featuresApi, environmentFeatureFlagsDao) } From 9de1d609f769a02f11f4cb7735cd7551672235e1 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Wed, 22 Nov 2023 12:41:40 +0100 Subject: [PATCH 10/49] [MBL-17114][Student] - Implement Dashboard Offline Unavailable features E2E test case (#2248) --- .../student/ui/e2e/LoginE2ETest.kt | 2 + .../offline/ManageOfflineContentE2ETest.kt | 5 +- .../ui/e2e/offline/OfflineDashboardE2ETest.kt | 70 ++++++++++++++++++- .../e2e/offline/OfflineSyncProgressE2ETest.kt | 2 - .../ui/e2e/offline/utils/OfflineTestUtils.kt | 30 ++++++-- .../student/ui/pages/DashboardPage.kt | 27 +++++-- .../espresso/matchers/WaitForViewMatcher.kt | 5 ++ .../panda_annotations/TestMetaData.kt | 2 +- 8 files changed, 122 insertions(+), 21 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt index 03c3a51ddc..374e00d019 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/LoginE2ETest.kt @@ -300,6 +300,8 @@ class LoginE2ETest : StudentTest() { private fun loginWithUser(user: CanvasUserApiModel, lastSchoolSaved: Boolean = false) { + Thread.sleep(5100) //Need to wait > 5 seconds before each login attempt because of new 'too many attempts' login policy on web. + if(lastSchoolSaved) { Log.d(STEP_TAG,"Click 'Find Another School' button.") loginLandingPage.clickFindAnotherSchoolButton() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt index 2d0af62c9e..aa8a2108a5 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt @@ -48,7 +48,6 @@ class ManageOfflineContentE2ETest : StudentTest() { val student = data.studentsList[0] val course1 = data.coursesList[0] val course2 = data.coursesList[1] - val testAnnouncement = data.announcementsList[0] Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) @@ -185,8 +184,8 @@ class ManageOfflineContentE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that both of the seeded courses are displayed as a selectable item in the Manage Offline Content page.") manageOfflineContentPage.assertCourseCountWithMatcher(2) - Log.d(STEP_TAG, "Click on the 'Sync' button.") - manageOfflineContentPage.clickOnSyncButton() + Log.d(STEP_TAG, "Click on the 'Sync' button and confirm sync.") + manageOfflineContentPage.clickOnSyncButtonAndConfirm() Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt index b874bb2430..309f23f989 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt @@ -17,9 +17,12 @@ package com.instructure.student.ui.e2e.offline import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice import com.instructure.canvas.espresso.OfflineE2E import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.SecondaryFeatureCategory import com.instructure.panda_annotations.TestCategory import com.instructure.panda_annotations.TestMetaData import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils @@ -40,6 +43,7 @@ class OfflineDashboardE2ETest : StudentTest() { @Test @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.E2E) fun testOfflineDashboardE2E() { + Log.d(PREPARATION_TAG,"Seeding data.") val data = seedData(students = 1, teachers = 1, courses = 2, announcements = 1) val student = data.studentsList[0] @@ -47,6 +51,9 @@ class OfflineDashboardE2ETest : StudentTest() { val course2 = data.coursesList[1] val testAnnouncement = data.announcementsList[0] + Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") tokenLogin(student) dashboardPage.waitForRender() @@ -57,8 +64,6 @@ class OfflineDashboardE2ETest : StudentTest() { Log.d(STEP_TAG, "Select the entire '${course1.name}' course for sync. Click on the 'Sync' button.") manageOfflineContentPage.changeItemSelectionState(course1.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - manageOfflineContentPage.changeItemSelectionState(course1.name) - manageOfflineContentPage.clickOnSyncButton() Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") dashboardPage.waitForRender() @@ -71,6 +76,7 @@ class OfflineDashboardE2ETest : StudentTest() { Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") OfflineTestUtils.turnOffConnectionViaADB() + device.waitForIdle() Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() @@ -78,7 +84,7 @@ class OfflineDashboardE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") OfflineTestUtils.assertOfflineIndicator() - Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's cours card.") + Log.d(STEP_TAG, "Assert that the offline sync icon only displayed on the synced course's course card.") dashboardPage.assertCourseOfflineSyncIconVisible(course1.name) dashboardPage.assertCourseOfflineSyncIconGone(course2.name) @@ -90,6 +96,64 @@ class OfflineDashboardE2ETest : StudentTest() { announcementListPage.assertTopicDisplayed(testAnnouncement.title) } + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.E2E, false, SecondaryFeatureCategory.OFFLINE_MODE) + fun testOfflineDashboardUnavailableFeaturesE2E() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1) + val student = data.studentsList[0] + val course = data.coursesList[0] + + Log.d(PREPARATION_TAG, "Get the device to be able to perform app-independent actions on it.") + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open the '${course.name}' course's 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.clickCourseOverflowMenu(course.name, "Manage Offline Content") + + Log.d(STEP_TAG, "Select the entire '${course.name}' course for sync. Click on the 'Sync' button.") + manageOfflineContentPage.changeItemSelectionState(course.name) + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") + dashboardPage.waitForRender() + dashboardPage.waitForSyncProgressDownloadStartedNotification() + dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() + + Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") + dashboardPage.waitForSyncProgressStartingNotification() + dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + OfflineTestUtils.turnOffConnectionViaADB() + device.waitForIdle() + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") + OfflineTestUtils.assertOfflineIndicator() + + Log.d(STEP_TAG, "Assert that the bottom menus (except Dashboard) are disabled and unavailable in offline mode.") + dashboardPage.assertBottomMenusAreDisabled() + + Log.d(STEP_TAG, "Try to open the '${course.name}' course's more menu of the Dashboard Page. Assert that the 'No Internet Connection' dialog is displayed. Dismiss it after the assertion.") + dashboardPage.clickOnCourseOverflowButton(course.name) + OfflineTestUtils.assertNoInternetConnectionDialog() + OfflineTestUtils.dismissNoInternetConnectionDialog() + + Log.d(STEP_TAG, "Try to open the global 'Manage Offline Content' page via the more menu of the Dashboard Page. Assert that the 'No Internet Connection' dialog is displayed. Dismiss it after the assertion.") + Thread.sleep(5000) //Wait for the system notification to disappear, because it overlaps the More menu button on the toolbar. + dashboardPage.openGlobalManageOfflineContentPage() + OfflineTestUtils.assertNoInternetConnectionDialog() + OfflineTestUtils.dismissNoInternetConnectionDialog() + } + @After fun tearDown() { Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt index dc0b91da01..77cea19bc2 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt @@ -59,8 +59,6 @@ class OfflineSyncProgressE2ETest : StudentTest() { Log.d(STEP_TAG, "Select the entire '${course1.name}' course for sync. Click on the 'Sync' button.") manageOfflineContentPage.changeItemSelectionState(course1.name) manageOfflineContentPage.clickOnSyncButtonAndConfirm() - manageOfflineContentPage.changeItemSelectionState(course1.name) - manageOfflineContentPage.clickOnSyncButton() Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") dashboardPage.waitForRender() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt index 717d37af98..7cd51b7a66 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt @@ -16,14 +16,14 @@ */ package com.instructure.student.ui.e2e.offline.utils -import androidx.test.espresso.matcher.ViewMatchers.withChild -import androidx.test.espresso.matcher.ViewMatchers.withId -import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.matcher.ViewMatchers.* import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiScrollable import androidx.test.uiautomator.UiSelector import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.click import com.instructure.espresso.matchers.WaitForViewMatcher.waitForView import com.instructure.espresso.page.plus import com.instructure.student.R @@ -31,16 +31,22 @@ import org.hamcrest.CoreMatchers.allOf object OfflineTestUtils { + private const val ENABLE_WIFI_COMMAND: String = "svc wifi enable" + private const val DISABLE_WIFI_COMMAND: String = "svc wifi disable" + + private const val ENABLE_MOBILE_DATA_COMMAND: String = "svc data enable" + private const val DISABLE_MOBILE_DATA_COMMAND: String = "svc data disable" + fun turnOffConnectionViaADB() { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - device.executeShellCommand("svc wifi disable") - device.executeShellCommand("svc data disable") + device.executeShellCommand(DISABLE_WIFI_COMMAND) + device.executeShellCommand(DISABLE_MOBILE_DATA_COMMAND) } fun turnOnConnectionViaADB() { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - device.executeShellCommand("svc wifi enable") - device.executeShellCommand("svc data enable") + device.executeShellCommand(ENABLE_WIFI_COMMAND) + device.executeShellCommand(ENABLE_MOBILE_DATA_COMMAND) } fun turnOffConnectionOnUI() { @@ -80,4 +86,14 @@ object OfflineTestUtils { ) ).assertDisplayed() } + + fun assertNoInternetConnectionDialog() { + waitForView(withId(R.id.alertTitle) + withText(R.string.noInternetConnectionTitle)).assertDisplayed() + } + + fun dismissNoInternetConnectionDialog() { + onView(withText(android.R.string.ok) + isDescendantOfA(withId(R.id.buttonPanel) + + hasSibling(withId(R.id.topPanel) + + hasDescendant(withText(R.string.noInternetConnectionTitle))))).click() + } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index 78121a4c7e..a2a1c189f4 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -38,6 +38,7 @@ import com.instructure.canvasapi2.models.Group import com.instructure.dataseeding.model.CourseApiModel import com.instructure.dataseeding.model.GroupApiModel import com.instructure.espresso.* +import com.instructure.espresso.matchers.WaitForViewMatcher.waitForViewToBeCompletelyDisplayed import com.instructure.espresso.page.* import com.instructure.student.R import com.instructure.student.ui.utils.ViewUtils @@ -255,18 +256,22 @@ class DashboardPage : BasePage(R.id.dashboardPage) { } fun switchCourseView() { - Espresso.openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getInstrumentation().targetContext) + clickDashboardGlobalOverflowButton() onView(withText(containsString("Switch to"))) .perform(click()); } - //OfflineMethod fun openGlobalManageOfflineContentPage() { - Espresso.openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getInstrumentation().targetContext) - onView(withText(containsString("Manage Offline Content"))) + clickDashboardGlobalOverflowButton() + onView(withText(containsString("Manage Offline Content"))) .perform(click()); } + private fun clickDashboardGlobalOverflowButton() { + waitForViewToBeCompletelyDisplayed(withContentDescription("More options") + withAncestor(R.id.toolbar)) + Espresso.openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getInstrumentation().targetContext) + } + fun clickEditDashboard() { onView(withId(R.id.editDashboardTextView)).scrollTo().click() } @@ -304,12 +309,16 @@ class DashboardPage : BasePage(R.id.dashboardPage) { } fun clickCourseOverflowMenu(courseTitle: String, menuTitle: String) { + clickOnCourseOverflowButton(courseTitle) + waitForView(withId(R.id.title) + withText(menuTitle)).click() + } + + fun clickOnCourseOverflowButton(courseTitle: String) { val courseOverflowMatcher = withId(R.id.overflow) + withAncestor( withId(R.id.cardView) + withDescendant(withId(R.id.titleTextView) + withText(courseTitle)) ) waitForView(courseOverflowMatcher).scrollTo().click() - waitForView(withId(R.id.title) + withText(menuTitle)).click() } fun assertCourseGrade(courseName: String, courseGrade: String) { @@ -379,6 +388,14 @@ class DashboardPage : BasePage(R.id.dashboardPage) { fun waitForSyncProgressStartingNotificationToDisappear() { ViewUtils.waitForViewToDisappear(withText(com.instructure.pandautils.R.string.syncProgress_syncingOfflineContent), 30) } + + //OfflineMethod + fun assertBottomMenusAreDisabled() { + onView(withId(R.id.bottomNavigationCalendar)).check(matches(isNotEnabled())) + onView(withId(R.id.bottomNavigationToDo)).check(matches(isNotEnabled())) + onView(withId(R.id.bottomNavigationNotifications)).check(matches(isNotEnabled())) + onView(withId(R.id.bottomNavigationInbox)).check(matches(isNotEnabled())) + } } /** diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt index 0d543a6b25..9c56c3e5a6 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/matchers/WaitForViewMatcher.kt @@ -51,6 +51,11 @@ object WaitForViewMatcher { return waitForViewWithCustomMatcher(viewMatcher, duration, ViewMatchers.isClickable()) } + fun waitForViewToBeCompletelyDisplayed(viewMatcher: Matcher, duration: Long = 10): ViewInteraction { + log.i("Wait for View to be completely displayed.") + return waitForViewWithCustomMatcher(viewMatcher, duration, ViewMatchers.isCompletelyDisplayed()) + } + private fun waitForViewWithCustomMatcher(viewMatcher: Matcher, duration: Long = 10, customMatcher: Matcher): ViewInteraction { waiting.set(true) val waitTime = TimeUnit.SECONDS.toMillis(duration) diff --git a/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt b/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt index 7b7909c6e6..e9f5f0ddb6 100644 --- a/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt +++ b/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt @@ -43,7 +43,7 @@ enum class SecondaryFeatureCategory { ASSIGNMENT_COMMENTS, ASSIGNMENT_QUIZZES, ASSIGNMENT_DISCUSSIONS, GROUPS_DASHBOARD, GROUPS_FILES, GROUPS_ANNOUNCEMENTS, GROUPS_DISCUSSIONS, GROUPS_PAGES, GROUPS_PEOPLE, EVENTS_DISCUSSIONS, EVENTS_QUIZZES, EVENTS_ASSIGNMENTS, EVENTS_NOTIFICATIONS, - MODULES_ASSIGNMENTS, MODULES_DISCUSSIONS, MODULES_FILES, MODULES_PAGES, MODULES_QUIZZES + MODULES_ASSIGNMENTS, MODULES_DISCUSSIONS, MODULES_FILES, MODULES_PAGES, MODULES_QUIZZES, OFFLINE_MODE } enum class TestCategory { From f36a3bda027be7edb5d044780569e6a11034f87e Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Wed, 22 Nov 2023 15:47:22 +0100 Subject: [PATCH 11/49] [MBL-17146][Student] - Workaround to stabilize flaky Inbox E2E tests (#2252) --- .../student/ui/e2e/InboxE2ETest.kt | 18 +++++++-- .../instructure/student/ui/pages/InboxPage.kt | 5 +-- .../teacher/ui/e2e/InboxE2ETest.kt | 28 ++++++++++---- .../com/instructure/espresso/TestingUtils.kt | 38 +++++++++++++++++++ 4 files changed, 75 insertions(+), 14 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt index b2d370238f..35baba10c0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt @@ -25,6 +25,8 @@ import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.ConversationsApi import com.instructure.dataseeding.api.GroupsApi import com.instructure.dataseeding.model.CanvasUserApiModel +import com.instructure.espresso.retry +import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory @@ -113,6 +115,7 @@ class InboxE2ETest: StudentTest() { inboxPage.selectConversation(seededConversation) inboxPage.assertSelectedConversationNumber("1") inboxPage.clickUnArchive() + inboxPage.assertInboxEmpty() inboxPage.assertConversationNotDisplayed(seededConversation.subject) @@ -127,7 +130,10 @@ class InboxE2ETest: StudentTest() { inboxPage.selectConversations(listOf(seededConversation.subject)) inboxPage.assertSelectedConversationNumber("1") inboxPage.clickUnstar() - inboxPage.assertConversationNotStarred(seededConversation.subject) + + retryWithIncreasingDelay(times = 10, maxDelay = 3000) { + inboxPage.assertConversationNotStarred(seededConversation.subject) + } Log.d(STEP_TAG, "Select the conversations (${seededConversation.subject} and archive it. Assert that it has not displayed in the 'INBOX' scope.") inboxPage.selectConversations(listOf(seededConversation.subject)) @@ -161,7 +167,10 @@ class InboxE2ETest: StudentTest() { Log.d(STEP_TAG, "Select the conversation. Unarchive it, and assert that it has not displayed in the 'ARCHIVED' scope.") inboxPage.selectConversations(listOf(seededConversation.subject)) inboxPage.clickUnArchive() - inboxPage.assertConversationNotDisplayed(seededConversation.subject) + + retryWithIncreasingDelay(times = 10, maxDelay = 3000) { + inboxPage.assertConversationNotDisplayed(seededConversation.subject) + } Log.d(STEP_TAG, "Navigate to 'STARRED' scope and assert that the conversations is displayed there.") inboxPage.filterInbox("Starred") @@ -346,7 +355,10 @@ class InboxE2ETest: StudentTest() { Log.d(STEP_TAG, "Navigate to 'STARRED' scope. Assert that the conversation is displayed in the 'STARRED' scope.") inboxPage.filterInbox("Starred") - inboxPage.assertConversationDisplayed(seededConversation.subject) + + retry(times = 10, delay = 3000) { + inboxPage.assertConversationDisplayed(seededConversation.subject) + } Log.d(STEP_TAG, "Swipe '${seededConversation.subject}' left and assert it is removed from the 'STARRED' scope because it has became unstarred.") inboxPage.swipeConversationLeft(seededConversation) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt index 47ac688d03..8626f8d2a0 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/InboxPage.kt @@ -145,7 +145,6 @@ class InboxPage : BasePage(R.id.inboxPage) { hasSibling(allOf(withId(R.id.subjectView), withText(subject)))) waitForMatcherWithRefreshes(matcher) // May need to refresh before the star shows up onView(matcher).scrollTo().assertDisplayed() - } fun assertConversationNotStarred(subject: String) { @@ -154,9 +153,7 @@ class InboxPage : BasePage(R.id.inboxPage) { hasSibling(withId(R.id.userName)), hasSibling(withId(R.id.date)), hasSibling(allOf(withId(R.id.subjectView), withText(subject)))) - waitForMatcherWithRefreshes(matcher) // May need to refresh before the star shows up onView(matcher).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.GONE))) - } fun assertUnreadMarkerVisibility(conversation: Conversation, visibility: ViewMatchers.Visibility) { @@ -192,7 +189,7 @@ class InboxPage : BasePage(R.id.inboxPage) { } } - fun assertInboxEmpty() { + fun assertInboxEmpty() { waitForView(withId(R.id.emptyInboxView)).assertDisplayed() } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt index f9ecf29cbc..3fe3c27741 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt @@ -4,10 +4,13 @@ import android.util.Log import androidx.test.espresso.Espresso import androidx.test.espresso.matcher.ViewMatchers import com.instructure.canvas.espresso.E2E +import com.instructure.canvas.espresso.refresh import com.instructure.dataseeding.api.ConversationsApi import com.instructure.dataseeding.api.GroupsApi import com.instructure.dataseeding.model.CanvasUserApiModel import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.espresso.retry +import com.instructure.espresso.retryWithIncreasingDelay import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory @@ -218,7 +221,11 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG, "Select 'ARCHIVED' scope and assert that '${seedConversation2[0].subject}' conversation is displayed in the 'ARCHIVED' scope.") inboxPage.filterMessageScope("Archived") - inboxPage.assertConversationDisplayed(seedConversation2[0].subject) + + retry(times = 10, delay = 3000) { + refresh() + inboxPage.assertConversationDisplayed(seedConversation2[0].subject) + } Log.d(STEP_TAG, "Select '${seedConversation2[0].subject}' conversation and unarchive it." + "Assert that the selected number of conversation on the toolbar is 1 and '${seedConversation2[0].subject}' conversation is not displayed in the 'ARCHIVED' scope.") @@ -372,21 +379,28 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG, "Select both of the conversations. Star them and mark the unread.") inboxPage.selectConversations(listOf(seedConversation2[0].subject, seedConversation3[0].subject)) - inboxPage.clickStar() inboxPage.clickMarkAsRead() + retry(times = 10, delay = 3000) { + Log.d(STEP_TAG, "Assert that '${seedConversation3[0].subject}' conversation is read.") + inboxPage.assertUnreadMarkerVisibility(seedConversation3[0].subject, ViewMatchers.Visibility.GONE) + } + + Log.d(STEP_TAG, "Select both of the conversations. Star them and mark the unread.") + inboxPage.clickStar() + Log.d(STEP_TAG, "Navigate to 'STARRED' scope. Assert that both of the conversation are displayed in the 'STARRED' scope.") inboxPage.filterMessageScope("Starred") - inboxPage.assertConversationDisplayed(seedConversation2[0].subject) - inboxPage.assertConversationDisplayed(seedConversation3[0].subject) + + retryWithIncreasingDelay(times = 10, maxDelay = 3000) { + inboxPage.assertConversationDisplayed(seedConversation2[0].subject) + inboxPage.assertConversationDisplayed(seedConversation3[0].subject) + } Log.d(STEP_TAG, "Swipe '${seedConversation2[0].subject}' left and assert it is removed from the 'STARRED' scope because it has became unstarred.") inboxPage.swipeConversationLeft(seedConversation2[0]) inboxPage.assertConversationNotDisplayed(seedConversation2[0].subject) - Log.d(STEP_TAG, "Assert that '${seedConversation3[0].subject}' conversation is read.") - inboxPage.assertUnreadMarkerVisibility(seedConversation3[0].subject, ViewMatchers.Visibility.GONE) - Log.d(STEP_TAG, "Swipe '${seedConversation3[0].subject}' conversation right and assert that it has became unread.") inboxPage.swipeConversationRight(seedConversation3[0].subject) inboxPage.assertUnreadMarkerVisibility(seedConversation3[0].subject, ViewMatchers.Visibility.VISIBLE) diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt index 254db9d454..3c21299ac9 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt @@ -49,4 +49,42 @@ fun getCurrentDateInCanvasFormat(): String { val dayString = expectedDate.dayOfMonth val yearString = expectedDate.year return "$monthString $dayString, $yearString" +} + +fun retry( + times: Int = 3, + delay: Long = 1000, + block: () -> Unit +) { + repeat(times - 1) { + try { + block() + return + } catch (e: Throwable) { + e.printStackTrace() + } + Thread.sleep(delay) + } + block() +} + +fun retryWithIncreasingDelay( + times: Int = 3, + initialDelay: Long = 100, + maxDelay: Long = 1000, + factor: Double = 2.0, + block: () -> Unit +) { + var currentDelay = initialDelay + repeat(times - 1) { + try { + block() + return + } catch (e: Throwable) { + e.printStackTrace() + } + Thread.sleep(currentDelay) + currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay) + } + block() } \ No newline at end of file From 2211a4f5737dd9f8d1007349abbd59e37cd8d06e Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Wed, 22 Nov 2023 14:34:05 +0100 Subject: [PATCH 12/49] [MBL-17212][Student][Teacher] Open external routes from discussions (#2258) refs: MBL-17212 affects: Student, Teacher release note: none test plan: Navigate to an external route from the discussion redesign page. --- .../student/navigation/StudentWebViewRouter.kt | 5 +++++ .../teacher/navigation/TeacherWebViewRouter.kt | 14 ++++++++++++++ .../details/DiscussionDetailsWebViewFragment.kt | 2 +- .../pandautils/navigation/WebViewRouter.kt | 2 ++ 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/student/src/main/java/com/instructure/student/navigation/StudentWebViewRouter.kt b/apps/student/src/main/java/com/instructure/student/navigation/StudentWebViewRouter.kt index 6eb681b270..7ae5299c74 100644 --- a/apps/student/src/main/java/com/instructure/student/navigation/StudentWebViewRouter.kt +++ b/apps/student/src/main/java/com/instructure/student/navigation/StudentWebViewRouter.kt @@ -19,6 +19,7 @@ package com.instructure.student.navigation import androidx.fragment.app.FragmentActivity import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.pandautils.navigation.WebViewRouter +import com.instructure.student.fragment.InternalWebviewFragment import com.instructure.student.router.RouteMatcher class StudentWebViewRouter(val activity: FragmentActivity) : WebViewRouter { @@ -34,4 +35,8 @@ class StudentWebViewRouter(val activity: FragmentActivity) : WebViewRouter { override fun openMedia(url: String) { RouteMatcher.openMedia(activity, url) } + + override fun routeExternally(url: String) { + RouteMatcher.route(activity, InternalWebviewFragment.makeRoute(url, url, false, "")) + } } \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/navigation/TeacherWebViewRouter.kt b/apps/teacher/src/main/java/com/instructure/teacher/navigation/TeacherWebViewRouter.kt index 2f457b39c3..07c508bb48 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/navigation/TeacherWebViewRouter.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/navigation/TeacherWebViewRouter.kt @@ -17,8 +17,12 @@ package com.instructure.teacher.navigation import androidx.fragment.app.FragmentActivity +import com.instructure.canvasapi2.models.CanvasContext import com.instructure.canvasapi2.utils.ApiPrefs +import com.instructure.interactions.router.Route import com.instructure.pandautils.navigation.WebViewRouter +import com.instructure.teacher.fragments.FullscreenInternalWebViewFragment +import com.instructure.teacher.fragments.InternalWebViewFragment import com.instructure.teacher.router.RouteMatcher class TeacherWebViewRouter(val activity: FragmentActivity) : WebViewRouter { @@ -34,4 +38,14 @@ class TeacherWebViewRouter(val activity: FragmentActivity) : WebViewRouter { override fun openMedia(url: String) { RouteMatcher.openMedia(activity, url) } + + override fun routeExternally(url: String) { + val bundle = InternalWebViewFragment.makeBundle(url, url, false, "") + RouteMatcher.route( + activity, Route( + FullscreenInternalWebViewFragment::class.java, + CanvasContext.emptyUserContext(), bundle + ) + ) + } } \ No newline at end of file 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 3d706811c4..c402282c08 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 @@ -96,7 +96,7 @@ class DiscussionDetailsWebViewFragment : Fragment() { override fun routeInternallyCallback(url: String) { if (!webViewRouter.canRouteInternally(url, routeIfPossible = true)) { - webViewRouter.routeInternally(url) + webViewRouter.routeExternally(url) } } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/navigation/WebViewRouter.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/navigation/WebViewRouter.kt index 2816a16f53..f577adddc1 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/navigation/WebViewRouter.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/navigation/WebViewRouter.kt @@ -23,4 +23,6 @@ interface WebViewRouter { fun routeInternally(url: String) fun openMedia(url: String) + + fun routeExternally(url: String) } \ No newline at end of file From 1c2a47ba6b56d593d8ba1c703de5e131b61aeb94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Thu, 23 Nov 2023 12:42:52 +0100 Subject: [PATCH 13/49] file null url error handling --- .../com/instructure/student/fragment/ParentFragment.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/student/src/main/java/com/instructure/student/fragment/ParentFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/ParentFragment.kt index 4cbdbeb85e..24f868fa74 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/ParentFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/ParentFragment.kt @@ -60,6 +60,7 @@ import com.instructure.pandautils.utils.LoaderUtils import com.instructure.pandautils.utils.PermissionUtils import com.instructure.pandautils.utils.getDrawableCompat import com.instructure.pandautils.utils.hasPermissions +import com.instructure.pandautils.utils.toast import com.instructure.pandautils.views.EmptyView import com.instructure.student.R import com.instructure.student.activity.VideoViewActivity @@ -381,7 +382,12 @@ abstract class ParentFragment : DialogFragment(), FragmentInteractions, Navigati } onMainThread { - LoaderUtils.restartLoaderWithBundle>(LoaderManager.getInstance(owner), openMediaBundle, loaderCallbacks, R.id.openMediaLoaderID) + try { + LoaderUtils.restartLoaderWithBundle>(LoaderManager.getInstance(owner), openMediaBundle, loaderCallbacks, R.id.openMediaLoaderID) + } catch (e: Exception) { + toast(R.string.unexpectedErrorOpeningFile) + onMediaLoadingComplete() + } } } From 4fb93c6df99bd52f177d5f84df2c250f26758cf4 Mon Sep 17 00:00:00 2001 From: "kristof.nemere" Date: Thu, 23 Nov 2023 12:55:25 +0100 Subject: [PATCH 14/49] Wait for user --- .../loginapi/login/viewmodel/LoginViewModel.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt index 6bfee40ec4..35e523ced4 100644 --- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt +++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt @@ -24,10 +24,10 @@ import com.instructure.canvasapi2.managers.OAuthManager import com.instructure.canvasapi2.managers.UserManager import com.instructure.canvasapi2.utils.ApiPrefs import com.instructure.pandautils.mvvm.Event -import com.instructure.pandautils.utils.FEATURE_FLAG_OFFLINE import com.instructure.pandautils.utils.FeatureFlagProvider import com.instructure.pandautils.utils.NetworkStateProvider import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import javax.inject.Inject @@ -49,6 +49,7 @@ class LoginViewModel @Inject constructor( fun checkLogin(checkToken: Boolean, checkElementary: Boolean): LiveData> { viewModelScope.launch { try { + waitForUser() val offlineEnabled = featureFlagProvider.offlineEnabled() val offlineLogin = offlineEnabled && !networkStateProvider.isOnline() if (checkToken && !offlineLogin) { @@ -70,6 +71,14 @@ class LoginViewModel @Inject constructor( return loginResultAction } + // We need to wait for the user to be set in ApiPrefs + private suspend fun waitForUser() { + repeat(30) { + if (ApiPrefs.user != null) return + delay(100) + } + } + private suspend fun checkTermsAcceptance(canvasForElementary: Boolean, offlineLogin: Boolean = false) { val authenticatedSession = oauthManager.getAuthenticatedSessionAsync("${apiPrefs.fullDomain}/users/self").await() val requiresTermsAcceptance = authenticatedSession.dataOrNull?.requiresTermsAcceptance ?: false From 8cf5726fd5ca6ea0df0b95636cb57554429e9df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Thu, 23 Nov 2023 13:02:25 +0100 Subject: [PATCH 15/49] version bump --- apps/student/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/student/build.gradle b/apps/student/build.gradle index 9aa4e3023d..644403aff7 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 256 - versionName = '7.0.0' + versionCode = 257 + versionName = '7.0.1' vectorDrawables.useSupportLibrary = true multiDexEnabled = true From e30d261ad73744bacdb64288eb62a3f49121dd36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Thu, 23 Nov 2023 13:18:06 +0100 Subject: [PATCH 16/49] fix tests --- .../com/instructure/loginapi/login/viewmodel/LoginViewModel.kt | 2 +- .../instructure/loginapi/login/viewmodel/LoginViewModelTest.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt index 35e523ced4..562e9cf516 100644 --- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt +++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt @@ -74,7 +74,7 @@ class LoginViewModel @Inject constructor( // We need to wait for the user to be set in ApiPrefs private suspend fun waitForUser() { repeat(30) { - if (ApiPrefs.user != null) return + if (apiPrefs.user != null) return delay(100) } } diff --git a/libs/login-api-2/src/test/java/com/instructure/loginapi/login/viewmodel/LoginViewModelTest.kt b/libs/login-api-2/src/test/java/com/instructure/loginapi/login/viewmodel/LoginViewModelTest.kt index 7d8548de08..0e576df331 100644 --- a/libs/login-api-2/src/test/java/com/instructure/loginapi/login/viewmodel/LoginViewModelTest.kt +++ b/libs/login-api-2/src/test/java/com/instructure/loginapi/login/viewmodel/LoginViewModelTest.kt @@ -67,6 +67,8 @@ class LoginViewModelTest { fun setUp() { lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE) Dispatchers.setMain(testDispatcher) + + every { apiPrefs.user } returns mockk() } @After From 78bf26b6ca876190edccdaab7dc372c176e5ef6c Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Tue, 28 Nov 2023 09:59:31 +0100 Subject: [PATCH 17/49] [MBL-17116][Student] - Create E2E test for CourseBrowser Page Offline Unavailable menus (#2261) --- .../offline/OfflineCourseBrowserE2ETest.kt | 147 ++++++++++++++++++ .../student/ui/pages/CourseBrowserPage.kt | 16 ++ .../student/ui/pages/DashboardPage.kt | 16 ++ .../com/instructure/espresso/TestingUtils.kt | 6 +- 4 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt new file mode 100644 index 0000000000..59244510b3 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2023 - 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.student.ui.e2e.offline + +import android.util.Log +import androidx.test.espresso.Espresso +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import com.instructure.canvas.espresso.OfflineE2E +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils +import com.instructure.student.ui.pages.CourseBrowserPage +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.seedData +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Test +import java.lang.Thread.sleep + +@HiltAndroidTest +class OfflineCourseBrowserE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.E2E) + fun testOfflineCourseBrowserPageUnavailableE2E() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 1, announcements = 1) + val student = data.studentsList[0] + val course1 = data.coursesList[0] + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + + Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open global 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.openGlobalManageOfflineContentPage() + + Log.d(STEP_TAG, "Select the entire '${course1.name}' course for sync. Click on the 'Sync' button.") + Log.d(STEP_TAG, "Expand '${course1.name}' course. Select only the 'Announcements' of the '${course1.name}' course. Click on the 'Sync' button and confirm the sync process.") + manageOfflineContentPage.expandCollapseItem(course1.name) + manageOfflineContentPage.changeItemSelectionState("Announcements") + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") + dashboardPage.waitForRender() + dashboardPage.waitForSyncProgressDownloadStartedNotification() + dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() + + Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") + dashboardPage.waitForSyncProgressStartingNotification() + dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + OfflineTestUtils.turnOffConnectionViaADB() + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Select '${course1.name}' course and open 'Announcements' menu.") + sleep(5000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. + dashboardPage.selectCourse(course1) + + Log.d(STEP_TAG, "Assert that only the 'Announcements' tab is enabled because it is the only one which has been synced, and assert that all the other, previously synced tabs are disabled, because they weren't synced now.") + var enabledTabs = arrayOf("Announcements") + var disabledTabs = arrayOf("Discussions", "Grades", "People", "Syllabus", "BigBlueButton") + assertTabsEnabled(courseBrowserPage, enabledTabs) + assertTabsDisabled(courseBrowserPage, disabledTabs) + + Log.d(STEP_TAG, "Navigate back to Dashboard Page.Turn back on the Wi-Fi and Mobile Data on the device, and wait for it to come online.") + Espresso.pressBack() + OfflineTestUtils.turnOnConnectionViaADB() + dashboardPage.waitForNetworkComeBack() + + Log.d(STEP_TAG, "Open global 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.openGlobalManageOfflineContentPage() + + Log.d(STEP_TAG, "Deselect the entire '${course1.name}' course for sync.") + manageOfflineContentPage.changeItemSelectionState(course1.name) + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") + dashboardPage.waitForRender() + dashboardPage.waitForSyncProgressDownloadStartedNotification() + dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() + + Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") + dashboardPage.waitForSyncProgressStartingNotification() + dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + OfflineTestUtils.turnOffConnectionViaADB() + + Log.d(STEP_TAG, "Select '${course1.name}' course and open 'Announcements' menu.") + sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. + device.waitForIdle() + device.waitForWindowUpdate(null, 10000) + dashboardPage.selectCourse(course1) + + Log.d(STEP_TAG, "Assert that the 'Google Drive' and 'Collaborations' tabs are disabled because they aren't supported in offline mode, but the rest of the tabs are enabled because the whole course has been synced.") + enabledTabs = arrayOf("Announcements", "Discussions", "Grades", "People", "Syllabus", "BigBlueButton") + disabledTabs = arrayOf("Google Drive", "Collaborations") + assertTabsEnabled(courseBrowserPage, enabledTabs) + assertTabsDisabled(courseBrowserPage, disabledTabs) + } + + @After + fun tearDown() { + Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") + OfflineTestUtils.turnOnConnectionViaADB() + } + + private fun assertTabsEnabled(courseBrowserPage: CourseBrowserPage, tabs: Array) { + tabs.forEach { tab -> + courseBrowserPage.assertTabEnabled(tab) + } + } + + private fun assertTabsDisabled(courseBrowserPage: CourseBrowserPage, tabs: Array) { + tabs.forEach { tab -> + courseBrowserPage.assertTabDisabled(tab) + } + } +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt index d3a9a95924..87cbc0de71 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseBrowserPage.kt @@ -17,12 +17,15 @@ package com.instructure.student.ui.pages import android.view.View +import android.widget.LinearLayout +import androidx.constraintlayout.widget.ConstraintLayout import androidx.test.espresso.Espresso.onView import androidx.test.espresso.PerformException import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction import androidx.test.espresso.action.ViewActions import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvas.espresso.scrollRecyclerView import com.instructure.canvas.espresso.withCustomConstraints @@ -34,11 +37,15 @@ import com.instructure.espresso.WaitForViewWithId import com.instructure.espresso.assertHasText import com.instructure.espresso.click import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.plus +import com.instructure.espresso.scrollTo import com.instructure.espresso.swipeUp import com.instructure.pandautils.views.SwipeRefreshLayoutAppBar import com.instructure.student.R import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.anyOf +import org.hamcrest.Matchers.not open class CourseBrowserPage : BasePage(R.id.courseBrowserPage) { @@ -140,6 +147,15 @@ open class CourseBrowserPage : BasePage(R.id.courseBrowserPage) { onView(allOf(withText(tabTitle), withId(R.id.label))).check(doesNotExist()) } + //OfflineMethod + fun assertTabDisabled(tabTitle: String) { + onView(allOf(anyOf(isAssignableFrom(LinearLayout::class.java), isAssignableFrom(ConstraintLayout::class.java)), withChild(anyOf(withId(R.id.label), withId(R.id.unsupportedLabel)) + withText(tabTitle)))).scrollTo().check(matches(not(isEnabled()))) + } + + fun assertTabEnabled(tabTitle: String) { + onView(allOf(anyOf(isAssignableFrom(LinearLayout::class.java), isAssignableFrom(ConstraintLayout::class.java)), withChild(anyOf(withId(R.id.label), withId(R.id.unsupportedLabel)) + withText(tabTitle)))).scrollTo().check(matches(isEnabled())) + } + // Minimizes toolbar if it is not already minimized private fun minimizeToolbar() { try { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index a2a1c189f4..c4d2b55143 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -354,6 +354,22 @@ class DashboardPage : BasePage(R.id.dashboardPage) { onView(withId(R.id.offlineIndicator)).check(matches(withEffectiveVisibility(Visibility.GONE))) } + //OfflineMethod + fun waitForNetworkComeBack() { + assertDisplaysCourses() + retry(times = 5, delay = 2000) { + assertOfflineIndicatorNotDisplayed() + } + } + + //OfflineMethod + fun waitForNetworkOff() { + assertDisplaysCourses() + retry(times = 5, delay = 2000) { + assertOfflineIndicatorDisplayed() + } + } + //OfflineMethod fun assertCourseOfflineSyncIconVisible(courseName: String) { waitForView(withId(R.id.offlineSyncIcon) + hasSibling(withId(R.id.titleTextView) + withText(courseName))).check(matches(withEffectiveVisibility(Visibility.VISIBLE))) diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt index 3c21299ac9..101695828e 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt @@ -62,8 +62,8 @@ fun retry( return } catch (e: Throwable) { e.printStackTrace() + Thread.sleep(delay) } - Thread.sleep(delay) } block() } @@ -82,9 +82,9 @@ fun retryWithIncreasingDelay( return } catch (e: Throwable) { e.printStackTrace() + Thread.sleep(currentDelay) + currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay) } - Thread.sleep(currentDelay) - currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay) } block() } \ No newline at end of file From 5d54fedd78da3a91169efe2ba534c8a1434fcf6d Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Tue, 28 Nov 2023 11:43:50 +0100 Subject: [PATCH 18/49] Nightly - Stabilize SpeedGrader and Todo E2E tests. (#2262) --- .../com/instructure/student/ui/e2e/InboxE2ETest.kt | 4 ++-- .../com/instructure/student/ui/e2e/TodoE2ETest.kt | 12 +++++++----- .../com/instructure/teacher/ui/e2e/InboxE2ETest.kt | 10 +++++----- .../instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt | 11 +++++++++-- .../kotlin/com/instructure/espresso/TestingUtils.kt | 4 ++++ 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt index 35baba10c0..0f5b709514 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/InboxE2ETest.kt @@ -131,7 +131,7 @@ class InboxE2ETest: StudentTest() { inboxPage.assertSelectedConversationNumber("1") inboxPage.clickUnstar() - retryWithIncreasingDelay(times = 10, maxDelay = 3000) { + retryWithIncreasingDelay(times = 10, maxDelay = 3000, catchBlock = { refresh() }) { inboxPage.assertConversationNotStarred(seededConversation.subject) } @@ -168,7 +168,7 @@ class InboxE2ETest: StudentTest() { inboxPage.selectConversations(listOf(seededConversation.subject)) inboxPage.clickUnArchive() - retryWithIncreasingDelay(times = 10, maxDelay = 3000) { + retryWithIncreasingDelay(times = 10, maxDelay = 3000, catchBlock = { refresh() }) { inboxPage.assertConversationNotDisplayed(seededConversation.subject) } diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt index 2c98efb118..4968c7105f 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt @@ -15,6 +15,7 @@ import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 +import com.instructure.espresso.retry import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory @@ -71,12 +72,13 @@ class TodoE2ETest: StudentTest() { dashboardPage.clickTodoTab() Log.d(STEP_TAG,"Assert that ${testAssignment.name} assignment is displayed and ${borderDateAssignment.name} is displayed because it's 7 days away from now..") - todoPage.assertAssignmentDisplayed(testAssignment) - todoPage.assertAssignmentDisplayed(borderDateAssignment) - Log.d(STEP_TAG,"Assert that ${quiz.title} quiz is displayed and ${tooFarAwayQuiz.title} quiz is not displayed because it's end date is more than a week away..") - todoPage.assertQuizDisplayed(quiz) - todoPage.assertQuizNotDisplayed(tooFarAwayQuiz) + retry(times = 5, delay = 3000, catchBlock = { refresh() } ) { + todoPage.assertAssignmentDisplayed(testAssignment) + todoPage.assertAssignmentDisplayed(borderDateAssignment) + todoPage.assertQuizDisplayed(quiz) + todoPage.assertQuizNotDisplayed(tooFarAwayQuiz) + } Log.d(PREPARATION_TAG,"Submit ${testAssignment.name} assignment for ${student.name} student.") SubmissionsApi.seedAssignmentSubmission(SubmissionsApi.SubmissionSeedRequest( diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt index 3fe3c27741..4bcbed23a2 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/InboxE2ETest.kt @@ -222,10 +222,10 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG, "Select 'ARCHIVED' scope and assert that '${seedConversation2[0].subject}' conversation is displayed in the 'ARCHIVED' scope.") inboxPage.filterMessageScope("Archived") - retry(times = 10, delay = 3000) { + retry(times = 10, delay = 3000, block = { refresh() inboxPage.assertConversationDisplayed(seedConversation2[0].subject) - } + }) Log.d(STEP_TAG, "Select '${seedConversation2[0].subject}' conversation and unarchive it." + "Assert that the selected number of conversation on the toolbar is 1 and '${seedConversation2[0].subject}' conversation is not displayed in the 'ARCHIVED' scope.") @@ -381,10 +381,10 @@ class InboxE2ETest : TeacherTest() { inboxPage.selectConversations(listOf(seedConversation2[0].subject, seedConversation3[0].subject)) inboxPage.clickMarkAsRead() - retry(times = 10, delay = 3000) { + retry(times = 10, delay = 3000, block = { Log.d(STEP_TAG, "Assert that '${seedConversation3[0].subject}' conversation is read.") inboxPage.assertUnreadMarkerVisibility(seedConversation3[0].subject, ViewMatchers.Visibility.GONE) - } + }) Log.d(STEP_TAG, "Select both of the conversations. Star them and mark the unread.") inboxPage.clickStar() @@ -392,7 +392,7 @@ class InboxE2ETest : TeacherTest() { Log.d(STEP_TAG, "Navigate to 'STARRED' scope. Assert that both of the conversation are displayed in the 'STARRED' scope.") inboxPage.filterMessageScope("Starred") - retryWithIncreasingDelay(times = 10, maxDelay = 3000) { + retryWithIncreasingDelay(times = 10, maxDelay = 3000, catchBlock = { refresh() }) { inboxPage.assertConversationDisplayed(seedConversation2[0].subject) inboxPage.assertConversationDisplayed(seedConversation3[0].subject) } diff --git a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt index 175815be85..cd33a35bd1 100644 --- a/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt +++ b/apps/teacher/src/androidTest/java/com/instructure/teacher/ui/e2e/SpeedGraderE2ETest.kt @@ -33,12 +33,17 @@ import com.instructure.dataseeding.model.SubmissionType import com.instructure.dataseeding.util.days import com.instructure.dataseeding.util.fromNow import com.instructure.dataseeding.util.iso8601 +import com.instructure.espresso.retry import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory import com.instructure.panda_annotations.TestMetaData import com.instructure.teacher.R -import com.instructure.teacher.ui.utils.* +import com.instructure.teacher.ui.utils.TeacherTest +import com.instructure.teacher.ui.utils.seedAssignmentSubmission +import com.instructure.teacher.ui.utils.seedAssignments +import com.instructure.teacher.ui.utils.seedData +import com.instructure.teacher.ui.utils.tokenLogin import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Test @@ -153,7 +158,9 @@ class SpeedGraderE2ETest : TeacherTest() { assignmentSubmissionListPage.clickFilterDialogOk() Log.d(STEP_TAG,"Assert that there is one submission displayed.") - assignmentSubmissionListPage.assertHasSubmission(1) + retry(times = 5, delay = 3000, catchBlock = { refresh() }) { + assignmentSubmissionListPage.assertHasSubmission(1) + } Log.d(STEP_TAG, "Navigate back assignment's details page.") Espresso.pressBack() diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt index 101695828e..2901820aae 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/TestingUtils.kt @@ -54,6 +54,7 @@ fun getCurrentDateInCanvasFormat(): String { fun retry( times: Int = 3, delay: Long = 1000, + catchBlock: (() -> Unit)? = null, block: () -> Unit ) { repeat(times - 1) { @@ -63,6 +64,7 @@ fun retry( } catch (e: Throwable) { e.printStackTrace() Thread.sleep(delay) + catchBlock?.invoke() } } block() @@ -73,6 +75,7 @@ fun retryWithIncreasingDelay( initialDelay: Long = 100, maxDelay: Long = 1000, factor: Double = 2.0, + catchBlock: (() -> Unit)? = null, block: () -> Unit ) { var currentDelay = initialDelay @@ -84,6 +87,7 @@ fun retryWithIncreasingDelay( e.printStackTrace() Thread.sleep(currentDelay) currentDelay = (currentDelay * factor).toLong().coerceAtMost(maxDelay) + catchBlock?.invoke() } } block() From dcebcb47e68781f6d5fb78f59ee96ebb9af012c6 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Tue, 28 Nov 2023 12:13:07 +0100 Subject: [PATCH 19/49] Nightly - Offline - Attempt to fix CI UnknownHostException. (#2264) --- .../offline/ManageOfflineContentE2ETest.kt | 5 +- .../offline/OfflineCourseBrowserE2ETest.kt | 9 +- .../ui/e2e/offline/OfflineDashboardE2ETest.kt | 6 +- .../e2e/offline/OfflineSyncProgressE2ETest.kt | 4 +- .../e2e/offline/OfflineSyncSettingsE2ETest.kt | 3 +- .../ui/e2e/offline/utils/OfflineTestUtils.kt | 18 -- automation/espresso/build.gradle | 2 +- .../espresso/src/main/AndroidManifest.xml | 2 + .../instructure/canvas/espresso/CanvasTest.kt | 181 +++++++++++------- 9 files changed, 132 insertions(+), 98 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt index aa8a2108a5..3d300a2e1a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt @@ -24,7 +24,6 @@ import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory import com.instructure.panda_annotations.TestMetaData -import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData import com.instructure.student.ui.utils.tokenLogin @@ -197,7 +196,7 @@ class ManageOfflineContentE2ETest : StudentTest() { dashboardPage.waitForSyncProgressStartingNotificationToDisappear() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") - OfflineTestUtils.turnOffConnectionViaADB() + turnOffConnectionViaADB() dashboardPage.waitForRender() Log.d(STEP_TAG, "Select '${course2.name}' course and open 'Grades' menu to check if it's really synced and can be seen in offline mode.") @@ -211,7 +210,7 @@ class ManageOfflineContentE2ETest : StudentTest() { @After fun tearDown() { Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device via ADB, so it will come back online.") - OfflineTestUtils.turnOnConnectionViaADB() + turnOnConnectionViaADB() } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt index 59244510b3..04a6fc4337 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt @@ -25,7 +25,6 @@ import com.instructure.panda_annotations.FeatureCategory import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory import com.instructure.panda_annotations.TestMetaData -import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils import com.instructure.student.ui.pages.CourseBrowserPage import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.seedData @@ -75,7 +74,7 @@ class OfflineCourseBrowserE2ETest : StudentTest() { dashboardPage.waitForSyncProgressStartingNotificationToDisappear() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") - OfflineTestUtils.turnOffConnectionViaADB() + turnOffConnectionViaADB() Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") dashboardPage.waitForRender() @@ -92,7 +91,7 @@ class OfflineCourseBrowserE2ETest : StudentTest() { Log.d(STEP_TAG, "Navigate back to Dashboard Page.Turn back on the Wi-Fi and Mobile Data on the device, and wait for it to come online.") Espresso.pressBack() - OfflineTestUtils.turnOnConnectionViaADB() + turnOnConnectionViaADB() dashboardPage.waitForNetworkComeBack() Log.d(STEP_TAG, "Open global 'Manage Offline Content' page via the more menu of the Dashboard Page.") @@ -112,7 +111,7 @@ class OfflineCourseBrowserE2ETest : StudentTest() { dashboardPage.waitForSyncProgressStartingNotificationToDisappear() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") - OfflineTestUtils.turnOffConnectionViaADB() + turnOffConnectionViaADB() Log.d(STEP_TAG, "Select '${course1.name}' course and open 'Announcements' menu.") sleep(10000) //Need to wait a bit here because of a UI glitch that when network state change, the dashboard page 'pops' a bit and it can confuse the automation script. @@ -130,7 +129,7 @@ class OfflineCourseBrowserE2ETest : StudentTest() { @After fun tearDown() { Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") - OfflineTestUtils.turnOnConnectionViaADB() + turnOnConnectionViaADB() } private fun assertTabsEnabled(courseBrowserPage: CourseBrowserPage, tabs: Array) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt index 309f23f989..21a3fa625b 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineDashboardE2ETest.kt @@ -75,7 +75,7 @@ class OfflineDashboardE2ETest : StudentTest() { dashboardPage.waitForSyncProgressStartingNotificationToDisappear() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") - OfflineTestUtils.turnOffConnectionViaADB() + turnOffConnectionViaADB() device.waitForIdle() Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") @@ -130,7 +130,7 @@ class OfflineDashboardE2ETest : StudentTest() { dashboardPage.waitForSyncProgressStartingNotificationToDisappear() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") - OfflineTestUtils.turnOffConnectionViaADB() + turnOffConnectionViaADB() device.waitForIdle() Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered. Refresh the page.") @@ -157,7 +157,7 @@ class OfflineDashboardE2ETest : StudentTest() { @After fun tearDown() { Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") - OfflineTestUtils.turnOnConnectionViaADB() + turnOnConnectionViaADB() } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt index 77cea19bc2..26637e2f1a 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncProgressE2ETest.kt @@ -80,7 +80,7 @@ class OfflineSyncProgressE2ETest : StudentTest() { Espresso.pressBack() Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") - OfflineTestUtils.turnOffConnectionViaADB() + turnOffConnectionViaADB() dashboardPage.waitForRender() Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") @@ -101,7 +101,7 @@ class OfflineSyncProgressE2ETest : StudentTest() { @After fun tearDown() { Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device via ADB, so it will come back online.") - OfflineTestUtils.turnOnConnectionViaADB() + turnOnConnectionViaADB() } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt index fac7879901..107d91c4c6 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineSyncSettingsE2ETest.kt @@ -23,7 +23,6 @@ import com.instructure.panda_annotations.Priority import com.instructure.panda_annotations.TestCategory import com.instructure.panda_annotations.TestMetaData import com.instructure.pandautils.R -import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils import com.instructure.student.ui.utils.StudentTest import com.instructure.student.ui.utils.ViewUtils import com.instructure.student.ui.utils.seedData @@ -139,7 +138,7 @@ class OfflineSyncSettingsE2ETest : StudentTest() { @After fun tearDown() { Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device via ADB, so it will come back online.") - OfflineTestUtils.turnOnConnectionViaADB() + turnOnConnectionViaADB() } } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt index 7cd51b7a66..0696eec9b3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/utils/OfflineTestUtils.kt @@ -31,24 +31,6 @@ import org.hamcrest.CoreMatchers.allOf object OfflineTestUtils { - private const val ENABLE_WIFI_COMMAND: String = "svc wifi enable" - private const val DISABLE_WIFI_COMMAND: String = "svc wifi disable" - - private const val ENABLE_MOBILE_DATA_COMMAND: String = "svc data enable" - private const val DISABLE_MOBILE_DATA_COMMAND: String = "svc data disable" - - fun turnOffConnectionViaADB() { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - device.executeShellCommand(DISABLE_WIFI_COMMAND) - device.executeShellCommand(DISABLE_MOBILE_DATA_COMMAND) - } - - fun turnOnConnectionViaADB() { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - device.executeShellCommand(ENABLE_WIFI_COMMAND) - device.executeShellCommand(ENABLE_MOBILE_DATA_COMMAND) - } - fun turnOffConnectionOnUI() { val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) diff --git a/automation/espresso/build.gradle b/automation/espresso/build.gradle index 3439d3cc76..195d693576 100644 --- a/automation/espresso/build.gradle +++ b/automation/espresso/build.gradle @@ -45,7 +45,7 @@ android { buildToolsVersion '28.0.3' defaultConfig { - minSdkVersion 17 + minSdkVersion 24 targetSdkVersion 28 } diff --git a/automation/espresso/src/main/AndroidManifest.xml b/automation/espresso/src/main/AndroidManifest.xml index 649274d1a5..a6af28ef54 100644 --- a/automation/espresso/src/main/AndroidManifest.xml +++ b/automation/espresso/src/main/AndroidManifest.xml @@ -20,6 +20,8 @@ package="com.instructure.espresso"> + + diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt index 185d7aa675..adf8d1f501 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CanvasTest.kt @@ -21,6 +21,9 @@ import android.app.Activity import android.content.Context import android.content.res.Configuration import android.content.res.Resources +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities import android.os.Build import android.os.Environment import android.util.DisplayMetrics @@ -31,6 +34,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.matcher.ViewMatchers import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule +import androidx.test.uiautomator.UiDevice import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesCheckNames import com.google.android.apps.common.testing.accessibility.framework.AccessibilityCheckResultUtils.matchesViews import com.google.android.apps.common.testing.accessibility.framework.AccessibilityViewCheckResult @@ -74,6 +78,8 @@ abstract class CanvasTest : InstructureTestingContract { var extraAccessibilitySupressions: Matcher? = Matchers.anyOf() + val connectivityManager = InstrumentationRegistry.getInstrumentation().context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + @Rule(order = 1) override fun chain(): TestRule { return RuleChain @@ -107,70 +113,20 @@ abstract class CanvasTest : InstructureTestingContract { // Only continue if we're on Bitrise // (More accurately, if we are on FTL launched from Bitrise.) if(splunkToken != null && !splunkToken.isEmpty()) { - val bitriseWorkflow = InstrumentationRegistry.getArguments().getString("BITRISE_TRIGGERED_WORKFLOW_ID") - val bitriseApp = InstrumentationRegistry.getArguments().getString("BITRISE_APP_TITLE") - val bitriseBranch = InstrumentationRegistry.getArguments().getString("BITRISE_GIT_BRANCH") - val bitriseBuildNumber = InstrumentationRegistry.getArguments().getString("BITRISE_BUILD_NUMBER") - - val eventObject = JSONObject() - eventObject.put("workflow", bitriseWorkflow) - eventObject.put("branch", bitriseBranch) - eventObject.put("bitriseApp", bitriseApp) - eventObject.put("status", disposition) - eventObject.put("testName", testMethod) - eventObject.put("testClass", testClass) - eventObject.put("stackTrace", error.stackTrace.take(15).joinToString(", ")) - eventObject.put("osVersion", Build.VERSION.SDK_INT.toString()) - // Limit our error message to 4096 chars; they can be unreasonably long (e.g., 137K!) when - // they contain a view hierarchy, and there is typically not much useful info after the - // first few lines. - eventObject.put("message", error.toString().take(4096)) - - val payloadObject = JSONObject() - payloadObject.put("sourcetype", "mobile-android-qa-testresult") - payloadObject.put("event", eventObject) - - val payload = payloadObject.toString() - Log.d("CanvasTest", "payload = $payload") - - // Can't run a curl command from FTL, so let's do this the hard way - var os : OutputStream? = null - var inputStream : InputStream? = null - var conn : HttpURLConnection? = null - - try { - - // Set up our url/connection - val url = URL("https://http-inputs-inst.splunkcloud.com:443/services/collector") - conn = url.openConnection() as HttpURLConnection - conn.requestMethod = "POST" - conn.setRequestProperty("Authorization", "Splunk $splunkToken") - conn.setRequestProperty("Content-Type", "application/json; utf-8") - conn.setRequestProperty("Accept", "application/json") - conn.setDoInput(true) - conn.setDoOutput(true) - - // Connect - conn.connect() - - // Send out our post body - os = BufferedOutputStream(conn.outputStream) - os.write(payload.toByteArray()) - os.flush() - - // Report the result summary - Log.d("CanvasTest", "Response code: ${conn.responseCode}, message: ${conn.responseMessage}") - - // Report the splunk result JSON - inputStream = conn.inputStream - val content = inputStream.bufferedReader().use(BufferedReader::readText) - Log.d("CanvasTest", "Response: $content") - } - finally { - // Clean up our mess - if(os != null) os.close() - if(inputStream != null) inputStream.close() - if(conn != null) conn.disconnect() + val networkCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + val hasActiveNetwork = networkCapabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: false + + if (hasActiveNetwork) { + reportToSplunk(disposition, testMethod, testClass, error, splunkToken) + } else { + turnOnConnectionViaADB() + connectivityManager.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + connectivityManager.unregisterNetworkCallback(this) + reportToSplunk(disposition, testMethod, testClass, error, splunkToken) + } + }) } } @@ -181,6 +137,84 @@ abstract class CanvasTest : InstructureTestingContract { } + private fun reportToSplunk( + disposition: String, + testMethod: String, + testClass: String, + error: Throwable, + splunkToken: String? + ) { + val bitriseWorkflow = + InstrumentationRegistry.getArguments().getString("BITRISE_TRIGGERED_WORKFLOW_ID") + val bitriseApp = InstrumentationRegistry.getArguments().getString("BITRISE_APP_TITLE") + val bitriseBranch = InstrumentationRegistry.getArguments().getString("BITRISE_GIT_BRANCH") + val bitriseBuildNumber = + InstrumentationRegistry.getArguments().getString("BITRISE_BUILD_NUMBER") + + val eventObject = JSONObject() + eventObject.put("workflow", bitriseWorkflow) + eventObject.put("branch", bitriseBranch) + eventObject.put("bitriseApp", bitriseApp) + eventObject.put("status", disposition) + eventObject.put("testName", testMethod) + eventObject.put("testClass", testClass) + eventObject.put("stackTrace", error.stackTrace.take(15).joinToString(", ")) + eventObject.put("osVersion", Build.VERSION.SDK_INT.toString()) + // Limit our error message to 4096 chars; they can be unreasonably long (e.g., 137K!) when + // they contain a view hierarchy, and there is typically not much useful info after the + // first few lines. + eventObject.put("message", error.toString().take(4096)) + + val payloadObject = JSONObject() + payloadObject.put("sourcetype", "mobile-android-qa-testresult") + payloadObject.put("event", eventObject) + + val payload = payloadObject.toString() + Log.d("CanvasTest", "payload = $payload") + + // Can't run a curl command from FTL, so let's do this the hard way + var os: OutputStream? = null + var inputStream: InputStream? = null + var conn: HttpURLConnection? = null + + try { + + // Set up our url/connection + val url = URL("https://http-inputs-inst.splunkcloud.com:443/services/collector") + conn = url.openConnection() as HttpURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Authorization", "Splunk $splunkToken") + conn.setRequestProperty("Content-Type", "application/json; utf-8") + conn.setRequestProperty("Accept", "application/json") + conn.setDoInput(true) + conn.setDoOutput(true) + + // Connect + conn.connect() + + // Send out our post body + os = BufferedOutputStream(conn.outputStream) + os.write(payload.toByteArray()) + os.flush() + + // Report the result summary + Log.d( + "CanvasTest", + "Response code: ${conn.responseCode}, message: ${conn.responseMessage}" + ) + + // Report the splunk result JSON + inputStream = conn.inputStream + val content = inputStream.bufferedReader().use(BufferedReader::readText) + Log.d("CanvasTest", "Response: $content") + } finally { + // Clean up our mess + if (os != null) os.close() + if (inputStream != null) inputStream.close() + if (conn != null) conn.disconnect() + } + } + // Creates an /sdcard/coverage folder if it does not already exist. // This is necessary for us to generate/process code coverage data. private fun setupCoverageFolder() { @@ -464,6 +498,12 @@ abstract class CanvasTest : InstructureTestingContract { private var configChecked = false + private const val ENABLE_WIFI_COMMAND: String = "svc wifi enable" + private const val DISABLE_WIFI_COMMAND: String = "svc wifi disable" + + private const val ENABLE_MOBILE_DATA_COMMAND: String = "svc data enable" + private const val DISABLE_MOBILE_DATA_COMMAND: String = "svc data disable" + private fun getDeviceOrientation(context: Context): Int { val configuration = context.resources.configuration return configuration.orientation @@ -481,6 +521,19 @@ abstract class CanvasTest : InstructureTestingContract { fun isLowResDevice() : Boolean { return ApplicationProvider.getApplicationContext().resources.displayMetrics.densityDpi < DisplayMetrics.DENSITY_HIGH } + + fun turnOffConnectionViaADB() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.executeShellCommand(DISABLE_WIFI_COMMAND) + device.executeShellCommand(DISABLE_MOBILE_DATA_COMMAND) + } + + fun turnOnConnectionViaADB() { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.executeShellCommand(ENABLE_WIFI_COMMAND) + device.executeShellCommand(ENABLE_MOBILE_DATA_COMMAND) + } + } } From 2bbc135e8a46c6aaada3e4cfdbfca4decc945604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Wed, 29 Nov 2023 11:12:26 +0100 Subject: [PATCH 20/49] fix login crash --- .../student/di/ApplicationModule.kt | 36 +++++++++++++++++++ .../student/util/StudentLogoutHelper.kt | 30 ++++++++++++++++ .../teacher/di/ApplicationModule.kt | 8 ++++- .../teacher/utils/TeacherLogoutHelper.kt | 31 ++++++++++++++++ .../instructure/canvasapi2/utils/ApiPrefs.kt | 4 +-- .../instructure/canvasapi2/utils/PrefUtils.kt | 11 +++++- .../login/viewmodel/LoginViewModel.kt | 9 ----- .../di/OfflineDatabaseProviderModule.kt | 6 ++-- .../room/offline/OfflineDatabaseProvider.kt | 20 +++++++++-- .../pandautils/utils/LogoutHelper.kt | 26 ++++++++++++++ 10 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 apps/student/src/main/java/com/instructure/student/di/ApplicationModule.kt create mode 100644 apps/student/src/main/java/com/instructure/student/util/StudentLogoutHelper.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/utils/TeacherLogoutHelper.kt create mode 100644 libs/pandautils/src/main/java/com/instructure/pandautils/utils/LogoutHelper.kt diff --git a/apps/student/src/main/java/com/instructure/student/di/ApplicationModule.kt b/apps/student/src/main/java/com/instructure/student/di/ApplicationModule.kt new file mode 100644 index 0000000000..38d3f681b2 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/di/ApplicationModule.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2023 - 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.student.di + +import com.instructure.pandautils.utils.LogoutHelper +import com.instructure.student.util.StudentLogoutHelper +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +class ApplicationModule { + + @Provides + fun provideLogoutHelper(): LogoutHelper { + return StudentLogoutHelper() + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/util/StudentLogoutHelper.kt b/apps/student/src/main/java/com/instructure/student/util/StudentLogoutHelper.kt new file mode 100644 index 0000000000..620eb7d127 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/util/StudentLogoutHelper.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2023 - 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.student.util + +import com.instructure.loginapi.login.tasks.LogoutTask +import com.instructure.pandautils.room.offline.DatabaseProvider +import com.instructure.pandautils.utils.LogoutHelper +import com.instructure.student.tasks.StudentLogoutTask + +class StudentLogoutHelper : LogoutHelper { + override fun logout(databaseProvider: DatabaseProvider) { + StudentLogoutTask(LogoutTask.Type.LOGOUT, databaseProvider = databaseProvider).execute() + } +} \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/di/ApplicationModule.kt b/apps/teacher/src/main/java/com/instructure/teacher/di/ApplicationModule.kt index c4cd8b0482..dba25af48c 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/di/ApplicationModule.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/di/ApplicationModule.kt @@ -16,12 +16,13 @@ */ package com.instructure.teacher.di +import com.instructure.pandautils.utils.LogoutHelper +import com.instructure.teacher.utils.TeacherLogoutHelper import com.instructure.teacher.utils.TeacherPrefs import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton /** * Module that provides all the application scope dependencies, that are not related to other module. @@ -34,4 +35,9 @@ class ApplicationModule { fun provideTeacherPrefs(): TeacherPrefs { return TeacherPrefs } + + @Provides + fun provideLogoutHelper(): LogoutHelper { + return TeacherLogoutHelper() + } } \ No newline at end of file diff --git a/apps/teacher/src/main/java/com/instructure/teacher/utils/TeacherLogoutHelper.kt b/apps/teacher/src/main/java/com/instructure/teacher/utils/TeacherLogoutHelper.kt new file mode 100644 index 0000000000..aa905c8d21 --- /dev/null +++ b/apps/teacher/src/main/java/com/instructure/teacher/utils/TeacherLogoutHelper.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2023 - 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.teacher.utils + +import com.instructure.loginapi.login.tasks.LogoutTask +import com.instructure.pandautils.room.offline.DatabaseProvider +import com.instructure.pandautils.utils.LogoutHelper +import com.instructure.teacher.tasks.TeacherLogoutTask + +class TeacherLogoutHelper : LogoutHelper { + + override fun logout(databaseProvider: DatabaseProvider) { + TeacherLogoutTask(LogoutTask.Type.LOGOUT).execute() + } +} \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt index 33d12155f4..d7fcb748d6 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/ApiPrefs.kt @@ -65,7 +65,7 @@ object ApiPrefs : PrefManager(PREFERENCE_FILE_NAME) { /* Non-masquerading Prefs */ internal var originalDomain by StringPref("", "domain") - private var originalUser: User? by GsonPref(User::class.java, null, "user") + private var originalUser: User? by GsonPref(User::class.java, null, "user", false) var selectedLocale by StringPref(ACCOUNT_LOCALE) @@ -84,7 +84,7 @@ object ApiPrefs : PrefManager(PREFERENCE_FILE_NAME) { var isMasqueradingFromQRCode by BooleanPref() var masqueradeId by LongPref(-1L) internal var masqueradeDomain by StringPref() - internal var masqueradeUser: User? by GsonPref(User::class.java, null, "masq-user") + internal var masqueradeUser: User? by GsonPref(User::class.java, null, "masq-user", false) // Used to determine if a student can generate a pairing code, saved during splash var canGeneratePairingCode by NBooleanPref() diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/PrefUtils.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/PrefUtils.kt index c836664ff0..eed66ffbfd 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/PrefUtils.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/utils/PrefUtils.kt @@ -390,7 +390,8 @@ class ColorPref(@ColorRes defaultValue: Int, keyName: String? = null) : Pref( private val klazz: Class, defaultValue: T? = null, - keyName: String? = null + private val keyName: String? = null, + private val async: Boolean = true ) : Pref(defaultValue, keyName) { private var cachedObject: T? = null @@ -413,6 +414,14 @@ class GsonPref( } return this } + + override fun setValue(thisRef: PrefManager, property: KProperty<*>, value: T?) { + if (async) { + super.setValue(thisRef, property, value) + } else { + thisRef.editor.setValue(keyName ?: property.name, value).commit() + } + } } /** diff --git a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt index 562e9cf516..3cbfbd4573 100644 --- a/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt +++ b/libs/login-api-2/src/main/java/com/instructure/loginapi/login/viewmodel/LoginViewModel.kt @@ -49,7 +49,6 @@ class LoginViewModel @Inject constructor( fun checkLogin(checkToken: Boolean, checkElementary: Boolean): LiveData> { viewModelScope.launch { try { - waitForUser() val offlineEnabled = featureFlagProvider.offlineEnabled() val offlineLogin = offlineEnabled && !networkStateProvider.isOnline() if (checkToken && !offlineLogin) { @@ -71,14 +70,6 @@ class LoginViewModel @Inject constructor( return loginResultAction } - // We need to wait for the user to be set in ApiPrefs - private suspend fun waitForUser() { - repeat(30) { - if (apiPrefs.user != null) return - delay(100) - } - } - private suspend fun checkTermsAcceptance(canvasForElementary: Boolean, offlineLogin: Boolean = false) { val authenticatedSession = oauthManager.getAuthenticatedSessionAsync("${apiPrefs.fullDomain}/users/self").await() val requiresTermsAcceptance = authenticatedSession.dataOrNull?.requiresTermsAcceptance ?: false diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/di/OfflineDatabaseProviderModule.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/di/OfflineDatabaseProviderModule.kt index fea3f2b3d2..9f902d14a0 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/di/OfflineDatabaseProviderModule.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/di/OfflineDatabaseProviderModule.kt @@ -19,8 +19,10 @@ package com.instructure.pandautils.di import android.content.Context +import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.pandautils.room.offline.DatabaseProvider import com.instructure.pandautils.room.offline.OfflineDatabaseProvider +import com.instructure.pandautils.utils.LogoutHelper import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -34,7 +36,7 @@ class OfflineDatabaseProviderModule { @Provides @Singleton - fun provideOfflineDatabaseProvider(@ApplicationContext context: Context): DatabaseProvider { - return OfflineDatabaseProvider(context) + fun provideOfflineDatabaseProvider(@ApplicationContext context: Context, logoutHelper: LogoutHelper, firebaseCrashlytics: FirebaseCrashlytics): DatabaseProvider { + return OfflineDatabaseProvider(context, logoutHelper, firebaseCrashlytics) } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseProvider.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseProvider.kt index f429f63454..020eff3ee9 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseProvider.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/OfflineDatabaseProvider.kt @@ -18,16 +18,32 @@ package com.instructure.pandautils.room.offline import android.content.Context +import android.widget.Toast +import androidx.appcompat.app.AlertDialog import androidx.room.Room +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.instructure.pandautils.R +import com.instructure.pandautils.utils.LogoutHelper +import com.instructure.pandautils.utils.toast private const val OFFLINE_DB_PREFIX = "offline-db-" -class OfflineDatabaseProvider(private val context: Context) : DatabaseProvider { +class OfflineDatabaseProvider( + private val context: Context, + private val logoutHelper: LogoutHelper, + private val firebaseCrashlytics: FirebaseCrashlytics +) : DatabaseProvider { private val dbMap = mutableMapOf() override fun getDatabase(userId: Long?): OfflineDatabase { - if (userId == null) throw IllegalStateException("You can't access the database while logged out") + if (userId == null) { + logoutHelper.logout(this) + firebaseCrashlytics.recordException(IllegalStateException("You can't access the database while logged out")) + return Room.databaseBuilder(context, OfflineDatabase::class.java, "dummy-db") + .addMigrations(*offlineDatabaseMigrations) + .build() + } return dbMap.getOrPut(userId) { Room.databaseBuilder(context, OfflineDatabase::class.java, "$OFFLINE_DB_PREFIX$userId") diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/LogoutHelper.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/LogoutHelper.kt new file mode 100644 index 0000000000..dc2a418863 --- /dev/null +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/LogoutHelper.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2023 - 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.utils + +import com.instructure.pandautils.room.offline.DatabaseProvider + +interface LogoutHelper { + + fun logout(databaseProvider: DatabaseProvider) +} \ No newline at end of file From 46c4c3ee944d10bf20caaa73a35adde7f17ad7fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81kos=20Hermann?= Date: Wed, 29 Nov 2023 11:13:30 +0100 Subject: [PATCH 21/49] version bump --- apps/student/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/student/build.gradle b/apps/student/build.gradle index 644403aff7..df8c0ecaa2 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -50,8 +50,8 @@ android { applicationId "com.instructure.candroid" minSdkVersion Versions.MIN_SDK targetSdkVersion Versions.TARGET_SDK - versionCode = 257 - versionName = '7.0.1' + versionCode = 258 + versionName = '7.0.2' vectorDrawables.useSupportLibrary = true multiDexEnabled = true From c4afa72d1cceddd18f5d43deec7a9250f3675350 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Mon, 4 Dec 2023 15:28:09 +0100 Subject: [PATCH 22/49] [MBL-17230][Student][Teacher] Update necessary dependencies for Jetpack Compose (#2265) Test plan: Smoke test the app. Some important build dependencies were changed that could affect the whole app. Also smoke test the places where PSPDFKit is used: - Student Annotation submission (Student) - View PDF submissions (Student) - View and edit PDF files from the files list (Student) - Annotate PDF submissions (Teacher) - View PDF files from the files list (Teacher) refs: MBL-17230 affects: Student, Teacher release note: none --- android-vault | 2 +- apps/flutter_parent/android/app/build.gradle | 2 +- apps/student/build.gradle | 5 ++++- .../instructure/student/test/util/TestUtils.kt | 1 - apps/teacher/build.gradle | 2 ++ .../view/grade_slider/PossiblePointView.kt | 6 +++--- .../teacher/unit/utils/MobiusTestUtils.kt | 1 - automation/espresso/build.gradle | 2 +- buildSrc/build.gradle.kts | 1 + buildSrc/src/main/java/GlobalDependencies.kt | 18 +++++++++++------- libs/login-api-2/build.gradle | 3 ++- .../pandarecycler/BaseRecyclerAdapter.kt | 2 +- 12 files changed, 27 insertions(+), 18 deletions(-) diff --git a/android-vault b/android-vault index ddd561a8ef..15225ede6c 160000 --- a/android-vault +++ b/android-vault @@ -1 +1 @@ -Subproject commit ddd561a8ef8289f2ed248925679827b2da5ab016 +Subproject commit 15225ede6c44da8265e5cdaea34d49dbc47cb8f5 diff --git a/apps/flutter_parent/android/app/build.gradle b/apps/flutter_parent/android/app/build.gradle index 823d43f00f..5fd86f30e5 100644 --- a/apps/flutter_parent/android/app/build.gradle +++ b/apps/flutter_parent/android/app/build.gradle @@ -104,7 +104,7 @@ dependencies { testImplementation 'junit:junit:4.12' androidTestImplementation 'com.android.support.test:runner:1.0.2' - androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' implementation "com.squareup.okhttp3:okhttp:4.9.1" implementation 'org.jsoup:jsoup:1.11.3' implementation 'com.google.gms:google-services:4.3.14' diff --git a/apps/student/build.gradle b/apps/student/build.gradle index eb671160b8..e0246c94c1 100644 --- a/apps/student/build.gradle +++ b/apps/student/build.gradle @@ -320,7 +320,8 @@ dependencies { implementation Libs.SQLDELIGHT /* Qr Code */ - implementation Libs.JOURNEY_ZXING + implementation (Libs.JOURNEY_ZXING) { transitive = false } + implementation Libs.JOURNEY_ZXING_CORE /* AAC */ implementation Libs.VIEW_MODEL @@ -349,6 +350,8 @@ dependencies { implementation Libs.ROOM kapt Libs.ROOM_COMPILER implementation Libs.ROOM_COROUTINES + + testImplementation Libs.HAMCREST } // Comment out this line if the reporting logic starts going wonky. diff --git a/apps/student/src/test/java/com/instructure/student/test/util/TestUtils.kt b/apps/student/src/test/java/com/instructure/student/test/util/TestUtils.kt index 5f57e39188..b3066f1c5c 100644 --- a/apps/student/src/test/java/com/instructure/student/test/util/TestUtils.kt +++ b/apps/student/src/test/java/com/instructure/student/test/util/TestUtils.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.runBlocking import okhttp3.Protocol import okhttp3.Request import okhttp3.Response -import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody import org.hamcrest.Matcher import org.hamcrest.Matchers diff --git a/apps/teacher/build.gradle b/apps/teacher/build.gradle index e0699ea43f..1b9fdb880c 100644 --- a/apps/teacher/build.gradle +++ b/apps/teacher/build.gradle @@ -310,6 +310,8 @@ dependencies { implementation Libs.ROOM kapt Libs.ROOM_COMPILER implementation Libs.ROOM_COROUTINES + + testImplementation Libs.HAMCREST } apply plugin: 'com.google.gms.google-services' diff --git a/apps/teacher/src/main/java/com/instructure/teacher/view/grade_slider/PossiblePointView.kt b/apps/teacher/src/main/java/com/instructure/teacher/view/grade_slider/PossiblePointView.kt index a915a867aa..5165bf6045 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/view/grade_slider/PossiblePointView.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/view/grade_slider/PossiblePointView.kt @@ -48,11 +48,11 @@ class PossiblePointView @JvmOverloads constructor( strokeWidth = 4.toPx.toFloat() } - override fun onDraw(canvas: Canvas?) { + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - canvas?.drawLine(anchorRect.left.toFloat(), anchorRect.bottom.toFloat() - 16.toPx, anchorRect.left.toFloat(), anchorRect.bottom.toFloat() - 8.toPx, linePaint) - canvas?.drawText(label, anchorRect.left.toFloat(), anchorRect.bottom.toFloat() + 16.toPx, textPaint) + canvas.drawLine(anchorRect.left.toFloat(), anchorRect.bottom.toFloat() - 16.toPx, anchorRect.left.toFloat(), anchorRect.bottom.toFloat() - 8.toPx, linePaint) + canvas.drawText(label, anchorRect.left.toFloat(), anchorRect.bottom.toFloat() + 16.toPx, textPaint) } fun showPossiblePoint(anchorRect: Rect, label: String) { diff --git a/apps/teacher/src/test/java/com/instructure/teacher/unit/utils/MobiusTestUtils.kt b/apps/teacher/src/test/java/com/instructure/teacher/unit/utils/MobiusTestUtils.kt index a50ba7e97b..18d223afb8 100644 --- a/apps/teacher/src/test/java/com/instructure/teacher/unit/utils/MobiusTestUtils.kt +++ b/apps/teacher/src/test/java/com/instructure/teacher/unit/utils/MobiusTestUtils.kt @@ -24,7 +24,6 @@ import com.spotify.mobius.test.NextMatchers import okhttp3.Protocol import okhttp3.Request import okhttp3.Response -import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody import org.hamcrest.Matcher import org.hamcrest.Matchers diff --git a/automation/espresso/build.gradle b/automation/espresso/build.gradle index 195d693576..05043f647a 100644 --- a/automation/espresso/build.gradle +++ b/automation/espresso/build.gradle @@ -45,7 +45,7 @@ android { buildToolsVersion '28.0.3' defaultConfig { - minSdkVersion 24 + minSdkVersion 26 targetSdkVersion 28 } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index daadb630e2..d761e661c8 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation("com.android.tools.build:gradle-api:$agpVersion") implementation("org.javassist:javassist:3.24.1-GA") implementation("com.google.code.gson:gson:2.8.8") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20") } plugins { diff --git a/buildSrc/src/main/java/GlobalDependencies.kt b/buildSrc/src/main/java/GlobalDependencies.kt index 4370c14f67..8ad33f36dd 100644 --- a/buildSrc/src/main/java/GlobalDependencies.kt +++ b/buildSrc/src/main/java/GlobalDependencies.kt @@ -2,21 +2,21 @@ object Versions { /* SDK Versions */ - const val COMPILE_SDK = 33 + const val COMPILE_SDK = 34 const val MIN_SDK = 26 const val TARGET_SDK = 33 /* Build/tooling */ const val ANDROID_GRADLE_TOOLS = "7.1.3" - const val BUILD_TOOLS = "30.0.3" + const val BUILD_TOOLS = "34.0.0" /* Testing */ const val JUNIT = "4.13.2" - const val ROBOLECTRIC = "4.3.1" + const val ROBOLECTRIC = "4.11.1" const val JACOCO_ANDROID = "0.1.5" /* Kotlin */ - const val KOTLIN = "1.8.10" + const val KOTLIN = "1.9.20" const val KOTLIN_COROUTINES = "1.6.4" /* Google, Play Services */ @@ -24,11 +24,11 @@ object Versions { /* Others */ const val APOLLO = "2.5.14" // There is already a brand new version, Apollo 3, that requires lots of migration - const val PSPDFKIT = "8.7.1" + const val PSPDFKIT = "8.9.1" const val PHOTO_VIEW = "2.3.0" const val MOBIUS = "1.2.1" const val SQLDELIGHT = "1.5.4" - const val HILT = "2.45" + const val HILT = "2.48" const val HILT_ANDROIDX = "1.0.0" const val LIFECYCLE = "2.6.0" const val FRAGMENT = "1.5.5" @@ -37,7 +37,8 @@ object Versions { const val RETROFIT = "2.9.0" const val OKHTTP = "4.10.0" const val HEAP = "1.10.5" - const val ROOM = "2.5.0" + const val ROOM = "2.6.0" + const val HAMCREST = "2.2" } object Libs { @@ -101,6 +102,7 @@ object Libs { /* Qr Code (zxing) */ const val JOURNEY_ZXING = "com.journeyapps:zxing-android-embedded:4.3.0" + const val JOURNEY_ZXING_CORE = "com.google.zxing:core:3.5.2" /* Dependency Inejction */ const val HILT = "com.google.dagger:hilt-android:${Versions.HILT}" @@ -157,6 +159,8 @@ object Libs { const val ROOM_COMPILER = "androidx.room:room-compiler:${Versions.ROOM}" const val ROOM_COROUTINES = "androidx.room:room-ktx:${Versions.ROOM}" const val ROOM_TEST = "androidx.room:room-testing:${Versions.ROOM}" + + const val HAMCREST = "org.hamcrest:hamcrest:${Versions.HAMCREST}" } object Plugins { diff --git a/libs/login-api-2/build.gradle b/libs/login-api-2/build.gradle index 007c66956d..5df6ac4c54 100644 --- a/libs/login-api-2/build.gradle +++ b/libs/login-api-2/build.gradle @@ -116,7 +116,8 @@ dependencies { implementation Libs.ANDROIDX_RECYCLERVIEW implementation Libs.ANDROIDX_CONSTRAINT_LAYOUT - implementation Libs.JOURNEY_ZXING + implementation (Libs.JOURNEY_ZXING) { transitive = false } + implementation Libs.JOURNEY_ZXING_CORE implementation Libs.FRAGMENT_KTX implementation Libs.HILT diff --git a/libs/recyclerview/src/main/java/com/instructure/pandarecycler/BaseRecyclerAdapter.kt b/libs/recyclerview/src/main/java/com/instructure/pandarecycler/BaseRecyclerAdapter.kt index 6365e3f807..71cf71916f 100644 --- a/libs/recyclerview/src/main/java/com/instructure/pandarecycler/BaseRecyclerAdapter.kt +++ b/libs/recyclerview/src/main/java/com/instructure/pandarecycler/BaseRecyclerAdapter.kt @@ -22,7 +22,7 @@ import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -abstract class BaseRecyclerAdapter(var context: Context) : RecyclerView.Adapter() { +abstract class BaseRecyclerAdapter(var context: Context) : RecyclerView.Adapter() { abstract fun createViewHolder(v: View, viewType: Int): T abstract fun itemLayoutResId(viewType: Int): Int abstract fun loadData() From 8f790f168eff0f3ff82668679cdd17a4ad05ebdd Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Thu, 7 Dec 2023 09:38:09 +0100 Subject: [PATCH 23/49] [MBL-17120][Student] - Create Offline All Courses E2E Test (#2268) --- .../student/ui/e2e/DashboardE2ETest.kt | 38 ++--- .../instructure/student/ui/e2e/TodoE2ETest.kt | 4 +- .../e2e/offline/OfflineAllCoursesE2ETest.kt | 141 ++++++++++++++++++ .../offline/OfflineCourseBrowserE2ETest.kt | 2 +- .../interaction/DashboardInteractionTest.kt | 28 ++-- ...EditDashboardPage.kt => AllCoursesPage.kt} | 85 ++++++++++- .../student/ui/pages/DashboardPage.kt | 4 +- .../pages/offline/ManageOfflineContentPage.kt | 4 +- .../student/ui/utils/StudentTest.kt | 4 +- .../espresso/CustomViewAssertions.kt | 9 ++ .../panda_annotations/TestMetaData.kt | 2 +- 11 files changed, 273 insertions(+), 48 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt rename apps/student/src/androidTest/java/com/instructure/student/ui/pages/{EditDashboardPage.kt => AllCoursesPage.kt} (62%) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt index e0fe3bcffa..1f50e7b9ac 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/DashboardE2ETest.kt @@ -101,11 +101,11 @@ class DashboardE2ETest : StudentTest() { dashboardPage.assertDisplaysGroup(group2, course1) Log.d(STEP_TAG,"Click on 'All Courses' button. Assert that the All Courses Page is loaded.") - dashboardPage.clickEditDashboard() - editDashboardPage.assertPageObjects() + dashboardPage.openAllCoursesPage() + allCoursesPage.assertPageObjects() Log.d(STEP_TAG, "Favorite '${course1.name}' course and navigate back to Dashboard Page.") - editDashboardPage.favoriteCourse(course1.name) + allCoursesPage.favoriteCourse(course1.name) Espresso.pressBack() Log.d(STEP_TAG,"Assert that only the favoured course, '${course1.name}' is displayed." + @@ -124,16 +124,16 @@ class DashboardE2ETest : StudentTest() { Log.d(STEP_TAG,"Click on 'All Courses' button. Assert that the All Courses Page is loaded.") dashboardPage.assertPageObjects() - dashboardPage.clickEditDashboard() - editDashboardPage.assertPageObjects() + dashboardPage.openAllCoursesPage() + allCoursesPage.assertPageObjects() Log.d(STEP_TAG, "Assert that the mass select button's text is 'Unselect All', since one of the courses is selected.") - editDashboardPage.assertCourseMassSelectButtonIsDisplayed(true) + allCoursesPage.assertCourseMassSelectButtonIsDisplayed(true) Log.d(STEP_TAG, "Toggle off favourite star icon of '${course1.name}' course." + "Assert that the 'mass' select button's label is 'Select All'.") - editDashboardPage.unfavoriteCourse(course1.name) - editDashboardPage.assertCourseMassSelectButtonIsDisplayed(false) + allCoursesPage.unfavoriteCourse(course1.name) + allCoursesPage.assertCourseMassSelectButtonIsDisplayed(false) Log.d(STEP_TAG, "Navigate back to Dashboard Page.") Espresso.pressBack() @@ -186,15 +186,15 @@ class DashboardE2ETest : StudentTest() { dashboardPage.assertCourseGrade(course2.name, "N/A") Log.d(STEP_TAG,"Click on 'All Courses' button.") - dashboardPage.clickEditDashboard() - editDashboardPage.assertPageObjects() + dashboardPage.openAllCoursesPage() + allCoursesPage.assertPageObjects() Log.d(STEP_TAG, "Assert that the group 'mass' select button's label is 'Select All'.") - editDashboardPage.swipeUp() - editDashboardPage.assertGroupMassSelectButtonIsDisplayed(false) + allCoursesPage.swipeUp() + allCoursesPage.assertGroupMassSelectButtonIsDisplayed(false) Log.d(STEP_TAG, "Favorite '${group.name}' course and navigate back to Dashboard Page.") - editDashboardPage.favoriteGroup(group.name) + allCoursesPage.favoriteGroup(group.name) Espresso.pressBack() Log.d(STEP_TAG,"Assert that only the favoured group, '${group.name}' is displayed." + @@ -203,18 +203,18 @@ class DashboardE2ETest : StudentTest() { dashboardPage.assertGroupNotDisplayed(group2) Log.d(STEP_TAG,"Click on 'All Courses' button.") - dashboardPage.clickEditDashboard() - editDashboardPage.assertPageObjects() + dashboardPage.openAllCoursesPage() + allCoursesPage.assertPageObjects() Thread.sleep(2000) //It can be flaky without this 2 seconds - editDashboardPage.swipeUp() + allCoursesPage.swipeUp() Log.d(STEP_TAG, "Assert that the group 'mass' select button's label is 'Unselect All'.") - editDashboardPage.assertGroupMassSelectButtonIsDisplayed(true) + allCoursesPage.assertGroupMassSelectButtonIsDisplayed(true) Log.d(STEP_TAG, "Toggle off favourite star icon of '${group.name}' group." + "Assert that the 'mass' select button's label is 'Select All'.") - editDashboardPage.unfavoriteGroup(group.name) - editDashboardPage.assertGroupMassSelectButtonIsDisplayed(false) + allCoursesPage.unfavoriteGroup(group.name) + allCoursesPage.assertGroupMassSelectButtonIsDisplayed(false) Log.d(STEP_TAG, "Navigate back to Dashboard Page.") Espresso.pressBack() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt index 4968c7105f..103b61215f 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/TodoE2ETest.kt @@ -120,8 +120,8 @@ class TodoE2ETest: StudentTest() { Log.d(STEP_TAG, "Navigate back to the Dashboard Page. Open ${favoriteCourse.name} course. Mark it as favorite.") Espresso.pressBack() - dashboardPage.clickEditDashboard() - editDashboardPage.favoriteCourse(favoriteCourse.name) + dashboardPage.openAllCoursesPage() + allCoursesPage.favoriteCourse(favoriteCourse.name) Log.d(STEP_TAG, "Navigate back to the Dashboard Page and open the To Do Page again.") Espresso.pressBack() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt new file mode 100644 index 0000000000..8eaceb62d5 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineAllCoursesE2ETest.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2023 - 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.student.ui.e2e.offline + +import android.util.Log +import androidx.test.espresso.Espresso +import androidx.test.espresso.matcher.ViewMatchers +import com.google.android.material.checkbox.MaterialCheckBox +import com.instructure.canvas.espresso.OfflineE2E +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.SecondaryFeatureCategory +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.seedData +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Test +import java.lang.Thread.sleep + +@HiltAndroidTest +class OfflineAllCoursesE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.E2E, false, SecondaryFeatureCategory.ALL_COURSES) + fun testOfflineAllCoursesE2E() { + + Log.d(PREPARATION_TAG, "Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 3, announcements = 1) + val student = data.studentsList[0] + val course1 = data.coursesList[0] + val course2 = data.coursesList[1] + val course3 = data.coursesList[2] + + Log.d(STEP_TAG, "Login with user: ${student.name}, login id: ${student.loginId}.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open the 'All Courses' page and wait for it to be rendered.") + dashboardPage.openAllCoursesPage() + allCoursesPage.assertPageObjects() + + Log.d(STEP_TAG, "Favourite '${course1.name}' course and assert if it became favourited. Then navigate back to Dashboard page.") + allCoursesPage.favoriteCourse(course1.name) + allCoursesPage.assertCourseFavorited(course1) + Espresso.pressBack() + + Log.d(STEP_TAG, "Open global 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.openGlobalManageOfflineContentPage() + manageOfflineContentPage.assertPageObjects() + + Log.d(STEP_TAG, "Assert that the '${course1.name}' course's checkbox state is 'Unchecked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course1.name, MaterialCheckBox.STATE_UNCHECKED) + manageOfflineContentPage.assertCheckedStateOfItem(course2.name, MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Select '${course1.name}' and '${course2.name}' courses' checkboxes and Sync them.") + manageOfflineContentPage.changeItemSelectionState(course1.name) + manageOfflineContentPage.changeItemSelectionState(course2.name) + manageOfflineContentPage.clickOnSyncButtonAndConfirm() + + Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") + dashboardPage.waitForRender() + dashboardPage.waitForSyncProgressDownloadStartedNotification() + dashboardPage.waitForSyncProgressDownloadStartedNotificationToDisappear() + + Log.d(STEP_TAG, "Wait for the 'Syncing Offline Content' dashboard notification to be displayed, and the to disappear. (It should be displayed after the 'Download Started' notification immediately.)") + dashboardPage.waitForSyncProgressStartingNotification() + dashboardPage.waitForSyncProgressStartingNotificationToDisappear() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + turnOffConnectionViaADB() + + Log.d(STEP_TAG, "Wait for the Dashboard Page to be rendered, and assert that '${course1.name}' is the only course which is displayed on the offline mode Dashboard Page.") + dashboardPage.waitForRender() + sleep(10000) + dashboardPage.assertDisplaysCourse(course1) + dashboardPage.assertCourseNotDisplayed(course2) + dashboardPage.assertCourseNotDisplayed(course3) + + Log.d(STEP_TAG, "Open the 'All Courses' page and wait for it to be rendered.") + dashboardPage.openAllCoursesPage() + allCoursesPage.assertPageObjects() + + Log.d(STEP_TAG, "Assert that the plus 'Note' box is displayed in which warns the user that favouring courses can only be done in online mode.") + allCoursesPage.assertOfflineNoteDisplayed() + + Log.d(STEP_TAG, "Dismiss the offline 'Note' box and assert if it's disappear.") + allCoursesPage.dismissOfflineNoteBox() + allCoursesPage.assertOfflineNoteNotDisplayed() + + Log.d(STEP_TAG, "Assert that the select/unselect all button is not clickable because offline mode does not supports it.") + allCoursesPage.assertSelectUnselectAllButtonNotClickable() + + Log.d(STEP_TAG, "Try to unfavorite '${course1.name}' course and assert it does not happened because favoring does not allowed in offline state.") + allCoursesPage.unfavoriteCourse(course1.name) + allCoursesPage.assertCourseFavorited(course1) + + Log.d(STEP_TAG, "Assert that '${course3.name}' course's details are faded (and they having 0.4 alpha value) and it's offline sync icon is not displayed since it's not synced.") + allCoursesPage.assertCourseDetailsAlpha(course3.name, 0.4f) + allCoursesPage.assertCourseOfflineSyncButton(course3.name, ViewMatchers.Visibility.GONE) + + Log.d(STEP_TAG, "Assert that '${course1.name}' course's favourite star is faded (and it's having 0.4 alpha value) because favoring is not possible in offline mode," + + "the course title and open button are not faded (1.0 alpha value) and the offline sync icon is displayed since the course is synced.") + allCoursesPage.assertCourseFavouriteStarAlpha(course1.name, 0.4f) + allCoursesPage.assertCourseTitleAlpha(course1.name, 1.0f) + allCoursesPage.assertCourseOpenButtonAlpha(course1.name, 1.0f) + allCoursesPage.assertCourseOfflineSyncButton(course1.name, ViewMatchers.Visibility.VISIBLE) + + Log.d(STEP_TAG, "Click on '${course1.name}' course and assert if it will navigate the user to the CourseBrowser Page.") + allCoursesPage.openCourse(course1.name) + courseBrowserPage.assertTitleCorrect(course1) + + } + + @After + fun tearDown() { + Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device, so it will come back online.") + turnOnConnectionViaADB() + } + +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt index 04a6fc4337..0c36ed3721 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineCourseBrowserE2ETest.kt @@ -42,7 +42,7 @@ class OfflineCourseBrowserE2ETest : StudentTest() { @OfflineE2E @Test - @TestMetaData(Priority.MANDATORY, FeatureCategory.DASHBOARD, TestCategory.E2E) + @TestMetaData(Priority.MANDATORY, FeatureCategory.COURSE, TestCategory.E2E) fun testOfflineCourseBrowserPageUnavailableE2E() { Log.d(PREPARATION_TAG,"Seeding data.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt index f02805dfff..e9ec0dcbb3 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/interaction/DashboardInteractionTest.kt @@ -93,10 +93,10 @@ class DashboardInteractionTest : StudentTest() { dashboardPage.assertCourseNotShown(nonFavorite) dashboardPage.editFavorites() - editDashboardPage.assertCourseDisplayed(nonFavorite) - editDashboardPage.assertCourseNotFavorited(nonFavorite) - editDashboardPage.favoriteCourse(nonFavorite) - editDashboardPage.assertCourseFavorited(nonFavorite) + allCoursesPage.assertCourseDisplayed(nonFavorite) + allCoursesPage.assertCourseNotFavorited(nonFavorite) + allCoursesPage.favoriteCourse(nonFavorite) + allCoursesPage.assertCourseFavorited(nonFavorite) Espresso.pressBack() @@ -115,10 +115,10 @@ class DashboardInteractionTest : StudentTest() { dashboardPage.assertDisplaysCourse(favorite) dashboardPage.editFavorites() - editDashboardPage.assertCourseDisplayed(favorite) - editDashboardPage.assertCourseFavorited(favorite) - editDashboardPage.unfavoriteCourse(favorite) - editDashboardPage.assertCourseNotFavorited(favorite) + allCoursesPage.assertCourseDisplayed(favorite) + allCoursesPage.assertCourseFavorited(favorite) + allCoursesPage.unfavoriteCourse(favorite) + allCoursesPage.assertCourseNotFavorited(favorite) Espresso.pressBack() @@ -137,9 +137,9 @@ class DashboardInteractionTest : StudentTest() { data.courses.values.forEach { dashboardPage.assertDisplaysCourse(it) } dashboardPage.editFavorites() - toFavorite.forEach { editDashboardPage.assertCourseNotFavorited(it) } - editDashboardPage.selectAllCourses() - toFavorite.forEach { editDashboardPage.assertCourseFavorited(it) } + toFavorite.forEach { allCoursesPage.assertCourseNotFavorited(it) } + allCoursesPage.selectAllCourses() + toFavorite.forEach { allCoursesPage.assertCourseFavorited(it) } Espresso.pressBack() @@ -156,9 +156,9 @@ class DashboardInteractionTest : StudentTest() { toRemove.forEach { dashboardPage.assertDisplaysCourse(it) } dashboardPage.editFavorites() - toRemove.forEach { editDashboardPage.assertCourseFavorited(it) } - editDashboardPage.unselectAllCourses() - toRemove.forEach { editDashboardPage.assertCourseNotFavorited(it) } + toRemove.forEach { allCoursesPage.assertCourseFavorited(it) } + allCoursesPage.unselectAllCourses() + toRemove.forEach { allCoursesPage.assertCourseNotFavorited(it) } Espresso.pressBack() diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/EditDashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AllCoursesPage.kt similarity index 62% rename from apps/student/src/androidTest/java/com/instructure/student/ui/pages/EditDashboardPage.kt rename to apps/student/src/androidTest/java/com/instructure/student/ui/pages/AllCoursesPage.kt index c4bd4d94cf..d14942bad9 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/EditDashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/AllCoursesPage.kt @@ -16,25 +16,31 @@ package com.instructure.student.ui.pages -import androidx.test.espresso.matcher.ViewMatchers.hasDescendant -import androidx.test.espresso.matcher.ViewMatchers.withContentDescription -import androidx.test.espresso.matcher.ViewMatchers.withParent -import androidx.test.espresso.matcher.ViewMatchers.withText +import android.view.View +import androidx.appcompat.widget.AppCompatImageView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.* import com.instructure.canvasapi2.models.Course +import com.instructure.dataseeding.model.CourseApiModel +import com.instructure.espresso.DoesNotExistAssertion +import com.instructure.espresso.ViewAlphaAssertion import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView import com.instructure.espresso.page.plus +import com.instructure.espresso.page.waitForView import com.instructure.espresso.page.withId import com.instructure.espresso.page.withText import com.instructure.espresso.scrollTo import com.instructure.espresso.swipeUp import com.instructure.student.R +import org.hamcrest.Matcher import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.anyOf import org.hamcrest.Matchers.containsString -class EditDashboardPage : BasePage(R.id.editDashboardPage) { +class AllCoursesPage : BasePage(R.id.editDashboardPage) { fun assertCourseDisplayed(course: Course) { val itemMatcher = allOf(withText(containsString(course.name)), withId(R.id.title)) @@ -102,6 +108,14 @@ class EditDashboardPage : BasePage(R.id.editDashboardPage) { onView(itemMatcher).assertDisplayed() } + fun assertCourseFavorited(course: CourseApiModel) { + val childMatcher = withContentDescription("Remove from dashboard") + val itemMatcher = allOf( + withContentDescription(containsString("Course ${course.name}, favorite")), + hasDescendant(childMatcher)) + onView(itemMatcher).assertDisplayed() + } + fun selectAllCourses() { val childMatcher = withContentDescription("Add all to dashboard") val itemMatcher = allOf(hasDescendant(withText(R.string.allCoursesCourseHeader)), hasDescendant(childMatcher)) @@ -116,6 +130,17 @@ class EditDashboardPage : BasePage(R.id.editDashboardPage) { onView(withParent(itemMatcher) + childMatcher).click() } + fun openCourse(courseName: String) { + onView(withId(R.id.title) + withText(courseName)).click() + } + + //OfflineMethod + fun assertSelectUnselectAllButtonNotClickable() { + val unselectAllMatcher = withContentDescription("Remove all from dashboard") + val selectAllMatcher = withContentDescription("Add all to dashboard") + onView(anyOf(unselectAllMatcher, selectAllMatcher)).check(matches(isNotClickable())) + } + fun assertCourseMassSelectButtonIsDisplayed(someSelected: Boolean) { if (someSelected) { @@ -149,4 +174,54 @@ class EditDashboardPage : BasePage(R.id.editDashboardPage) { onView(withId(R.id.swipeRefreshLayout) + withParent(withId(R.id.editDashboardPage))).swipeUp() } + //OfflineMethod + fun assertOfflineNoteDisplayed() { + waitForView(withId(R.id.noteTitle) + withText(R.string.allCoursesOfflineNoteTitle)).assertDisplayed() + onView(withId(R.id.note) + withText(R.string.allCoursesOfflineNote)) + } + + //OfflineMethod + fun assertOfflineNoteNotDisplayed() { + onView(withId(R.id.noteTitle) + withText(R.string.allCoursesOfflineNoteTitle)).check(DoesNotExistAssertion(10)) + } + + //OfflineMethod + fun dismissOfflineNoteBox() { + onView(allOf(isAssignableFrom(AppCompatImageView::class.java), hasSibling(withId(R.id.noteTitle)), hasSibling(withId(R.id.note)))).click() + onView(withId(R.id.noteTitle) + withText(R.string.allCoursesOfflineNoteTitle)).check(DoesNotExistAssertion(10)) + } + + //OfflineMethod + private fun assertViewAlpha(matcher: Matcher, expectedAlphaValue: Float) { + onView(matcher).check(ViewAlphaAssertion(expectedAlphaValue)) + } + + //OfflineMethod + fun assertCourseFavouriteStarAlpha(courseName: String, expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.favoriteButton) + hasSibling(withId(R.id.title) + withText(courseName)), expectedAlphaValue) + } + + //OfflineMethod + fun assertCourseTitleAlpha(courseName: String, expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.title) + withText(courseName), expectedAlphaValue) + } + + //OfflineMethod + fun assertCourseOpenButtonAlpha(courseName: String, expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.openButton) + hasSibling(withId(R.id.title) + withText(courseName)), expectedAlphaValue) + } + + //OfflineMethod + fun assertCourseDetailsAlpha(courseName: String, expectedAlphaValue: Float) { + assertCourseFavouriteStarAlpha(courseName, expectedAlphaValue) + assertCourseTitleAlpha(courseName, expectedAlphaValue) + assertCourseOpenButtonAlpha(courseName, expectedAlphaValue) + } + + //OfflineMethod + fun assertCourseOfflineSyncButton(courseName: String, visibility: Visibility) { + onView(withId(R.id.offlineSyncIcon) + hasSibling(withId(R.id.title) + withText(courseName))) + .check(matches(withEffectiveVisibility(visibility))) + } + } \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index c4d2b55143..a8b51842eb 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -272,8 +272,8 @@ class DashboardPage : BasePage(R.id.dashboardPage) { Espresso.openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getInstrumentation().targetContext) } - fun clickEditDashboard() { - onView(withId(R.id.editDashboardTextView)).scrollTo().click() + fun openAllCoursesPage() { + waitForView(withId(R.id.editDashboardTextView)).scrollTo().click() } fun assertCourseNotDisplayed(course: CourseApiModel) { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/ManageOfflineContentPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/ManageOfflineContentPage.kt index 383f9fe2df..ad3815b554 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/ManageOfflineContentPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/offline/ManageOfflineContentPage.kt @@ -116,11 +116,11 @@ class ManageOfflineContentPage : BasePage(R.id.manageOfflineContentPage) { } fun assertCheckedStateOfItem(itemName: String, state: Int) { - onView(withId(R.id.checkbox) + hasSibling(withId(R.id.title) + withText(itemName)) + hasCheckedState(state)).scrollTo().assertDisplayed() + waitForView(withId(R.id.checkbox) + hasSibling(withId(R.id.title) + withText(itemName)) + hasCheckedState(state)).scrollTo().assertDisplayed() } fun waitForItemDisappear(itemName: String) { - onView(withId(R.id.checkbox) + hasSibling(withId(R.id.title) + withText(itemName))).check(DoesNotExistAssertion(5)) + waitForView(withId(R.id.checkbox) + hasSibling(withId(R.id.title) + withText(itemName))).check(DoesNotExistAssertion(5)) } fun assertDisplaysNoCourses() { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt index 88c4ab96d8..d11f313b76 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/utils/StudentTest.kt @@ -42,6 +42,7 @@ import com.instructure.student.R import com.instructure.student.activity.LoginActivity import com.instructure.student.espresso.StudentHiltTestApplication_Application import com.instructure.student.ui.pages.AboutPage +import com.instructure.student.ui.pages.AllCoursesPage import com.instructure.student.ui.pages.AnnotationCommentListPage import com.instructure.student.ui.pages.AnnouncementListPage import com.instructure.student.ui.pages.AssignmentDetailsPage @@ -56,7 +57,6 @@ import com.instructure.student.ui.pages.CourseGradesPage import com.instructure.student.ui.pages.DashboardPage import com.instructure.student.ui.pages.DiscussionDetailsPage import com.instructure.student.ui.pages.DiscussionListPage -import com.instructure.student.ui.pages.EditDashboardPage import com.instructure.student.ui.pages.ElementaryCoursePage import com.instructure.student.ui.pages.ElementaryDashboardPage import com.instructure.student.ui.pages.FileListPage @@ -162,7 +162,7 @@ abstract class StudentTest : CanvasTest() { val leftSideNavigationDrawerPage = LeftSideNavigationDrawerPage() val discussionDetailsPage = DiscussionDetailsPage() val discussionListPage = DiscussionListPage(Searchable(R.id.search, R.id.search_src_text, R.id.search_close_btn)) - val editDashboardPage = EditDashboardPage() + val allCoursesPage = AllCoursesPage() val fileListPage = FileListPage(Searchable(R.id.search, R.id.queryInput, R.id.clearButton, R.id.backButton)) val fileUploadPage = FileUploadPage() val helpPage = HelpPage() diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt index 0951e8236d..77a1c2d6ec 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt @@ -26,9 +26,11 @@ import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.ViewAssertion import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.assertThat import androidx.viewpager.widget.ViewPager import com.google.android.material.bottomnavigation.BottomNavigationView import junit.framework.AssertionFailedError +import org.hamcrest.CoreMatchers.`is` import org.hamcrest.Matcher import org.hamcrest.Matchers import org.junit.Assert.assertEquals @@ -120,3 +122,10 @@ class ConstraintLayoutItemCountAssertion(private val expectedCount: Int) : ViewA } } +class ViewAlphaAssertion(private val expectedAlpha: Float): ViewAssertion { + override fun check(view: View, noViewFoundException: NoMatchingViewException?) { + noViewFoundException?.let { throw it } + assertThat("View alpha should be $expectedAlpha", view.alpha, `is`(expectedAlpha)) + } +} + diff --git a/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt b/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt index e9f5f0ddb6..4400d2346b 100644 --- a/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt +++ b/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt @@ -43,7 +43,7 @@ enum class SecondaryFeatureCategory { ASSIGNMENT_COMMENTS, ASSIGNMENT_QUIZZES, ASSIGNMENT_DISCUSSIONS, GROUPS_DASHBOARD, GROUPS_FILES, GROUPS_ANNOUNCEMENTS, GROUPS_DISCUSSIONS, GROUPS_PAGES, GROUPS_PEOPLE, EVENTS_DISCUSSIONS, EVENTS_QUIZZES, EVENTS_ASSIGNMENTS, EVENTS_NOTIFICATIONS, - MODULES_ASSIGNMENTS, MODULES_DISCUSSIONS, MODULES_FILES, MODULES_PAGES, MODULES_QUIZZES, OFFLINE_MODE + MODULES_ASSIGNMENTS, MODULES_DISCUSSIONS, MODULES_FILES, MODULES_PAGES, MODULES_QUIZZES, OFFLINE_MODE, ALL_COURSES } enum class TestCategory { From 886a3068ef04e53b97eaf8ba6fdeb7fc074e595d Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Thu, 7 Dec 2023 15:33:32 +0100 Subject: [PATCH 24/49] [MBL-17188][Student][Parent] Late penalty display for assignments refs: MBL-17188 affects: Student, Parent release note: Improved display of the late penalty. * Student late penalty changes * Parent late penalty changes * Fixed string * Fixed strings * Test fix * test fix --- .../lib/l10n/app_localizations.dart | 13 +++- .../lib/models/grade_cell_data.dart | 12 +++- .../lib/models/grade_cell_data.g.dart | 14 ++++ .../lib/screens/assignments/grade_cell.dart | 71 +++++++++++-------- .../assignments/grade_cell_data_test.dart | 7 +- .../assignments/grade_cell_widget_test.dart | 7 +- .../renderTests/views/GradeCellRenderTest.kt | 6 +- .../gradecellview/GradeCellViewData.kt | 10 ++- .../ui/gradeCell/GradeCellView.kt | 2 +- .../ui/gradeCell/GradeCellViewState.kt | 13 ++-- .../view_student_enhanced_grade_cell.xml | 24 +++++-- .../res/layout/view_student_grade_cell.xml | 24 +++++-- .../assignment/details/GradeCellStateTest.kt | 10 +-- libs/pandares/src/main/res/values/strings.xml | 2 + 14 files changed, 150 insertions(+), 65 deletions(-) diff --git a/apps/flutter_parent/lib/l10n/app_localizations.dart b/apps/flutter_parent/lib/l10n/app_localizations.dart index 53eda6d918..86768767b3 100644 --- a/apps/flutter_parent/lib/l10n/app_localizations.dart +++ b/apps/flutter_parent/lib/l10n/app_localizations.dart @@ -989,11 +989,18 @@ class AppLocalizations { desc: 'Screen reader-friendly replacement for the "-" character in letter grades like "A-"', ); - String latePenalty(String pointsLost) => Intl.message( - 'Late penalty (-$pointsLost)', + String yourGrade(String pointsAchieved) => Intl.message( + 'Your grade: $pointsAchieved', + desc: 'Text displayed when a late penalty has been applied to the assignment, this is the achieved score without the penalty', + args: [pointsAchieved], + name: 'yourGrade', + ); + + String latePenaltyUpdated(String pointsLost) => Intl.message( + 'Late Penalty: -$pointsLost pts', desc: 'Text displayed when a late penalty has been applied to the assignment', args: [pointsLost], - name: 'latePenalty', + name: 'latePenaltyUpdated', ); String finalGrade(String grade) => Intl.message( diff --git a/apps/flutter_parent/lib/models/grade_cell_data.dart b/apps/flutter_parent/lib/models/grade_cell_data.dart index 642c29700e..f30b60241f 100644 --- a/apps/flutter_parent/lib/models/grade_cell_data.dart +++ b/apps/flutter_parent/lib/models/grade_cell_data.dart @@ -40,6 +40,7 @@ abstract class GradeCellData implements Built 0.0) { grade = ''; // Grade will be shown in the 'final grade' text var pointsDeducted = NumberFormat.decimalPattern().format(submission.pointsDeducted ?? 0.0); - latePenalty = l10n.latePenalty(pointsDeducted); + var pointsAchieved = NumberFormat.decimalPattern().format(submission.enteredScore); + yourGrade = l10n.yourGrade(pointsAchieved); + latePenalty = l10n.latePenaltyUpdated(pointsDeducted); finalGrade = l10n.finalGrade(submission.grade ?? grade); } @@ -166,6 +171,7 @@ abstract class GradeCellData implements Built _$this._outOf; set outOf(String? outOf) => _$this._outOf = outOf; + String? _yourGrade; + String? get yourGrade => _$this._yourGrade; + set yourGrade(String? yourGrade) => _$this._yourGrade = yourGrade; + String? _latePenalty; String? get latePenalty => _$this._latePenalty; set latePenalty(String? latePenalty) => _$this._latePenalty = latePenalty; @@ -221,6 +233,7 @@ class GradeCellDataBuilder _grade = $v.grade; _gradeContentDescription = $v.gradeContentDescription; _outOf = $v.outOf; + _yourGrade = $v.yourGrade; _latePenalty = $v.latePenalty; _finalGrade = $v.finalGrade; _$v = null; @@ -264,6 +277,7 @@ class GradeCellDataBuilder grade: BuiltValueNullFieldError.checkNotNull(grade, r'GradeCellData', 'grade'), gradeContentDescription: BuiltValueNullFieldError.checkNotNull(gradeContentDescription, r'GradeCellData', 'gradeContentDescription'), outOf: BuiltValueNullFieldError.checkNotNull(outOf, r'GradeCellData', 'outOf'), + yourGrade: BuiltValueNullFieldError.checkNotNull(yourGrade, r'GradeCellData', 'yourGrade'), latePenalty: BuiltValueNullFieldError.checkNotNull(latePenalty, r'GradeCellData', 'latePenalty'), finalGrade: BuiltValueNullFieldError.checkNotNull(finalGrade, r'GradeCellData', 'finalGrade')); replace(_$result); diff --git a/apps/flutter_parent/lib/screens/assignments/grade_cell.dart b/apps/flutter_parent/lib/screens/assignments/grade_cell.dart index 9d8bb559bf..d8bac77e11 100644 --- a/apps/flutter_parent/lib/screens/assignments/grade_cell.dart +++ b/apps/flutter_parent/lib/screens/assignments/grade_cell.dart @@ -18,6 +18,7 @@ import 'package:flutter_parent/models/assignment.dart'; import 'package:flutter_parent/models/course.dart'; import 'package:flutter_parent/models/grade_cell_data.dart'; import 'package:flutter_parent/models/submission.dart'; +import 'package:flutter_parent/utils/design/parent_colors.dart'; import 'package:flutter_parent/utils/design/parent_theme.dart'; import 'package:percent_indicator/circular_percent_indicator.dart'; @@ -134,37 +135,49 @@ class GradeCell extends StatelessWidget { ], ), if (!_isGradedRestrictQuantitativeData) SizedBox(width: 16), - if (!_isGradedRestrictQuantitativeData) Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (data.grade.isNotEmpty) - Text( - data.grade, - key: Key('grade-cell-grade'), - style: Theme.of(context).textTheme.headlineMedium, - semanticsLabel: data.gradeContentDescription, - ), - if (data.outOf.isNotEmpty) Text(data.outOf, key: Key('grade-cell-out-of')), - if (data.latePenalty.isNotEmpty) - Text( - data.latePenalty, - style: TextStyle(color: data.accentColor), - key: Key('grade-cell-late-penalty'), - ), - if (data.finalGrade.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - data.finalGrade, - key: Key('grade-cell-final-grade'), - style: Theme.of(context).textTheme.titleMedium, + if (!_isGradedRestrictQuantitativeData) + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (data.grade.isNotEmpty) + Text( + data.grade, + key: Key('grade-cell-grade'), + style: Theme.of(context).textTheme.headlineMedium, + semanticsLabel: data.gradeContentDescription, + ), + if (data.outOf.isNotEmpty) Text(data.outOf, key: Key('grade-cell-out-of')), + if (data.yourGrade.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + data.yourGrade, + key: Key('grade-cell-your-grade'), + ), + ), + if (data.latePenalty.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + data.latePenalty, + style: TextStyle(color: ParentColors.failure), + key: Key('grade-cell-late-penalty'), + ), + ), + if (data.finalGrade.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + data.finalGrade, + key: Key('grade-cell-final-grade'), + style: Theme.of(context).textTheme.titleMedium, + ), ), - ), - ], + ], + ), ), - ), ], ); } diff --git a/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart b/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart index 134975c7df..2fe2782299 100644 --- a/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart +++ b/apps/flutter_parent/test/screens/assignments/grade_cell_data_test.dart @@ -250,10 +250,11 @@ void main() { ..grade = '79' ..score = 79.0); var expected = baseGradedState.rebuild((b) => b - ..graphPercent = 0.85 - ..score = '85' + ..graphPercent = 0.79 + ..score = '79' ..showPointsLabel = true - ..latePenalty = 'Late penalty (-6)' + ..yourGrade = 'Your grade: 85' + ..latePenalty = 'Late Penalty: -6 pts' ..finalGrade = 'Final Grade: 79'); var actual = GradeCellData.forSubmission(baseCourse, baseAssignment, submission, theme, l10n); expect(actual, expected); diff --git a/apps/flutter_parent/test/screens/assignments/grade_cell_widget_test.dart b/apps/flutter_parent/test/screens/assignments/grade_cell_widget_test.dart index 37bd688ec3..3cc7c10885 100644 --- a/apps/flutter_parent/test/screens/assignments/grade_cell_widget_test.dart +++ b/apps/flutter_parent/test/screens/assignments/grade_cell_widget_test.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_parent/l10n/app_localizations.dart'; import 'package:flutter_parent/models/grade_cell_data.dart'; import 'package:flutter_parent/screens/assignments/grade_cell.dart'; +import 'package:flutter_parent/utils/design/parent_colors.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:percent_indicator/circular_percent_indicator.dart'; @@ -158,11 +159,13 @@ void main() { GradeCellData data = GradeCellData((b) => b ..state = GradeCellState.graded ..accentColor = Colors.pinkAccent - ..latePenalty = 'Late penalty: (-25)'); + ..yourGrade = 'Your grade: 85' + ..latePenalty = 'Late penalty: -25'); await setupWithData(tester, data); expect(find.byKey(Key('grade-cell-late-penalty')).evaluate(), find.text(data.latePenalty).evaluate()); - expect(tester.widget(find.byKey(Key('grade-cell-late-penalty'))).style!.color, data.accentColor); + expect(find.byKey(Key('grade-cell-your-grade')).evaluate(), find.text(data.yourGrade).evaluate()); + expect(tester.widget(find.byKey(Key('grade-cell-late-penalty'))).style!.color, ParentColors.failure); }); testWidgetsWithAccessibilityChecks('Displays final grade text', (tester) async { diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/renderTests/views/GradeCellRenderTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/renderTests/views/GradeCellRenderTest.kt index 432f6a7c6c..ecb161ed32 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/renderTests/views/GradeCellRenderTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/renderTests/views/GradeCellRenderTest.kt @@ -37,6 +37,7 @@ class GradeCellRenderTest : StudentRenderTest() { val submittedTitle by OnViewWithId(R.id.submittedTitle) val submittedSubtitle by OnViewWithId(R.id.submittedSubtitle) val pointsLabel by OnViewWithId(R.id.pointsLabel) + val yourGrade by OnViewWithId(R.id.yourGrade) val latePenalty by OnViewWithId(R.id.latePenalty) val finalGrade by OnViewWithId(R.id.finalGrade) val grade by OnViewWithId(R.id.grade) @@ -122,7 +123,8 @@ class GradeCellRenderTest : StudentRenderTest() { score = "91", showPointsLabel = true, outOf = "Out of 100 pts", - latePenalty = "Late Penalty (-2 pts)", + yourGrade = "Your Grade: 91 pts", + latePenalty = "Late Penalty: -2 pts", finalGrade = "Final Grade: 89 pts" ) setupViewWithState(state) @@ -132,6 +134,7 @@ class GradeCellRenderTest : StudentRenderTest() { score.assertDisplayed() pointsLabel.assertDisplayed() outOf.assertDisplayed() + yourGrade.assertDisplayed() latePenalty.assertDisplayed() finalGrade.assertDisplayed() } @@ -140,6 +143,7 @@ class GradeCellRenderTest : StudentRenderTest() { with(gradeCell) { score.assertHasText(state.score) outOf.assertHasText(state.outOf) + yourGrade.assertHasText(state.yourGrade) latePenalty.assertHasText(state.latePenalty) finalGrade.assertHasText(state.finalGrade) } diff --git a/apps/student/src/main/java/com/instructure/student/features/assignments/details/gradecellview/GradeCellViewData.kt b/apps/student/src/main/java/com/instructure/student/features/assignments/details/gradecellview/GradeCellViewData.kt index 7fb3b6eae8..fe8c7ca580 100644 --- a/apps/student/src/main/java/com/instructure/student/features/assignments/details/gradecellview/GradeCellViewData.kt +++ b/apps/student/src/main/java/com/instructure/student/features/assignments/details/gradecellview/GradeCellViewData.kt @@ -24,6 +24,7 @@ data class GradeCellViewData( val grade: String = "", val gradeCellContentDescription: String = "", val outOf: String = "", + val yourGrade: String = "", val latePenalty: String = "", val finalGrade: String = "", val stats: GradeCellViewState.GradeStats? = null @@ -39,7 +40,6 @@ data class GradeCellViewData( } companion object { - @Suppress("DEPRECATION") fun fromSubmission( resources: Resources, courseColor: ThemedColor, @@ -145,7 +145,7 @@ data class GradeCellViewData( gradeCellContentDescription = contentDescription, ) } else { - val score = NumberHelper.formatDecimal(submission.enteredScore, 2, true) + val score = NumberHelper.formatDecimal(submission.score, 2, true) val chartPercent = (submission.enteredScore / assignment.pointsPossible).coerceIn(0.0, 1.0).toFloat() // If grading type is Points, don't show the grade since we're already showing it as the score var grade = if (assignment.gradingType != Assignment.POINTS_TYPE) submission.grade.orEmpty() else "" @@ -170,12 +170,15 @@ data class GradeCellViewData( var latePenalty = "" var finalGrade = "" + var yourGrade = "" // Adjust for late penalty, if any if (submission.pointsDeducted.orDefault() > 0.0) { grade = "" // Grade will be shown in the 'final grade' text val pointsDeducted = NumberHelper.formatDecimal(submission.pointsDeducted.orDefault(), 2, true) - latePenalty = resources.getString(R.string.latePenalty, pointsDeducted) + val achievedScore = NumberHelper.formatDecimal(submission.enteredScore, 2, true) + yourGrade = resources.getString(R.string.yourGrade, achievedScore) + latePenalty = resources.getString(R.string.latePenaltyUpdated, pointsDeducted) finalGrade = resources.getString(R.string.finalGradeFormatted, submission.grade) } @@ -210,6 +213,7 @@ data class GradeCellViewData( grade = grade, gradeCellContentDescription = gradeCellContentDescription, outOf = outOfText, + yourGrade = yourGrade, latePenalty = latePenalty, finalGrade = finalGrade, stats = stats diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellView.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellView.kt index 49b796f7ad..b5a9df7835 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellView.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellView.kt @@ -67,6 +67,7 @@ class GradeCellView @JvmOverloads constructor( pointsLabel.setVisible(state.showPointsLabel) completeIcon.setVisible(state.showCompleteIcon) incompleteIcon.setVisible(state.showIncompleteIcon) + yourGrade.setTextForVisibility(state.yourGrade) latePenalty.setTextForVisibility(state.latePenalty) finalGrade.setTextForVisibility(state.finalGrade) grade.setTextForVisibility(state.grade) @@ -75,7 +76,6 @@ class GradeCellView @JvmOverloads constructor( outOf.contentDescription = state.outOfContentDescription // Accent color - latePenalty.setTextColor(state.accentColor) chart.setColor(state.accentColor) completeIcon.imageTintList = ColorStateList.valueOf(state.accentColor) incompleteIcon.imageTintList = ColorStateList.valueOf(state.accentColor) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt index 42cf48e75c..f5f368b06a 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/ui/gradeCell/GradeCellViewState.kt @@ -43,6 +43,7 @@ sealed class GradeCellViewState { val gradeCellContentDescription: String = "", val outOf: String = "", val outOfContentDescription: String = "", + val yourGrade: String = "", val latePenalty: String = "", val finalGrade: String = "", val stats: GradeStats? = null @@ -132,8 +133,8 @@ sealed class GradeCellViewState { ) } - val score = NumberHelper.formatDecimal(submission.enteredScore, 2, true) - val graphPercent = (submission.enteredScore / assignment.pointsPossible).coerceIn(0.0, 1.0).toFloat() + val score = NumberHelper.formatDecimal(submission.score, 2, true) + val graphPercent = (submission.score / assignment.pointsPossible).coerceIn(0.0, 1.0).toFloat() // If grading type is Points, don't show the grade since we're already showing it as the score var grade = if (assignment.gradingType != Assignment.POINTS_TYPE) submission.grade.orEmpty() else "" @@ -148,12 +149,15 @@ sealed class GradeCellViewState { var latePenalty = "" var finalGrade = "" + var yourGrade = "" // Adjust for late penalty, if any - if (submission.pointsDeducted ?: 0.0 > 0.0) { + if ((submission.pointsDeducted ?: 0.0) > 0.0) { grade = "" // Grade will be shown in the 'final grade' text val pointsDeducted = NumberHelper.formatDecimal(submission.pointsDeducted!!, 2, true) - latePenalty = context.getString(R.string.latePenalty, pointsDeducted) + val achievedScore = NumberHelper.formatDecimal(submission.enteredScore, 2, true) + yourGrade = context.getString(R.string.yourGrade, achievedScore) + latePenalty = context.getString(R.string.latePenaltyUpdated, pointsDeducted) finalGrade = context.getString(R.string.finalGradeFormatted, submission.grade) } @@ -190,6 +194,7 @@ sealed class GradeCellViewState { grade = grade, gradeContentDescription = accessibleGradeString, gradeCellContentDescription = gradeCellContentDescription, + yourGrade = yourGrade, latePenalty = latePenalty, finalGrade = finalGrade, stats = stats diff --git a/apps/student/src/main/res/layout/view_student_enhanced_grade_cell.xml b/apps/student/src/main/res/layout/view_student_enhanced_grade_cell.xml index fb89c22521..7eeb284f87 100644 --- a/apps/student/src/main/res/layout/view_student_enhanced_grade_cell.xml +++ b/apps/student/src/main/res/layout/view_student_enhanced_grade_cell.xml @@ -273,12 +273,27 @@ android:text="@{viewData.outOf}" android:textColor="@color/textDarkest" android:visibility="@{viewData.outOf.empty ? View.GONE : View.VISIBLE}" - app:layout_constraintBottom_toTopOf="@id/latePenalty" + app:layout_constraintBottom_toTopOf="@id/yourGrade" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/chart" app:layout_constraintTop_toBottomOf="@id/grade" tools:text="Out of 100 pts" /> + + + tools:text="89%" /> + tools:text="Out of 100 pts" /> + + + android:textColor="@color/textWarning" + tools:text="Late Penalty: -46 pts" /> + tools:text="Final Grade: 89 pts" /> diff --git a/apps/student/src/test/java/com/instructure/student/test/assignment/details/GradeCellStateTest.kt b/apps/student/src/test/java/com/instructure/student/test/assignment/details/GradeCellStateTest.kt index 461a326251..0f60757041 100644 --- a/apps/student/src/test/java/com/instructure/student/test/assignment/details/GradeCellStateTest.kt +++ b/apps/student/src/test/java/com/instructure/student/test/assignment/details/GradeCellStateTest.kt @@ -314,11 +314,13 @@ class GradeCellStateTest : Assert() { score = 79.0 ) val expected = baseGradedState.copy( - graphPercent = 0.85f, - score = "85", + graphPercent = 0.79f, + score = "79", showPointsLabel = true, - latePenalty = "Late penalty (-6)", - finalGrade = "Final Grade: 79" + yourGrade = "Your Grade: 85 pts", + latePenalty = "Late Penalty: -6 pts", + finalGrade = "Final Grade: 79", + gradeCellContentDescription = "79 Out of 100 points" ) val actual = GradeCellViewState.fromSubmission(context, baseAssignment, submission) assertEquals(expected, actual) diff --git a/libs/pandares/src/main/res/values/strings.xml b/libs/pandares/src/main/res/values/strings.xml index c301e10fc8..e10ab2c072 100644 --- a/libs/pandares/src/main/res/values/strings.xml +++ b/libs/pandares/src/main/res/values/strings.xml @@ -957,6 +957,8 @@ Invite accepted! Invite declined. Late penalty (-%s) + Late Penalty: -%s pts + Your Grade: %s pts Final Grade 99+ From dd1682a39951b92c0b0d5d2fff7730c2aa17bbf9 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Thu, 7 Dec 2023 16:21:41 +0100 Subject: [PATCH 25/49] [MBL-17115][Student] - Offline LeftSideMenu Unavailable features E2E test (#2275) --- .../e2e/offline/OfflineLeftSideMenuE2ETest.kt | 101 ++++++++++++++++++ .../student/ui/pages/DashboardPage.kt | 4 + .../ui/pages/LeftSideNavigationDrawerPage.kt | 89 ++++++++++++++- .../espresso/CustomViewAssertions.kt | 1 - .../panda_annotations/TestMetaData.kt | 2 +- 5 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt new file mode 100644 index 0000000000..b3dedd84bb --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/OfflineLeftSideMenuE2ETest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2023 - 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.student.ui.e2e.offline + +import android.util.Log +import com.instructure.canvas.espresso.OfflineE2E +import com.instructure.panda_annotations.FeatureCategory +import com.instructure.panda_annotations.Priority +import com.instructure.panda_annotations.TestCategory +import com.instructure.panda_annotations.TestMetaData +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertNoInternetConnectionDialog +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.assertOfflineIndicator +import com.instructure.student.ui.e2e.offline.utils.OfflineTestUtils.dismissNoInternetConnectionDialog +import com.instructure.student.ui.utils.StudentTest +import com.instructure.student.ui.utils.seedData +import com.instructure.student.ui.utils.tokenLogin +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Test + +@HiltAndroidTest +class OfflineLeftSideMenuE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.LEFT_SIDE_MENU, TestCategory.E2E) + fun testOfflineLeftSideMenuUnavailableFunctionsE2E() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 2, announcements = 1) + val student = data.studentsList[0] + + Log.d(STEP_TAG,"Login with user: ${student.name}, login id: ${student.loginId}.") + tokenLogin(student) + dashboardPage.waitForRender() + + Log.d(PREPARATION_TAG, "Turn off the Wi-Fi and Mobile Data on the device, so it will go offline.") + turnOffConnectionViaADB() + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Assert that the Offline Indicator (bottom banner) is displayed on the Dashboard Page.") + assertOfflineIndicator() + + Log.d(STEP_TAG, "Open Left Side Menu by clicking on the 'hamburger icon' on the Dashboard Page.") + dashboardPage.openLeftSideMenu() + + Log.d(STEP_TAG, "Assert that the offline indicator is displayed below the user info within the header.") + leftSideNavigationDrawerPage.assertOfflineIndicatorDisplayed() + + Log.d(STEP_TAG, "Assert that the 'Files, Bookmarks, Studio, Color Overlay, Help' menus are disabled in offline mode.") + leftSideNavigationDrawerPage.assertOfflineDisabledMenus(0.5f) + + Log.d(STEP_TAG, "Assert that the 'Settings, Show Grades, Change User, Log Out' menus are enabled in offline mode because they are supported.") + leftSideNavigationDrawerPage.assertOfflineEnabledMenus(1.0f) + + Log.d(STEP_TAG, "Click on 'Files' menu and assert that the 'No Internet Connection' dialog is popping-up. Dismiss it.") + leftSideNavigationDrawerPage.clickFilesMenu() + assertNoInternetConnectionDialog() + dismissNoInternetConnectionDialog() + + Log.d(STEP_TAG, "Click on 'Bookmarks' menu and assert that the 'No Internet Connection' dialog is popping-up. Dismiss it.") + leftSideNavigationDrawerPage.clickBookmarksMenu() + assertNoInternetConnectionDialog() + dismissNoInternetConnectionDialog() + + Log.d(STEP_TAG, "Click on 'Studio' menu and assert that the 'No Internet Connection' dialog is popping-up. Dismiss it.") + leftSideNavigationDrawerPage.clickStudioMenu() + assertNoInternetConnectionDialog() + dismissNoInternetConnectionDialog() + + Log.d(STEP_TAG, "Click on 'Help' menu and assert that the 'No Internet Connection' dialog is popping-up. Dismiss it.") + leftSideNavigationDrawerPage.clickHelpMenu() + assertNoInternetConnectionDialog() + dismissNoInternetConnectionDialog() + + } + + @After + fun tearDown() { + Log.d(PREPARATION_TAG, "Turn back on the Wi-Fi and Mobile Data on the device via ADB, so it will come back online.") + turnOnConnectionViaADB() + } + +} \ No newline at end of file diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt index a8b51842eb..18971f74a5 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/DashboardPage.kt @@ -147,6 +147,10 @@ class DashboardPage : BasePage(R.id.dashboardPage) { onView(hamburgerButtonMatcher).waitForCheck(matches(isDisplayed())) } + fun openLeftSideMenu() { + onView(hamburgerButtonMatcher).click() + } + private fun scrollAndAssertDisplayed(matcher: Matcher) { // Arggghhh... This scrolling logic on the recycler view is really unreliable and seems // to fail for nonsensical reasons. For now, "scrollAndAssertDisplayed"" is just going to diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt index da39b4cefe..0fa4f1c98e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/LeftSideNavigationDrawerPage.kt @@ -12,8 +12,22 @@ import com.instructure.canvas.espresso.CanvasTest import com.instructure.canvas.espresso.waitForMatcherWithSleeps import com.instructure.canvasapi2.models.User import com.instructure.dataseeding.model.CanvasUserApiModel -import com.instructure.espresso.* -import com.instructure.espresso.page.* +import com.instructure.espresso.OnViewWithContentDescription +import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.ViewAlphaAssertion +import com.instructure.espresso.assertDisplayed +import com.instructure.espresso.assertNotDisplayed +import com.instructure.espresso.click +import com.instructure.espresso.page.BasePage +import com.instructure.espresso.page.onView +import com.instructure.espresso.page.onViewWithId +import com.instructure.espresso.page.onViewWithText +import com.instructure.espresso.page.waitForView +import com.instructure.espresso.page.waitForViewWithId +import com.instructure.espresso.page.withId +import com.instructure.espresso.scrollTo +import com.instructure.espresso.swipeDown +import com.instructure.espresso.swipeUp import com.instructure.student.R import org.hamcrest.CoreMatchers import org.hamcrest.Matcher @@ -86,6 +100,10 @@ class LeftSideNavigationDrawerPage : BasePage() { clickMenu(R.id.navigationDrawerItem_bookmarks) } + fun clickStudioMenu() { + clickMenu(R.id.navigationDrawerItem_studio) + } + fun clickSettingsMenu() { clickMenu(R.id.navigationDrawerSettings) } @@ -181,6 +199,73 @@ class LeftSideNavigationDrawerPage : BasePage() { logoutButton.assertDisplayed() } + //OfflineMethod + private fun assertViewAlpha(matcher: Matcher, expectedAlphaValue: Float) { + onView(matcher).check(ViewAlphaAssertion(expectedAlphaValue)) + } + + //OfflineMethod + private fun assertFilesMenuAlphaValue(expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.navigationDrawerItem_files), expectedAlphaValue) + } + + //OfflineMethod + private fun assertBookmarksMenuAlphaValue(expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.navigationDrawerItem_bookmarks), expectedAlphaValue) + } + + //OfflineMethod + private fun assertStudioMenuAlphaValue(expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.navigationDrawerItem_studio), expectedAlphaValue) + } + + //OfflineMethod + private fun assertSettingsMenuAlphaValue(expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.navigationDrawerSettings), expectedAlphaValue) + } + + //OfflineMethod + private fun assertShowGradesMenuAlphaValue(expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.navigationDrawerItem_showGrades), expectedAlphaValue) + } + + //OfflineMethod + private fun assertColorOverlayMenuAlphaValue(expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.navigationDrawerItem_colorOverlay), expectedAlphaValue) + } + + //OfflineMethod + private fun assertHelpMenuAlphaValue(expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.navigationDrawerItem_help), expectedAlphaValue) + } + + //OfflineMethod + private fun assertChangeUserMenuAlphaValue(expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.navigationDrawerItem_changeUser), expectedAlphaValue) + } + + //OfflineMethod + private fun assertLogoutMenuAlphaValue(expectedAlphaValue: Float) { + assertViewAlpha(withId(R.id.navigationDrawerItem_logout), expectedAlphaValue) + } + + //OfflineMethod + fun assertOfflineDisabledMenus(expectedAlphaValue: Float) { + assertFilesMenuAlphaValue(expectedAlphaValue) + assertBookmarksMenuAlphaValue(expectedAlphaValue) + assertStudioMenuAlphaValue(expectedAlphaValue) + assertColorOverlayMenuAlphaValue(expectedAlphaValue) + assertHelpMenuAlphaValue(expectedAlphaValue) + } + + //OfflineMethod + fun assertOfflineEnabledMenus(expectedAlphaValue: Float) { + assertSettingsMenuAlphaValue(expectedAlphaValue) + assertShowGradesMenuAlphaValue(expectedAlphaValue) + assertChangeUserMenuAlphaValue(expectedAlphaValue) + assertLogoutMenuAlphaValue(expectedAlphaValue) + } + /** * Custom ViewAction to set a SwitchCompat to the desired on/off position * [position]: true -> "on", false -> "off" diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt index 77a1c2d6ec..64eee4f838 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt @@ -128,4 +128,3 @@ class ViewAlphaAssertion(private val expectedAlpha: Float): ViewAssertion { assertThat("View alpha should be $expectedAlpha", view.alpha, `is`(expectedAlpha)) } } - diff --git a/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt b/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt index 4400d2346b..4dfec47796 100644 --- a/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt +++ b/libs/panda_annotations/src/main/java/com/instructure/panda_annotations/TestMetaData.kt @@ -34,7 +34,7 @@ enum class Priority { enum class FeatureCategory { ASSIGNMENTS, SUBMISSIONS, LOGIN, COURSE, DASHBOARD, GROUPS, SETTINGS, PAGES, DISCUSSIONS, MODULES, INBOX, GRADES, FILES, EVENTS, PEOPLE, CONFERENCES, COLLABORATIONS, SYLLABUS, TODOS, QUIZZES, NOTIFICATIONS, - ANNOTATIONS, ANNOUNCEMENTS, COMMENTS, BOOKMARKS, NONE, K5_DASHBOARD, SPEED_GRADER, SYNC_SETTINGS, SYNC_PROGRESS, OFFLINE_CONTENT + ANNOTATIONS, ANNOUNCEMENTS, COMMENTS, BOOKMARKS, NONE, K5_DASHBOARD, SPEED_GRADER, SYNC_SETTINGS, SYNC_PROGRESS, OFFLINE_CONTENT, LEFT_SIDE_MENU } enum class SecondaryFeatureCategory { From eae402259694d1e0cf829937adc07c0645342304 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Fri, 8 Dec 2023 10:26:48 +0100 Subject: [PATCH 26/49] Stabilize Offline ManageOfflineContentE2ETest. (#2278) --- .../student/ui/e2e/offline/ManageOfflineContentE2ETest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt index 3d300a2e1a..431b8d1509 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt @@ -73,6 +73,9 @@ class ManageOfflineContentE2ETest : StudentTest() { Log.d(STEP_TAG, "Assert that the tool bar texts are displayed properly, so the subtitle is '${course1.name}', because we are on the Manage Offline Content page of '${course1.name}' course.") manageOfflineContentPage.assertToolbarTexts(course1.name) + Log.d(STEP_TAG, "Assert that the '${course1.name}' course's checkbox state became 'Checked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course1.name, MaterialCheckBox.STATE_CHECKED) + Log.d(STEP_TAG, "Deselect the 'Announcements' and 'Discussions' of the '${course1.name}' course.") manageOfflineContentPage.changeItemSelectionState("Announcements") manageOfflineContentPage.changeItemSelectionState("Discussions") From b2527335642cf573dda3771515a7e6d8e9aeaf88 Mon Sep 17 00:00:00 2001 From: Akos Hermann <72087159+hermannakos@users.noreply.github.com> Date: Fri, 8 Dec 2023 12:19:33 +0100 Subject: [PATCH 27/49] [MBL-17174][Student][Teacher] Dependency update (#2272) Test plan: Smoke test the apps. refs: MBL-17174 affects: Student, Teacher release note: none --- .../student/espresso/TestAppManager.kt | 5 +- .../student/fragment/ViewImageFragment.kt | 12 +++-- .../drawer/files/ui/SubmissionFilesAdapter.kt | 13 +++-- .../student/util/BaseAppManager.kt | 10 ++-- .../teacher/fragments/ViewImageFragment.kt | 12 +++-- .../instructure/teacher/utils/AppManager.kt | 7 +-- .../teacher/utils/BaseAppManager.kt | 10 ++-- buildSrc/src/main/java/GlobalDependencies.kt | 48 +++++++++---------- libs/DocumentScanner/build.gradle | 14 +++--- .../canvasapi2/utils/DataResultTest.kt | 21 +++----- libs/pandautils/build.gradle | 4 +- .../pandautils/utils/AvatarCropUtils.kt | 4 +- .../instructure/pandautils/utils/SvgUtils.kt | 15 +++++- .../pandautils/utils/ViewExtensions.kt | 8 ++-- .../pandautils/views/RecipientChipsInput.kt | 8 ++-- .../DashboardNotificationsViewModelTest.kt | 16 +++---- .../offline/sync/OfflineSyncHelperTest.kt | 10 ++-- .../ShareExtensionProgressViewModelTest.kt | 20 ++++---- 18 files changed, 124 insertions(+), 113 deletions(-) diff --git a/apps/student/src/androidTest/java/com/instructure/student/espresso/TestAppManager.kt b/apps/student/src/androidTest/java/com/instructure/student/espresso/TestAppManager.kt index c99b8cf92d..fc00cc637e 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/espresso/TestAppManager.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/espresso/TestAppManager.kt @@ -16,7 +16,6 @@ */ package com.instructure.student.espresso -import androidx.work.Configuration import androidx.work.WorkerFactory import com.instructure.student.util.BaseAppManager @@ -25,8 +24,6 @@ open class TestAppManager : BaseAppManager() { var workerFactory: WorkerFactory? = null override fun getWorkManagerFactory(): WorkerFactory { - return workerFactory?.let { - it - } ?: WorkerFactory.getDefaultWorkerFactory() + return workerFactory ?: WorkerFactory.getDefaultWorkerFactory() } } \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/fragment/ViewImageFragment.kt b/apps/student/src/main/java/com/instructure/student/fragment/ViewImageFragment.kt index e5c586603f..bce47bc595 100644 --- a/apps/student/src/main/java/com/instructure/student/fragment/ViewImageFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/fragment/ViewImageFragment.kt @@ -91,7 +91,7 @@ class ViewImageFragment : Fragment(), ShareableFile { private val requestListener = object : RequestListener { - override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target?, p3: Boolean): Boolean { + override fun onLoadFailed(p0: GlideException?, p1: Any?, target: Target, p3: Boolean): Boolean { binding.photoView.setGone() binding.progressBar.setGone() binding.errorContainer.setVisible() @@ -100,11 +100,17 @@ class ViewImageFragment : Fragment(), ShareableFile { return false } - override fun onResourceReady(drawable: Drawable?, p1: Any?, p2: Target?, p3: DataSource?, p4: Boolean): Boolean { + override fun onResourceReady( + resource: Drawable, + model: Any, + p2: Target?, + dataSource: DataSource, + p4: Boolean + ): Boolean { binding.progressBar.setGone() // Try to set the background color using palette if we can - (drawable as? BitmapDrawable)?.bitmap?.let { colorBackground(it) } + (resource as? BitmapDrawable)?.bitmap?.let { colorBackground(it) } return false } } diff --git a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/files/ui/SubmissionFilesAdapter.kt b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/files/ui/SubmissionFilesAdapter.kt index f9018ce7f9..0c1c06eb89 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/files/ui/SubmissionFilesAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/assignmentDetails/submissionDetails/drawer/files/ui/SubmissionFilesAdapter.kt @@ -69,17 +69,22 @@ internal class SubmissionFilesHolder(view: View) : RecyclerView.ViewHolder(view) thumbnail.setVisible(data.thumbnailUrl.isValid()) data.thumbnailUrl.validOrNull()?.let { Glide.with(root.context).load(it).listener(object : RequestListener { - override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { fileIcon.setVisible(true) thumbnail.setVisible(false) return false } override fun onResourceReady( - resource: Drawable?, - model: Any?, + resource: Drawable, + model: Any, target: Target?, - dataSource: DataSource?, + dataSource: DataSource, isFirstResource: Boolean ): Boolean { return false diff --git a/apps/student/src/main/java/com/instructure/student/util/BaseAppManager.kt b/apps/student/src/main/java/com/instructure/student/util/BaseAppManager.kt index 51b573aaa4..09f8856ab6 100644 --- a/apps/student/src/main/java/com/instructure/student/util/BaseAppManager.kt +++ b/apps/student/src/main/java/com/instructure/student/util/BaseAppManager.kt @@ -49,6 +49,11 @@ import io.flutter.embedding.engine.dart.DartExecutor abstract class BaseAppManager : com.instructure.canvasapi2.AppManager(), AnalyticsEventHandling, Configuration.Provider { + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(getWorkManagerFactory()) + .build() + override fun onCreate() { super.onCreate() @@ -153,11 +158,6 @@ abstract class BaseAppManager : com.instructure.canvasapi2.AppManager(), Analyti override fun performLogoutOnAuthError() = Unit - override fun getWorkManagerConfiguration(): Configuration = - Configuration.Builder() - .setWorkerFactory(getWorkManagerFactory()) - .build() - abstract fun getWorkManagerFactory(): WorkerFactory companion object { diff --git a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewImageFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewImageFragment.kt index 1cd5cc518a..26488116a0 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewImageFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/fragments/ViewImageFragment.kt @@ -109,7 +109,7 @@ class ViewImageFragment : Fragment(), ShareableFile { private val requestListener = object : RequestListener { - override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target?, p3: Boolean): Boolean = with(binding) { + override fun onLoadFailed(p0: GlideException?, p1: Any?, target: Target, p3: Boolean): Boolean = with(binding) { photoView.setGone() progressBar.setGone() errorContainer.setVisible() @@ -118,11 +118,17 @@ class ViewImageFragment : Fragment(), ShareableFile { return false } - override fun onResourceReady(bitmap: Bitmap?, p1: Any?, p2: Target?, p3: DataSource?, p4: Boolean): Boolean { + override fun onResourceReady( + resource: Bitmap, + model: Any, + p2: Target?, + dataSource: DataSource, + p4: Boolean + ): Boolean { binding.progressBar.setGone() // Try to set the background color using palette if we can - bitmap?.let { colorBackground(it) } + colorBackground(resource) return false } } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/utils/AppManager.kt b/apps/teacher/src/main/java/com/instructure/teacher/utils/AppManager.kt index 34cb5dcc21..d87159491f 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/utils/AppManager.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/utils/AppManager.kt @@ -23,15 +23,10 @@ import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject @HiltAndroidApp -class AppManager : BaseAppManager(), Configuration.Provider { +class AppManager : BaseAppManager() { @Inject lateinit var workerFactory: HiltWorkerFactory - override fun getWorkManagerConfiguration(): Configuration = - Configuration.Builder() - .setWorkerFactory(workerFactory) - .build() - override fun getWorkManagerFactory(): WorkerFactory = workerFactory } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/utils/BaseAppManager.kt b/apps/teacher/src/main/java/com/instructure/teacher/utils/BaseAppManager.kt index f79b57adcc..5acca986bb 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/utils/BaseAppManager.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/utils/BaseAppManager.kt @@ -43,6 +43,11 @@ import com.pspdfkit.exceptions.PSPDFKitInitializationFailedException abstract class BaseAppManager : com.instructure.canvasapi2.AppManager(), Configuration.Provider { + override val workManagerConfiguration: Configuration + get() = Configuration.Builder() + .setWorkerFactory(getWorkManagerFactory()) + .build() + override fun onCreate() { super.onCreate() @@ -98,11 +103,6 @@ abstract class BaseAppManager : com.instructure.canvasapi2.AppManager(), Configu TeacherLogoutTask(LogoutTask.Type.LOGOUT).execute() } - override fun getWorkManagerConfiguration(): Configuration = - Configuration.Builder() - .setWorkerFactory(getWorkManagerFactory()) - .build() - abstract fun getWorkManagerFactory(): WorkerFactory companion object { diff --git a/buildSrc/src/main/java/GlobalDependencies.kt b/buildSrc/src/main/java/GlobalDependencies.kt index 8ad33f36dd..287dfe6e02 100644 --- a/buildSrc/src/main/java/GlobalDependencies.kt +++ b/buildSrc/src/main/java/GlobalDependencies.kt @@ -27,17 +27,17 @@ object Versions { const val PSPDFKIT = "8.9.1" const val PHOTO_VIEW = "2.3.0" const val MOBIUS = "1.2.1" - const val SQLDELIGHT = "1.5.4" - const val HILT = "2.48" - const val HILT_ANDROIDX = "1.0.0" - const val LIFECYCLE = "2.6.0" - const val FRAGMENT = "1.5.5" - const val WORK_MANAGER = "2.8.1" - const val GLIDE_VERSION = "4.15.1" + const val SQLDELIGHT = "1.5.4" // 2.0 is out but may break stuff. We should look into migrating to Room. + const val HILT = "2.49" + const val HILT_ANDROIDX = "1.1.0" + const val LIFECYCLE = "2.6.2" + const val FRAGMENT = "1.6.2" + const val WORK_MANAGER = "2.9.0" + const val GLIDE_VERSION = "4.16.0" const val RETROFIT = "2.9.0" - const val OKHTTP = "4.10.0" - const val HEAP = "1.10.5" - const val ROOM = "2.6.0" + const val OKHTTP = "4.12.0" + const val HEAP = "1.10.6" + const val ROOM = "2.6.1" const val HAMCREST = "2.2" } @@ -54,35 +54,35 @@ object Libs { const val APOLLO_HTTP_CACHE = "com.apollographql.apollo:apollo-http-cache:${Versions.APOLLO}" /* Support Libs */ - const val ANDROIDX_ANNOTATION = "androidx.annotation:annotation:1.6.0" + const val ANDROIDX_ANNOTATION = "androidx.annotation:annotation:1.7.0" const val ANDROIDX_APPCOMPAT = "androidx.appcompat:appcompat:1.6.1" - const val ANDROIDX_BROWSER = "androidx.browser:browser:1.5.0" + const val ANDROIDX_BROWSER = "androidx.browser:browser:1.7.0" const val ANDROIDX_CARDVIEW = "androidx.cardview:cardview:1.0.0" const val ANDROIDX_CONSTRAINT_LAYOUT = "androidx.constraintlayout:constraintlayout:2.1.4" - const val ANDROIDX_DESIGN = "com.google.android.material:material:1.8.0" + const val ANDROIDX_DESIGN = "com.google.android.material:material:1.10.0" const val ANDROIDX_EXIF = "androidx.exifinterface:exifinterface:1.3.6" const val ANDROIDX_FRAGMENT = "androidx.fragment:fragment:${Versions.FRAGMENT}" const val FRAGMENT_KTX = "androidx.fragment:fragment-ktx:${Versions.FRAGMENT}" const val ANDROIDX_PALETTE = "androidx.palette:palette:1.0.0" const val ANDROIDX_PERCENT = "androidx.percentlayout:percentlayout:1.0.0" - const val ANDROIDX_RECYCLERVIEW = "androidx.recyclerview:recyclerview:1.3.0" + const val ANDROIDX_RECYCLERVIEW = "androidx.recyclerview:recyclerview:1.3.2" const val ANDROIDX_VECTOR = "androidx.vectordrawable:vectordrawable:1.1.0" const val ANDROIDX_SWIPE_REFRESH_LAYOUT = "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" const val ANDROIDX_CORE_TESTING = "androidx.arch.core:core-testing:2.2.0" const val ANDROIDX_WORK_MANAGER = "androidx.work:work-runtime:${Versions.WORK_MANAGER}" const val ANDROIDX_WORK_MANAGER_KTX = "androidx.work:work-runtime-ktx:${Versions.WORK_MANAGER}" - const val ANDROIDX_WEBKIT = "androidx.webkit:webkit:1.6.0" + const val ANDROIDX_WEBKIT = "androidx.webkit:webkit:1.9.0" const val ANDROIDX_DATABINDING_COMPILER = "androidx.databinding:databinding-compiler:${Versions.ANDROID_GRADLE_TOOLS}" // This is bundled with the gradle plugin so we use the same version /* Firebase */ - const val FIREBASE_BOM = "com.google.firebase:firebase-bom:31.2.3" + const val FIREBASE_BOM = "com.google.firebase:firebase-bom:32.6.0" const val FIREBASE_CRASHLYTICS = "com.google.firebase:firebase-crashlytics" const val FIREBASE_MESSAGING = "com.google.firebase:firebase-messaging" const val FIREBASE_CONFIG = "com.google.firebase:firebase-config" const val FIREBASE_CRASHLYTICS_NDK = "com.google.firebase:firebase-crashlytics-ndk" /* Play Services */ - const val PLAY_IN_APP_UPDATES = "com.google.android.play:app-update:2.0.1" + const val PLAY_IN_APP_UPDATES = "com.google.android.play:app-update:2.1.0" const val FLEXBOX_LAYOUT = "com.google.android.flexbox:flexbox:3.0.0" /* Mobius */ @@ -95,8 +95,8 @@ object Libs { const val JUNIT = "junit:junit:${Versions.JUNIT}" const val ROBOLECTRIC = "org.robolectric:robolectric:${Versions.ROBOLECTRIC}" const val ANDROIDX_TEST_JUNIT = "androidx.test.ext:junit:1.1.5" - const val MOCKK = "io.mockk:mockk:1.12.3" - const val THREETEN_BP = "org.threeten:threetenbp:1.6.5" + const val MOCKK = "io.mockk:mockk:1.12.8" + const val THREETEN_BP = "org.threeten:threetenbp:1.6.8" const val UI_AUTOMATOR = "com.android.support.test.uiautomator:uiautomator-v18:2.1.3" const val TEST_ORCHESTRATOR = "androidx.test:orchestrator:1.4.2" @@ -120,14 +120,14 @@ object Libs { /* Media and content handling */ const val PSPDFKIT = "com.pspdfkit:pspdfkit:${Versions.PSPDFKIT}" - const val EXOPLAYER = "com.google.android.exoplayer:exoplayer:2.18.5" + const val EXOPLAYER = "com.google.android.exoplayer:exoplayer:2.18.5" // This is deprecated, we should migrate to https://developer.android.com/guide/topics/media/media3/getting-started/migration-guide const val PHOTO_VIEW = "com.github.chrisbanes:PhotoView:${Versions.PHOTO_VIEW}" const val ANDROID_SVG = "com.caverock:androidsvg:1.4" const val RICH_EDITOR = "jp.wasabeef:richeditor-android:2.0.0" const val GLIDE = "com.github.bumptech.glide:glide:${Versions.GLIDE_VERSION}" const val GLIDE_OKHTTP = "com.github.bumptech.glide:okhttp3-integration:${Versions.GLIDE_VERSION}" const val GLIDE_COMPILER = "com.github.bumptech.glide:compiler:${Versions.GLIDE_VERSION}" - const val SCALE_IMAGE_VIEW = "com.davemorrissey.labs:subsampling-scale-image-view:3.9.0" + const val SCALE_IMAGE_VIEW = "com.davemorrissey.labs:subsampling-scale-image-view:3.10.0" /* Network */ const val RETROFIT = "com.squareup.retrofit2:retrofit:${Versions.RETROFIT}" @@ -138,15 +138,15 @@ object Libs { const val OKHTTP = "com.squareup.okhttp3:okhttp:${Versions.OKHTTP}" const val OKHTTP_LOGGING = "com.squareup.okhttp3:logging-interceptor:${Versions.OKHTTP}" const val OKHTTP_URL_CONNECTION = "com.squareup.okhttp3:okhttp-urlconnection:${Versions.OKHTTP}" - const val OKIO = "com.squareup.okio:okio:3.3.0" + const val OKIO = "com.squareup.okio:okio:3.6.0" /* Other */ - const val LOTTIE = "com.airbnb.android:lottie:6.0.0" + const val LOTTIE = "com.airbnb.android:lottie:6.2.0" const val SLIDING_UP_PANEL = "com.sothree.slidinguppanel:library:3.3.1" const val SQLDELIGHT = "com.squareup.sqldelight:android-driver:${Versions.SQLDELIGHT}" const val DISK_LRU_CACHE = "com.jakewharton:disklrucache:2.0.2" const val EVENTBUS = "org.greenrobot:eventbus:3.3.1" - const val JW_THREETEN_BP = "com.jakewharton.threetenabp:threetenabp:1.4.4" + const val JW_THREETEN_BP = "com.jakewharton.threetenabp:threetenabp:1.4.6" const val PROCESS_PHOENIX = "com.jakewharton:process-phoenix:2.1.2" const val PAPERDB = "io.github.pilgr:paperdb:2.7.2" const val KEYBOARD_VISIBILITY_LISTENER = "net.yslibrary.keyboardvisibilityevent:keyboardvisibilityevent:2.2.1" diff --git a/libs/DocumentScanner/build.gradle b/libs/DocumentScanner/build.gradle index 47145c3f4d..7c7bf810a8 100644 --- a/libs/DocumentScanner/build.gradle +++ b/libs/DocumentScanner/build.gradle @@ -63,20 +63,20 @@ dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation Libs.KOTLIN_STD_LIB - implementation 'androidx.core:core-ktx:1.3.2' - implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'io.reactivex.rxjava3:rxandroid:3.0.0' + implementation 'io.reactivex.rxjava3:rxandroid:3.0.2' implementation 'com.github.zynkware:Tiny-OpenCV:4.4.0-4' - implementation "androidx.camera:camera-camera2:1.0.0" - implementation "androidx.camera:camera-lifecycle:1.0.0" - implementation "androidx.camera:camera-view:1.0.0-alpha24" + implementation "androidx.camera:camera-camera2:1.3.0" + implementation "androidx.camera:camera-lifecycle:1.3.0" + implementation "androidx.camera:camera-view:1.4.0-alpha02" implementation 'com.github.tbruyelle:rxpermissions:0.12' - implementation 'androidx.exifinterface:exifinterface:1.3.2' + implementation 'androidx.exifinterface:exifinterface:1.3.6' implementation Libs.KOTLIN_COROUTINES_ANDROID implementation 'id.zelory:compressor:3.0.1' } diff --git a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/DataResultTest.kt b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/DataResultTest.kt index df82ca9630..3eae2c1028 100644 --- a/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/DataResultTest.kt +++ b/libs/canvas-api-2/src/test/java/com/instructure/canvasapi2/utils/DataResultTest.kt @@ -87,8 +87,7 @@ class DataResultTest : Assert() { val result: DataResult = DataResult.Success(expectedValue) val block = mockk<(String) -> Unit>(relaxed = true) result.onSuccess(block) - verify { block.invoke(expectedValue) } - confirmVerified() + verify(exactly = 1) { block.invoke(expectedValue) } } @Test @@ -97,7 +96,6 @@ class DataResultTest : Assert() { val block = mockk<(String) -> Unit>(relaxed = true) result.onSuccess(block) verify(exactly = 0) { block.invoke(capture(slot())) } - confirmVerified() } @Test @@ -106,8 +104,7 @@ class DataResultTest : Assert() { val result: DataResult = DataResult.Fail(expectedFailure) val block = mockk<(Failure) -> Unit>(relaxed = true) result.onFail(block) - verify { block.invoke(expectedFailure) } - confirmVerified() + verify(exactly = 1) { block.invoke(expectedFailure) } } @Test @@ -116,8 +113,7 @@ class DataResultTest : Assert() { val result: DataResult = DataResult.Fail(expectedFailure) val block = mockk<(Failure) -> Unit>(relaxed = true) result.onFail(block) - verify { block.invoke(expectedFailure) } - confirmVerified() + verify(exactly = 1) { block.invoke(expectedFailure) } } @Test @@ -126,8 +122,7 @@ class DataResultTest : Assert() { val result: DataResult = DataResult.Fail(expectedFailure) val block = mockk<(Failure) -> Unit>(relaxed = true) result.onFail(block) - verify { block.invoke(expectedFailure) } - confirmVerified() + verify(exactly = 1) { block.invoke(expectedFailure) } } @Test @@ -135,8 +130,7 @@ class DataResultTest : Assert() { val result: DataResult = DataResult.Fail() val block = mockk<(Failure?) -> Unit>(relaxed = true) result.onFailure(block) - verify { block.invoke(null) } - confirmVerified() + verify(exactly = 1) { block.invoke(null) } } @Test @@ -145,8 +139,7 @@ class DataResultTest : Assert() { val result: DataResult = DataResult.Fail(expectedFailure) val block = mockk<(Failure?) -> Unit>(relaxed = true) result.onFailure(block) - verify { block.invoke(expectedFailure) } - confirmVerified() + verify(exactly = 1) { block.invoke(expectedFailure) } } @Test @@ -155,7 +148,6 @@ class DataResultTest : Assert() { val block = mockk<(Failure?) -> Unit>(relaxed = true) result.onFailure(block) verify(exactly = 0) { block.invoke(capture(slot())) } - confirmVerified() } @Test @@ -164,6 +156,5 @@ class DataResultTest : Assert() { val block = mockk<(Failure) -> Unit>(relaxed = true) result.onFail(block) verify(exactly = 0) { block.invoke(capture(slot())) } - confirmVerified() } } diff --git a/libs/pandautils/build.gradle b/libs/pandautils/build.gradle index e0adde4efa..2353288f5c 100644 --- a/libs/pandautils/build.gradle +++ b/libs/pandautils/build.gradle @@ -125,8 +125,8 @@ dependencies { implementation Libs.KOTLIN_STD_LIB implementation Libs.ANDROIDX_BROWSER implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1' + implementation Libs.LIVE_DATA + implementation Libs.VIEW_MODEL /* Test Dependencies */ testImplementation Libs.JUNIT diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AvatarCropUtils.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AvatarCropUtils.kt index 2932710efc..2c9a8a6c93 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AvatarCropUtils.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/AvatarCropUtils.kt @@ -199,8 +199,8 @@ class AvatarCropActivity : AppCompatActivity() { * crop boundary as a percentage (0f to 1f) of the source image dimensions. */ private fun getCropInfo(): RectF = with(binding) { - val origin = imageView.viewToSourceCoord(0f, 0f) - val dimen = imageView.viewToSourceCoord(imageView.width.toFloat(), imageView.height.toFloat()) + val origin = imageView.viewToSourceCoord(0f, 0f) ?: return RectF(0f, 0f, 1f, 1f) + val dimen = imageView.viewToSourceCoord(imageView.width.toFloat(), imageView.height.toFloat()) ?: return RectF(0f, 0f, 1f, 1f) val (appliedWidth, appliedHeight) = when (imageView.appliedOrientation) { 90, 270 -> imageView.sHeight to imageView.sWidth else -> imageView.sWidth to imageView.sHeight diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/SvgUtils.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/SvgUtils.kt index 99bb9cf272..e4eb8050de 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/SvgUtils.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/SvgUtils.kt @@ -91,12 +91,23 @@ internal class SvgDrawableTranscoder : ResourceTranscoder * can't render on a hardware backed [Canvas][android.graphics.Canvas]. */ internal class SvgSoftwareLayerSetter : RequestListener { - override fun onResourceReady(resource: PictureDrawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + override fun onResourceReady( + resource: PictureDrawable, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { (target as? ImageViewTarget)?.view?.setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null) return false } - override fun onLoadFailed(p0: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { (target as? ImageViewTarget)?.view?.setLayerType(ImageView.LAYER_TYPE_NONE, null) return false } diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt index 1980d35c8b..1189f32411 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ViewExtensions.kt @@ -781,7 +781,7 @@ fun ImageView.loadCircularImage( override fun onLoadFailed( e: GlideException?, model: Any?, - target: Target?, + target: Target, isFirstResource: Boolean ): Boolean { onFailure?.invoke() @@ -789,10 +789,10 @@ fun ImageView.loadCircularImage( } override fun onResourceReady( - resource: Bitmap?, - model: Any?, + resource: Bitmap, + model: Any, target: Target?, - dataSource: DataSource?, + dataSource: DataSource, isFirstResource: Boolean ): Boolean { return false diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/views/RecipientChipsInput.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/views/RecipientChipsInput.kt index 5a91c11bcd..a09bed935f 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/views/RecipientChipsInput.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/views/RecipientChipsInput.kt @@ -196,7 +196,7 @@ class RecipientChip(context: Context, val recipient: Recipient, onRemoved: (Stri override fun onLoadFailed( e: GlideException?, model: Any?, - target: Target?, + target: Target, isFirstResource: Boolean ): Boolean { chipIcon = placeholder @@ -204,10 +204,10 @@ class RecipientChip(context: Context, val recipient: Recipient, onRemoved: (Stri } override fun onResourceReady( - resource: Drawable?, - model: Any?, + resource: Drawable, + model: Any, target: Target?, - source: DataSource?, + source: DataSource, isFirstResource: Boolean ): Boolean { chipIcon = resource diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/notifications/DashboardNotificationsViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/notifications/DashboardNotificationsViewModelTest.kt index 7a917177c8..5343a8829a 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/notifications/DashboardNotificationsViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/dashboard/notifications/DashboardNotificationsViewModelTest.kt @@ -505,8 +505,8 @@ class DashboardNotificationsViewModelTest { WorkInfo( workerId, WorkInfo.State.RUNNING, + emptySet(), Data.EMPTY, - emptyList(), Data.Builder() .putString(FileUploadWorker.PROGRESS_DATA_TITLE, title) .putString(FileUploadWorker.PROGRESS_DATA_ASSIGNMENT_NAME, subTitle) @@ -522,8 +522,8 @@ class DashboardNotificationsViewModelTest { WorkInfo( workerId2, WorkInfo.State.SUCCEEDED, + emptySet(), Data.EMPTY, - emptyList(), Data.Builder() .putString(FileUploadWorker.PROGRESS_DATA_TITLE, title2) .putString(FileUploadWorker.PROGRESS_DATA_ASSIGNMENT_NAME, subTitle2) @@ -539,8 +539,8 @@ class DashboardNotificationsViewModelTest { WorkInfo( workerId3, WorkInfo.State.FAILED, + emptySet(), Data.EMPTY, - emptyList(), Data.Builder() .putString(FileUploadWorker.PROGRESS_DATA_TITLE, title3) .putString(FileUploadWorker.PROGRESS_DATA_ASSIGNMENT_NAME, subTitle3) @@ -586,8 +586,8 @@ class DashboardNotificationsViewModelTest { WorkInfo( workerId, WorkInfo.State.RUNNING, + emptySet(), Data.EMPTY, - emptyList(), Data.EMPTY, 1, 1 @@ -603,8 +603,8 @@ class DashboardNotificationsViewModelTest { WorkInfo( workerId, WorkInfo.State.SUCCEEDED, + emptySet(), Data.EMPTY, - emptyList(), Data.EMPTY, 1, 1 @@ -631,8 +631,8 @@ class DashboardNotificationsViewModelTest { WorkInfo( workerId, WorkInfo.State.RUNNING, + emptySet(), Data.EMPTY, - emptyList(), Data.EMPTY, 1, 1 @@ -666,8 +666,8 @@ class DashboardNotificationsViewModelTest { WorkInfo( workerId, WorkInfo.State.SUCCEEDED, + emptySet(), Data.EMPTY, - emptyList(), Data.EMPTY, 1, 1 @@ -707,8 +707,8 @@ class DashboardNotificationsViewModelTest { WorkInfo( workerId, WorkInfo.State.SUCCEEDED, + emptySet(), Data.EMPTY, - emptyList(), Data.EMPTY, 1, 1 diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelperTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelperTest.kt index d84a44897d..f4bd92e64e 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelperTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/offline/sync/OfflineSyncHelperTest.kt @@ -262,7 +262,7 @@ class OfflineSyncHelperTest { every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture( listOf( - WorkInfo(originalRequest.id, WorkInfo.State.ENQUEUED, Data.EMPTY, emptyList(), Data.EMPTY, 1, 1) + WorkInfo(originalRequest.id, WorkInfo.State.ENQUEUED, emptySet(), Data.EMPTY, Data.EMPTY, 1, 1) ) ) @@ -281,7 +281,7 @@ class OfflineSyncHelperTest { every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture(emptyList()) every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( listOf( - WorkInfo(uuid, WorkInfo.State.RUNNING, Data.EMPTY, emptyList(), Data.EMPTY, 1, 1) + WorkInfo(uuid, WorkInfo.State.RUNNING, emptySet(), Data.EMPTY, Data.EMPTY, 1, 1) ) ) @@ -297,7 +297,7 @@ class OfflineSyncHelperTest { val uuid = UUID.randomUUID() every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture( listOf( - WorkInfo(uuid, WorkInfo.State.RUNNING, Data.EMPTY, emptyList(), Data.EMPTY, 1, 1) + WorkInfo(uuid, WorkInfo.State.RUNNING, emptySet(), Data.EMPTY, Data.EMPTY, 1, 1) ) ) every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( @@ -350,7 +350,7 @@ class OfflineSyncHelperTest { val uuid = UUID.randomUUID() every { workManager.getWorkInfosForUniqueWork(any()) } returns Futures.immediateFuture( listOf( - WorkInfo(uuid, WorkInfo.State.RUNNING, Data.EMPTY, emptyList(), Data.EMPTY, 1, 1) + WorkInfo(uuid, WorkInfo.State.RUNNING, emptySet(), Data.EMPTY, Data.EMPTY, 1, 1) ) ) every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( @@ -377,7 +377,7 @@ class OfflineSyncHelperTest { ) every { workManager.getWorkInfosByTag(OfflineSyncWorker.ONE_TIME_TAG) } returns Futures.immediateFuture( listOf( - WorkInfo(uuid, WorkInfo.State.RUNNING, Data.EMPTY, emptyList(), Data.EMPTY, 1, 1) + WorkInfo(uuid, WorkInfo.State.RUNNING, emptySet(), Data.EMPTY, Data.EMPTY, 1, 1) ) ) diff --git a/libs/pandautils/src/test/java/com/instructure/pandautils/features/shareextension/progress/ShareExtensionProgressViewModelTest.kt b/libs/pandautils/src/test/java/com/instructure/pandautils/features/shareextension/progress/ShareExtensionProgressViewModelTest.kt index 6b1eb762ff..77a3757aca 100644 --- a/libs/pandautils/src/test/java/com/instructure/pandautils/features/shareextension/progress/ShareExtensionProgressViewModelTest.kt +++ b/libs/pandautils/src/test/java/com/instructure/pandautils/features/shareextension/progress/ShareExtensionProgressViewModelTest.kt @@ -71,7 +71,7 @@ class ShareExtensionProgressViewModelTest { @Test fun `Show success dialog after uploading`() { viewModel.setUUID(uuid) - mockLiveData.postValue(WorkInfo(uuid, WorkInfo.State.SUCCEEDED, Data.EMPTY, emptyList(), Data.EMPTY, 1, 1)) + mockLiveData.postValue(WorkInfo(uuid, WorkInfo.State.SUCCEEDED, emptySet(), Data.EMPTY, Data.EMPTY, 1, 1)) viewModel.events.observe(lifecycleOwner) {} assertEquals(ShareExtensionProgressAction.ShowSuccessDialog(FileUploadType.USER), viewModel.events.value?.getContentIfNotHandled()) @@ -86,7 +86,7 @@ class ShareExtensionProgressViewModelTest { .putStringArray(FileUploadWorker.PROGRESS_DATA_FILES_TO_UPLOAD, emptyArray()) .build() - mockLiveData.postValue(WorkInfo(uuid, WorkInfo.State.FAILED, outputData, emptyList(), Data.EMPTY, 1, 1)) + mockLiveData.postValue(WorkInfo(uuid, WorkInfo.State.FAILED, emptySet(), outputData, Data.EMPTY, 1, 1)) assertEquals("Error", viewModel.data.value?.subtitle) } @@ -124,8 +124,8 @@ class ShareExtensionProgressViewModelTest { WorkInfo( uuid, WorkInfo.State.RUNNING, + emptySet(), Data.EMPTY, - emptyList(), progressData, 1, 1 @@ -156,8 +156,8 @@ class ShareExtensionProgressViewModelTest { WorkInfo( uuid, WorkInfo.State.RUNNING, + emptySet(), Data.EMPTY, - emptyList(), progressData, 1, 1 @@ -213,8 +213,8 @@ class ShareExtensionProgressViewModelTest { WorkInfo( uuid, WorkInfo.State.RUNNING, + emptySet(), Data.EMPTY, - emptyList(), progressData.build(), 1, 1 @@ -248,8 +248,8 @@ class ShareExtensionProgressViewModelTest { WorkInfo( uuid, WorkInfo.State.RUNNING, + emptySet(), Data.EMPTY, - emptyList(), progressData.build(), 1, 1 @@ -290,8 +290,8 @@ class ShareExtensionProgressViewModelTest { WorkInfo( uuid, WorkInfo.State.FAILED, + emptySet(), progressData, - emptyList(), Data.EMPTY, 1, 1 @@ -342,8 +342,8 @@ class ShareExtensionProgressViewModelTest { WorkInfo( uuid, WorkInfo.State.FAILED, + emptySet(), failedOutputData, - emptyList(), Data.EMPTY, 1, 1 @@ -379,8 +379,8 @@ class ShareExtensionProgressViewModelTest { WorkInfo( uuid, WorkInfo.State.RUNNING, + emptySet(), Data.EMPTY, - emptyList(), successProgressData, 1, 1 @@ -395,8 +395,8 @@ class ShareExtensionProgressViewModelTest { WorkInfo( uuid, WorkInfo.State.SUCCEEDED, + emptySet(), Data.EMPTY, - emptyList(), successProgressData, 1, 1 From 6c1fce5feca02f97ee2791a30d999709285c5202 Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Fri, 8 Dec 2023 12:31:02 +0100 Subject: [PATCH 28/49] [MBL-17237][Teacher] SpeedGraderCommentHolder crash refs: MBL-17237 affects: Teacher, Student release note: none --- .../mobius/common/ui/MobiusFragment.kt | 27 ++++++++++++------- .../holders/SpeedGraderCommentHolder.kt | 18 +++++++++---- .../mobius/common/ui/MobiusFragment.kt | 26 +++++++++++------- .../pandautils/utils/ActivityExtensions.kt | 10 ++++++- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/apps/student/src/main/java/com/instructure/student/mobius/common/ui/MobiusFragment.kt b/apps/student/src/main/java/com/instructure/student/mobius/common/ui/MobiusFragment.kt index c1e5000be0..bd4b8b96b5 100644 --- a/apps/student/src/main/java/com/instructure/student/mobius/common/ui/MobiusFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/mobius/common/ui/MobiusFragment.kt @@ -18,7 +18,6 @@ package com.instructure.student.mobius.common.ui import android.app.Activity import android.content.Context -import android.content.ContextWrapper import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -29,10 +28,24 @@ import com.google.firebase.crashlytics.FirebaseCrashlytics import com.instructure.canvasapi2.utils.Analytics import com.instructure.interactions.FragmentInteractions import com.instructure.interactions.Navigation +import com.instructure.pandautils.utils.getFragmentActivity import com.instructure.student.BuildConfig import com.instructure.student.fragment.ParentFragment -import com.instructure.student.mobius.common.* -import com.spotify.mobius.* +import com.instructure.student.mobius.common.ConsumerQueueWrapper +import com.instructure.student.mobius.common.CoroutineConnection +import com.instructure.student.mobius.common.GlobalEventMapper +import com.instructure.student.mobius.common.GlobalEventSource +import com.instructure.student.mobius.common.LateInit +import com.instructure.student.mobius.common.MobiusExceptionLogger +import com.instructure.student.mobius.common.contraMap +import com.spotify.mobius.Connectable +import com.spotify.mobius.Connection +import com.spotify.mobius.EventSource +import com.spotify.mobius.First +import com.spotify.mobius.Init +import com.spotify.mobius.Mobius +import com.spotify.mobius.MobiusLoop +import com.spotify.mobius.Update import com.spotify.mobius.android.MobiusAndroid import com.spotify.mobius.android.runners.MainThreadWorkRunner import com.spotify.mobius.functions.Consumer @@ -176,13 +189,7 @@ abstract class MobiusView(inflater: Lay get() = parent.context protected val activity: Activity - get() = getActivity(context) - - private fun getActivity(context: Context): Activity { - if (context is Activity) return context - if (context is ContextWrapper) return getActivity(context.baseContext) - else throw IllegalStateException("Not activity context") - } + get() = context.getFragmentActivity() abstract fun onConnect(output: Consumer) diff --git a/apps/teacher/src/main/java/com/instructure/teacher/holders/SpeedGraderCommentHolder.kt b/apps/teacher/src/main/java/com/instructure/teacher/holders/SpeedGraderCommentHolder.kt index f42843bd3a..46bff66066 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/holders/SpeedGraderCommentHolder.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/holders/SpeedGraderCommentHolder.kt @@ -17,13 +17,17 @@ package com.instructure.teacher.holders import android.widget.ImageView -import androidx.fragment.app.FragmentActivity import androidx.recyclerview.widget.RecyclerView -import com.instructure.canvasapi2.models.* +import com.instructure.canvasapi2.models.Assignee +import com.instructure.canvasapi2.models.Attachment +import com.instructure.canvasapi2.models.GroupAssignee +import com.instructure.canvasapi2.models.StudentAssignee +import com.instructure.canvasapi2.models.User import com.instructure.canvasapi2.models.postmodels.CommentSendStatus import com.instructure.canvasapi2.utils.Pronouns import com.instructure.canvasapi2.utils.isValid import com.instructure.interactions.router.Route +import com.instructure.pandautils.utils.getFragmentActivity import com.instructure.pandautils.utils.onClick import com.instructure.pandautils.utils.setGone import com.instructure.pandautils.utils.setVisible @@ -41,7 +45,11 @@ import com.instructure.teacher.utils.getColorCompat import com.instructure.teacher.utils.getSubmissionFormattedDate import com.instructure.teacher.utils.iconRes import com.instructure.teacher.utils.setAnonymousAvatar -import com.instructure.teacher.view.* +import com.instructure.teacher.view.CommentAttachmentsView +import com.instructure.teacher.view.CommentDirection +import com.instructure.teacher.view.CommentMediaAttachmentView +import com.instructure.teacher.view.CommentSubmissionView +import com.instructure.teacher.view.CommentView class SpeedGraderCommentHolder(private val binding: AdapterSubmissionCommentBinding) : RecyclerView.ViewHolder(binding.root) { fun bind( @@ -83,7 +91,7 @@ class SpeedGraderCommentHolder(private val binding: AdapterSubmissionCommentBind avatarView.setupAvatarA11y(comment.authorName) avatarView.onClick { val bundle = StudentContextFragment.makeBundle(comment.authorId, courseId) - RouteMatcher.route(context as FragmentActivity, Route(StudentContextFragment::class.java, null, bundle)) + RouteMatcher.route(context.getFragmentActivity(), Route(StudentContextFragment::class.java, null, bundle)) } } Triple( @@ -143,7 +151,7 @@ class SpeedGraderCommentHolder(private val binding: AdapterSubmissionCommentBind avatarView.setupAvatarA11y(assignee.name) avatarView.onClick { val bundle = StudentContextFragment.makeBundle(assignee.id, courseId) - RouteMatcher.route(context as FragmentActivity, Route(StudentContextFragment::class.java, null, bundle)) + RouteMatcher.route(context.getFragmentActivity(), Route(StudentContextFragment::class.java, null, bundle)) } Triple(null, Pronouns.span(assignee.name, assignee.pronouns), assignee.student.avatarUrl) } diff --git a/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/ui/MobiusFragment.kt b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/ui/MobiusFragment.kt index d6267030a5..6149b5b7ee 100644 --- a/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/ui/MobiusFragment.kt +++ b/apps/teacher/src/main/java/com/instructure/teacher/mobius/common/ui/MobiusFragment.kt @@ -18,15 +18,27 @@ package com.instructure.teacher.mobius.common.ui import android.app.Activity import android.content.Context -import android.content.ContextWrapper import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding -import com.instructure.teacher.mobius.common.* -import com.spotify.mobius.* +import com.instructure.pandautils.utils.getFragmentActivity +import com.instructure.teacher.mobius.common.ConsumerQueueWrapper +import com.instructure.teacher.mobius.common.CoroutineConnection +import com.instructure.teacher.mobius.common.GlobalEventMapper +import com.instructure.teacher.mobius.common.GlobalEventSource +import com.instructure.teacher.mobius.common.LateInit +import com.instructure.teacher.mobius.common.contraMap +import com.spotify.mobius.Connectable +import com.spotify.mobius.Connection +import com.spotify.mobius.EventSource +import com.spotify.mobius.First +import com.spotify.mobius.Init +import com.spotify.mobius.Mobius +import com.spotify.mobius.MobiusLoop +import com.spotify.mobius.Update import com.spotify.mobius.android.MobiusAndroid import com.spotify.mobius.android.runners.MainThreadWorkRunner import com.spotify.mobius.functions.Consumer @@ -154,13 +166,7 @@ abstract class MobiusView( get() = parent.context protected val activity: Activity - get() = getActivity(context) - - private fun getActivity(context: Context): Activity { - if (context is Activity) return context - if (context is ContextWrapper) return getActivity(context.baseContext) - else throw IllegalStateException("Not activity context") - } + get() = context.getFragmentActivity() abstract fun onConnect(output: Consumer) diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ActivityExtensions.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ActivityExtensions.kt index 37f7385d6f..31e9928ca0 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ActivityExtensions.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/utils/ActivityExtensions.kt @@ -16,6 +16,8 @@ */ package com.instructure.pandautils.utils +import android.content.Context +import android.content.ContextWrapper import androidx.appcompat.app.AlertDialog import androidx.fragment.app.FragmentActivity import com.instructure.canvasapi2.utils.APIHelper @@ -32,4 +34,10 @@ fun FragmentActivity.withRequireNetwork(block: () -> Unit) { .setPositiveButton(android.R.string.ok, { dialog, _ -> dialog.dismiss() }) .showThemed() } -} \ No newline at end of file +} + +fun Context.getFragmentActivity(): FragmentActivity { + if (this is FragmentActivity) return this + if (this is ContextWrapper) return this.baseContext.getFragmentActivity() + else throw IllegalStateException("Not FragmentActivity context") +} From cb4fceac92ab532db8a1ee400af2b86b8fbf7ff2 Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Fri, 8 Dec 2023 12:57:34 +0100 Subject: [PATCH 29/49] [MBL-14485][Teacher] Module items have no previous or next buttons refs: MBL-14485 affects: Teacher release note: Added previous and next buttons to module items. * teacher module progression * Fixed routing * Added file details fragment * Edit file details update fix * fixed caching * Tablet toolbar changes * Chevron changes * fixed tests * fixed some comments * PR findings * Test fix * Test fix * Test fix * Binding issue fix * Test fix * Test fix * Module item remove issue fix * Test fix --- .../CourseModuleProgressionFragment.kt | 4 +- .../res/layout/course_module_progression.xml | 18 +- .../teacher/di/FileDetailsModule.kt | 44 +++ .../teacher/di/ModuleProgressionModule.kt | 34 +++ .../discussion/DiscussionsDetailsFragment.kt | 9 +- .../files/details/FileDetailsFragment.kt | 130 ++++++++ .../files/details/FileDetailsRepository.kt | 44 +++ .../files/details/FileDetailsViewData.kt | 60 ++++ .../files/details/FileDetailsViewModel.kt | 144 +++++++++ .../modules/list/ModuleListEffectHandler.kt | 46 +-- .../features/modules/list/ModuleListModels.kt | 4 - .../features/modules/list/ModuleListUpdate.kt | 9 +- .../modules/list/ui/ModuleListView.kt | 73 +---- .../modules/progression/ModuleItemAsset.kt | 37 +++ .../progression/ModuleProgressionAdapter.kt | 31 ++ .../progression/ModuleProgressionFragment.kt | 194 ++++++++++++ .../ModuleProgressionRepository.kt | 59 ++++ .../progression/ModuleProgressionViewData.kt | 40 +++ .../progression/ModuleProgressionViewModel.kt | 117 ++++++++ .../commentlibrary/CommentLibraryViewModel.kt | 3 +- .../teacher/fragments/AddMessageFragment.kt | 40 ++- .../fragments/AssignmentDetailsFragment.kt | 34 ++- .../fragments/CourseBrowserEmptyFragment.kt | 14 +- .../fragments/CourseSettingsFragment.kt | 19 +- .../fragments/CreateDiscussionFragment.kt | 50 +++- .../CreateOrEditAnnouncementFragment.kt | 37 ++- .../CreateOrEditPageDetailsFragment.kt | 34 ++- .../fragments/DiscussionsReplyFragment.kt | 20 +- .../fragments/DiscussionsUpdateFragment.kt | 23 +- .../fragments/EditFileFolderFragment.kt | 39 ++- .../fragments/EditQuizDetailsFragment.kt | 36 ++- .../fragments/InternalWebViewFragment.kt | 66 ++++- .../teacher/fragments/PageDetailsFragment.kt | 27 +- .../teacher/fragments/ProfileEditFragment.kt | 27 +- .../teacher/fragments/QuizDetailsFragment.kt | 42 ++- .../teacher/fragments/SettingsFragment.kt | 22 +- .../fragments/SpeedGraderGradeFragment.kt | 30 +- .../teacher/fragments/ViewHtmlFragment.kt | 25 +- .../teacher/fragments/ViewImageFragment.kt | 31 +- .../teacher/fragments/ViewMediaFragment.kt | 78 ++++- .../teacher/fragments/ViewPdfFragment.kt | 32 +- .../fragments/ViewUnsupportedFileFragment.kt | 36 ++- .../teacher/router/RouteMatcher.kt | 62 +++- .../teacher/router/RouteResolver.kt | 3 + .../main/res/layout/fragment_file_details.xml | 30 ++ .../res/layout/fragment_internal_webview.xml | 1 + .../layout/fragment_module_progression.xml | 89 ++++++ .../layout/fragment_speed_grader_media.xml | 16 +- .../details/FileDetailsRepositoryTest.kt | 90 ++++++ .../files/details/FileDetailsViewModelTest.kt | 277 ++++++++++++++++++ .../{ => list}/ModuleListEffectHandlerTest.kt | 62 ++-- .../{ => list}/ModuleListPresenterTest.kt | 2 +- .../{ => list}/ModuleListUpdateTest.kt | 36 +-- .../ModuleProgressionRepositoryTest.kt | 119 ++++++++ .../ModuleProgressionViewModelTest.kt | 225 ++++++++++++++ .../canvasapi2/apis/FileFolderAPI.kt | 3 + .../instructure/canvasapi2/apis/ModuleAPI.kt | 3 + .../src/main/res/drawable/ic_chevron_left.xml | 11 +- .../main/res/drawable/ic_chevron_right.xml | 11 +- .../pandautils/binding/BindingAdapters.kt | 6 + .../fragments/BasePresenterFragment.kt | 12 +- .../instructure/pandautils/mvvm/ViewState.kt | 4 + 62 files changed, 2539 insertions(+), 385 deletions(-) create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/di/FileDetailsModule.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/di/ModuleProgressionModule.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/files/details/FileDetailsFragment.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/files/details/FileDetailsRepository.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/files/details/FileDetailsViewData.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/files/details/FileDetailsViewModel.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/progression/ModuleItemAsset.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/progression/ModuleProgressionAdapter.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/progression/ModuleProgressionFragment.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/progression/ModuleProgressionRepository.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/progression/ModuleProgressionViewData.kt create mode 100644 apps/teacher/src/main/java/com/instructure/teacher/features/modules/progression/ModuleProgressionViewModel.kt create mode 100644 apps/teacher/src/main/res/layout/fragment_file_details.xml create mode 100644 apps/teacher/src/main/res/layout/fragment_module_progression.xml create mode 100644 apps/teacher/src/test/java/com/instructure/teacher/features/files/details/FileDetailsRepositoryTest.kt create mode 100644 apps/teacher/src/test/java/com/instructure/teacher/features/files/details/FileDetailsViewModelTest.kt rename apps/teacher/src/test/java/com/instructure/teacher/unit/modules/{ => list}/ModuleListEffectHandlerTest.kt (86%) rename apps/teacher/src/test/java/com/instructure/teacher/unit/modules/{ => list}/ModuleListPresenterTest.kt (99%) rename apps/teacher/src/test/java/com/instructure/teacher/unit/modules/{ => list}/ModuleListUpdateTest.kt (95%) create mode 100644 apps/teacher/src/test/java/com/instructure/teacher/unit/modules/progression/ModuleProgressionRepositoryTest.kt create mode 100644 apps/teacher/src/test/java/com/instructure/teacher/unit/modules/progression/ModuleProgressionViewModelTest.kt diff --git a/apps/student/src/main/java/com/instructure/student/features/modules/progression/CourseModuleProgressionFragment.kt b/apps/student/src/main/java/com/instructure/student/features/modules/progression/CourseModuleProgressionFragment.kt index 9bf146ebba..7338f5d9a9 100644 --- a/apps/student/src/main/java/com/instructure/student/features/modules/progression/CourseModuleProgressionFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/modules/progression/CourseModuleProgressionFragment.kt @@ -127,8 +127,8 @@ class CourseModuleProgressionFragment : ParentFragment(), Bookmarkable { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.prevItem.background = ColorKeeper.getColoredDrawable(requireActivity(), R.drawable.ic_chevron_left, canvasContext.textAndIconColor) - binding.nextItem.background = ColorKeeper.getColoredDrawable(requireActivity(), R.drawable.ic_chevron_right, canvasContext.textAndIconColor) + binding.prevItem.setImageDrawable(ColorKeeper.getColoredDrawable(requireActivity(), R.drawable.ic_chevron_left, canvasContext.textAndIconColor)) + binding.nextItem.setImageDrawable(ColorKeeper.getColoredDrawable(requireActivity(), R.drawable.ic_chevron_right, canvasContext.textAndIconColor)) } override fun onActivityCreated(savedInstanceState: Bundle?) { diff --git a/apps/student/src/main/res/layout/course_module_progression.xml b/apps/student/src/main/res/layout/course_module_progression.xml index 4918489d1c..59ac4aa00f 100644 --- a/apps/student/src/main/res/layout/course_module_progression.xml +++ b/apps/student/src/main/res/layout/course_module_progression.xml @@ -147,23 +147,25 @@ -