From ce38e00b8b2f7d246f035f0c4ce5f8fe3bb69bfd Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Mon, 30 Oct 2023 12:41:24 +0100 Subject: [PATCH 01/19] [MBL-17150][Student] Offline file search (#2220) refs: MBL-17150 affects: Student release note: none * Offline file search. * File opening. * Tests. --- .../student/di/feature/FileSearchModule.kt | 64 ++++++++++++++ .../files/details/FileDetailsFragment.kt | 12 +-- .../features/files/list/FileListFragment.kt | 12 +-- .../files/search/FileSearchAdapter.kt | 7 +- .../files/search/FileSearchDataSource.kt | 27 ++++++ .../files/search/FileSearchFragment.kt | 11 ++- .../files/search/FileSearchLocalDataSource.kt | 37 ++++++++ .../search/FileSearchNetworkDataSource.kt | 34 +++++++ .../files/search/FileSearchRepository.kt | 44 ++++++++++ .../student/fragment/ParentFragment.kt | 22 ++--- .../search/FileSearchLocalDataSourceTest.kt | 88 +++++++++++++++++++ .../search/FileSearchNetworkDataSourceTest.kt | 59 +++++++++++++ .../file/search/FileSearchRepositoryTest.kt | 68 ++++++++++++++ .../canvasapi2/apis/FileFolderAPI.kt | 3 + .../room/offline/daos/FileFolderDaoTest.kt | 29 ++++++ .../room/offline/daos/FileFolderDao.kt | 4 + 16 files changed, 479 insertions(+), 42 deletions(-) create mode 100644 apps/student/src/main/java/com/instructure/student/di/feature/FileSearchModule.kt create mode 100644 apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchDataSource.kt create mode 100644 apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchLocalDataSource.kt create mode 100644 apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchNetworkDataSource.kt create mode 100644 apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchRepository.kt create mode 100644 apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchLocalDataSourceTest.kt create mode 100644 apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchNetworkDataSourceTest.kt create mode 100644 apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchRepositoryTest.kt diff --git a/apps/student/src/main/java/com/instructure/student/di/feature/FileSearchModule.kt b/apps/student/src/main/java/com/instructure/student/di/feature/FileSearchModule.kt new file mode 100644 index 0000000000..040dd05b97 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/di/feature/FileSearchModule.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.student.di.feature + +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.pandautils.room.offline.daos.FileFolderDao +import com.instructure.pandautils.room.offline.daos.LocalFileDao +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import com.instructure.student.features.files.search.FileSearchLocalDataSource +import com.instructure.student.features.files.search.FileSearchNetworkDataSource +import com.instructure.student.features.files.search.FileSearchRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.FragmentComponent + +@Module +@InstallIn(FragmentComponent::class) +class FileSearchModule { + + @Provides + fun provideFileSearchLocalDataSource( + fileFolderDao: FileFolderDao, + localFileDao: LocalFileDao + ): FileSearchLocalDataSource { + return FileSearchLocalDataSource(fileFolderDao, localFileDao) + } + + @Provides + fun provideFileSearchNetworkDataSource( + fileFolderApi: FileFolderAPI.FilesFoldersInterface + ): FileSearchNetworkDataSource { + return FileSearchNetworkDataSource(fileFolderApi) + } + + @Provides + fun provideFileSearchRepository( + fileSearchLocalDataSource: FileSearchLocalDataSource, + fileSearchNetworkDataSource: FileSearchNetworkDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider + ): FileSearchRepository { + return FileSearchRepository( + fileSearchLocalDataSource, + fileSearchNetworkDataSource, + networkStateProvider, + featureFlagProvider) + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/files/details/FileDetailsFragment.kt b/apps/student/src/main/java/com/instructure/student/features/files/details/FileDetailsFragment.kt index 6908031cde..4c4bfbbfe4 100644 --- a/apps/student/src/main/java/com/instructure/student/features/files/details/FileDetailsFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/files/details/FileDetailsFragment.kt @@ -119,17 +119,7 @@ class FileDetailsFragment : ParentFragment() { private fun setupClickListeners() { binding.openButton.setOnClickListener { file?.let { fileFolder -> - when { - fileFolder.isLocalFile -> { - openLocalMedia( - fileFolder.contentType, - fileFolder.url, - fileFolder.displayName, - canvasContext - ) - } - else -> openMedia(fileFolder.contentType, fileFolder.url, fileFolder.displayName, canvasContext) - } + openMedia(fileFolder.contentType, fileFolder.url, fileFolder.displayName, canvasContext, fileFolder.isLocalFile) markAsRead() } } diff --git a/apps/student/src/main/java/com/instructure/student/features/files/list/FileListFragment.kt b/apps/student/src/main/java/com/instructure/student/features/files/list/FileListFragment.kt index ace8cd4d9f..553bb47c90 100644 --- a/apps/student/src/main/java/com/instructure/student/features/files/list/FileListFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/files/list/FileListFragment.kt @@ -222,13 +222,9 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent recordFilePreviewEvent(item) openHtmlUrl(item) } - item.isLocalFile -> { - recordFilePreviewEvent(item) - openLocalMedia(item.contentType, item.url, item.displayName, canvasContext) - } else -> { recordFilePreviewEvent(item) - openMedia(item.contentType, item.url, item.displayName, canvasContext) + openMedia(item.contentType, item.url, item.displayName, canvasContext, localFile = item.isLocalFile) } } } @@ -378,11 +374,7 @@ class FileListFragment : ParentFragment(), Bookmarkable, FileUploadDialogParent when (menuItem.itemId) { R.id.openAlternate -> { recordFilePreviewEvent(item) - if (fileListRepository.isOnline()) { - openMedia(item.contentType, item.url, item.displayName, true, canvasContext) - } else { - openLocalMedia(item.contentType, item.url, item.displayName, canvasContext, true) - } + openMedia(item.contentType, item.url, item.displayName, canvasContext, localFile = !fileListRepository.isOnline(), useOutsideApps = true) } R.id.download -> downloadItem(item) R.id.rename -> renameItem(item) diff --git a/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchAdapter.kt b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchAdapter.kt index d0b4a54768..35cc826a59 100644 --- a/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchAdapter.kt +++ b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchAdapter.kt @@ -26,6 +26,8 @@ import com.instructure.canvasapi2.utils.weave.WeaveJob import com.instructure.canvasapi2.utils.weave.awaitApi import com.instructure.canvasapi2.utils.weave.catch import com.instructure.canvasapi2.utils.weave.tryWeave +import com.instructure.pandautils.room.offline.daos.FileFolderDao +import com.instructure.pandautils.utils.NetworkStateProvider import com.instructure.pandautils.utils.textAndIconColor import com.instructure.student.adapter.BaseListRecyclerAdapter import com.instructure.student.features.files.list.FileFolderCallback @@ -34,6 +36,7 @@ import com.instructure.student.holders.FileViewHolder class FileSearchAdapter( context: Context, private val canvasContext: CanvasContext, + private val fileSearchRepository: FileSearchRepository, private val viewCallback: FileSearchView ) : BaseListRecyclerAdapter(context, FileFolder::class.java) { @@ -83,9 +86,7 @@ class FileSearchAdapter( private fun performSearch() { apiCall = tryWeave { viewCallback.onRefreshStarted() - val files = awaitApi> { - FileFolderManager.searchFiles(searchQuery, canvasContext, true, it) - } + val files = fileSearchRepository.searchFiles(canvasContext, searchQuery) clear() addAll(files) viewCallback.onRefreshFinished() diff --git a/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchDataSource.kt b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchDataSource.kt new file mode 100644 index 0000000000..263fba8b2c --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchDataSource.kt @@ -0,0 +1,27 @@ +/* + * 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.features.files.search + +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.FileFolder + +interface FileSearchDataSource { + + suspend fun searchFiles(canvasContext: CanvasContext, searchQuery: String): List +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchFragment.kt b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchFragment.kt index 39342f2b74..3cf60d7cf3 100644 --- a/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchFragment.kt +++ b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchFragment.kt @@ -31,24 +31,31 @@ import com.instructure.interactions.router.Route import com.instructure.pandautils.analytics.SCREEN_VIEW_FILE_SEARCH import com.instructure.pandautils.analytics.ScreenView import com.instructure.pandautils.binding.viewBinding +import com.instructure.pandautils.room.offline.daos.FileFolderDao import com.instructure.pandautils.utils.* import com.instructure.student.R import com.instructure.student.databinding.FragmentFileSearchBinding import com.instructure.student.fragment.ParentFragment +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import com.instructure.pandautils.utils.ColorUtils as PandaColorUtils @ScreenView(SCREEN_VIEW_FILE_SEARCH) +@AndroidEntryPoint class FileSearchFragment : ParentFragment(), FileSearchView { private var canvasContext by ParcelableArg(key = Const.CANVAS_CONTEXT) private val binding by viewBinding(FragmentFileSearchBinding::bind) + @Inject + lateinit var fileSearchRepository: FileSearchRepository + private fun makePageViewUrl() = if (canvasContext.type == CanvasContext.Type.USER) "${ApiPrefs.fullDomain}/files" else "${ApiPrefs.fullDomain}/${canvasContext.contextId.replace("_", "s/")}/files" - private val searchAdapter by lazy { FileSearchAdapter(requireContext(), canvasContext, this) } + private val searchAdapter by lazy { FileSearchAdapter(requireContext(), canvasContext, fileSearchRepository, this) } override fun title() = "" override fun applyTheme() = Unit @@ -137,7 +144,7 @@ class FileSearchFragment : ParentFragment(), FileSearchView { override fun fileClicked(file: FileFolder) { PageViewUtils.saveSingleEvent("FilePreview", "${makePageViewUrl()}?preview=${file.id}") - openMedia(file.contentType, file.url, file.displayName, canvasContext) + openMedia(file.contentType, file.url, file.displayName, canvasContext, file.isLocalFile) } override fun onMediaLoadingStarted() { diff --git a/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchLocalDataSource.kt b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchLocalDataSource.kt new file mode 100644 index 0000000000..cb054d231e --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchLocalDataSource.kt @@ -0,0 +1,37 @@ +/* + * 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.features.files.search + +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.FileFolder +import com.instructure.pandautils.room.offline.daos.FileFolderDao +import com.instructure.pandautils.room.offline.daos.LocalFileDao + +class FileSearchLocalDataSource( + private val fileFolderDao: FileFolderDao, + private val localFileDao: LocalFileDao +) : FileSearchDataSource { + override suspend fun searchFiles(canvasContext: CanvasContext, searchQuery: String): List { + val files = fileFolderDao.searchCourseFiles(canvasContext.id, searchQuery).map { it.toApiModel() } + val fileIds = files.map { it.id } + val localFileMap = localFileDao.findByIds(fileIds).associate { it.id to it.path } + + return files.map { it.copy(url = localFileMap[it.id], thumbnailUrl = null) } + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchNetworkDataSource.kt b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchNetworkDataSource.kt new file mode 100644 index 0000000000..2f8a42cb24 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchNetworkDataSource.kt @@ -0,0 +1,34 @@ +/* + * 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.features.files.search + +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.builders.RestParams +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.FileFolder +import com.instructure.canvasapi2.utils.depaginate + +class FileSearchNetworkDataSource(private val fileFolderApi: FileFolderAPI.FilesFoldersInterface) : FileSearchDataSource { + override suspend fun searchFiles(canvasContext: CanvasContext, searchQuery: String): List { + val params = RestParams(isForceReadFromNetwork = true, usePerPageQueryParam = true) + return fileFolderApi.searchFiles(canvasContext.toAPIString().substring(1), searchQuery, params) + .depaginate { nextUrl -> fileFolderApi.getNextPageFileFoldersList(nextUrl, params) } + .dataOrNull.orEmpty() + } +} \ No newline at end of file diff --git a/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchRepository.kt b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchRepository.kt new file mode 100644 index 0000000000..b0a1fec013 --- /dev/null +++ b/apps/student/src/main/java/com/instructure/student/features/files/search/FileSearchRepository.kt @@ -0,0 +1,44 @@ +/* + * 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.features.files.search + +import com.instructure.canvasapi2.models.CanvasContext +import com.instructure.canvasapi2.models.FileFolder +import com.instructure.pandautils.repository.Repository +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider + +class FileSearchRepository( + fileSearchLocalDataSource: FileSearchLocalDataSource, + fileSearchNetworkDataSource: FileSearchNetworkDataSource, + networkStateProvider: NetworkStateProvider, + featureFlagProvider: FeatureFlagProvider +) : Repository( + fileSearchLocalDataSource, + fileSearchNetworkDataSource, + networkStateProvider, + featureFlagProvider +) { + suspend fun searchFiles( + canvasContext: CanvasContext, + searchQuery: String + ): List { + return dataSource().searchFiles(canvasContext, searchQuery) + } +} \ No newline at end of file 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 2f52aadbb7..4cbdbeb85e 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 @@ -371,18 +371,16 @@ abstract class ParentFragment : DialogFragment(), FragmentInteractions, Navigati return recyclerView } - fun openMedia(mime: String?, url: String?, filename: String?, canvasContext: CanvasContext) { + fun openMedia(mime: String?, url: String?, filename: String?, canvasContext: CanvasContext, localFile: Boolean = false, useOutsideApps: Boolean = false) { val owner = activity ?: return - onMainThread { - openMediaBundle = OpenMediaAsyncTaskLoader.createBundle(canvasContext, mime, url, filename) - LoaderUtils.restartLoaderWithBundle>(LoaderManager.getInstance(owner), openMediaBundle, loaderCallbacks, R.id.openMediaLoaderID) + + openMediaBundle = if (localFile) { + OpenMediaAsyncTaskLoader.createLocalBundle(canvasContext, mime, url, filename, useOutsideApps) + } else { + OpenMediaAsyncTaskLoader.createBundle(canvasContext, mime, url, filename, useOutsideApps) } - } - fun openLocalMedia(mime: String?, path: String?, filename: String?, canvasContext: CanvasContext, useOutsideApps: Boolean = false) { - val owner = activity ?: return onMainThread { - openMediaBundle = OpenMediaAsyncTaskLoader.createLocalBundle(canvasContext, mime, path, filename, useOutsideApps) LoaderUtils.restartLoaderWithBundle>(LoaderManager.getInstance(owner), openMediaBundle, loaderCallbacks, R.id.openMediaLoaderID) } } @@ -395,14 +393,6 @@ abstract class ParentFragment : DialogFragment(), FragmentInteractions, Navigati } } - fun openMedia(mime: String?, url: String?, filename: String?, useOutsideApps: Boolean, canvasContext: CanvasContext) { - val owner = activity ?: return - onMainThread { - openMediaBundle = OpenMediaAsyncTaskLoader.createBundle(canvasContext, mime, url, filename, useOutsideApps) - LoaderUtils.restartLoaderWithBundle>(LoaderManager.getInstance(owner), openMediaBundle, loaderCallbacks, R.id.openMediaLoaderID) - } - } - private fun downloadFileToDownloadDir(url: String): File? { // We should have the file cached locally at this point; We'll just move it to the user's Downloads folder diff --git a/apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchLocalDataSourceTest.kt b/apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchLocalDataSourceTest.kt new file mode 100644 index 0000000000..c9a46e177a --- /dev/null +++ b/apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchLocalDataSourceTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.student.features.file.search + +import android.webkit.URLUtil +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.FileFolder +import com.instructure.pandautils.room.offline.daos.FileFolderDao +import com.instructure.pandautils.room.offline.daos.LocalFileDao +import com.instructure.pandautils.room.offline.entities.FileFolderEntity +import com.instructure.pandautils.room.offline.entities.LocalFileEntity +import com.instructure.student.features.files.search.FileSearchLocalDataSource +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import junit.framework.TestCase +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.util.Date + +@ExperimentalCoroutinesApi +class FileSearchLocalDataSourceTest { + + private val fileFolderDao: FileFolderDao = mockk(relaxed = true) + private val localFileDao: LocalFileDao = mockk(relaxed = true) + + private val fileSearchLocalDataSource = FileSearchLocalDataSource(fileFolderDao, localFileDao) + + @Before + fun setup() { + mockkStatic(URLUtil::class) + every { URLUtil.isNetworkUrl(any()) } returns true + } + + @After + fun teardown() { + unmockkAll() + } + + + @Test + fun `File Search replaces url with path`() = runTest { + val files = listOf( + FileFolder(id = 1, name = "File 1", url = "url_1", thumbnailUrl = "thumbnail_url_1"), + FileFolder(id = 2, name = "File 2", url = "url_2", thumbnailUrl = "thumbnail_url_2"), + FileFolder(id = 3, name = "File 3", url = "url_3", thumbnailUrl = "thumbnail_url_3") + ) + + val localFiles = listOf( + LocalFileEntity(id = 1, courseId = 1, createdDate = Date(), path = "path_1"), + LocalFileEntity(id = 2, courseId = 1, createdDate = Date(), path = "path_2"), + LocalFileEntity(id = 3, courseId = 1, createdDate = Date(), path = "path_3") + ) + + coEvery { localFileDao.findByIds(any()) } returns localFiles + coEvery { fileFolderDao.searchCourseFiles(any(), any()) } returns files.map { FileFolderEntity(it) } + + val expected = files.map { it.copy(url = "path_${it.id}", thumbnailUrl = null) } + val result = fileSearchLocalDataSource.searchFiles(Course(1L), "") + + coVerify { + localFileDao.findByIds(listOf(1, 2, 3)) + fileFolderDao.searchCourseFiles(1L, "") + } + + TestCase.assertEquals(expected, result) + } +} \ No newline at end of file diff --git a/apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchNetworkDataSourceTest.kt b/apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchNetworkDataSourceTest.kt new file mode 100644 index 0000000000..a8e68b0870 --- /dev/null +++ b/apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchNetworkDataSourceTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2023 - present Instructure, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +package com.instructure.student.features.file.search + +import com.instructure.canvasapi2.apis.FileFolderAPI +import com.instructure.canvasapi2.models.Course +import com.instructure.canvasapi2.models.FileFolder +import com.instructure.canvasapi2.utils.DataResult +import com.instructure.student.features.files.search.FileSearchNetworkDataSource +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +@ExperimentalCoroutinesApi +class FileSearchNetworkDataSourceTest { + + private val fileFolderApi: FileFolderAPI.FilesFoldersInterface = mockk(relaxed = true) + + private val fileSearchNetworkDataSource = FileSearchNetworkDataSource(fileFolderApi) + + @Test + fun `searchFiles() calls api and returns data from api`() = runTest { + coEvery { fileFolderApi.searchFiles(any(), any(), any()) } returns DataResult.Success(listOf(FileFolder(id = 1, name = "File"))) + + val result = fileSearchNetworkDataSource.searchFiles(Course(1), "file") + + coVerify { fileFolderApi.searchFiles("courses/1", "file",any()) } + assertEquals(1, result.size) + assertEquals("File", result[0].name) + } + + @Test + fun `searchFiles() returns empty list for failed result`() = runTest { + coEvery { fileFolderApi.searchFiles(any(), any(), any()) } returns DataResult.Fail() + + val result = fileSearchNetworkDataSource.searchFiles(Course(1), "file") + + coVerify { fileFolderApi.searchFiles("courses/1", "file",any()) } + assertEquals(0, result.size) + } +} \ No newline at end of file diff --git a/apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchRepositoryTest.kt b/apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchRepositoryTest.kt new file mode 100644 index 0000000000..a6bed1cf59 --- /dev/null +++ b/apps/student/src/test/java/com/instructure/student/features/file/search/FileSearchRepositoryTest.kt @@ -0,0 +1,68 @@ +/* + * 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.features.file.search + +import com.instructure.pandautils.utils.FeatureFlagProvider +import com.instructure.pandautils.utils.NetworkStateProvider +import com.instructure.student.features.files.search.FileSearchLocalDataSource +import com.instructure.student.features.files.search.FileSearchNetworkDataSource +import com.instructure.student.features.files.search.FileSearchRepository +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@ExperimentalCoroutinesApi +class FileSearchRepositoryTest { + + private val fileSearchLocalDataSource: FileSearchLocalDataSource = mockk(relaxed = true) + private val fileSearchNetworkDataSource: FileSearchNetworkDataSource = mockk(relaxed = true) + private val networkStateProvider: NetworkStateProvider = mockk(relaxed = true) + private val featureFlagProvider: FeatureFlagProvider = mockk(relaxed = true) + + private val fileSearchRepository = FileSearchRepository( + fileSearchLocalDataSource, + fileSearchNetworkDataSource, + networkStateProvider, + featureFlagProvider + ) + + @Before + fun setup() { + coEvery { featureFlagProvider.offlineEnabled() } returns true + coEvery { networkStateProvider.isOnline() } returns true + } + + @Test + fun `use localDataSource when network is offline`() = runTest { + coEvery { networkStateProvider.isOnline() } returns false + + assertTrue(fileSearchRepository.dataSource() is FileSearchLocalDataSource) + } + + @Test + fun `use networkDataSource when network is online`() = runTest { + coEvery { networkStateProvider.isOnline() } returns true + + assertTrue(fileSearchRepository.dataSource() is FileSearchNetworkDataSource) + } +} \ No newline at end of file diff --git a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt index 511e8c4759..0f8beec278 100644 --- a/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt +++ b/libs/canvas-api-2/src/main/java/com/instructure/canvasapi2/apis/FileFolderAPI.kt @@ -89,6 +89,9 @@ object FileFolderAPI { @GET("{canvasContext}/files") fun searchFiles(@Path(value = "canvasContext", encoded = true) contextPath: String, @Query("search_term") query: String): Call> + @GET("{canvasContext}/files") + suspend fun searchFiles(@Path(value = "canvasContext", encoded = true) contextPath: String, @Query("search_term") query: String, @Tag params: RestParams): DataResult> + @DELETE("files/{fileId}") fun deleteFile(@Path("fileId") fileId: Long): Call diff --git a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileFolderDaoTest.kt b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileFolderDaoTest.kt index be60845c7a..b412ce5b54 100644 --- a/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileFolderDaoTest.kt +++ b/libs/pandautils/src/androidTest/java/com/instructure/pandautils/room/offline/daos/FileFolderDaoTest.kt @@ -574,4 +574,33 @@ class FileFolderDaoTest { assertEquals(listOf(files[2]), result) } + + @Test + fun testSearchFiles() = runTest { + val folders = listOf( + FileFolderEntity( + FileFolder( + id = 1L, + contextId = 1L, + contextType = "Course", + name = "folder", + parentFolderId = 0 + ) + ) + ) + val files = listOf( + FileFolderEntity(FileFolder(id = 2L, displayName = "file1", folderId = 1L)), + FileFolderEntity(FileFolder(id = 3L, displayName = "file2", folderId = 1L)), + FileFolderEntity(FileFolder(id = 4L, displayName = "different name", folderId = 1L)), + FileFolderEntity(FileFolder(id = 5L, displayName = "file hidden", folderId = 1L, isHidden = true)), + FileFolderEntity(FileFolder(id = 6L, displayName = "file hidden for user", folderId = 1L, isHiddenForUser = true)), + ) + + fileFolderDao.insertAll(folders) + fileFolderDao.insertAll(files) + + val result = fileFolderDao.searchCourseFiles(1L, "fil") + + assertEquals(files.subList(0, 2), result) + } } \ No newline at end of file diff --git a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/FileFolderDao.kt b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/FileFolderDao.kt index 8950d6ef61..55afa8385d 100644 --- a/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/FileFolderDao.kt +++ b/libs/pandautils/src/main/java/com/instructure/pandautils/room/offline/daos/FileFolderDao.kt @@ -44,6 +44,10 @@ abstract class FileFolderDao { @Query("SELECT * FROM FileFolderEntity WHERE folderId in (SELECT id FROM FileFolderEntity WHERE contextId = :courseId)") abstract suspend fun findAllFilesByCourseId(courseId: Long): List + @Query("SELECT * FROM FileFolderEntity WHERE folderId in (SELECT id FROM FileFolderEntity WHERE contextId = :courseId)" + + " AND displayName LIKE '%' || :searchQuery || '%' AND isHidden = 0 AND isHiddenForUser = 0") + abstract suspend fun searchCourseFiles(courseId: Long, searchQuery: String): List + @Query("SELECT * FROM FileFolderEntity WHERE id = :id") abstract suspend fun findById(id: Long): FileFolderEntity? From 55a2af5941d6ca2c6adbaf10e28caf54997ee5d5 Mon Sep 17 00:00:00 2001 From: Kristof Deak <92309696+kdeakinstructure@users.noreply.github.com> Date: Thu, 2 Nov 2023 12:03:12 +0100 Subject: [PATCH 02/19] [MBL-17117][Student] - Manage Offline Content Page E2E test implementation (#2223) --- .../ui/e2e/offline/DashboardE2EOfflineTest.kt | 2 +- .../offline/ManageOfflineContentE2ETest.kt | 218 ++++++++++++++++++ .../e2e/offline/OfflineSyncProgressE2ETest.kt | 2 +- .../student/ui/pages/CourseGradesPage.kt | 4 + .../student/ui/pages/DashboardPage.kt | 3 +- .../ui/pages/LeftSideNavigationDrawerPage.kt | 1 + .../pages/offline/ManageOfflineContentPage.kt | 88 ++++++- .../canvas/espresso/CustomMatchers.kt | 25 ++ .../espresso/CustomViewAssertions.kt | 28 ++- .../espresso/actions/ForceClick.kt | 50 ++++ .../panda_annotations/TestMetaData.kt | 2 +- 11 files changed, 414 insertions(+), 9 deletions(-) create mode 100644 apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt create mode 100644 automation/espresso/src/main/kotlin/com/instructure/espresso/actions/ForceClick.kt diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/DashboardE2EOfflineTest.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/DashboardE2EOfflineTest.kt index d40df12d12..c8e1e8bcae 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/DashboardE2EOfflineTest.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/DashboardE2EOfflineTest.kt @@ -55,7 +55,7 @@ class DashboardE2EOfflineTest : StudentTest() { dashboardPage.openGlobalManageOfflineContentPage() Log.d(STEP_TAG, "Select the entire '${course1.name}' course for sync. Click on the 'Sync' button.") - manageOfflineContentPage.selectEntireCourseForSync(course1.name) + manageOfflineContentPage.changeItemSelectionState(course1.name) manageOfflineContentPage.clickOnSyncButton() Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") 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 new file mode 100644 index 0000000000..acbc812997 --- /dev/null +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/e2e/offline/ManageOfflineContentE2ETest.kt @@ -0,0 +1,218 @@ +/* + * 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 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.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 +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.After +import org.junit.Test + +@HiltAndroidTest +class ManageOfflineContentE2ETest : StudentTest() { + override fun displaysPageObjects() = Unit + + override fun enableAndConfigureAccessibilityChecks() = Unit + + @OfflineE2E + @Test + @TestMetaData(Priority.MANDATORY, FeatureCategory.OFFLINE_CONTENT, TestCategory.E2E) + fun testManageOfflineContentE2ETest() { + + Log.d(PREPARATION_TAG,"Seeding data.") + val data = seedData(students = 1, teachers = 1, courses = 2, announcements = 1) + 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) + dashboardPage.waitForRender() + + Log.d(STEP_TAG, "Open global 'Manage Offline Content' page via the more menu of the Dashboard Page.") + dashboardPage.clickCourseOverflowMenu(course1.name, "Manage Offline Content") + + Log.d(STEP_TAG, "Assert that if there is nothing selected yet, the 'SELECT ALL' button text will be displayed on the top-left corner of the toolbar.") + manageOfflineContentPage.assertSelectButtonText(selectAll = true) + + Log.d(STEP_TAG, "Assert that the '${course1.name}' course's checkbox state is 'Unchecked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course1.name, MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Select the entire '${course1.name}' course for sync.") + manageOfflineContentPage.changeItemSelectionState(course1.name) + + Log.d(STEP_TAG, "Assert that if there is something selected yet, the 'DESELECT ALL' button text will be displayed on the top-left corner of the toolbar.") + manageOfflineContentPage.assertSelectButtonText(selectAll = false) + + Log.d(STEP_TAG, "Assert that the Storage info details are displayed properly.") + manageOfflineContentPage.assertStorageInfoDetails() + + 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, "Deselect the 'Announcements' and 'Discussions' of the '${course1.name}' course.") + manageOfflineContentPage.changeItemSelectionState("Announcements") + manageOfflineContentPage.changeItemSelectionState("Discussions") + + Log.d(STEP_TAG, "Assert that the '${course1.name}' course's checkbox state is 'Indeterminate'.") + manageOfflineContentPage.assertCheckedStateOfItem(course1.name, MaterialCheckBox.STATE_INDETERMINATE) + + Log.d(STEP_TAG, "Select the entire '${course1.name}' course (again) for sync.") + manageOfflineContentPage.changeItemSelectionState(course1.name) + + Log.d(STEP_TAG, "Assert that the '${course1.name}' course's checkbox state and became 'Checked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course1.name, MaterialCheckBox.STATE_CHECKED) + + Log.d(STEP_TAG, "Assert that the previously unchecked 'Announcements' and 'Discussions' checkboxes are became 'Checked'.") + manageOfflineContentPage.assertCheckedStateOfItem("Announcements", MaterialCheckBox.STATE_CHECKED) + manageOfflineContentPage.assertCheckedStateOfItem("Discussions", MaterialCheckBox.STATE_CHECKED) + + Log.d(STEP_TAG, "Assert that the 'DESELECT ALL' button is still displayed because there are still more than zero item checked.") + manageOfflineContentPage.assertSelectButtonText(selectAll = false) + + Log.d(STEP_TAG, "Deselect '${course1.name}' course's checkbox.") + manageOfflineContentPage.changeItemSelectionState(course1.name) + + Log.d(STEP_TAG, "Assert that the '${course1.name}' course's checkbox state and became 'Unchecked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course1.name, MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Assert that the previously unchecked 'Announcements' and 'Discussions' checkboxes are became 'Unchecked'.") + manageOfflineContentPage.assertCheckedStateOfItem("Announcements", MaterialCheckBox.STATE_UNCHECKED) + manageOfflineContentPage.assertCheckedStateOfItem("Discussions", MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Assert that the 'SELECT ALL' button is displayed because there is no item checked.") + manageOfflineContentPage.assertSelectButtonText(selectAll = true) + + Log.d(STEP_TAG, "Click on 'SELECT ALL' button.") + manageOfflineContentPage.clickOnSelectAllButton() + + Log.d(STEP_TAG, "Assert that the '${course1.name}' course's checkbox state and became 'Checked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course1.name, MaterialCheckBox.STATE_CHECKED) + + Log.d(STEP_TAG, "Assert that the previously unchecked 'Announcements' and 'Discussions' checkboxes are became 'Checked'.") + manageOfflineContentPage.assertCheckedStateOfItem("Announcements", MaterialCheckBox.STATE_CHECKED) + manageOfflineContentPage.assertCheckedStateOfItem("Discussions", MaterialCheckBox.STATE_CHECKED) + + Log.d(STEP_TAG, "Assert that the 'DESELECT ALL' will be displayed after clicking the 'SELECT ALL' button.") + manageOfflineContentPage.assertSelectButtonText(selectAll = false) + + Log.d(STEP_TAG, "Click on 'DESELECT ALL' button.") + manageOfflineContentPage.clickOnDeselectAllButton() + + Log.d(STEP_TAG, "Assert that the '${course1.name}' course's checkbox state that it became 'Unchecked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course1.name, MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Assert that the previously checked 'Announcements' and 'Discussions' checkboxes are became 'Unchecked'.") + manageOfflineContentPage.assertCheckedStateOfItem("Announcements", MaterialCheckBox.STATE_UNCHECKED) + manageOfflineContentPage.assertCheckedStateOfItem("Discussions", MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Assert that the 'SELECT ALL' will be displayed after clicking the 'SELECT ALL' button.") + manageOfflineContentPage.assertSelectButtonText(selectAll = true) + + Log.d(STEP_TAG, "Navigate back to Dashboard Page. Open 'Global' Manage Offline Content page.") + Espresso.pressBack() + dashboardPage.openGlobalManageOfflineContentPage() + + Log.d(STEP_TAG, "Assert that the Storage info details are displayed properly.") + manageOfflineContentPage.assertStorageInfoDetails() + + Log.d(STEP_TAG, "Assert that the tool bar texts are displayed properly, so the subtitle is 'All Courses', because we are on the 'Global' Manage Offline Content page.") + manageOfflineContentPage.assertToolbarTexts("All Courses") + + Log.d(STEP_TAG, "Assert that the '${course1.name}' and '${course2.name}' courses' checkboxes are 'Unchecked' yet.") + manageOfflineContentPage.assertCheckedStateOfItem(course1.name, MaterialCheckBox.STATE_UNCHECKED) + manageOfflineContentPage.assertCheckedStateOfItem(course2.name, MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Expand '${course1.name}' course.") + manageOfflineContentPage.expandCollapseItem(course1.name) + + Log.d(STEP_TAG, "Assert that the 'Announcements' and 'Discussions' items are 'unchecked' (and so all the other tabs of the course) because the course is NOT selected.") + manageOfflineContentPage.assertCheckedStateOfItem("Announcements", MaterialCheckBox.STATE_UNCHECKED) + manageOfflineContentPage.assertCheckedStateOfItem("Discussions", MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Collapse '${course1.name}' course.") + manageOfflineContentPage.expandCollapseItem(course1.name) + + manageOfflineContentPage.waitForItemDisappear("Announcements") + manageOfflineContentPage.waitForItemDisappear("Discussions") + + Thread.sleep(1000) //need to wait 1 second here because sometimes expand/collapse happens too fast + Log.d(STEP_TAG, "Expand '${course2.name}' course.") + manageOfflineContentPage.expandCollapseItem(course2.name) + + Log.d(STEP_TAG, "Assert that the 'Grades' and 'Discussions' items are 'Unchecked' (and so all the other tabs of the course) because the course has not selected.") + manageOfflineContentPage.assertCheckedStateOfItem("Discussions", MaterialCheckBox.STATE_UNCHECKED) + manageOfflineContentPage.assertCheckedStateOfItem("Grades", MaterialCheckBox.STATE_UNCHECKED) + + Log.d(STEP_TAG, "Check '${course2.name}' course.") + manageOfflineContentPage.changeItemSelectionState(course2.name) + + Log.d(STEP_TAG, "Assert that the '${course2.name}' course's checkbox state and became 'Checked'.") + manageOfflineContentPage.assertCheckedStateOfItem(course2.name, MaterialCheckBox.STATE_CHECKED) + + Log.d(STEP_TAG, "Assert that the 'Grades' and 'Discussions' items are 'checked' (and so all the other tabs of the course) because the course has selected.") + manageOfflineContentPage.assertCheckedStateOfItem("Discussions", MaterialCheckBox.STATE_CHECKED) + manageOfflineContentPage.assertCheckedStateOfItem("Grades", MaterialCheckBox.STATE_CHECKED) + + Log.d(STEP_TAG, "Collapse '${course2.name}' course.") + manageOfflineContentPage.expandCollapseItem(course2.name) + + 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, "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() + 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.") + dashboardPage.selectCourse(course2) + courseBrowserPage.selectGrades() + + Log.d(STEP_TAG,"Assert that the empty view is displayed on the 'Grades' page (just to check that it's available in offline mode.") + courseGradesPage.assertEmptyView() + } + + @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() + } + +} \ 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 7c3413a09f..228df34db0 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 @@ -57,7 +57,7 @@ class OfflineSyncProgressE2ETest : StudentTest() { dashboardPage.openGlobalManageOfflineContentPage() Log.d(STEP_TAG, "Select the entire '${course1.name}' course for sync. Click on the 'Sync' button.") - manageOfflineContentPage.selectEntireCourseForSync(course1.name) + manageOfflineContentPage.changeItemSelectionState(course1.name) manageOfflineContentPage.clickOnSyncButton() Log.d(STEP_TAG, "Wait for the 'Download Started' dashboard notification to be displayed, and the to disappear.") diff --git a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt index 58f45c6131..a7f4775120 100644 --- a/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt +++ b/apps/student/src/androidTest/java/com/instructure/student/ui/pages/CourseGradesPage.kt @@ -82,6 +82,10 @@ class CourseGradesPage : BasePage(R.id.courseGradesPage) { gradeValue.check(matches(matcher)) } + fun assertEmptyView() { + onView(withId(R.id.title) + withText(R.string.noItemsToDisplayShort) + withAncestor(R.id.gradesEmptyView)).assertDisplayed() + } + fun assertAssignmentDisplayed(name: String, gradeString: String) { val siblingMatcher = withId(R.id.title) + withParent(R.id.textContainer) + withText(name) + withAncestor(R.id.courseGradesPage) onView(withId(R.id.points) + hasSibling(siblingMatcher)).scrollTo().assertHasText(gradeString) 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 116ea38c00..78121a4c7e 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 @@ -260,6 +260,7 @@ class DashboardPage : BasePage(R.id.dashboardPage) { .perform(click()); } + //OfflineMethod fun openGlobalManageOfflineContentPage() { Espresso.openActionBarOverflowOrOptionsMenu(InstrumentationRegistry.getInstrumentation().targetContext) onView(withText(containsString("Manage Offline Content"))) @@ -307,7 +308,7 @@ class DashboardPage : BasePage(R.id.dashboardPage) { withId(R.id.cardView) + withDescendant(withId(R.id.titleTextView) + withText(courseTitle)) ) - onView(courseOverflowMatcher).scrollTo().click() + waitForView(courseOverflowMatcher).scrollTo().click() waitForView(withId(R.id.title) + withText(menuTitle)).click() } 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 dd263c60e8..bb0300d219 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 @@ -65,6 +65,7 @@ class LeftSideNavigationDrawerPage : BasePage() { ) private fun clickMenu(menuId: Int) { + sleep(1000) //to avoid listview a11y error (content description is missing) waitForView(hamburgerButtonMatcher).click() waitForViewWithId(menuId).scrollTo().click() } 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 96c5e6ffea..c7e6efd27e 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,8 +17,18 @@ 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 com.instructure.canvas.espresso.containsTextCaseInsensitive +import com.instructure.canvas.espresso.hasCheckedState +import com.instructure.espresso.ConstraintLayoutItemCountAssertion +import com.instructure.espresso.ConstraintLayoutItemCountAssertionWithMatcher +import com.instructure.espresso.DoesNotExistAssertion import com.instructure.espresso.OnViewWithId +import com.instructure.espresso.WaitForViewWithId +import com.instructure.espresso.actions.ForceClick +import com.instructure.espresso.assertDisplayed import com.instructure.espresso.click import com.instructure.espresso.page.BasePage import com.instructure.espresso.page.onView @@ -26,21 +36,35 @@ import com.instructure.espresso.page.plus import com.instructure.espresso.page.waitForView import com.instructure.espresso.page.withAncestor import com.instructure.espresso.page.withId +import com.instructure.espresso.page.withParent import com.instructure.espresso.page.withText +import com.instructure.espresso.scrollTo import com.instructure.pandautils.R +import org.hamcrest.CoreMatchers.allOf class ManageOfflineContentPage : BasePage(R.id.manageOfflineContentPage) { - private val toolbar by OnViewWithId(R.id.toolbar) + private val syncButton by OnViewWithId(R.id.syncButton) + private val storageInfoContainer by WaitForViewWithId(R.id.storageInfoContainer) //OfflineMethod - fun selectEntireCourseForSync(courseName: String) { - onView(withId(R.id.checkbox) + hasSibling(withId(R.id.title) + withText(courseName))).click() + fun changeItemSelectionState(courseName: String) { + onView(withId(R.id.checkbox) + hasSibling(withId(R.id.title) + withText(courseName))).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()) + } + + //OfflineMethod + fun expandCollapseFiles() { + expandCollapseItem("Files") } //OfflineMethod fun clickOnSyncButton() { - onView(withId(R.id.syncButton)).click() + syncButton.click() confirmSync() } @@ -48,4 +72,60 @@ class ManageOfflineContentPage : BasePage(R.id.manageOfflineContentPage) { private fun confirmSync() { waitForView(withText("Sync") + withAncestor(R.id.buttonPanel)).click() } + + //OfflineMethod + fun confirmDiscardChanges() { + waitForView(withText("Discard") + withAncestor(R.id.buttonPanel)).click() + } + + //OfflineMethod + fun assertStorageInfoDetails() { + onView(withId(R.id.storageLabel) + withText(R.string.offline_content_storage)).assertDisplayed() + onView(withId(R.id.storageInfo) + containsTextCaseInsensitive("Used")).assertDisplayed() + onView(withId(R.id.progress) + withParent(withId(R.id.storageInfoContainer))).assertDisplayed() + onView(withId(R.id.otherLabel) + withText(R.string.offline_content_other)).assertDisplayed() + onView(withId(R.id.canvasLabel) + withText(R.string.offline_content_canvas_student)).assertDisplayed() + onView(withId(R.id.remainingLabel) + withText(R.string.offline_content_remaining)).assertDisplayed() + } + + //OfflineMethod + fun assertSelectButtonText(selectAll: Boolean) { + 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() + } + + //OfflineMethod + fun clickOnSelectAllButton() { + waitForView(withId(R.id.menu_select_all) + withText(R.string.offline_content_select_all)).click() + } + + //OfflineMethod + fun clickOnDeselectAllButton() { + waitForView(withId(R.id.menu_select_all) + withText(R.string.offline_content_deselect_all)).click() + } + + //OfflineMethod + fun assertCourseCountWithMatcher(expectedCount: Int) { + ConstraintLayoutItemCountAssertionWithMatcher((allOf(withId(R.id.arrow), withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE))), expectedCount) + } + + //OfflineMethod + fun assertCourseCount(expectedCount: Int) { + onView((allOf(withId(R.id.arrow), withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))).check(ConstraintLayoutItemCountAssertion(expectedCount)) + } + + //OfflineMethod + fun assertToolbarTexts(courseName: String) { + onView(withText(courseName) + withParent(R.id.toolbar) + withAncestor(R.id.manageOfflineContentPage)).assertDisplayed() + onView(withText(R.string.offline_content_toolbar_title) + withParent(R.id.toolbar) + withAncestor(R.id.manageOfflineContentPage)).assertDisplayed() + } + + fun assertCheckedStateOfItem(itemName: String, state: Int) { + onView(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)) + } + } diff --git a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt index 001c0a9222..9c0b7c01cb 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/canvas/espresso/CustomMatchers.kt @@ -37,6 +37,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.util.HumanReadables import androidx.test.espresso.util.TreeIterables import com.google.android.apps.common.testing.accessibility.framework.AccessibilityViewCheckResult +import com.google.android.material.checkbox.MaterialCheckBox import com.google.android.material.textfield.TextInputLayout import com.instructure.espresso.ActivityHelper import junit.framework.AssertionFailedError @@ -203,6 +204,30 @@ fun withIndex(matcher: Matcher, index: Int): Matcher { } } +fun withRotation(rotation: Float): Matcher { + return object : TypeSafeMatcher() { + override fun matchesSafely(item: View): Boolean { + return item.rotation == rotation + } + + override fun describeTo(description: Description) { + description.appendText("with rotation: $rotation") + } + } +} + +fun hasCheckedState(checkedState: Int) : Matcher { + return object : TypeSafeMatcher() { + override fun matchesSafely(item: View): Boolean { + return item is MaterialCheckBox && item.checkedState == checkedState + } + + override fun describeTo(description: Description?) { + description?.appendText("has the proper checked state.") + } + } +} + // A matcher for views whose width is less than the specified amount (in dp), // but whose height is at least the specified amount. // This is used to suppress accessibility failures related to overflow menus 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 92a6c1f62d..0951e8236d 100644 --- a/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/CustomViewAssertions.kt @@ -20,6 +20,7 @@ import android.graphics.Color import android.view.View import android.widget.TextView import androidx.annotation.IdRes +import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.ViewAssertion @@ -28,6 +29,7 @@ import androidx.test.espresso.matcher.ViewMatchers import androidx.viewpager.widget.ViewPager import com.google.android.material.bottomnavigation.BottomNavigationView import junit.framework.AssertionFailedError +import org.hamcrest.Matcher import org.hamcrest.Matchers import org.junit.Assert.assertEquals @@ -93,4 +95,28 @@ class DoesNotExistAssertion(private val timeoutInSeconds: Long, private val poll throw AssertionError("View still exists after $timeoutInSeconds seconds.") } -} \ No newline at end of file +} + +class ConstraintLayoutItemCountAssertionWithMatcher(private val matcher: Matcher, private val expectedCount: Int) : ViewAssertion { + override fun check(view: View, noViewFoundException: NoMatchingViewException?) { + noViewFoundException?.let { throw it } + if (view !is ConstraintLayout) { + throw ClassCastException("View of type ${view.javaClass.simpleName} must be a ConstraintLayout") + } + val count = (0 until view.childCount) + .map { view.getChildAt(it) }.count { matcher.matches(it) } + ViewMatchers.assertThat(count, Matchers.`is`(expectedCount)) + } +} + +class ConstraintLayoutItemCountAssertion(private val expectedCount: Int) : ViewAssertion { + override fun check(view: View, noViewFoundException: NoMatchingViewException?) { + noViewFoundException?.let { throw it } + if (view !is ConstraintLayout) { + throw ClassCastException("View of type ${view.javaClass.simpleName} must be a ConstraintLayout") + } + val count = view.childCount + ViewMatchers.assertThat(count, Matchers.`is`(expectedCount)) + } +} + diff --git a/automation/espresso/src/main/kotlin/com/instructure/espresso/actions/ForceClick.kt b/automation/espresso/src/main/kotlin/com/instructure/espresso/actions/ForceClick.kt new file mode 100644 index 0000000000..a69bc625b9 --- /dev/null +++ b/automation/espresso/src/main/kotlin/com/instructure/espresso/actions/ForceClick.kt @@ -0,0 +1,50 @@ +// +// 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.espresso.actions + + +import android.view.View +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers.isClickable +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isEnabled +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.Matcher + + +/** + * During Espresso click the coordinates calculation may have been broken by setRotation call on the view. + * This forceClick will perform click without checking the coordinates. + * + */ +class ForceClick : ViewAction { + + override fun getConstraints(): Matcher? { + return allOf(isClickable(), isEnabled(), isDisplayed()) + } + + override fun getDescription(): String? { + return "force click" + } + + override fun perform(uiController: UiController, view: View) { + view.performClick() // perform click without checking view coordinates. + uiController.loopMainThreadUntilIdle() + } + +} 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 4f5a8ad474..7b7909c6e6 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 + ANNOTATIONS, ANNOUNCEMENTS, COMMENTS, BOOKMARKS, NONE, K5_DASHBOARD, SPEED_GRADER, SYNC_SETTINGS, SYNC_PROGRESS, OFFLINE_CONTENT } enum class SecondaryFeatureCategory { From 9744113913fd226994a24e526a19b91e2ae38f8b Mon Sep 17 00:00:00 2001 From: Kristof Nemere <109959688+kristofnemere@users.noreply.github.com> Date: Thu, 2 Nov 2023 13:05:56 +0100 Subject: [PATCH 03/19] [MBL-17141][Teacher] Canvas Studio global navigation displays the old Arc icon refs: MBL-17141 affects: Teacher release note: none --- .../src/main/res/layout/navigation_drawer.xml | 2 +- .../main/res/drawable/ic_navigation_arc.xml | 28 ------------------- 2 files changed, 1 insertion(+), 29 deletions(-) delete mode 100644 libs/pandares/src/main/res/drawable/ic_navigation_arc.xml diff --git a/apps/teacher/src/main/res/layout/navigation_drawer.xml b/apps/teacher/src/main/res/layout/navigation_drawer.xml index 57de755a08..de3f459f15 100644 --- a/apps/teacher/src/main/res/layout/navigation_drawer.xml +++ b/apps/teacher/src/main/res/layout/navigation_drawer.xml @@ -119,7 +119,7 @@ + app:srcCompat="@drawable/ic_navigation_studio" /> - - - - - \ No newline at end of file From 6d832fae1ab2c380bbac07ad75ec1752c4cb2326 Mon Sep 17 00:00:00 2001 From: Tamas Kozmer <72397075+tamaskozmer@users.noreply.github.com> Date: Thu, 2 Nov 2023 15:41:01 +0100 Subject: [PATCH 04/19] [MBL-17143][Student][Teacher] JS events are not responsive when we have an lti or google docs iframe in the discussion reply #2225 refs: MBL-17143 affects: Student, Teacher release note: Fixed a bug where the discussion reply actions were not working in some cases. --- .../main/assets/discussion_topic_header_html_template.html | 2 +- .../assets/discussion_topic_header_html_template_rtl.html | 2 +- .../com/instructure/pandautils/binding/BindingAdapters.kt | 4 ++-- .../com/instructure/pandautils/utils/HtmlContentFormatter.kt | 2 +- .../com/instructure/pandautils/utils/WebViewExtensions.kt | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/libs/pandautils/src/main/assets/discussion_topic_header_html_template.html b/libs/pandautils/src/main/assets/discussion_topic_header_html_template.html index 076ac0b643..b2b9e8c605 100644 --- a/libs/pandautils/src/main/assets/discussion_topic_header_html_template.html +++ b/libs/pandautils/src/main/assets/discussion_topic_header_html_template.html @@ -29,7 +29,7 @@