From 8f6aaf487fafd0388db540d5d9fd07e945714b01 Mon Sep 17 00:00:00 2001 From: Igor Date: Sun, 25 Feb 2024 16:57:18 +0100 Subject: [PATCH] Feat: Download all videos together (#234) * feat: ability to download all videos * feat: confirmation dialog for downloading videos larger than 1 GB * refactor: renamed the Video tab to Videos * refactor: hide All videos download element if there is no videos to download * fix: bug when unable to see all videos * fix: lags when updating downloading state * refactor: changed the type of allBlocks to HashMap in the BaseDownloadViewModel * feat: confirmation dialog when trying to remove all downloads * feat: show download progress on the download queue screen * feat: view downloads for subsection * refactor: changed how all modules are download * refactor: optimized way to remove models * refactor: changed the way the download progress is displayed * feat: added confirmation dialogs * feat: show Untitled title if the block has no title * refactor: removed unused logs * fix: after rebase * fix: after rebase * refactor: change the name of the Discussion tab to Discussions * fix: fixed issues after PR --- app/src/main/AndroidManifest.xml | 1 + .../main/java/org/openedx/app/AppRouter.kt | 12 +- .../app/data/storage/PreferencesManager.kt | 32 +- .../main/java/org/openedx/app/di/AppModule.kt | 4 + .../java/org/openedx/app/di/ScreenModule.kt | 16 +- .../java/org/openedx/core/AppDataConstants.kt | 5 +- .../core/domain/model/VideoSettings.kt | 5 +- .../org/openedx/core/extension/LongExt.kt | 18 + .../org/openedx/core/module/DownloadWorker.kt | 26 +- .../core/module/DownloadWorkerController.kt | 70 +- .../org/openedx/core/module/db/DownloadDao.kt | 9 +- .../module/download/BaseDownloadViewModel.kt | 199 +++-- .../module/download/DownloadModelsSize.kt | 10 + .../core/module/download/FileDownloader.kt | 6 +- .../settings}/VideoQualityFragment.kt | 49 +- .../presentation/settings/VideoQualityType.kt | 5 + .../settings/VideoQualityViewModel.kt | 47 + .../core/system/notifier/DownloadEvent.kt | 3 + .../core/system/notifier/DownloadNotifier.kt | 15 + .../notifier/DownloadProgressChanged.kt | 5 + .../core/system/notifier/VideoEvent.kt | 3 + .../core/system/notifier/VideoNotifier.kt | 14 + .../system/notifier/VideoQualityChanged.kt | 3 + core/src/main/res/values-uk/strings.xml | 2 + core/src/main/res/values/strings.xml | 13 +- .../domain/interactor/CourseInteractor.kt | 12 +- .../course/presentation/CourseRouter.kt | 5 + .../container/CourseContainerAdapter.kt | 4 +- .../container/CourseContainerFragment.kt | 1 + .../outline/CourseOutlineFragment.kt | 7 +- .../section/CourseSectionFragment.kt | 6 +- .../course/presentation/ui/CourseUI.kt | 110 ++- .../course/presentation/ui/CourseVideosUI.kt | 827 ++++++++++++++++++ .../unit/video/EncodedVideoUnitViewModel.kt | 4 +- .../presentation/unit/video/VideoViewModel.kt | 4 +- .../videos/CourseVideoViewModel.kt | 71 +- .../videos/CourseVideosFragment.kt | 449 +--------- .../videos/CourseVideosUIState.kt | 6 +- .../download/DownloadQueueFragment.kt | 254 ++++++ .../settings/download/DownloadQueueUIState.kt | 16 + .../download/DownloadQueueViewModel.kt | 72 ++ .../res/menu/bottom_course_container_menu.xml | 6 +- course/src/main/res/values-uk/strings.xml | 4 +- course/src/main/res/values/strings.xml | 15 +- .../outline/CourseOutlineViewModelTest.kt | 8 +- .../section/CourseSectionViewModelTest.kt | 14 +- .../videos/CourseVideoViewModelTest.kt | 75 +- .../profile/presentation/ProfileRouter.kt | 3 +- .../delete/DeleteProfileViewModel.kt | 2 +- .../presentation/edit/EditProfileViewModel.kt | 2 +- .../presentation/profile/ProfileViewModel.kt | 2 +- .../settings/video/VideoQualityViewModel.kt | 37 - .../settings/video/VideoSettingsFragment.kt | 54 +- .../settings/video/VideoSettingsViewModel.kt | 12 +- .../system/notifier/AccountDeactivated.kt | 2 +- .../profile/system/notifier/AccountUpdated.kt | 2 +- .../profile/system/notifier/ProfileEvent.kt | 3 +- .../system/notifier/ProfileNotifier.kt | 4 +- .../system/notifier/VideoQualityChanged.kt | 3 - profile/src/main/res/values-uk/strings.xml | 3 +- profile/src/main/res/values/strings.xml | 3 +- .../edit/EditProfileViewModelTest.kt | 2 +- .../profile/ProfileViewModelTest.kt | 6 +- 63 files changed, 1994 insertions(+), 688 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/extension/LongExt.kt create mode 100644 core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt rename {profile/src/main/java/org/openedx/profile/presentation/settings/video => core/src/main/java/org/openedx/core/presentation/settings}/VideoQualityFragment.kt (85%) create mode 100644 core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt create mode 100644 core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/DownloadEvent.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/VideoEvent.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/VideoNotifier.kt create mode 100644 core/src/main/java/org/openedx/core/system/notifier/VideoQualityChanged.kt create mode 100644 course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt create mode 100644 course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt create mode 100644 course/src/main/java/org/openedx/course/settings/download/DownloadQueueUIState.kt create mode 100644 course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt delete mode 100644 profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityViewModel.kt delete mode 100644 profile/src/main/java/org/openedx/profile/system/notifier/VideoQualityChanged.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b5581c340..e3af19210 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index eff44c9a7..daf3662f0 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -13,6 +13,8 @@ import org.openedx.core.presentation.course.CourseViewMode import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.presentation.global.app_upgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.WebContentFragment +import org.openedx.core.presentation.settings.VideoQualityFragment +import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment import org.openedx.course.presentation.container.NoAccessCourseContainerFragment @@ -24,6 +26,7 @@ import org.openedx.course.presentation.section.CourseSectionFragment import org.openedx.course.presentation.unit.container.CourseUnitContainerFragment import org.openedx.course.presentation.unit.video.VideoFullScreenFragment import org.openedx.course.presentation.unit.video.YoutubeVideoFullScreenFragment +import org.openedx.course.settings.download.DownloadQueueFragment import org.openedx.dashboard.presentation.DashboardRouter import org.openedx.dashboard.presentation.program.ProgramFragment import org.openedx.discovery.presentation.DiscoveryRouter @@ -44,7 +47,6 @@ import org.openedx.profile.presentation.anothers_account.AnothersProfileFragment import org.openedx.profile.presentation.delete.DeleteProfileFragment import org.openedx.profile.presentation.edit.EditProfileFragment import org.openedx.profile.presentation.profile.ProfileFragment -import org.openedx.profile.presentation.settings.video.VideoQualityFragment import org.openedx.profile.presentation.settings.video.VideoSettingsFragment import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment @@ -72,6 +74,10 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, LogistrationFragment.newInstance(courseId)) } + override fun navigateToDownloadQueue(fm: FragmentManager, descendants: List) { + replaceFragmentWithBackStack(fm, DownloadQueueFragment.newInstance(descendants)) + } + override fun navigateToRestorePassword(fm: FragmentManager) { replaceFragmentWithBackStack(fm, RestorePasswordFragment()) } @@ -325,8 +331,8 @@ class AppRouter : AuthRouter, DiscoveryRouter, DashboardRouter, CourseRouter, Di replaceFragmentWithBackStack(fm, VideoSettingsFragment()) } - override fun navigateToVideoQuality(fm: FragmentManager) { - replaceFragmentWithBackStack(fm, VideoQualityFragment()) + override fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) { + replaceFragmentWithBackStack(fm, VideoQualityFragment.newInstance(videoQualityType.name)) } override fun navigateToDeleteAccount(fm: FragmentManager) { diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index bd7eb17e5..eeeccd39c 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -6,6 +6,7 @@ import org.openedx.app.BuildConfig import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences +import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings import org.openedx.profile.data.model.Account import org.openedx.profile.data.storage.ProfilePreferences @@ -23,7 +24,9 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } - private fun getString(key: String): String = sharedPreferences.getString(key, "") ?: "" + private fun getString(key: String, defValue: String = ""): String { + return sharedPreferences.getString(key, defValue) ?: defValue + } private fun saveLong(key: String, value: Long) { sharedPreferences.edit().apply { @@ -39,7 +42,9 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences }.apply() } - private fun getBoolean(key: String): Boolean = sharedPreferences.getBoolean(key, false) + private fun getBoolean(key: String, defValue: Boolean = false): Boolean { + return sharedPreferences.getBoolean(key, defValue) + } override fun clear() { sharedPreferences.edit().apply { @@ -90,13 +95,22 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences override var videoSettings: VideoSettings set(value) { - val videoSettingsJson = Gson().toJson(value) - saveString(VIDEO_SETTINGS, videoSettingsJson) + saveBoolean(VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY, value.wifiDownloadOnly) + saveString(VIDEO_SETTINGS_STREAMING_QUALITY, value.videoStreamingQuality.name) + saveString(VIDEO_SETTINGS_DOWNLOAD_QUALITY, value.videoDownloadQuality.name) } get() { - val videoSettingsString = getString(VIDEO_SETTINGS) - return Gson().fromJson(videoSettingsString, VideoSettings::class.java) - ?: VideoSettings.default + val wifiDownloadOnly = getBoolean(VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY, defValue = true) + val streamingQualityString = + getString(VIDEO_SETTINGS_STREAMING_QUALITY, defValue = VideoQuality.AUTO.name) + val downloadQualityString = + getString(VIDEO_SETTINGS_DOWNLOAD_QUALITY, defValue = VideoQuality.AUTO.name) + + return VideoSettings( + wifiDownloadOnly = wifiDownloadOnly, + videoStreamingQuality = VideoQuality.valueOf(streamingQualityString), + videoDownloadQuality = VideoQuality.valueOf(downloadQualityString) + ) } override var lastWhatsNewVersion: String @@ -132,9 +146,11 @@ class PreferencesManager(context: Context) : CorePreferences, ProfilePreferences private const val EXPIRES_IN = "expires_in" private const val USER = "user" private const val ACCOUNT = "account" - private const val VIDEO_SETTINGS = "video_settings" private const val LAST_WHATS_NEW_VERSION = "last_whats_new_version" private const val LAST_REVIEW_VERSION = "last_review_version" private const val APP_WAS_POSITIVE_RATED = "app_was_positive_rated" + private const val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY = "video_settings_wifi_download_only" + private const val VIDEO_SETTINGS_STREAMING_QUALITY = "video_settings_streaming_quality" + private const val VIDEO_SETTINGS_DOWNLOAD_QUALITY = "video_settings_download_quality" } } diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index c5a267ece..dba5727d5 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -42,6 +42,8 @@ import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.dashboard.notifier.DashboardNotifier +import org.openedx.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.VideoNotifier import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.dashboard.presentation.dashboard.DashboardAnalytics @@ -79,7 +81,9 @@ val appModule = module { single { DiscussionNotifier() } single { ProfileNotifier() } single { AppUpgradeNotifier() } + single { DownloadNotifier() } single { DashboardNotifier() } + single { VideoNotifier() } single { AppRouter() } single { get() } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 2f86dbb5a..8391a0e03 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -12,6 +12,7 @@ import org.openedx.auth.presentation.signin.SignInViewModel import org.openedx.auth.presentation.signup.SignUpViewModel import org.openedx.core.Validator import org.openedx.core.presentation.dialog.selectorbottomsheet.SelectDialogViewModel +import org.openedx.core.presentation.settings.VideoQualityViewModel import org.openedx.course.data.repository.CourseRepository import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.container.CourseContainerViewModel @@ -27,6 +28,7 @@ import org.openedx.course.presentation.unit.video.EncodedVideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoUnitViewModel import org.openedx.course.presentation.unit.video.VideoViewModel import org.openedx.course.presentation.videos.CourseVideoViewModel +import org.openedx.course.settings.download.DownloadQueueViewModel import org.openedx.dashboard.data.repository.DashboardRepository import org.openedx.dashboard.domain.interactor.DashboardInteractor import org.openedx.dashboard.presentation.dashboard.DashboardViewModel @@ -52,7 +54,6 @@ import org.openedx.profile.presentation.anothers_account.AnothersProfileViewMode import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.profile.ProfileViewModel -import org.openedx.profile.presentation.settings.video.VideoQualityViewModel import org.openedx.profile.presentation.settings.video.VideoSettingsViewModel import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel @@ -120,7 +121,7 @@ val screenModule = module { } viewModel { (account: Account) -> EditProfileViewModel(get(), get(), get(), get(), account) } viewModel { VideoSettingsViewModel(get(), get()) } - viewModel { VideoQualityViewModel(get(), get()) } + viewModel { (qualityType: String) -> VideoQualityViewModel(get(), get(), qualityType) } viewModel { DeleteProfileViewModel(get(), get(), get(), get()) } viewModel { (username: String) -> AnothersProfileViewModel(get(), get(), username) } @@ -210,6 +211,7 @@ val screenModule = module { get(), get(), get(), + get(), get() ) } @@ -293,6 +295,16 @@ val screenModule = module { get() ) } + + viewModel { (descendants: List) -> + DownloadQueueViewModel( + descendants, + get(), + get(), + get(), + get() + ) + } viewModel { HtmlUnitViewModel(get(), get(), get(), get()) } viewModel { ProgramViewModel(get(), get(), get(), get(), get(), get(), get()) } diff --git a/core/src/main/java/org/openedx/core/AppDataConstants.kt b/core/src/main/java/org/openedx/core/AppDataConstants.kt index 0bb5a95d0..eb2580e99 100644 --- a/core/src/main/java/org/openedx/core/AppDataConstants.kt +++ b/core/src/main/java/org/openedx/core/AppDataConstants.kt @@ -10,4 +10,7 @@ object AppDataConstants { const val VIDEO_FORMAT_M3U8 = ".m3u8" const val VIDEO_FORMAT_MP4 = ".mp4" -} \ No newline at end of file + + // Equal 1GB + const val DOWNLOADS_CONFIRMATION_SIZE = 1024 * 1024 * 1024L +} diff --git a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt index eb9d6309b..ec6391fe4 100644 --- a/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt +++ b/core/src/main/java/org/openedx/core/domain/model/VideoSettings.kt @@ -4,10 +4,11 @@ import org.openedx.core.R data class VideoSettings( val wifiDownloadOnly: Boolean, - val videoQuality: VideoQuality + val videoStreamingQuality: VideoQuality, + val videoDownloadQuality: VideoQuality ) { companion object { - val default = VideoSettings(true, VideoQuality.AUTO) + val default = VideoSettings(true, VideoQuality.AUTO, VideoQuality.AUTO) } } diff --git a/core/src/main/java/org/openedx/core/extension/LongExt.kt b/core/src/main/java/org/openedx/core/extension/LongExt.kt new file mode 100644 index 000000000..06f052616 --- /dev/null +++ b/core/src/main/java/org/openedx/core/extension/LongExt.kt @@ -0,0 +1,18 @@ +package org.openedx.core.extension + +import kotlin.math.log10 +import kotlin.math.pow + +fun Long.toFileSize(round: Int = 2): String { + try { + if (this <= 0) return "0" + val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + val digitGroups = (log10(this.toDouble()) / log10(1024.0)).toInt() + return String.format( + "%." + round + "f", this / 1024.0.pow(digitGroups.toDouble()) + ) + " " + units[digitGroups] + } catch (e: Exception) { + println(e.toString()) + } + return "" +} diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt index 551d5b823..9234ec023 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -10,7 +10,9 @@ import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.inject import org.openedx.core.R import org.openedx.core.module.db.DownloadDao @@ -19,12 +21,14 @@ import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.download.CurrentProgress import org.openedx.core.module.download.FileDownloader +import org.openedx.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.DownloadProgressChanged import java.io.File class DownloadWorker( val context: Context, parameters: WorkerParameters -) : CoroutineWorker(context, parameters) { +) : CoroutineWorker(context, parameters), CoroutineScope { private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as @@ -32,6 +36,7 @@ class DownloadWorker( private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) + private val notifier by inject(DownloadNotifier::class.java) private val downloadDao: DownloadDao by inject(DownloadDao::class.java) private var downloadEnqueue = listOf() @@ -43,6 +48,7 @@ class DownloadWorker( ) private var currentDownload: DownloadModel? = null + private var lastUpdateTime = 0L private val fileDownloader by inject(FileDownloader::class.java) @@ -79,14 +85,24 @@ class DownloadWorker( private fun updateProgress() { fileDownloader.progressListener = object : CurrentProgress { - override fun progress(value: Long) { - if (!fileDownloader.isCanceled) { + override fun progress(value: Long, size: Long) { + val progress = 100 * value / size + // Update no more than 5 times per sec + if (!fileDownloader.isCanceled && + (System.currentTimeMillis() - lastUpdateTime > 200) + ) { + lastUpdateTime = System.currentTimeMillis() + currentDownload?.let { + launch { + notifier.send(DownloadProgressChanged(it.id, value, size)) + } + notificationManager.notify( NOTIFICATION_ID, notificationBuilder .setSmallIcon(R.drawable.core_ic_check_in_box) - .setProgress(100, value.toInt(), false) + .setProgress(100, progress.toInt(), false) .setPriority(NotificationManager.IMPORTANCE_LOW) .setContentText(context.getString(R.string.core_downloading_in_progress)) .setContentTitle(it.title) @@ -152,4 +168,4 @@ class DownloadWorker( private const val NOTIFICATION_ID = 10 } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt index 54bdd6dde..a4e83c07e 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -5,14 +5,15 @@ import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkInfo import androidx.work.WorkManager import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.download.FileDownloader -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch +import java.io.File import java.util.concurrent.ExecutionException class DownloadWorkerController( @@ -51,34 +52,46 @@ class DownloadWorkerController( } } - suspend fun saveModels(vararg downloadModel: DownloadModel) { - downloadDao.insertDownloadModel( - *downloadModel.map { DownloadModelEntity.createFrom(it) }.toTypedArray() - ) + suspend fun saveModels(downloadModels: List) { + downloadDao.insertDownloadModel( + downloadModels.map { DownloadModelEntity.createFrom(it) } + ) } - suspend fun cancelWork(vararg ids: String) { - for (id in ids.toList()) { - updateList() - val downloadModel = downloadTaskList.find { it.id == id } - if (downloadTaskList.size == 1) { - fileDownloader.cancelDownloading() - downloadDao.removeDownloadModel(id) - workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) - return - } - downloadModel?.let { - if (it.downloadedState == DownloadedState.WAITING) { - downloadDao.removeDownloadModel(id) - } else { - fileDownloader.cancelDownloading() - downloadDao.removeDownloadModel(id) - } - } + suspend fun removeModel(id: String) { + removeModels(listOf(id)) + } + + suspend fun removeModels(ids: List) { + val downloadModels = getDownloadModelsById(ids) + val removeIds = mutableListOf() + var hasDownloading = false + + downloadModels.forEach { downloadModel -> + removeIds.add(downloadModel.id) + + if (downloadModel.downloadedState == DownloadedState.DOWNLOADING) { + hasDownloading = true } + + try { + File(downloadModel.path).delete() + } catch (e: Exception) { + e.printStackTrace() + } + } + + if (hasDownloading) fileDownloader.cancelDownloading() + downloadDao.removeAllDownloadModels(removeIds) + + updateList() + + if (downloadTaskList.isEmpty()) { + workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) + } } - suspend fun cancelWork() { + suspend fun removeModels() { fileDownloader.cancelDownloading() workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) } @@ -101,4 +114,7 @@ class DownloadWorkerController( } } -} \ No newline at end of file + private suspend fun getDownloadModelsById(ids: List): List { + return downloadDao.readAllDataByIds(ids).first().map { it.mapToDomain() } + } +} diff --git a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt index d3e9d84b7..5bdfc637b 100644 --- a/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt +++ b/core/src/main/java/org/openedx/core/module/db/DownloadDao.kt @@ -10,7 +10,7 @@ interface DownloadDao { suspend fun removeDownloadModel(id: String) @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertDownloadModel(vararg downloadModelEntity: DownloadModelEntity) + suspend fun insertDownloadModel(downloadModelEntities: List) @Update(onConflict = OnConflictStrategy.REPLACE) suspend fun updateDownloadModel(downloadModelEntity: DownloadModelEntity) @@ -18,4 +18,9 @@ interface DownloadDao { @Query("SELECT * FROM download_model") fun readAllData() : Flow> -} \ No newline at end of file + @Query("SELECT * FROM download_model WHERE id in (:ids)") + fun readAllDataByIds(ids: List) : Flow> + + @Query("DELETE FROM download_model WHERE id in (:ids)") + suspend fun removeAllDownloadModels(ids: List) +} diff --git a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt index f5c7c8f8a..c96c986a2 100644 --- a/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt +++ b/core/src/main/java/org/openedx/core/module/download/BaseDownloadViewModel.kt @@ -2,20 +2,20 @@ package org.openedx.core.module.download import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.BlockType +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.utils.Sha1Util -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import org.openedx.core.data.storage.CorePreferences import java.io.File abstract class BaseDownloadViewModel( @@ -24,14 +24,18 @@ abstract class BaseDownloadViewModel( private val workerController: DownloadWorkerController ) : BaseViewModel() { - private val allBlocks = mutableListOf() + private val allBlocks = hashMapOf() - private var downloadableChildrenMap = hashMapOf>() + private val downloadableChildrenMap = hashMapOf>() private val downloadModelsStatus = hashMapOf() private val _downloadModelsStatusFlow = MutableSharedFlow>() protected val downloadModelsStatusFlow = _downloadModelsStatusFlow.asSharedFlow() + private var downloadingModelsList = listOf() + private val _downloadingModelsFlow = MutableSharedFlow>() + protected val downloadingModelsFlow = _downloadingModelsFlow.asSharedFlow() + override fun onCreate(owner: LifecycleOwner) { super.onCreate(owner) viewModelScope.launch { @@ -52,26 +56,46 @@ abstract class BaseDownloadViewModel( return downloadDao.readAllData().first().map { it.mapToDomain() } } - private fun updateDownloadModelsStatus(models: List) { + private suspend fun updateDownloadModelsStatus(models: List) { + val downloadModelMap = models.associateBy { it.id } for (item in downloadableChildrenMap) { - if (models.find { item.value.contains(it.id) && it.downloadedState.isWaitingOrDownloading } != null) { - downloadModelsStatus[item.key] = DownloadedState.DOWNLOADING - } else if (item.value.all { id -> models.find { it.id == id && it.downloadedState == DownloadedState.DOWNLOADED } != null }) { - downloadModelsStatus[item.key] = DownloadedState.DOWNLOADED - } else { - downloadModelsStatus[item.key] = DownloadedState.NOT_DOWNLOADED + var downloadingCount = 0 + var downloadedCount = 0 + item.value.forEach { blockId -> + val downloadModel = downloadModelMap[blockId] + if (downloadModel != null) { + if (downloadModel.downloadedState.isWaitingOrDownloading) { + downloadModelsStatus[blockId] = DownloadedState.DOWNLOADING + downloadingCount++ + } else if (downloadModel.downloadedState.isDownloaded) { + downloadModelsStatus[blockId] = DownloadedState.DOWNLOADED + downloadedCount++ + } + } else { + downloadModelsStatus[blockId] = DownloadedState.NOT_DOWNLOADED + } + } + + downloadModelsStatus[item.key] = when { + downloadingCount > 0 -> DownloadedState.DOWNLOADING + downloadedCount == item.value.size -> DownloadedState.DOWNLOADED + else -> DownloadedState.NOT_DOWNLOADED } } + + downloadingModelsList = models.filter { it.downloadedState.isWaitingOrDownloading } + _downloadingModelsFlow.emit(downloadingModelsList) } protected fun setBlocks(list: List) { + downloadableChildrenMap.clear() allBlocks.clear() - allBlocks.addAll(list) + allBlocks.putAll(list.map { it.id to it }) } fun isBlockDownloading(id: String): Boolean { val blockDownloadingState = downloadModelsStatus[id] - return blockDownloadingState == DownloadedState.DOWNLOADING || blockDownloadingState == DownloadedState.WAITING + return blockDownloadingState?.isWaitingOrDownloading == true } fun isBlockDownloaded(id: String): Boolean { @@ -82,73 +106,117 @@ abstract class BaseDownloadViewModel( open fun saveDownloadModels(folder: String, id: String) { viewModelScope.launch { val saveBlocksIds = downloadableChildrenMap[id] ?: listOf() - val downloadModels = mutableListOf() - for (blockId in saveBlocksIds) { - allBlocks.find { it.id == blockId }?.let { block -> - val videoInfo = - block.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( - preferencesManager.videoSettings.videoQuality - ) - val size = videoInfo?.fileSize ?: 0 - val url = videoInfo?.url ?: "" - val extension = url.split('.').lastOrNull() ?: "mp4" - val path = - folder + File.separator + "${Sha1Util.SHA1(block.displayName)}.$extension" - if (getDownloadModelList().find { it.id == blockId && it.downloadedState.isDownloaded } == null) { - downloadModels.add( - DownloadModel( - block.id, - block.displayName, - size, - path, - url, - block.downloadableType, - DownloadedState.WAITING, - null - ) + saveDownloadModels(folder, saveBlocksIds) + } + } + + open fun saveAllDownloadModels(folder: String) { + viewModelScope.launch { + val saveBlocksIds = downloadableChildrenMap.values.flatten() + saveDownloadModels(folder, saveBlocksIds) + } + } + + private suspend fun saveDownloadModels(folder: String, saveBlocksIds: List) { + val downloadModels = mutableListOf() + val downloadModelList = getDownloadModelList() + for (blockId in saveBlocksIds) { + allBlocks[blockId]?.let { block -> + val videoInfo = + block.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( + preferencesManager.videoSettings.videoDownloadQuality + ) + val size = videoInfo?.fileSize ?: 0 + val url = videoInfo?.url ?: "" + val extension = url.split('.').lastOrNull() ?: "mp4" + val path = + folder + File.separator + "${Sha1Util.SHA1(block.displayName)}.$extension" + if (downloadModelList.find { it.id == blockId && it.downloadedState.isDownloaded } == null) { + downloadModels.add( + DownloadModel( + block.id, + block.displayName, + size, + path, + url, + block.downloadableType, + DownloadedState.WAITING, + null ) - } + ) } } - workerController.saveModels(*downloadModels.toTypedArray()) } + workerController.saveModels(downloadModels) } - fun removeDownloadedModels(id: String) { - viewModelScope.launch { - val saveBlocksIds = downloadableChildrenMap[id] ?: listOf() - val downloaded = - getDownloadModelList().filter { saveBlocksIds.contains(it.id) && it.downloadedState.isDownloaded } - downloaded.forEach { - downloadDao.removeDownloadModel(it.id) + fun getDownloadModelsStatus() = downloadModelsStatus.toMap() + + fun getDownloadModelsSize(): DownloadModelsSize { + var isAllBlocksDownloadedOrDownloading = true + var remainingCount = 0 + var remainingSize = 0L + var allCount = 0 + var allSize = 0L + + downloadableChildrenMap.keys.forEach { id -> + if (!isBlockDownloaded(id) && !isBlockDownloading(id)) { + isAllBlocksDownloadedOrDownloading = false + } + + downloadableChildrenMap[id]?.forEach { downloadableBlock -> + val block = allBlocks[downloadableBlock] + val videoInfo = + block?.studentViewData?.encodedVideos?.getPreferredVideoInfoForDownloading( + preferencesManager.videoSettings.videoDownloadQuality + ) + + allCount++ + allSize += videoInfo?.fileSize ?: 0 + + if (!isBlockDownloaded(downloadableBlock)) { + remainingCount++ + remainingSize += videoInfo?.fileSize ?: 0 + } } } + return DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = isAllBlocksDownloadedOrDownloading, + remainingCount = remainingCount, + remainingSize = remainingSize, + allCount = allCount, + allSize = allSize + ) } - fun getDownloadModelsStatus() = downloadModelsStatus.toMap() + fun hasDownloadModelsInQueue() = downloadingModelsList.isNotEmpty() - fun cancelWork(blockId: String) { + fun getDownloadableChildren(id: String) = downloadableChildrenMap[id] + + open fun removeDownloadModels(blockId: String) { viewModelScope.launch { val downloadableChildren = downloadableChildrenMap[blockId] ?: listOf() - val ids = getDownloadModelList().filter { - (it.downloadedState == DownloadedState.DOWNLOADING || - it.downloadedState == DownloadedState.WAITING) && downloadableChildren.contains( - it.id - ) - }.map { it.id } - workerController.cancelWork(*ids.toTypedArray()) + workerController.removeModels(downloadableChildren) + } + } + + fun removeAllDownloadModels() { + viewModelScope.launch { + val downloadableChildren = downloadableChildrenMap.values.flatten() + workerController.removeModels(downloadableChildren) } } protected fun addDownloadableChildrenForSequentialBlock(sequentialBlock: Block) { for (item in sequentialBlock.descendants) { - allBlocks.find { it.id == item }?.let { blockDescendant -> + allBlocks[item]?.let { blockDescendant -> if (blockDescendant.type == BlockType.VERTICAL) { for (unitBlockId in blockDescendant.descendants) { - allBlocks.find { it.id == unitBlockId && it.isDownloadable }?.let { + val block = allBlocks[unitBlockId] + if (block?.isDownloadable == true) { val id = sequentialBlock.id val children = downloadableChildrenMap[id] ?: listOf() - downloadableChildrenMap[id] = children + it.id + downloadableChildrenMap[id] = children + block.id } } } @@ -158,12 +226,13 @@ abstract class BaseDownloadViewModel( protected fun addDownloadableChildrenForVerticalBlock(verticalBlock: Block) { for (unitBlockId in verticalBlock.descendants) { - allBlocks.find { it.id == unitBlockId && it.isDownloadable }?.let { + val block = allBlocks[unitBlockId] + if (block?.isDownloadable == true) { val id = verticalBlock.id val children = downloadableChildrenMap[id] ?: listOf() - downloadableChildrenMap[id] = children + it.id + downloadableChildrenMap[id] = children + block.id } } } -} \ No newline at end of file +} diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt b/core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt new file mode 100644 index 000000000..b40876c99 --- /dev/null +++ b/core/src/main/java/org/openedx/core/module/download/DownloadModelsSize.kt @@ -0,0 +1,10 @@ +package org.openedx.core.module.download + +data class DownloadModelsSize( + val isAllBlocksDownloadedOrDownloading: Boolean, + val remainingCount: Int, + val remainingSize: Long, + val allCount: Int, + val allSize: Long +) + diff --git a/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt b/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt index 018c4c14f..fe68e696f 100644 --- a/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt +++ b/core/src/main/java/org/openedx/core/module/download/FileDownloader.kt @@ -37,7 +37,7 @@ class FileDownloader : AbstractDownloader(), ProgressListener { } Log.d("DownloadProgress", "$bytesRead") if (contentLength != -1L) { - progressListener?.progress(100 * bytesRead / contentLength) + progressListener?.progress(bytesRead, contentLength) Log.d("DownloadProgress", "${100 * bytesRead / contentLength} done") } } @@ -46,7 +46,7 @@ class FileDownloader : AbstractDownloader(), ProgressListener { } interface CurrentProgress { - fun progress(value: Long) + fun progress(value: Long, size: Long) } interface DownloadApi { @@ -55,4 +55,4 @@ interface DownloadApi { @GET suspend fun downloadFile(@Url fileUrl: String): retrofit2.Response -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt similarity index 85% rename from profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt rename to core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt index cc76fc859..e26d882eb 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityFragment.kt +++ b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityFragment.kt @@ -1,7 +1,6 @@ -package org.openedx.profile.presentation.settings.video +package org.openedx.core.presentation.settings -import android.content.res.Configuration.UI_MODE_NIGHT_NO -import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup @@ -44,8 +43,11 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.R import org.openedx.core.domain.model.VideoQuality import org.openedx.core.extension.nonZero import org.openedx.core.extension.tagId @@ -59,11 +61,14 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue -import org.openedx.profile.R as profileR class VideoQualityFragment : Fragment() { - private val viewModel by viewModel() + private val viewModel by viewModel { + parametersOf( + requireArguments().getString(ARG_QUALITY_TYPE, "") + ) + } override fun onCreateView( inflater: LayoutInflater, @@ -75,13 +80,20 @@ class VideoQualityFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() - val videoQuality by viewModel.videoQuality.observeAsState(viewModel.currentVideoQuality) + val title = stringResource( + id = if (viewModel.getQualityType() == VideoQualityType.Streaming) + R.string.core_video_streaming_quality + else + R.string.core_video_download_quality + ) + val videoQuality by viewModel.videoQuality.observeAsState(viewModel.getCurrentVideoQuality()) VideoQualityScreen( windowSize = windowSize, + title = title, selectedVideoQuality = videoQuality, onQualityChanged = { - viewModel.setVideoDownloadQuality(it) + viewModel.setVideoQuality(it) }, onBackClick = { requireActivity().supportFragmentManager.popBackStack() @@ -90,12 +102,27 @@ class VideoQualityFragment : Fragment() { } } + companion object { + + private const val ARG_QUALITY_TYPE = "quality_type" + + fun newInstance( + type: String, + ): VideoQualityFragment { + val fragment = VideoQualityFragment() + fragment.arguments = bundleOf( + ARG_QUALITY_TYPE to type + ) + return fragment + } + } } @OptIn(ExperimentalComposeUiApi::class) @Composable private fun VideoQualityScreen( windowSize: WindowSize, + title: String, selectedVideoQuality: VideoQuality, onQualityChanged: (VideoQuality) -> Unit, onBackClick: () -> Unit @@ -145,7 +172,7 @@ private fun VideoQualityScreen( ) { Toolbar( modifier = topBarWidth, - label = stringResource(id = profileR.string.profile_video_streaming_quality), + label = title, canShowBackBtn = true, onBackClick = onBackClick ) @@ -223,15 +250,17 @@ private fun QualityOption( Divider() } -@Preview(uiMode = UI_MODE_NIGHT_NO) -@Preview(uiMode = UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Composable private fun VideoQualityScreenPreview() { OpenEdXTheme { VideoQualityScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + title = "", selectedVideoQuality = VideoQuality.OPTION_720P, onQualityChanged = {}, onBackClick = {}) } } + diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt new file mode 100644 index 000000000..4c7973d6a --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityType.kt @@ -0,0 +1,5 @@ +package org.openedx.core.presentation.settings + +enum class VideoQualityType { + Streaming, Download +} diff --git a/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt new file mode 100644 index 000000000..02f00851c --- /dev/null +++ b/core/src/main/java/org/openedx/core/presentation/settings/VideoQualityViewModel.kt @@ -0,0 +1,47 @@ +package org.openedx.core.presentation.settings + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import org.openedx.core.BaseViewModel +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.VideoQuality +import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.system.notifier.VideoQualityChanged + +class VideoQualityViewModel( + private val preferencesManager: CorePreferences, + private val notifier: VideoNotifier, + private val qualityType: String +) : BaseViewModel() { + + private val _videoQuality = MutableLiveData() + val videoQuality: LiveData + get() = _videoQuality + + init { + _videoQuality.value = getCurrentVideoQuality() + } + + fun getCurrentVideoQuality(): VideoQuality { + return if (getQualityType() == VideoQualityType.Streaming) + preferencesManager.videoSettings.videoStreamingQuality else + preferencesManager.videoSettings.videoDownloadQuality + } + + fun setVideoQuality(quality: VideoQuality) { + val currentSettings = preferencesManager.videoSettings + if (getQualityType() == VideoQualityType.Streaming) { + preferencesManager.videoSettings = currentSettings.copy(videoStreamingQuality = quality) + } else { + preferencesManager.videoSettings = currentSettings.copy(videoDownloadQuality = quality) + } + _videoQuality.value = getCurrentVideoQuality() + viewModelScope.launch { + notifier.send(VideoQualityChanged()) + } + } + + fun getQualityType() = VideoQualityType.valueOf(qualityType) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadEvent.kt new file mode 100644 index 000000000..232616f79 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +interface DownloadEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt new file mode 100644 index 000000000..eb16cf99f --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadNotifier.kt @@ -0,0 +1,15 @@ +package org.openedx.core.system.notifier + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class DownloadNotifier { + + private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) + + val notifier: Flow = channel.asSharedFlow() + + suspend fun send(event: DownloadProgressChanged) = channel.emit(event) + +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt b/core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt new file mode 100644 index 000000000..474b25f2f --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/DownloadProgressChanged.kt @@ -0,0 +1,5 @@ +package org.openedx.core.system.notifier + +data class DownloadProgressChanged( + val id: String, val value: Long, val size: Long +) : DownloadEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/VideoEvent.kt b/core/src/main/java/org/openedx/core/system/notifier/VideoEvent.kt new file mode 100644 index 000000000..117019693 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/VideoEvent.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +interface VideoEvent diff --git a/core/src/main/java/org/openedx/core/system/notifier/VideoNotifier.kt b/core/src/main/java/org/openedx/core/system/notifier/VideoNotifier.kt new file mode 100644 index 000000000..47287c603 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/VideoNotifier.kt @@ -0,0 +1,14 @@ +package org.openedx.core.system.notifier + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +class VideoNotifier { + + private val channel = MutableSharedFlow(replay = 0, extraBufferCapacity = 0) + + val notifier: Flow = channel.asSharedFlow() + + suspend fun send(event: VideoQualityChanged) = channel.emit(event) +} diff --git a/core/src/main/java/org/openedx/core/system/notifier/VideoQualityChanged.kt b/core/src/main/java/org/openedx/core/system/notifier/VideoQualityChanged.kt new file mode 100644 index 000000000..85eeb38c9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/system/notifier/VideoQualityChanged.kt @@ -0,0 +1,3 @@ +package org.openedx.core.system.notifier + +class VideoQualityChanged : VideoEvent diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml index ff7fa8fd6..5287ad2ea 100644 --- a/core/src/main/res/values-uk/strings.xml +++ b/core/src/main/res/values-uk/strings.xml @@ -65,4 +65,6 @@ %1$s зображення профілю + + Якість транслювання відео diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index c493da626..10ed72367 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -26,6 +26,9 @@ Password Soon Offline + Warning + Delete + Confirm Dismiss Reload Downloading in progress @@ -34,7 +37,7 @@ 360p Lower data usage 540p - 720p translatable="false" + 720p Best quality User account is not activated. Please activate your account first. Send email using… @@ -112,4 +115,12 @@ %1$s profile image + + Download to device + Downloading videos… + All videos downloaded + Remaining %d, %s Total + Videos %d, %s Total + Video streaming quality + Video download quality diff --git a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt index 8b0fb0f03..52a8a55ec 100644 --- a/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt +++ b/course/src/main/java/org/openedx/course/domain/interactor/CourseInteractor.kt @@ -43,15 +43,13 @@ class CourseInteractor( blocks.firstOrNull { it.descendants.contains(sequentialBlock.id) } if (chapterBlock != null) { resultBlocks.add(videoBlock) - if (!resultBlocks.contains(verticalBlock)) { + val verticalIndex = resultBlocks.indexOfFirst { it.id == verticalBlock.id } + if (verticalIndex == -1) { resultBlocks.add(verticalBlock.copy(descendants = listOf(videoBlock.id))) } else { - val index = resultBlocks.indexOfFirst { it.id == verticalBlock.id } - if (index != -1) { - val block = resultBlocks[index] - resultBlocks[index] = - block.copy(descendants = block.descendants + videoBlock.id) - } + val block = resultBlocks[verticalIndex] + resultBlocks[verticalIndex] = + block.copy(descendants = block.descendants + videoBlock.id) } if (!resultBlocks.contains(sequentialBlock)) { resultBlocks.add(sequentialBlock) diff --git a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt index 2eafb8d97..fae658fde 100644 --- a/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt +++ b/course/src/main/java/org/openedx/course/presentation/CourseRouter.kt @@ -2,6 +2,7 @@ package org.openedx.course.presentation import androidx.fragment.app.FragmentManager import org.openedx.core.presentation.course.CourseViewMode +import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.course.presentation.handouts.HandoutsType interface CourseRouter { @@ -73,4 +74,8 @@ interface CourseRouter { fun navigateToSignIn(fm: FragmentManager, courseId: String?, infoType: String?) fun navigateToLogistration(fm: FragmentManager, courseId: String?) + + fun navigateToDownloadQueue(fm: FragmentManager, descendants: List = arrayListOf()) + + fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt index cb9ca5930..d9447487c 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerAdapter.kt @@ -24,8 +24,8 @@ class CourseContainerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment enum class CourseContainerTab(val itemId: Int, val titleResId: Int) { COURSE(itemId = R.id.course, titleResId = R.string.course_navigation_course), - VIDEOS(itemId = R.id.videos, titleResId = R.string.course_navigation_video), - DISCUSSION(itemId = R.id.discussions, titleResId = R.string.course_navigation_discussion), + VIDEOS(itemId = R.id.videos, titleResId = R.string.course_navigation_videos), + DISCUSSION(itemId = R.id.discussions, titleResId = R.string.course_navigation_discussions), DATES(itemId = R.id.dates, titleResId = R.string.course_navigation_dates), HANDOUTS(itemId = R.id.resources, titleResId = R.string.course_navigation_handouts), } diff --git a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt index f2a27d510..936e75294 100644 --- a/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/container/CourseContainerFragment.kt @@ -173,3 +173,4 @@ class CourseContainerFragment : Fragment(R.layout.fragment_course_container) { } } } + diff --git a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt index e760400e9..f00055fa9 100644 --- a/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/outline/CourseOutlineFragment.kt @@ -181,9 +181,12 @@ class CourseOutlineFragment : Fragment() { }, onDownloadClick = { if (viewModel.isBlockDownloading(it.id)) { - viewModel.cancelWork(it.id) + router.navigateToDownloadQueue( + fm = requireActivity().supportFragmentManager, + viewModel.getDownloadableChildren(it.id) ?: arrayListOf() + ) } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadedModels(it.id) + viewModel.removeDownloadModels(it.id) } else { viewModel.saveDownloadModels( requireContext().externalCacheDir.toString() + diff --git a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt index fe6205879..882bafc9b 100644 --- a/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/section/CourseSectionFragment.kt @@ -96,10 +96,8 @@ class CourseSectionFragment : Fragment() { } }, onDownloadClick = { - if (viewModel.isBlockDownloading(it.id)) { - viewModel.cancelWork(it.id) - } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadedModels(it.id) + if (viewModel.isBlockDownloading(it.id) || viewModel.isBlockDownloaded(it.id)) { + viewModel.removeDownloadModels(it.id) } else { viewModel.saveDownloadModels( requireContext().externalCacheDir.toString() + diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt index 3595c9652..63e11c7f5 100644 --- a/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseUI.kt @@ -35,6 +35,7 @@ import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Divider import androidx.compose.material.Icon import androidx.compose.material.IconButton +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.Scaffold @@ -86,7 +87,10 @@ import org.openedx.core.domain.model.EnrolledCourse import org.openedx.core.domain.model.EnrolledCourseData import org.openedx.core.extension.isLinkValid import org.openedx.core.extension.nonZero +import org.openedx.core.extension.toFileSize +import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType import org.openedx.core.ui.BackBtn import org.openedx.core.ui.IconText import org.openedx.core.ui.OpenEdXButton @@ -292,6 +296,77 @@ fun CourseSectionCard( } } +@Composable +fun OfflineQueueCard( + downloadModel: DownloadModel, + progressValue: Long, + progressSize: Long, + onDownloadClick: (DownloadModel) -> Unit +) { + val iconModifier = Modifier.size(24.dp) + + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + .padding(start = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + modifier = Modifier + .weight(1f) + ) { + Text( + text = downloadModel.title.ifEmpty { stringResource(id = R.string.course_download_untitled) }, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + Text( + text = downloadModel.size.toLong().toFileSize(), + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textSecondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + + val progress = progressValue.toFloat() / progressSize + + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + progress = progress + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier + .padding(end = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + backgroundColor = Color.LightGray, + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary + ) + IconButton( + modifier = iconModifier + .padding(2.dp), + onClick = { onDownloadClick(downloadModel) }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), + tint = MaterialTheme.appColors.error + ) + } + } + } +} + @Composable fun CardArrow( degrees: Float @@ -758,10 +833,12 @@ fun CourseSubSectionItem( IconButton( modifier = iconModifier.padding(2.dp), onClick = { onDownloadClick(block) }) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.course_accessibility_stop_downloading_course_section), - tint = MaterialTheme.appColors.error + Text( + modifier = Modifier + .padding(bottom = 4.dp), + text = "i", + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.primary ) } } @@ -1206,6 +1283,31 @@ private fun CourseDatesBannerTabletPreview() { } } +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun OfflineQueueCardPreview() { + OpenEdXTheme { + Surface(color = MaterialTheme.appColors.background) { + OfflineQueueCard( + downloadModel = DownloadModel( + id = "", + title = "Problems of society", + size = 4000, + path = "", + url = "", + type = FileType.VIDEO, + downloadedState = DownloadedState.DOWNLOADING, + progress = 0f + ), + progressValue = 10, + progressSize = 30, + onDownloadClick = {} + ) + } + } +} + private val mockCourse = EnrolledCourse( auditAccessExpires = Date(), created = "created", diff --git a/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt new file mode 100644 index 000000000..8d5bc7172 --- /dev/null +++ b/course/src/main/java/org/openedx/course/presentation/ui/CourseVideosUI.kt @@ -0,0 +1,827 @@ +package org.openedx.course.presentation.ui + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.AlertDialog +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.LinearProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Switch +import androidx.compose.material.SwitchDefaults +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Videocam +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.core.AppDataConstants +import org.openedx.core.BlockType +import org.openedx.core.UIMessage +import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.BlockCounts +import org.openedx.core.domain.model.CourseStructure +import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.VideoSettings +import org.openedx.core.extension.toFileSize +import org.openedx.core.module.download.DownloadModelsSize +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OfflineModeDialog +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.course.R +import org.openedx.course.presentation.videos.CourseVideosUIState +import java.util.Date + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun CourseVideosScreen( + windowSize: WindowSize, + uiState: CourseVideosUIState, + uiMessage: UIMessage?, + courseTitle: String, + apiHostUrl: String, + isCourseNestedListEnabled: Boolean, + isCourseBannerEnabled: Boolean, + isUpdating: Boolean, + hasInternetConnection: Boolean, + videoSettings: VideoSettings, + onSwipeRefresh: () -> Unit, + onItemClick: (Block) -> Unit, + onExpandClick: (Block) -> Unit, + onSubSectionClick: (Block) -> Unit, + onReloadClick: () -> Unit, + onDownloadClick: (Block) -> Unit, + onDownloadAllClick: (Boolean) -> Unit, + onDownloadQueueClick: () -> Unit, + onVideoDownloadQualityClick: () -> Unit +) { + val scaffoldState = rememberScaffoldState() + val pullRefreshState = + rememberPullRefreshState(refreshing = isUpdating, onRefresh = { onSwipeRefresh() }) + + var isInternetConnectionShown by rememberSaveable { + mutableStateOf(false) + } + + Scaffold( + modifier = Modifier + .fillMaxSize(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { + + val screenWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + val listBottomPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = PaddingValues(bottom = 24.dp), + compact = PaddingValues(bottom = 24.dp) + ) + ) + } + + val listPadding by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.padding(horizontal = 6.dp), + compact = Modifier.padding(horizontal = 24.dp) + ) + ) + } + + var isDownloadConfirmationShowed by rememberSaveable { + mutableStateOf(false) + } + + var isDeleteDownloadsConfirmationShowed by rememberSaveable { + mutableStateOf(false) + } + + var deleteDownloadBlock by rememberSaveable { + mutableStateOf(null) + } + + HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Surface( + modifier = screenWidth, + color = MaterialTheme.appColors.background + ) { + Box(Modifier.pullRefresh(pullRefreshState)) { + Column( + Modifier + .fillMaxSize() + ) { + when (uiState) { + is CourseVideosUIState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(id = R.string.course_does_not_include_videos), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.headlineSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 40.dp) + ) + } + } + + is CourseVideosUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is CourseVideosUIState.CourseData -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = listBottomPadding + ) { + if (isCourseBannerEnabled) { + item { + CourseImageHeader( + modifier = Modifier + .aspectRatio(1.86f) + .padding(6.dp), + apiHostUrl = apiHostUrl, + courseImage = uiState.courseStructure.media?.image?.large + ?: "", + courseCertificate = uiState.courseStructure.certificate, + courseName = uiState.courseStructure.name + ) + } + } + + if (uiState.downloadModelsSize.allCount > 0) { + item { + AllVideosDownloadItem( + downloadModelsSize = uiState.downloadModelsSize, + videoSettings = videoSettings, + onShowDownloadConfirmationDialog = { + isDownloadConfirmationShowed = true + }, + onDownloadAllClick = { isSwitched -> + if (isSwitched) { + isDeleteDownloadsConfirmationShowed = true + + } else { + onDownloadAllClick(false) + } + }, + onDownloadQueueClick = onDownloadQueueClick, + onVideoDownloadQualityClick = onVideoDownloadQualityClick + ) + } + } + + if (isCourseNestedListEnabled) { + uiState.courseStructure.blockData.forEach { section -> + val courseSubSections = + uiState.courseSubSections[section.id] + val courseSectionsState = + uiState.courseSectionsState[section.id] + + item { + Column { + CourseExpandableChapterCard( + modifier = listPadding, + block = section, + onItemClick = onExpandClick, + arrowDegrees = if (courseSectionsState == true) -90f else 90f + ) + Divider() + } + } + + courseSubSections?.forEach { subSectionBlock -> + item { + Column { + AnimatedVisibility( + visible = courseSectionsState == true + ) { + Column { + val downloadsCount = + uiState.subSectionsDownloadsCount[subSectionBlock.id] + ?: 0 + + CourseSubSectionItem( + modifier = listPadding, + block = subSectionBlock, + downloadedState = uiState.downloadedState[subSectionBlock.id], + downloadsCount = downloadsCount, + onClick = onSubSectionClick, + onDownloadClick = { block -> + if (uiState.downloadedState[block.id]?.isDownloaded == true) { + deleteDownloadBlock = + block + + } else { + onDownloadClick(block) + } + } + ) + Divider() + } + } + } + } + } + } + return@LazyColumn + } + + items(uiState.courseStructure.blockData) { block -> + Column(listPadding) { + if (block.type == BlockType.CHAPTER) { + Text( + modifier = Modifier.padding( + top = 36.dp, + bottom = 8.dp + ), + text = block.displayName, + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimaryVariant + ) + } else { + CourseSectionCard( + block = block, + downloadedState = uiState.downloadedState[block.id], + onItemClick = onItemClick, + onDownloadClick = { block -> + if (uiState.downloadedState[block.id]?.isDownloaded == true) { + deleteDownloadBlock = block + + } else { + onDownloadClick(block) + } + } + ) + Divider() + } + } + } + } + } + } + } + PullRefreshIndicator( + isUpdating, + pullRefreshState, + Modifier.align(Alignment.TopCenter) + ) + if (!isInternetConnectionShown && !hasInternetConnection) { + OfflineModeDialog( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + onDismissCLick = { + isInternetConnectionShown = true + }, + onReloadClick = { + isInternetConnectionShown = true + onReloadClick() + } + ) + } + } + } + } + + if (isDownloadConfirmationShowed) { + AlertDialog( + title = { + Text( + text = stringResource(id = R.string.course_download_big_files_confirmation_title) + ) + }, + text = { + Text( + text = stringResource(id = R.string.course_download_big_files_confirmation_text) + ) + }, + onDismissRequest = { + isDownloadConfirmationShowed = false + }, + confirmButton = { + TextButton( + onClick = { + isDownloadConfirmationShowed = false + onDownloadAllClick(false) + } + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_confirm) + ) + } + }, + dismissButton = { + TextButton( + onClick = { + isDownloadConfirmationShowed = false + } + ) { + Text(text = stringResource(id = org.openedx.core.R.string.core_dismiss)) + } + } + ) + } + + if (isDeleteDownloadsConfirmationShowed) { + val downloadModelsSize = + (uiState as? CourseVideosUIState.CourseData)?.downloadModelsSize + val isDownloadedAllVideos = + downloadModelsSize?.isAllBlocksDownloadedOrDownloading == true && + downloadModelsSize.remainingCount == 0 + val dialogTextId = if (isDownloadedAllVideos) + R.string.course_delete_downloads_confirmation_text else + R.string.course_delete_while_downloading_confirmation_text + + AlertDialog( + title = { + Text( + text = stringResource(id = org.openedx.core.R.string.core_warning) + ) + }, + text = { + Text( + text = stringResource(id = dialogTextId, courseTitle) + ) + }, + onDismissRequest = { + isDeleteDownloadsConfirmationShowed = false + }, + confirmButton = { + TextButton( + onClick = { + isDeleteDownloadsConfirmationShowed = false + onDownloadAllClick(true) + } + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_delete) + ) + } + }, + dismissButton = { + TextButton( + onClick = { + isDeleteDownloadsConfirmationShowed = false + } + ) { + Text(text = stringResource(id = org.openedx.core.R.string.core_cancel)) + } + } + ) + } + + if (deleteDownloadBlock != null) { + AlertDialog( + title = { + Text( + text = stringResource(id = org.openedx.core.R.string.core_warning) + ) + }, + text = { + Text( + text = stringResource( + id = R.string.course_delete_download_confirmation_text, + deleteDownloadBlock?.displayName ?: "" + ) + ) + }, + onDismissRequest = { + deleteDownloadBlock = null + }, + confirmButton = { + TextButton( + onClick = { + deleteDownloadBlock?.let { block -> + onDownloadClick(block) + } + deleteDownloadBlock = null + } + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_delete) + ) + } + }, + dismissButton = { + TextButton( + onClick = { + deleteDownloadBlock = null + } + ) { + Text(text = stringResource(id = org.openedx.core.R.string.core_cancel)) + } + } + ) + } + } +} + +@Composable +private fun AllVideosDownloadItem( + downloadModelsSize: DownloadModelsSize, + videoSettings: VideoSettings, + onShowDownloadConfirmationDialog: () -> Unit, + onDownloadAllClick: (Boolean) -> Unit, + onDownloadQueueClick: () -> Unit, + onVideoDownloadQualityClick: () -> Unit +) { + val isDownloadingAllVideos = + downloadModelsSize.isAllBlocksDownloadedOrDownloading && + downloadModelsSize.remainingCount > 0 + val isDownloadedAllVideos = + downloadModelsSize.isAllBlocksDownloadedOrDownloading && + downloadModelsSize.remainingCount == 0 + + val downloadVideoTitleRes = when { + isDownloadingAllVideos -> org.openedx.core.R.string.core_video_downloading_to_device + isDownloadedAllVideos -> org.openedx.core.R.string.core_video_downloaded_to_device + else -> org.openedx.core.R.string.core_video_download_to_device + } + val downloadVideoSubTitle = + if (isDownloadedAllVideos) { + stringResource( + id = org.openedx.core.R.string.core_video_downloaded_subtitle, + downloadModelsSize.allCount, + downloadModelsSize.allSize.toFileSize() + ) + } else { + stringResource( + id = org.openedx.core.R.string.core_video_remaining_to_download, + downloadModelsSize.remainingCount, + downloadModelsSize.remainingSize.toFileSize() + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onDownloadQueueClick() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (isDownloadingAllVideos) { + CircularProgressIndicator( + modifier = Modifier + .padding(start = 16.dp) + .size(24.dp), + color = MaterialTheme.appColors.primary, + strokeWidth = 2.dp + ) + } else { + Icon( + modifier = Modifier + .padding(start = 16.dp), + imageVector = Icons.Outlined.Videocam, + tint = MaterialTheme.appColors.onSurface, + contentDescription = null + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(8.dp) + ) { + Text( + text = stringResource(id = downloadVideoTitleRes), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = downloadVideoSubTitle, + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelMedium + ) + } + val isChecked = downloadModelsSize.isAllBlocksDownloadedOrDownloading + Switch( + modifier = Modifier + .padding(end = 16.dp), + checked = isChecked, + onCheckedChange = { + if (!isChecked) { + if ( + downloadModelsSize.remainingSize > AppDataConstants.DOWNLOADS_CONFIRMATION_SIZE + ) { + onShowDownloadConfirmationDialog() + } else { + onDownloadAllClick(false) + } + + } else { + onDownloadAllClick(true) + } + }, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.appColors.primary, + checkedTrackColor = MaterialTheme.appColors.primary + ) + ) + } + if (isDownloadingAllVideos) { + val progress = 1 - downloadModelsSize.remainingSize.toFloat() / downloadModelsSize.allSize + + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(durationMillis = 2000, easing = LinearEasing), + label = "ProgressAnimation" + ) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth(), + progress = animatedProgress + ) + } + Divider() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onVideoDownloadQualityClick() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + modifier = Modifier + .padding(start = 16.dp), + imageVector = Icons.Outlined.Settings, + tint = MaterialTheme.appColors.onSurface, + contentDescription = null + ) + Column( + modifier = Modifier + .weight(1f) + .padding(8.dp) + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_video_download_quality), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(id = videoSettings.videoDownloadQuality.titleResId), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelMedium + ) + } + Icon( + modifier = Modifier + .padding(end = 16.dp), + imageVector = Icons.Filled.ChevronRight, + tint = MaterialTheme.appColors.onSurface, + contentDescription = "Expandable Arrow" + ) + } + Divider() +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseVideosScreenPreview() { + OpenEdXTheme { + CourseVideosScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiMessage = null, + uiState = CourseVideosUIState.CourseData( + mockCourseStructure, + emptyMap(), + mapOf(), + mapOf(), + mapOf(), + DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 0, + allSize = 0 + ) + ), + courseTitle = "", + apiHostUrl = "", + isCourseNestedListEnabled = false, + isCourseBannerEnabled = true, + onItemClick = { }, + onExpandClick = { }, + onSubSectionClick = { }, + hasInternetConnection = true, + isUpdating = false, + videoSettings = VideoSettings.default, + onSwipeRefresh = {}, + onReloadClick = {}, + onDownloadClick = {}, + onDownloadAllClick = {}, + onDownloadQueueClick = {}, + onVideoDownloadQualityClick = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CourseVideosScreenEmptyPreview() { + OpenEdXTheme { + CourseVideosScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiMessage = null, + uiState = CourseVideosUIState.Empty( + "This course does not include any videos." + ), + courseTitle = "", + apiHostUrl = "", + isCourseNestedListEnabled = false, + isCourseBannerEnabled = true, + onItemClick = { }, + onExpandClick = { }, + onSubSectionClick = { }, + onSwipeRefresh = {}, + onReloadClick = {}, + hasInternetConnection = true, + isUpdating = false, + videoSettings = VideoSettings.default, + onDownloadClick = {}, + onDownloadAllClick = {}, + onDownloadQueueClick = {}, + onVideoDownloadQualityClick = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) +@Composable +private fun CourseVideosScreenTabletPreview() { + OpenEdXTheme { + CourseVideosScreen( + windowSize = WindowSize(WindowType.Medium, WindowType.Medium), + uiMessage = null, + uiState = CourseVideosUIState.CourseData( + mockCourseStructure, + emptyMap(), + mapOf(), + mapOf(), + mapOf(), + DownloadModelsSize( + isAllBlocksDownloadedOrDownloading = false, + remainingCount = 0, + remainingSize = 0, + allCount = 0, + allSize = 0 + ) + ), + courseTitle = "", + apiHostUrl = "", + isCourseNestedListEnabled = false, + isCourseBannerEnabled = true, + onItemClick = { }, + onExpandClick = { }, + onSubSectionClick = { }, + onSwipeRefresh = {}, + onReloadClick = {}, + isUpdating = false, + hasInternetConnection = true, + videoSettings = VideoSettings.default, + onDownloadClick = {}, + onDownloadAllClick = {}, + onDownloadQueueClick = {}, + onVideoDownloadQualityClick = {} + ) + } +} + + +private val mockChapterBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.CHAPTER, + displayName = "Chapter", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.CHAPTER, + completion = 0.0, + containsGatedContent = false +) + +private val mockSequentialBlock = Block( + id = "id", + blockId = "blockId", + lmsWebUrl = "lmsWebUrl", + legacyWebUrl = "legacyWebUrl", + studentViewUrl = "studentViewUrl", + type = BlockType.SEQUENTIAL, + displayName = "Sequential", + graded = false, + studentViewData = null, + studentViewMultiDevice = false, + blockCounts = BlockCounts(1), + descendants = emptyList(), + descendantsType = BlockType.SEQUENTIAL, + completion = 0.0, + containsGatedContent = false +) + +private val mockCourseStructure = CourseStructure( + root = "", + blockData = listOf(mockSequentialBlock, mockChapterBlock), + id = "id", + name = "Course name", + number = "", + org = "Org", + start = Date(), + startDisplay = "", + startType = "", + end = Date(), + coursewareAccess = CoursewareAccess( + true, + "", + "", + "", + "", + "" + ), + media = null, + certificate = null, + isSelfPaced = false +) diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt index 73b15366f..1ff0df22e 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/EncodedVideoUnitViewModel.kt @@ -141,5 +141,5 @@ class EncodedVideoUnitViewModel( ).build() } - private fun getVideoQuality() = preferencesManager.videoSettings.videoQuality -} \ No newline at end of file + private fun getVideoQuality() = preferencesManager.videoSettings.videoStreamingQuality +} diff --git a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt index 6e127aeb4..c5d1430a7 100644 --- a/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/unit/video/VideoViewModel.kt @@ -49,5 +49,5 @@ class VideoViewModel( } } - fun getVideoQuality() = preferencesManager.videoSettings.videoQuality -} \ No newline at end of file + fun getVideoQuality() = preferencesManager.videoSettings.videoStreamingQuality +} diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt index 196bd28d2..03190efce 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideoViewModel.kt @@ -1,9 +1,10 @@ package org.openedx.course.presentation.videos -import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.core.BlockType import org.openedx.core.SingleEventLiveData @@ -11,6 +12,7 @@ import org.openedx.core.UIMessage import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block +import org.openedx.core.domain.model.VideoSettings import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.BaseDownloadViewModel @@ -18,6 +20,8 @@ import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.system.notifier.VideoQualityChanged import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics @@ -29,7 +33,8 @@ class CourseVideoViewModel( private val resourceManager: ResourceManager, private val networkConnection: NetworkConnection, private val preferencesManager: CorePreferences, - private val notifier: CourseNotifier, + private val courseNotifier: CourseNotifier, + private val videoNotifier: VideoNotifier, private val analytics: CourseAnalytics, downloadDao: DownloadDao, workerController: DownloadWorkerController @@ -55,6 +60,9 @@ class CourseVideoViewModel( val uiMessage: LiveData get() = _uiMessage + private val _videoSettings = MutableStateFlow(VideoSettings.default) + val videoSettings = _videoSettings.asStateFlow() + val hasInternetConnection: Boolean get() = networkConnection.isOnline() @@ -62,10 +70,9 @@ class CourseVideoViewModel( private val subSectionsDownloadsCount = mutableMapOf() val courseSubSectionUnit = mutableMapOf() - override fun onCreate(owner: LifecycleOwner) { - super.onCreate(owner) + init { viewModelScope.launch { - notifier.notifier.collect { event -> + courseNotifier.notifier.collect { event -> if (event is CourseStructureUpdated) { if (event.courseId == courseId) { updateVideos() @@ -78,20 +85,32 @@ class CourseVideoViewModel( downloadModelsStatusFlow.collect { if (_uiState.value is CourseVideosUIState.CourseData) { val state = _uiState.value as CourseVideosUIState.CourseData - _uiState.value = CourseVideosUIState.CourseData( - courseStructure = state.courseStructure, + _uiState.value = state.copy( downloadedState = it.toMap(), - courseSubSections = courseSubSections, - courseSectionsState = state.courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount + downloadModelsSize = getDownloadModelsSize() ) } } } - } - init { + viewModelScope.launch { + videoNotifier.notifier.collect { event -> + if (event is VideoQualityChanged) { + _videoSettings.value = preferencesManager.videoSettings + + if (_uiState.value is CourseVideosUIState.CourseData) { + val state = _uiState.value as CourseVideosUIState.CourseData + _uiState.value = state.copy( + downloadModelsSize = getDownloadModelsSize() + ) + } + } + } + } + getVideos() + + _videoSettings.value = preferencesManager.videoSettings } override fun saveDownloadModels(folder: String, id: String) { @@ -107,6 +126,16 @@ class CourseVideoViewModel( } } + override fun saveAllDownloadModels(folder: String) { + if (preferencesManager.videoSettings.wifiDownloadOnly && !networkConnection.isWifiConnected()) { + _uiMessage.value = + UIMessage.ToastMessage(resourceManager.getString(R.string.course_can_download_only_with_wifi)) + return + } + + super.saveAllDownloadModels(folder) + } + fun setIsUpdating() { _isUpdating.value = true } @@ -137,7 +166,7 @@ class CourseVideoViewModel( _uiState.value = CourseVideosUIState.CourseData( courseStructure, getDownloadModelsStatus(), courseSubSections, - courseSectionsState, subSectionsDownloadsCount + courseSectionsState, subSectionsDownloadsCount, getDownloadModelsSize() ) } } @@ -149,13 +178,7 @@ class CourseVideoViewModel( val courseSectionsState = state.courseSectionsState.toMutableMap() courseSectionsState[blockId] = !(state.courseSectionsState[blockId] ?: false) - _uiState.value = CourseVideosUIState.CourseData( - courseStructure = state.courseStructure, - downloadedState = state.downloadedState, - courseSubSections = courseSubSections, - courseSectionsState = courseSectionsState, - subSectionsDownloadsCount = subSectionsDownloadsCount - ) + _uiState.value = state.copy(courseSectionsState = courseSectionsState) } } @@ -166,6 +189,12 @@ class CourseVideoViewModel( } } + fun onChangingVideoQualityWhileDownloading() { + _uiMessage.value = UIMessage.SnackBarMessage( + resourceManager.getString(R.string.course_change_quality_when_downloading) + ) + } + private fun sortBlocks(blocks: List): List { val resultBlocks = mutableListOf() if (blocks.isEmpty()) return emptyList() @@ -190,4 +219,4 @@ class CourseVideoViewModel( } return resultBlocks.toList() } -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt index 97d7c8747..1d6f258f1 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosFragment.kt @@ -1,56 +1,27 @@ package org.openedx.course.presentation.videos -import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.* -import androidx.compose.material.pullrefresh.PullRefreshIndicator -import androidx.compose.material.pullrefresh.pullRefresh -import androidx.compose.material.pullrefresh.rememberPullRefreshState -import androidx.compose.runtime.* +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf -import org.openedx.core.BlockType import org.openedx.core.R -import org.openedx.core.UIMessage -import org.openedx.core.domain.model.Block -import org.openedx.core.domain.model.BlockCounts -import org.openedx.core.domain.model.CourseStructure -import org.openedx.core.domain.model.CoursewareAccess import org.openedx.core.presentation.course.CourseViewMode -import org.openedx.core.ui.* +import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appShapes -import org.openedx.core.ui.theme.appTypography import org.openedx.course.presentation.CourseRouter import org.openedx.course.presentation.container.CourseContainerFragment -import org.openedx.course.presentation.ui.CourseExpandableChapterCard -import org.openedx.course.presentation.ui.CourseImageHeader -import org.openedx.course.presentation.ui.CourseSectionCard -import org.openedx.course.presentation.ui.CourseSubSectionItem +import org.openedx.course.presentation.ui.CourseVideosScreen import java.io.File -import java.util.Date class CourseVideosFragment : Fragment() { @@ -80,16 +51,19 @@ class CourseVideosFragment : Fragment() { val uiState by viewModel.uiState.observeAsState(CourseVideosUIState.Loading) val uiMessage by viewModel.uiMessage.observeAsState() val isUpdating by viewModel.isUpdating.observeAsState(false) + val videoSettings by viewModel.videoSettings.collectAsState() CourseVideosScreen( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, + courseTitle = viewModel.courseTitle, apiHostUrl = viewModel.apiHostUrl, isCourseNestedListEnabled = viewModel.isCourseNestedListEnabled, isCourseBannerEnabled = viewModel.isCourseBannerEnabled, hasInternetConnection = viewModel.hasInternetConnection, isUpdating = isUpdating, + videoSettings = videoSettings, onSwipeRefresh = { viewModel.setIsUpdating() (parentFragment as CourseContainerFragment).updateCourseStructure(true) @@ -121,9 +95,12 @@ class CourseVideosFragment : Fragment() { }, onDownloadClick = { if (viewModel.isBlockDownloading(it.id)) { - viewModel.cancelWork(it.id) + router.navigateToDownloadQueue( + fm = requireActivity().supportFragmentManager, + viewModel.getDownloadableChildren(it.id) ?: arrayListOf() + ) } else if (viewModel.isBlockDownloaded(it.id)) { - viewModel.removeDownloadedModels(it.id) + viewModel.removeDownloadModels(it.id) } else { viewModel.saveDownloadModels( requireContext().externalCacheDir.toString() + @@ -133,6 +110,33 @@ class CourseVideosFragment : Fragment() { .replace(Regex("\\s"), "_"), it.id ) } + }, + onDownloadAllClick = { isAllBlocksDownloadedOrDownloading -> + if (isAllBlocksDownloadedOrDownloading) { + viewModel.removeAllDownloadModels() + } else { + viewModel.saveAllDownloadModels( + requireContext().externalCacheDir.toString() + + File.separator + + requireContext() + .getString(R.string.app_name) + .replace(Regex("\\s"), "_") + ) + } + }, + onDownloadQueueClick = { + if (viewModel.hasDownloadModelsInQueue()) { + router.navigateToDownloadQueue(fm = requireActivity().supportFragmentManager) + } + }, + onVideoDownloadQualityClick = { + if (viewModel.hasDownloadModelsInQueue()) { + viewModel.onChangingVideoQualityWhileDownloading() + } else { + router.navigateToVideoQuality( + requireActivity().supportFragmentManager, VideoQualityType.Download + ) + } } ) } @@ -156,376 +160,3 @@ class CourseVideosFragment : Fragment() { } } -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun CourseVideosScreen( - windowSize: WindowSize, - uiState: CourseVideosUIState, - uiMessage: UIMessage?, - apiHostUrl: String, - isCourseNestedListEnabled: Boolean, - isCourseBannerEnabled: Boolean, - isUpdating: Boolean, - hasInternetConnection: Boolean, - onSwipeRefresh: () -> Unit, - onItemClick: (Block) -> Unit, - onExpandClick: (Block) -> Unit, - onSubSectionClick: (Block) -> Unit, - onReloadClick: () -> Unit, - onDownloadClick: (Block) -> Unit -) { - val scaffoldState = rememberScaffoldState() - val pullRefreshState = - rememberPullRefreshState(refreshing = isUpdating, onRefresh = { onSwipeRefresh() }) - - var isInternetConnectionShown by rememberSaveable { - mutableStateOf(false) - } - - Scaffold( - modifier = Modifier - .fillMaxSize(), - scaffoldState = scaffoldState, - backgroundColor = MaterialTheme.appColors.background - ) { - - val screenWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), - compact = Modifier.fillMaxWidth() - ) - ) - } - - val listBottomPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = PaddingValues(bottom = 24.dp), - compact = PaddingValues(bottom = 24.dp) - ) - ) - } - - val listPadding by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.padding(horizontal = 6.dp), - compact = Modifier.padding(horizontal = 24.dp) - ) - ) - } - - HandleUIMessage(uiMessage = uiMessage, scaffoldState = scaffoldState) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(it) - .displayCutoutForLandscape(), - contentAlignment = Alignment.TopCenter - ) { - Surface( - modifier = screenWidth, - color = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.screenBackgroundShape - ) { - Box(Modifier.pullRefresh(pullRefreshState)) { - Column( - Modifier - .fillMaxSize() - ) { - when (uiState) { - is CourseVideosUIState.Empty -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(id = org.openedx.course.R.string.course_does_not_include_videos), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.headlineSmall, - textAlign = TextAlign.Center, - modifier = Modifier.padding(horizontal = 40.dp) - ) - } - } - - is CourseVideosUIState.Loading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } - } - - is CourseVideosUIState.CourseData -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = listBottomPadding - ) { - if (isCourseBannerEnabled) { - item { - CourseImageHeader( - modifier = Modifier - .aspectRatio(1.86f) - .padding(6.dp), - apiHostUrl = apiHostUrl, - courseImage = uiState.courseStructure.media?.image?.large - ?: "", - courseCertificate = uiState.courseStructure.certificate, - courseName = uiState.courseStructure.name - ) - } - } - - if (isCourseNestedListEnabled) { - item { - Spacer(Modifier.height(16.dp)) - } - uiState.courseStructure.blockData.forEach { section -> - val courseSubSections = - uiState.courseSubSections[section.id] - val courseSectionsState = - uiState.courseSectionsState[section.id] - - item { - Column { - CourseExpandableChapterCard( - modifier = listPadding, - block = section, - onItemClick = onExpandClick, - arrowDegrees = if (courseSectionsState == true) -90f else 90f - ) - Divider() - } - } - - courseSubSections?.forEach { subSectionBlock -> - item { - Column { - AnimatedVisibility( - visible = courseSectionsState == true - ) { - Column { - val downloadsCount = - uiState.subSectionsDownloadsCount[subSectionBlock.id] - ?: 0 - - CourseSubSectionItem( - modifier = listPadding, - block = subSectionBlock, - downloadedState = uiState.downloadedState[subSectionBlock.id], - downloadsCount = downloadsCount, - onClick = onSubSectionClick, - onDownloadClick = onDownloadClick - ) - Divider() - } - } - } - } - } - } - return@LazyColumn - } - - items(uiState.courseStructure.blockData) { block -> - Column(listPadding) { - if (block.type == BlockType.CHAPTER) { - Text( - modifier = Modifier.padding( - top = 36.dp, - bottom = 8.dp - ), - text = block.displayName, - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimaryVariant - ) - } else { - CourseSectionCard( - block = block, - downloadedState = uiState.downloadedState[block.id], - onItemClick = onItemClick, - onDownloadClick = onDownloadClick - ) - Divider() - } - } - } - } - } - } - } - PullRefreshIndicator( - isUpdating, - pullRefreshState, - Modifier.align(Alignment.TopCenter) - ) - if (!isInternetConnectionShown && !hasInternetConnection) { - OfflineModeDialog( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - onDismissCLick = { - isInternetConnectionShown = true - }, - onReloadClick = { - isInternetConnectionShown = true - onReloadClick() - } - ) - } - } - } - } - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseVideosScreenPreview() { - OpenEdXTheme { - CourseVideosScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiMessage = null, - uiState = CourseVideosUIState.CourseData( - mockCourseStructure, - emptyMap(), - mapOf(), - mapOf(), - mapOf() - ), - apiHostUrl = "", - isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, - onItemClick = { }, - onExpandClick = { }, - onSubSectionClick = { }, - hasInternetConnection = true, - isUpdating = false, - onSwipeRefresh = {}, - onReloadClick = {}, - onDownloadClick = {} - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Composable -private fun CourseVideosScreenEmptyPreview() { - OpenEdXTheme { - CourseVideosScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - uiMessage = null, - uiState = CourseVideosUIState.Empty( - "This course does not include any videos." - ), - apiHostUrl = "", - isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, - onItemClick = { }, - onExpandClick = { }, - onSubSectionClick = { }, - onSwipeRefresh = {}, - onReloadClick = {}, - hasInternetConnection = true, - isUpdating = false, - onDownloadClick = {} - ) - } -} - -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.NEXUS_9) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.NEXUS_9) -@Composable -private fun CourseVideosScreenTabletPreview() { - OpenEdXTheme { - CourseVideosScreen( - windowSize = WindowSize(WindowType.Medium, WindowType.Medium), - uiMessage = null, - uiState = CourseVideosUIState.CourseData( - mockCourseStructure, - emptyMap(), - mapOf(), - mapOf(), - mapOf() - ), - apiHostUrl = "", - isCourseNestedListEnabled = false, - isCourseBannerEnabled = true, - onItemClick = { }, - onExpandClick = { }, - onSubSectionClick = { }, - onSwipeRefresh = {}, - onReloadClick = {}, - isUpdating = false, - hasInternetConnection = true, - onDownloadClick = {} - ) - } -} - - -private val mockChapterBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.CHAPTER, - displayName = "Chapter", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.CHAPTER, - completion = 0.0, - containsGatedContent = false -) - -private val mockSequentialBlock = Block( - id = "id", - blockId = "blockId", - lmsWebUrl = "lmsWebUrl", - legacyWebUrl = "legacyWebUrl", - studentViewUrl = "studentViewUrl", - type = BlockType.SEQUENTIAL, - displayName = "Sequential", - graded = false, - studentViewData = null, - studentViewMultiDevice = false, - blockCounts = BlockCounts(1), - descendants = emptyList(), - descendantsType = BlockType.SEQUENTIAL, - completion = 0.0, - containsGatedContent = false -) - -private val mockCourseStructure = CourseStructure( - root = "", - blockData = listOf(mockSequentialBlock, mockChapterBlock), - id = "id", - name = "Course name", - number = "", - org = "Org", - start = Date(), - startDisplay = "", - startType = "", - end = Date(), - coursewareAccess = CoursewareAccess( - true, - "", - "", - "", - "", - "" - ), - media = null, - certificate = null, - isSelfPaced = false -) \ No newline at end of file diff --git a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt index 72289c7db..ce05913d6 100644 --- a/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt +++ b/course/src/main/java/org/openedx/course/presentation/videos/CourseVideosUIState.kt @@ -3,6 +3,7 @@ package org.openedx.course.presentation.videos import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.CourseStructure import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.download.DownloadModelsSize sealed class CourseVideosUIState { data class CourseData( @@ -10,9 +11,10 @@ sealed class CourseVideosUIState { val downloadedState: Map, val courseSubSections: Map>, val courseSectionsState: Map, - val subSectionsDownloadsCount: Map + val subSectionsDownloadsCount: Map, + val downloadModelsSize: DownloadModelsSize ) : CourseVideosUIState() data class Empty(val message: String) : CourseVideosUIState() object Loading : CourseVideosUIState() -} \ No newline at end of file +} diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt new file mode 100644 index 000000000..5e50ecf39 --- /dev/null +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueFragment.kt @@ -0,0 +1,254 @@ +package org.openedx.course.settings.download + +import android.content.res.Configuration +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Divider +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.module.db.DownloadModel +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.rememberWindowSize +import org.openedx.core.ui.statusBarsInset +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue +import org.openedx.course.R +import org.openedx.course.presentation.ui.OfflineQueueCard + +class DownloadQueueFragment : Fragment() { + + private val viewModel by viewModel { + parametersOf(requireArguments().getStringArrayList(ARG_DESCENDANTS)) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(viewModel) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val windowSize = rememberWindowSize() + val uiState by viewModel.uiState.collectAsState(DownloadQueueUIState.Loading) + + DownloadQueueScreen( + windowSize = windowSize, + uiState = uiState, + onBackClick = { + requireActivity().supportFragmentManager.popBackStack() + }, + onDownloadClick = { + viewModel.removeDownloadModels(it.id) + } + ) + } + } + } + + companion object { + private const val ARG_DESCENDANTS = "descendants" + fun newInstance(descendants: List): DownloadQueueFragment { + val fragment = DownloadQueueFragment() + fragment.arguments = bundleOf( + ARG_DESCENDANTS to descendants + ) + return fragment + } + } +} + +@Composable +private fun DownloadQueueScreen( + windowSize: WindowSize, + uiState: DownloadQueueUIState, + onBackClick: () -> Unit, + onDownloadClick: (DownloadModel) -> Unit +) { + val scaffoldState = rememberScaffoldState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + scaffoldState = scaffoldState, + backgroundColor = MaterialTheme.appColors.background + ) { paddingValues -> + + val contentWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 560.dp), + compact = Modifier.fillMaxWidth() + ) + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .displayCutoutForLandscape(), + contentAlignment = Alignment.TopCenter + ) { + Column(contentWidth) { + Box( + Modifier + .fillMaxWidth() + .statusBarsInset() + .zIndex(1f), + contentAlignment = Alignment.CenterStart + ) { + BackBtn { + onBackClick() + } + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 56.dp), + text = stringResource(id = R.string.course_download_queue_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } + Spacer(Modifier.height(6.dp)) + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape + ) { + when (uiState) { + is DownloadQueueUIState.Loading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } + } + + is DownloadQueueUIState.Models -> { + Column(Modifier.fillMaxSize()) { + LazyColumn { + items(uiState.downloadingModels) { model -> + val progressValue = + if (model.id == uiState.currentProgressId) + uiState.currentProgressValue else 0 + val progressSize = + if (model.id == uiState.currentProgressId) + uiState.currentProgressSize else 0 + + OfflineQueueCard( + downloadModel = model, + progressValue = progressValue, + progressSize = progressSize, + onDownloadClick = onDownloadClick + ) + Divider() + } + } + } + } + + else -> { + onBackClick() + } + } + } + } + } + } +} + + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, device = Devices.TABLET) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun DownloadQueueScreenPreview() { + OpenEdXTheme { + DownloadQueueScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + uiState = DownloadQueueUIState.Models( + listOf( + DownloadModel( + id = "", + title = "1", + size = 0, + path = "", + url = "", + type = FileType.VIDEO, + downloadedState = DownloadedState.DOWNLOADING, + progress = 0f + ), + DownloadModel( + id = "", + title = "2", + size = 0, + path = "", + url = "", + type = FileType.VIDEO, + downloadedState = DownloadedState.DOWNLOADING, + progress = 0f + ) + ), + currentProgressId = "", + currentProgressValue = 0, + currentProgressSize = 1 + ), + onBackClick = {}, + onDownloadClick = {} + ) + } +} diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueUIState.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueUIState.kt new file mode 100644 index 000000000..0ebcc45e3 --- /dev/null +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueUIState.kt @@ -0,0 +1,16 @@ +package org.openedx.course.settings.download + +import org.openedx.core.module.db.DownloadModel + +sealed class DownloadQueueUIState { + data class Models( + val downloadingModels: List, + val currentProgressId: String, + val currentProgressValue: Long, + val currentProgressSize: Long + ) : DownloadQueueUIState() + + object Loading : DownloadQueueUIState() + + object Empty : DownloadQueueUIState() +} diff --git a/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt new file mode 100644 index 000000000..9be72dd8c --- /dev/null +++ b/course/src/main/java/org/openedx/course/settings/download/DownloadQueueViewModel.kt @@ -0,0 +1,72 @@ +package org.openedx.course.settings.download + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.download.BaseDownloadViewModel +import org.openedx.core.system.notifier.DownloadNotifier +import org.openedx.core.system.notifier.DownloadProgressChanged + +class DownloadQueueViewModel( + private val descendants: List, + downloadDao: DownloadDao, + preferencesManager: CorePreferences, + private val workerController: DownloadWorkerController, + private val downloadNotifier: DownloadNotifier +) : BaseDownloadViewModel(downloadDao, preferencesManager, workerController) { + + private val _uiState = MutableStateFlow(DownloadQueueUIState.Loading) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + downloadingModelsFlow.collect { models -> + val filteredModels = + if (descendants.isEmpty()) models else models.filter { descendants.contains(it.id) } + if (filteredModels.isEmpty()) { + _uiState.value = DownloadQueueUIState.Empty + + } else { + if (_uiState.value is DownloadQueueUIState.Models) { + val state = _uiState.value as DownloadQueueUIState.Models + _uiState.value = state.copy( + downloadingModels = filteredModels + ) + } else { + _uiState.value = DownloadQueueUIState.Models( + downloadingModels = filteredModels, + currentProgressId = "", + currentProgressValue = 0L, + currentProgressSize = 0L + ) + } + } + } + } + + viewModelScope.launch { + downloadNotifier.notifier.collect { event -> + if (event is DownloadProgressChanged) { + if (_uiState.value is DownloadQueueUIState.Models) { + val state = _uiState.value as DownloadQueueUIState.Models + _uiState.value = state.copy( + currentProgressId = event.id, + currentProgressValue = event.value, + currentProgressSize = event.size + ) + } + } + } + } + } + + override fun removeDownloadModels(blockId: String) { + viewModelScope.launch { + workerController.removeModel(blockId) + } + } +} diff --git a/course/src/main/res/menu/bottom_course_container_menu.xml b/course/src/main/res/menu/bottom_course_container_menu.xml index da9eee1b9..97529a580 100644 --- a/course/src/main/res/menu/bottom_course_container_menu.xml +++ b/course/src/main/res/menu/bottom_course_container_menu.xml @@ -9,13 +9,13 @@ @@ -31,4 +31,4 @@ android:enabled="true" android:icon="@drawable/ic_course_navigation_more"/> - \ No newline at end of file + diff --git a/course/src/main/res/values-uk/strings.xml b/course/src/main/res/values-uk/strings.xml index 3fa3fd87d..28f5a4628 100644 --- a/course/src/main/res/values-uk/strings.xml +++ b/course/src/main/res/values-uk/strings.xml @@ -35,8 +35,8 @@ Цей курс ще не розпочався. Ви не підключені до Інтернету. Будь ласка, перевірте ваше підключення до Інтернету. Курс - Відео - Обговорення + Відео + Обговорення Матеріали Ви можете завантажувати контент тільки через Wi-Fi Ця інтерактивна компонента ще не доступна diff --git a/course/src/main/res/values/strings.xml b/course/src/main/res/values/strings.xml index 32a6875fa..9b22e4c95 100644 --- a/course/src/main/res/values/strings.xml +++ b/course/src/main/res/values/strings.xml @@ -35,8 +35,8 @@ This course hasn’t started yet. You are not connected to the Internet. Please check your Internet connection. Course - Video - Discussion + Videos + Discussions Handouts You can download content only from Wi-fi This interactive component isn’t yet available @@ -50,6 +50,8 @@ Dates You are already enrolled in this course. Discover + You cannot change the download video quality when all videos are downloading + Course dates are not currently available. @@ -62,4 +64,13 @@ Stop downloading course section Section completed Section uncompleted + + Downloads + (Untitled) + Download + The videos you\'ve selected are larger than 1 GB. Do you want to download these videos? + Turning off the switch will stop downloading and delete all downloaded videos for \"%s\"? + Are you sure you want to delete all video(s) for \"%s\"? + Are you sure you want to delete video(s) for \"%s\"? + diff --git a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt index e26cb019f..6f129106c 100644 --- a/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/outline/CourseOutlineViewModelTest.kt @@ -385,7 +385,7 @@ class CourseOutlineViewModelTest { every { interactor.getCourseStructureFromCache() } returns courseStructure every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit coEvery { interactor.getCourseStatus(any()) } returns CourseComponentStatus("id") coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false @@ -418,7 +418,7 @@ class CourseOutlineViewModelTest { every { networkConnection.isWifiConnected() } returns true every { networkConnection.isOnline() } returns true coEvery { downloadDao.readAllData() } returns mockk() - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false coEvery { interactor.getDatesBannerInfo(any()) } returns mockCourseDatesBannerInfo @@ -448,7 +448,7 @@ class CourseOutlineViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit coEvery { downloadDao.readAllData() } returns flow { emit(emptyList()) } every { config.isCourseNestedListEnabled() } returns false @@ -473,4 +473,4 @@ class CourseOutlineViewModelTest { assert(!viewModel.hasInternetConnection) } -} \ No newline at end of file +} diff --git a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt index db3309093..d2f8a0b6b 100644 --- a/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/section/CourseSectionViewModelTest.kt @@ -260,7 +260,10 @@ class CourseSectionViewModelTest { ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -283,7 +286,10 @@ class CourseSectionViewModelTest { ) every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -307,7 +313,7 @@ class CourseSectionViewModelTest { every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns false every { networkConnection.isOnline() } returns false - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit viewModel.saveDownloadModels("", "") @@ -353,4 +359,4 @@ class CourseSectionViewModelTest { } -} \ No newline at end of file +} diff --git a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt index 9057df980..d898c5f4b 100644 --- a/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt +++ b/course/src/test/java/org/openedx/course/presentation/videos/CourseVideoViewModelTest.kt @@ -4,12 +4,20 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import io.mockk.* +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.test.* +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.Rule @@ -22,17 +30,22 @@ import org.openedx.core.domain.model.Block import org.openedx.core.domain.model.BlockCounts import org.openedx.core.domain.model.CourseStructure import org.openedx.core.domain.model.CoursewareAccess +import org.openedx.core.domain.model.VideoSettings import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity +import org.openedx.core.module.db.DownloadedState +import org.openedx.core.module.db.FileType import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection import org.openedx.core.system.notifier.CourseNotifier import org.openedx.core.system.notifier.CourseStructureUpdated +import org.openedx.core.system.notifier.VideoNotifier import org.openedx.course.R import org.openedx.course.domain.interactor.CourseInteractor import org.openedx.course.presentation.CourseAnalytics -import java.util.* +import java.util.Date @OptIn(ExperimentalCoroutinesApi::class) class CourseVideoViewModelTest { @@ -44,7 +57,8 @@ class CourseVideoViewModelTest { private val config = mockk() private val resourceManager = mockk() private val interactor = mockk() - private val notifier = spyk() + private val courseNotifier = spyk() + private val videoNotifier = spyk() private val analytics = mockk() private val preferencesManager = mockk() private val networkConnection = mockk() @@ -131,6 +145,16 @@ class CourseVideoViewModelTest { private val downloadModelEntity = DownloadModelEntity("", "", 1, "", "", "VIDEO", "DOWNLOADED", null) + private val downloadModel = DownloadModel( + "id", + "title", + 0, + "", + "url", + FileType.VIDEO, + DownloadedState.NOT_DOWNLOADED, + null + ) @Before fun setUp() { @@ -150,7 +174,7 @@ class CourseVideoViewModelTest { every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure.copy(blockData = emptyList()) every { downloadDao.readAllData() } returns flow { emit(emptyList()) } - + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -158,7 +182,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -177,6 +202,8 @@ class CourseVideoViewModelTest { every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure every { downloadDao.readAllData() } returns flow { emit(emptyList()) } + every { preferencesManager.videoSettings } returns VideoSettings.default + val viewModel = CourseVideoViewModel( "", config, @@ -184,7 +211,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -203,13 +231,14 @@ class CourseVideoViewModelTest { fun `updateVideos success`() = runTest { every { config.isCourseNestedListEnabled() } returns false every { interactor.getCourseStructureForVideos() } returns courseStructure - coEvery { notifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } + coEvery { courseNotifier.notifier } returns flow { emit(CourseStructureUpdated("", false)) } every { downloadDao.readAllData() } returns flow { repeat(5) { delay(10000) emit(emptyList()) } } + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -217,7 +246,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -239,6 +269,7 @@ class CourseVideoViewModelTest { @Test fun `setIsUpdating success`() = runTest { every { config.isCourseNestedListEnabled() } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -246,7 +277,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -262,6 +294,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels test`() = runTest { every { config.isCourseNestedListEnabled() } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -269,7 +302,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -278,7 +312,7 @@ class CourseVideoViewModelTest { coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns false every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -289,6 +323,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, with connection`() = runTest { every { config.isCourseNestedListEnabled() } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -296,7 +331,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -305,7 +341,10 @@ class CourseVideoViewModelTest { coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } every { preferencesManager.videoSettings.wifiDownloadOnly } returns true every { networkConnection.isWifiConnected() } returns true - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit + coEvery { downloadDao.readAllData() } returns flow { + emit(listOf(DownloadModelEntity.createFrom(downloadModel))) + } viewModel.saveDownloadModels("", "") advanceUntilIdle() @@ -316,6 +355,7 @@ class CourseVideoViewModelTest { @Test fun `saveDownloadModels only wifi download, without conection`() = runTest { every { config.isCourseNestedListEnabled() } returns false + every { preferencesManager.videoSettings } returns VideoSettings.default val viewModel = CourseVideoViewModel( "", config, @@ -323,7 +363,8 @@ class CourseVideoViewModelTest { resourceManager, networkConnection, preferencesManager, - notifier, + courseNotifier, + videoNotifier, analytics, downloadDao, workerController @@ -333,7 +374,7 @@ class CourseVideoViewModelTest { every { networkConnection.isOnline() } returns false coEvery { interactor.getCourseStructureForVideos() } returns courseStructure coEvery { downloadDao.readAllData() } returns flow { emit(listOf(downloadModelEntity)) } - coEvery { workerController.saveModels(*anyVararg()) } returns Unit + coEvery { workerController.saveModels(any()) } returns Unit viewModel.saveDownloadModels("", "") @@ -344,4 +385,4 @@ class CourseVideoViewModelTest { } -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt index 2bf343284..e272c071f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -1,6 +1,7 @@ package org.openedx.profile.presentation import androidx.fragment.app.FragmentManager +import org.openedx.core.presentation.settings.VideoQualityType import org.openedx.profile.domain.model.Account interface ProfileRouter { @@ -9,7 +10,7 @@ interface ProfileRouter { fun navigateToVideoSettings(fm: FragmentManager) - fun navigateToVideoQuality(fm: FragmentManager) + fun navigateToVideoQuality(fm: FragmentManager, videoQualityType: VideoQualityType) fun navigateToDeleteAccount(fm: FragmentManager) diff --git a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt index 40e03d53f..1f39bd03f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/delete/DeleteProfileViewModel.kt @@ -59,4 +59,4 @@ class DeleteProfileViewModel( } } } -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt index 1aec603a2..b9f4c0991 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/edit/EditProfileViewModel.kt @@ -135,4 +135,4 @@ class EditProfileViewModel( analytics.profileDeleteAccountClickedEvent() } -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt index 0b0eafd8a..2ed5818ad 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt @@ -136,7 +136,7 @@ class ProfileViewModel( fun logout() { viewModelScope.launch { try { - workerController.cancelWork() + workerController.removeModels() withContext(dispatcher) { interactor.logout() } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityViewModel.kt deleted file mode 100644 index 06c9bd6e6..000000000 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoQualityViewModel.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.openedx.profile.presentation.settings.video - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.viewModelScope -import org.openedx.core.BaseViewModel -import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.domain.model.VideoQuality -import org.openedx.profile.system.notifier.ProfileNotifier -import org.openedx.profile.system.notifier.VideoQualityChanged -import kotlinx.coroutines.launch - -class VideoQualityViewModel( - private val preferencesManager: CorePreferences, - private val notifier: ProfileNotifier -) : BaseViewModel() { - - private val _videoQuality = MutableLiveData() - val videoQuality: LiveData - get() = _videoQuality - - val currentVideoQuality = preferencesManager.videoSettings.videoQuality - - init { - _videoQuality.value = preferencesManager.videoSettings.videoQuality - } - - fun setVideoDownloadQuality(quality: VideoQuality) { - val currentSettings = preferencesManager.videoSettings - preferencesManager.videoSettings = currentSettings.copy(videoQuality = quality) - _videoQuality.value = preferencesManager.videoSettings.videoQuality - viewModelScope.launch { - notifier.send(VideoQualityChanged()) - } - } - -} \ No newline at end of file diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt index 42ecf16f6..1fba67564 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsFragment.kt @@ -48,6 +48,8 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.presentation.settings.VideoQualityType +import org.openedx.core.ui.BackBtn import org.openedx.core.domain.model.VideoSettings import org.openedx.core.ui.Toolbar import org.openedx.core.ui.WindowSize @@ -60,8 +62,8 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.core.ui.theme.appColors import org.openedx.core.ui.theme.appTypography import org.openedx.core.ui.windowSizeValue +import org.openedx.profile.R import org.openedx.profile.presentation.ProfileRouter -import org.openedx.profile.R as profileR class VideoSettingsFragment : Fragment() { @@ -94,9 +96,14 @@ class VideoSettingsFragment : Fragment() { wifiDownloadChanged = { viewModel.setWifiDownloadOnly(it) }, + videoStreamingQualityClick = { + router.navigateToVideoQuality( + requireActivity().supportFragmentManager, VideoQualityType.Streaming + ) + }, videoDownloadQualityClick = { router.navigateToVideoQuality( - requireActivity().supportFragmentManager + requireActivity().supportFragmentManager, VideoQualityType.Download ) } ) @@ -112,6 +119,7 @@ private fun VideoSettingsScreen( windowSize: WindowSize, videoSettings: VideoSettings, wifiDownloadChanged: (Boolean) -> Unit, + videoStreamingQualityClick: () -> Unit, videoDownloadQualityClick: () -> Unit, onBackClick: () -> Unit, ) { @@ -188,14 +196,14 @@ private fun VideoSettingsScreen( Column(Modifier.weight(1f)) { Text( modifier = Modifier.testTag("txt_wifi_only_label"), - text = stringResource(id = profileR.string.profile_wifi_only_download), + text = stringResource(id = R.string.profile_wifi_only_download), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) Spacer(Modifier.height(4.dp)) Text( modifier = Modifier.testTag("txt_wifi_only_description"), - text = stringResource(id = profileR.string.profile_only_download_when_wifi_turned_on), + text = stringResource(id = R.string.profile_only_download_when_wifi_turned_on), color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.labelMedium ) @@ -214,6 +222,36 @@ private fun VideoSettingsScreen( ) } Divider() + Row( + Modifier + .fillMaxWidth() + .height(92.dp) + .clickable { + videoStreamingQualityClick() + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_video_streaming_quality), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(id = videoSettings.videoStreamingQuality.titleResId), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelMedium + ) + } + Icon( + imageVector = Icons.Filled.ChevronRight, + tint = MaterialTheme.appColors.onSurface, + contentDescription = "Expandable Arrow" + ) + } + Divider() Row( Modifier .testTag("btn_video_quality") @@ -227,15 +265,13 @@ private fun VideoSettingsScreen( ) { Column(Modifier.weight(1f)) { Text( - modifier = Modifier.testTag("txt_video_quality_label"), - text = stringResource(id = profileR.string.profile_video_streaming_quality), + text = stringResource(id = org.openedx.core.R.string.core_video_download_quality), color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleMedium ) Spacer(Modifier.height(4.dp)) Text( - modifier = Modifier.testTag("txt_video_quality_description"), - text = stringResource(id = videoSettings.videoQuality.titleResId), + text = stringResource(id = videoSettings.videoDownloadQuality.titleResId), color = MaterialTheme.appColors.textSecondary, style = MaterialTheme.appTypography.labelMedium ) @@ -261,9 +297,11 @@ private fun VideoSettingsScreenPreview() { VideoSettingsScreen( windowSize = WindowSize(WindowType.Compact, WindowType.Compact), wifiDownloadChanged = {}, + videoStreamingQualityClick = {}, videoDownloadQualityClick = {}, onBackClick = {}, videoSettings = VideoSettings.default ) } } + diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt index 743986b09..f5ca673c6 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/video/VideoSettingsViewModel.kt @@ -4,17 +4,17 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.openedx.core.BaseViewModel import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.VideoSettings -import org.openedx.profile.system.notifier.ProfileNotifier -import org.openedx.profile.system.notifier.VideoQualityChanged -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch +import org.openedx.core.system.notifier.VideoNotifier +import org.openedx.core.system.notifier.VideoQualityChanged class VideoSettingsViewModel( private val preferencesManager: CorePreferences, - private val notifier: ProfileNotifier + private val notifier: VideoNotifier ) : BaseViewModel() { private val _videoSettings = MutableLiveData() @@ -45,4 +45,4 @@ class VideoSettingsViewModel( _videoSettings.value = preferencesManager.videoSettings } -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt index 71f1d8d92..ff09cbf72 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/AccountDeactivated.kt @@ -1,3 +1,3 @@ package org.openedx.profile.system.notifier -class AccountDeactivated : ProfileEvent \ No newline at end of file +class AccountDeactivated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt b/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt index 26e940a3a..2870235f2 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/AccountUpdated.kt @@ -1,3 +1,3 @@ package org.openedx.profile.system.notifier -class AccountUpdated : ProfileEvent \ No newline at end of file +class AccountUpdated : ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt index e95caacdb..dbe877081 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileEvent.kt @@ -1,4 +1,3 @@ package org.openedx.profile.system.notifier -interface ProfileEvent { -} \ No newline at end of file +interface ProfileEvent diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt index 4d826c5d9..c51d82340 100644 --- a/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt +++ b/profile/src/main/java/org/openedx/profile/system/notifier/ProfileNotifier.kt @@ -3,6 +3,7 @@ package org.openedx.profile.system.notifier import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow +import org.openedx.core.system.notifier.VideoQualityChanged class ProfileNotifier { @@ -11,7 +12,6 @@ class ProfileNotifier { val notifier: Flow = channel.asSharedFlow() suspend fun send(event: AccountUpdated) = channel.emit(event) - suspend fun send(event: VideoQualityChanged) = channel.emit(event) suspend fun send(event: AccountDeactivated) = channel.emit(event) -} \ No newline at end of file +} diff --git a/profile/src/main/java/org/openedx/profile/system/notifier/VideoQualityChanged.kt b/profile/src/main/java/org/openedx/profile/system/notifier/VideoQualityChanged.kt deleted file mode 100644 index 4f372db31..000000000 --- a/profile/src/main/java/org/openedx/profile/system/notifier/VideoQualityChanged.kt +++ /dev/null @@ -1,3 +0,0 @@ -package org.openedx.profile.system.notifier - -class VideoQualityChanged : ProfileEvent \ No newline at end of file diff --git a/profile/src/main/res/values-uk/strings.xml b/profile/src/main/res/values-uk/strings.xml index 1cbb0a60a..7d617f734 100644 --- a/profile/src/main/res/values-uk/strings.xml +++ b/profile/src/main/res/values-uk/strings.xml @@ -30,7 +30,6 @@ Налаштування відео Завантаження тільки через Wi-Fi Завантажуйте вміст лише тоді, коли ввімкнено wi-fi - Якість транслювання відео Видалити акаунт Ви впевнені, що бажаєте видалити свій акаунт? @@ -44,4 +43,4 @@ Продовжити редагування Зміни, які ви внесли, можуть не бути збереженими. - \ No newline at end of file + diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index 03f82fa8a..ac4b4cbf3 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -38,10 +38,9 @@ Video settings Wi-fi only download Only download content when wi-fi is turned on - Video streaming quality Leave profile? Leave Keep editing Changes you have made may not be saved. - \ No newline at end of file + diff --git a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt index ecfb1fadf..3a6dd29bd 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/edit/EditProfileViewModelTest.kt @@ -185,4 +185,4 @@ class EditProfileViewModelTest { assert(viewModel.selectedImageUri.value != null) } -} \ No newline at end of file +} diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index 45d346671..7112eac51 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -222,7 +222,7 @@ class ProfileViewModelTest { appUpgradeNotifier ) coEvery { interactor.logout() } throws UnknownHostException() - coEvery { workerController.cancelWork() } returns Unit + coEvery { workerController.removeModels() } returns Unit every { analytics.logoutEvent(false) } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() @@ -252,7 +252,7 @@ class ProfileViewModelTest { appUpgradeNotifier ) coEvery { interactor.logout() } throws Exception() - coEvery { workerController.cancelWork() } returns Unit + coEvery { workerController.removeModels() } returns Unit every { analytics.logoutEvent(false) } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() @@ -287,7 +287,7 @@ class ProfileViewModelTest { coEvery { interactor.getAccount() } returns mockk() every { analytics.logoutEvent(false) } returns Unit coEvery { interactor.logout() } returns Unit - coEvery { workerController.cancelWork() } returns Unit + coEvery { workerController.removeModels() } returns Unit every { cookieManager.clearWebViewCookie() } returns Unit viewModel.logout() advanceUntilIdle()